diff --git a/README.md b/README.md index 834b8410..a0cdd8b9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/__tests__/utils/challengeMapUtils.test.js b/__tests__/utils/challengeMapUtils.test.js index 93f27c37..d13dfe68 100644 --- a/__tests__/utils/challengeMapUtils.test.js +++ b/__tests__/utils/challengeMapUtils.test.js @@ -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: {} }; } @@ -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( @@ -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'], @@ -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]) @@ -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); }); @@ -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' ); diff --git a/scripts/build-challenge-map-graphql.mjs b/scripts/build-challenge-map-graphql.mjs index cc85a5c9..ba42cff3 100644 --- a/scripts/build-challenge-map-graphql.mjs +++ b/scripts/build-challenge-map-graphql.mjs @@ -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 @@ -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 { @@ -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) { diff --git a/util/challengeMapUtils.js b/util/challengeMapUtils.js index 2c600276..ace33a37 100644 --- a/util/challengeMapUtils.js +++ b/util/challengeMapUtils.js @@ -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: {} }; }