Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ postgresql://postgres:password@localhost:5432/classroom
8. Run `npm run mock-fcc-data`
9. Run `npx prisma studio`

### Challenge map (FCC Proper)

The challenge map is built from the FCC Proper GraphQL curriculum database and
saved to `data/challengeMap.json`. We recommend regenerating it about once per
week so it stays aligned with upstream curriculum updates.

To generate or refresh the map:

```console
node scripts/build-challenge-map-graphql.mjs
```

To run the challenge map tests (they read the current `data/challengeMap.json`):

```console
npm run test:challenge-map
```

**Note:** The classroom app runs on port 3001 and mock data on port 3002 to avoid conflicts with freeCodeCamp's main platform (ports 3000/8000).

Need more help? Ran into issues? Check out this [guide](https://docs.google.com/document/d/1apfjzfIwDAfg6QQf2KD1E1aeD-KU7DEllwnH9Levq4A/edit) that walks you through all the steps of setting up the repository locally, without Docker.
Expand Down
182 changes: 151 additions & 31 deletions __tests__/utils/challengeMapUtils.test.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
// Mock the file system to avoid ES module issues
jest.mock('fs');
jest.mock('path');
const { existsSync, readFileSync } = require('fs');
const path = require('path');

// Create the mock functions directly to test the core logic
function buildStudentDashboardData(completedChallenges, challengeMap) {
const CHALLENGE_MAP_PATH = path.join(__dirname, '../../data/challengeMap.json');

const hasChallengeMap = existsSync(CHALLENGE_MAP_PATH);
const isCi = Boolean(process.env.CI);
let challengeMap = null;

function formatPathForLog(rawPath) {
const normalized = path.normalize(rawPath);
const match = normalized.match(/^([A-Za-z]:)\\\1\\(.*)$/);
if (match) {
return `${match[1]}\\${match[2]}`;
}
return normalized;
}

if (!hasChallengeMap) {
console.log(
[
'\x1b[31m[challengeMapUtils.test] Missing challenge map\x1b[0m',
` Missing challenge map path: ${formatPathForLog(CHALLENGE_MAP_PATH)}`,
` Current working directory: ${formatPathForLog(process.cwd())}`,
` Resolved map path: ${formatPathForLog(
path.resolve(CHALLENGE_MAP_PATH)
)}`,
' To generate the challengeMap.json please run:',
` \x1b[31m node scripts/build-challenge-map-graphql.mjs\x1b[0m`,
'',
' Tests that rely on the challenge map will fail until the map is generated.'
].join('\n')
);
}

function buildStudentDashboardData(completedChallenges, map) {
const result = { certifications: [] };
const certMap = {};

completedChallenges.forEach(challenge => {
const mapEntry = challengeMap[challenge.id];
const mapEntry = map[challenge.id];
if (!mapEntry) {
return; // skip unknown ids
return;
}

const name = mapEntry.name;
const certification =
mapEntry.certification || (mapEntry.superblocks || [])[0];
const block = mapEntry.block || (mapEntry.blocks || [])[0];

if (!certification || !block) {
return;
}
// Use first superblock as canonical for dashboard grouping
const { superblocks, blocks, name } = mapEntry;
const certification = superblocks[0];
const block = blocks[0];

if (!certMap[certification]) {
certMap[certification] = { blocks: {} };
}
Expand All @@ -28,8 +64,7 @@ function buildStudentDashboardData(completedChallenges, challengeMap) {
});
});

// Convert to the expected nested array format
for (const cert in certMap) {
Object.keys(certMap).forEach(cert => {
const certObj = {};
certObj[cert] = {
blocks: Object.entries(certMap[cert].blocks).map(
Expand All @@ -39,28 +74,121 @@ function buildStudentDashboardData(completedChallenges, challengeMap) {
)
};
result.certifications.push(certObj);
}
});

return result;
}

function resolveAllStudentsToDashboardFormat(
studentDataFromFCC,
curriculumMap = null
) {
const mockChallengeMap = {}; // Would load from file in actual implementation
function resolveAllStudentsToDashboardFormat(studentDataFromFCC, map) {
if (!studentDataFromFCC || typeof studentDataFromFCC !== 'object') return [];
const mapToUse = curriculumMap || mockChallengeMap;
return Object.entries(studentDataFromFCC).map(
([email, completedChallenges]) => ({
email,
...buildStudentDashboardData(completedChallenges, mapToUse)
...buildStudentDashboardData(completedChallenges, map)
})
);
}

describe('challengeMapUtils', () => {
// Mock challenge map with array structure (superblocks and blocks as arrays)
function getFirstMapEntry(map) {
const entries = Object.entries(map);
for (const [challengeId, mapEntry] of entries) {
const certification =
mapEntry.certification || (mapEntry.superblocks || [])[0];
const block = mapEntry.block || (mapEntry.blocks || [])[0];
if (certification && block) {
return { challengeId, mapEntry, certification, block };
}
}
return null;
}

beforeAll(() => {
if (!hasChallengeMap) {
return;
}

console.log(
'[challengeMapUtils.test] Using challenge map:',
CHALLENGE_MAP_PATH
);
const raw = readFileSync(CHALLENGE_MAP_PATH, 'utf8');
challengeMap = JSON.parse(raw);
console.log(
'[challengeMapUtils.test] Challenge map entries:',
Object.keys(challengeMap).length
);
});

const shouldSkipRealMap = !hasChallengeMap && isCi;
const describeRealMap = shouldSkipRealMap ? describe.skip : describe;

describeRealMap('challengeMapUtils (real challengeMap.json)', () => {
if (!hasChallengeMap) {
test('challengeMap.json must exist to run real-map tests', () => {
expect(true).toBe(true);
throw new Error(
'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs'
);
});
return;
}
it('loads a non-empty challenge map', () => {
expect(challengeMap).toBeTruthy();
expect(typeof challengeMap).toBe('object');
expect(Object.keys(challengeMap).length).toBeGreaterThan(0);
});

it('builds dashboard data using the first valid map entry', () => {
const entry = getFirstMapEntry(challengeMap);
expect(entry).toBeTruthy();

const completedChallenges = [
{ id: entry.challengeId, completedDate: '2024-01-15' }
];

const result = buildStudentDashboardData(completedChallenges, challengeMap);

expect(result.certifications.length).toBe(1);
const certKey = Object.keys(result.certifications[0])[0];
expect(certKey).toBe(entry.certification);
const blockKey = Object.keys(
result.certifications[0][certKey].blocks[0]
)[0];
expect(blockKey).toBe(entry.block);
});

it('skips unknown challenge IDs', () => {
const result = buildStudentDashboardData(
[{ id: 'unknown-challenge-id', completedDate: '2024-01-16' }],
challengeMap
);

expect(result.certifications).toEqual([]);
});

it('resolves multiple students against the current map', () => {
const entry = getFirstMapEntry(challengeMap);
expect(entry).toBeTruthy();

const studentDataFromFCC = {
'student1@test.com': [
{ id: entry.challengeId, completedDate: '2024-01-15' }
],
'student2@test.com': []
};

const result = resolveAllStudentsToDashboardFormat(
studentDataFromFCC,
challengeMap
);

expect(result.length).toBe(2);
expect(result[0]).toHaveProperty('email');
expect(result[0]).toHaveProperty('certifications');
});
});

describe('challengeMapUtils (synthetic map)', () => {
const mockChallengeMap = {
bd7123c8c441eddfaeb5bdef: {
superblocks: ['responsive-web-design'],
Expand Down Expand Up @@ -217,9 +345,6 @@ describe('challengeMapUtils', () => {
mockChallengeMap
);

// bd7123c8c441eddfaeb5bdef -> responsive-web-design
// 56533eb9ac21ba0edf2244cf -> javascript-algorithms-and-data-structures (first)
// m2n3o4p5q6r7s8t9u0v1w2x3 -> full-stack-developer
expect(result.certifications.length).toBe(3);
const certNames = result.certifications
.map(c => Object.keys(c)[0])
Expand Down Expand Up @@ -461,12 +586,9 @@ describe('challengeMapUtils', () => {

expect(result.length).toBe(2);

// Alice should have 2 certifications (responsive-web-design and javascript-algorithms-and-data-structures)
const alice = result.find(s => s.email === 'alice@example.com');
expect(alice.certifications.length).toBe(2);

// Bob should have 2 certifications (javascript-algorithms-and-data-structures from challenge 56533eb9ac21ba0edf2244cf
// and full-stack-developer from challenge m2n3o4p5q6r7s8t9u0v1w2x3)
const bob = result.find(s => s.email === 'bob@example.com');
expect(bob.certifications.length).toBe(2);
});
Expand All @@ -481,12 +603,10 @@ describe('challengeMapUtils', () => {
mockChallengeMap
);

// Challenge appears in 2 superblocks, but should be grouped under first one
const certification =
result.certifications[0]['javascript-algorithms-and-data-structures'];
expect(certification).toBeDefined();

// Should NOT have an entry for full-stack-developer since we use first occurrence
const hasFullStack = result.certifications.some(
c => Object.keys(c)[0] === 'full-stack-developer'
);
Expand Down
10 changes: 5 additions & 5 deletions scripts/build-challenge-map-graphql.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { writeFile, mkdir } from 'fs/promises';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

/**
* Build challenge map from freeCodeCamp GraphQL Curriculum Database
Expand All @@ -11,7 +12,7 @@ import { dirname } from 'path';
*/

const GRAPHQL_ENDPOINT = 'https://curriculum-db.freecodecamp.org/graphql';
const OUTPUT_PATH = new URL('../data/challengeMap.json', import.meta.url);
const OUTPUT_PATH = fileURLToPath(new URL('../data/challengeMap.json', import.meta.url));

const CHALLENGE_MAP_QUERY = `
query GetChallengeMap {
Expand Down Expand Up @@ -154,17 +155,16 @@ async function buildChallengeMapFromGraphQL() {
const challengeMap = buildChallengeMap(data);

// Ensure output directory exists
await mkdir(dirname(OUTPUT_PATH.pathname), { recursive: true });

await mkdir(dirname(OUTPUT_PATH), { recursive: true });
// Write to file
console.log(`\n💾 Writing challenge map to ${OUTPUT_PATH.pathname}...`);
console.log(`\n💾 Writing challenge map to ${OUTPUT_PATH}...`);
await writeFile(
OUTPUT_PATH,
JSON.stringify(challengeMap, null, 2)
);

console.log('✅ Challenge map successfully generated!\n');
console.log(` File: ${OUTPUT_PATH.pathname}`);
console.log(` File: ${OUTPUT_PATH}`);
console.log(` Size: ${Object.keys(challengeMap).length} challenges`);

} catch (err) {
Expand Down
10 changes: 7 additions & 3 deletions util/challengeMapUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ export function buildStudentDashboardData(completedChallenges, challengeMap) {
return; // skip unknown ids
}
// Use first superblock/block as canonical for dashboard grouping
const { superblocks, blocks, name } = mapEntry;
const certification = superblocks[0];
const block = blocks[0];
const name = mapEntry.name;
const certification =
mapEntry.certification || (mapEntry.superblocks || [])[0];
const block = mapEntry.block || (mapEntry.blocks || [])[0];
if (!certification || !block) {
return;
}
if (!certMap[certification]) {
certMap[certification] = { blocks: {} };
}
Expand Down
Loading