Skip to content
Open
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,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