refactor: top-down curriculum build (#61459)

This commit is contained in:
Oliver Eyton-Williams
2025-08-26 12:37:26 +02:00
committed by GitHub
parent 45c098d506
commit a801d503bc
14264 changed files with 4300 additions and 3151 deletions

View File

@@ -1,6 +1,8 @@
import { createSelector } from 'reselect';
import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json';
// TODO: source the superblock structure via a GQL query, rather than directly
// from the curriculum
import superBlockStructure from '../../../curriculum/structure/superblocks/full-stack-developer.json';
import { randomBetween } from '../utils/random-between';
import { getSessionChallengeData } from '../utils/session-storage';
import { ns as MainApp } from './action-types';
@@ -144,7 +146,7 @@ export const completionStateSelector = createSelector(
const populateBlocks = blocks =>
blocks.map(block => {
const blockChallenges = challenges.filter(
({ block: blockName }) => blockName === block.dashedName
({ block: blockName }) => blockName === block
);
const completedBlockChallenges = blockChallenges.every(({ id }) =>
@@ -152,7 +154,7 @@ export const completionStateSelector = createSelector(
);
return {
name: block.dashedName,
name: block,
isCompleted:
completedBlockChallenges.length === blockChallenges.length
};

View File

@@ -5,9 +5,9 @@ import { Disclosure } from '@headlessui/react';
import { SuperBlocks } from '../../../../../shared/config/curriculum';
import DropDown from '../../../assets/icons/dropdown';
// TODO: See if there's a nice way to incorporate the structure into data Gatsby
// sources from the curriculum, rather than importing it directly.
import superBlockStructure from '../../../../../curriculum/superblock-structure/full-stack.json';
// TODO: source the superblock structure via a GQL query, rather than directly
// from the curriculum
import superBlockStructure from '../../../../../curriculum/structure/superblocks/full-stack-developer.json';
import { ChapterIcon } from '../../../assets/chapter-icon';
import { BlockLayouts, BlockTypes } from '../../../../../shared/config/blocks';
import { FsdChapters } from '../../../../../shared/config/chapters';
@@ -74,7 +74,7 @@ const getBlockToChapterMap = () => {
chapters.forEach(chapter => {
chapter.modules.forEach(module => {
module.blocks.forEach(block => {
blockToChapterMap.set(block.dashedName, chapter.dashedName);
blockToChapterMap.set(block, chapter.dashedName);
});
});
});
@@ -86,7 +86,7 @@ const getBlockToModuleMap = () => {
const blockToModuleMap = new Map<string, string>();
modules.forEach(module => {
module.blocks.forEach(block => {
blockToModuleMap.set(block.dashedName, module.dashedName);
blockToModuleMap.set(block, module.dashedName);
});
});
@@ -212,14 +212,14 @@ export const SuperBlockAccordion = ({
}: SuperBlockAccordionProps) => {
const { t } = useTranslation();
const { allChapters } = useMemo(() => {
const populateBlocks = (blocks: { dashedName: string }[]) =>
const populateBlocks = (blocks: string[]) =>
blocks.map(block => {
const blockChallenges = challenges.filter(
({ block: blockName }) => blockName === block.dashedName
({ block: blockName }) => blockName === block
);
return {
name: block.dashedName,
name: block,
blockType: blockChallenges[0]?.blockType ?? null,
challenges: blockChallenges
};

View File

@@ -3,45 +3,34 @@ const path = require('path');
const _ = require('lodash');
const envData = require('../config/env.json');
const { getChallengesForLang } = require('../../curriculum/get-challenges');
const {
getChallengesForLang,
generateChallengeCreator,
ENGLISH_CHALLENGES_DIR,
META_DIR,
I18N_CHALLENGES_DIR,
getChallengesDirForLang
} = require('../../curriculum/get-challenges');
getContentDir,
getBlockCreator
} = require('../../curriculum/build-curriculum');
const { curriculumLocale } = envData;
exports.localeChallengesRootDir = getChallengesDirForLang(curriculumLocale);
exports.localeChallengesRootDir = getContentDir(curriculumLocale);
const blockCreator = getBlockCreator(curriculumLocale);
exports.replaceChallengeNode = () => {
return async function replaceChallengeNode(filePath) {
// get the meta so that challengeOrder is accurate
const blockNameRe = /\d\d-[-\w]+\/([^/]+)\//;
const posix = path.normalize(filePath).split(path.sep).join(path.posix.sep);
const blockName = posix.match(blockNameRe)[1];
const metaPath = path.resolve(META_DIR, `${blockName}/meta.json`);
delete require.cache[require.resolve(metaPath)];
const meta = require(metaPath);
const englishPath = path.resolve(
ENGLISH_CHALLENGES_DIR,
'english',
filePath
);
const i18nPath = path.resolve(
I18N_CHALLENGES_DIR,
curriculumLocale,
filePath
);
// TODO: reimplement hot-reloading of certifications
const createChallenge = generateChallengeCreator(
curriculumLocale,
englishPath,
i18nPath
);
return await createChallenge(filePath, meta);
const parentDir = path.dirname(filePath);
const block = path.basename(parentDir);
const filename = path.basename(filePath);
console.log(`Replacing challenge node for ${filePath}`);
const meta = blockCreator.getMetaForBlock(block);
return await blockCreator.createChallenge({
filename,
block,
meta,
isAudited: true
});
};
};

View File

@@ -0,0 +1,8 @@
const fs = require('fs');
const yaml = require('js-yaml');
const buildCertification = filePath => ({
challenges: [yaml.load(fs.readFileSync(filePath, 'utf8'))]
});
module.exports = { buildCertification };

View File

@@ -0,0 +1,387 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const { isEmpty } = require('lodash');
const debug = require('debug')('fcc:build-curriculum');
const {
SuperblockCreator,
BlockCreator,
transformSuperBlock
} = require('./build-superblock');
const { buildCertification } = require('./build-certification');
const { applyFilters } = require('./utils');
const CURRICULUM_DIR = __dirname;
const I18N_CURRICULUM_DIR = path.resolve(
CURRICULUM_DIR,
'i18n-curriculum',
'curriculum'
);
const STRUCTURE_DIR = path.resolve(CURRICULUM_DIR, 'structure');
/**
* Creates a BlockCreator instance for a specific language with appropriate configuration
* @param {string} lang - The language code (e.g., 'english', 'spanish', etc.)
* @param {boolean} [skipValidation=false] - Whether to skip validation of challenges
* @param {Object} [opts] - Optional configuration object
* @param {string} [opts.baseDir] - Base directory for curriculum content
* @param {string} [opts.i18nBaseDir] - Base directory for i18n curriculum content
* @param {string} [opts.structureDir] - Directory containing curriculum structure
* @returns {BlockCreator} A configured BlockCreator instance
*/
const getBlockCreator = (lang, skipValidation, opts) => {
const {
blockContentDir,
blockStructureDir,
i18nBlockContentDir,
dictionariesDir,
i18nDictionariesDir
} = getLanguageConfig(lang, opts);
const targetDictionariesDir =
lang === 'english' ? dictionariesDir : i18nDictionariesDir;
return new BlockCreator({
lang,
blockContentDir,
blockStructureDir,
i18nBlockContentDir,
commentTranslations: createCommentMap(
dictionariesDir,
targetDictionariesDir
),
skipValidation: skipValidation ?? false
});
};
/**
* Gets a translation entry for a specific English ID and text across all languages
* @param {Object} dicts - Dictionary object containing translations for each language
* @param {Object} params - Parameters object
* @param {string} params.engId - The English ID to look up in dictionaries
* @param {string} params.text - The fallback English text to use if translation not found
* @returns {Object} Object mapping language codes to translated text or fallback English text
*/
function getTranslationEntry(dicts, { engId, text }) {
return Object.keys(dicts).reduce((acc, lang) => {
const entry = dicts[lang][engId];
if (entry) {
return { ...acc, [lang]: entry };
} else {
// default to english
return { ...acc, [lang]: text };
}
}, {});
}
/**
* Creates a mapping of English comments to their translations across all supported languages
* @param {string} dictionariesDir - Path to the main (english) dictionaries directory
* @param {string} targetDictionariesDir - Path to the target (i18n or english) dictionaries directory
* @returns {Object} Object mapping English comment text to translations in all languages
*/
function createCommentMap(dictionariesDir, targetDictionariesDir) {
debug(
`Creating comment map from ${dictionariesDir} and ${targetDictionariesDir}`
);
const languages = fs.readdirSync(targetDictionariesDir);
const dictionaries = languages.reduce((acc, lang) => {
const commentsPath = path.resolve(
targetDictionariesDir,
lang,
'comments.json'
);
const commentsData = JSON.parse(fs.readFileSync(commentsPath, 'utf8'));
return {
...acc,
[lang]: commentsData
};
}, {});
const COMMENTS_TO_TRANSLATE = JSON.parse(
fs.readFileSync(
path.resolve(dictionariesDir, 'english', 'comments.json'),
'utf8'
)
);
const COMMENTS_TO_NOT_TRANSLATE = JSON.parse(
fs.readFileSync(
path.resolve(
dictionariesDir,
'english',
'comments-to-not-translate.json'
),
'utf8'
)
);
// map from english comment text to translations
const translatedCommentMap = Object.entries(COMMENTS_TO_TRANSLATE).reduce(
(acc, [id, text]) => {
return {
...acc,
[text]: getTranslationEntry(dictionaries, { engId: id, text })
};
},
{}
);
// map from english comment text to itself
const untranslatableCommentMap = Object.values(
COMMENTS_TO_NOT_TRANSLATE
).reduce((acc, text) => {
const englishEntry = languages.reduce(
(acc, lang) => ({
...acc,
[lang]: text
}),
{}
);
return {
...acc,
[text]: englishEntry
};
}, {});
const allComments = { ...translatedCommentMap, ...untranslatableCommentMap };
// the english entries need to be added here, because english is not in
// languages
Object.keys(allComments).forEach(comment => {
allComments[comment].english = comment;
});
return allComments;
}
// Map of superblock filenames to their SuperBlocks enum values
const superBlockNames = {
'responsive-web-design': 'responsive-web-design',
'javascript-algorithms-and-data-structures':
'javascript-algorithms-and-data-structures',
'front-end-development-libraries': 'front-end-development-libraries',
'data-visualization': 'data-visualization',
'back-end-development-and-apis': 'back-end-development-and-apis',
'quality-assurance': 'quality-assurance',
'scientific-computing-with-python': 'scientific-computing-with-python',
'data-analysis-with-python': 'data-analysis-with-python',
'information-security': 'information-security',
'coding-interview-prep': 'coding-interview-prep',
'machine-learning-with-python': 'machine-learning-with-python',
'relational-databases': 'relational-database',
'responsive-web-design-22': '2022/responsive-web-design',
'javascript-algorithms-and-data-structures-22':
'javascript-algorithms-and-data-structures-v8',
'the-odin-project': 'the-odin-project',
'college-algebra-with-python': 'college-algebra-with-python',
'project-euler': 'project-euler',
'foundational-c-sharp-with-microsoft': 'foundational-c-sharp-with-microsoft',
'a2-english-for-developers': 'a2-english-for-developers',
'rosetta-code': 'rosetta-code',
'python-for-everybody': 'python-for-everybody',
'b1-english-for-developers': 'b1-english-for-developers',
'full-stack-developer': 'full-stack-developer',
'a2-professional-spanish': 'a2-professional-spanish',
'a2-professional-chinese': 'a2-professional-chinese',
'basic-html': 'basic-html',
'semantic-html': 'semantic-html',
'a1-professional-chinese': 'a1-professional-chinese',
'dev-playground': 'dev-playground'
};
/**
* Gets language-specific configuration paths for curriculum content
* @param {string} lang - The language code (e.g., 'english', 'spanish', etc.)
* @param {Object} [options] - Optional configuration object with directory overrides
* @param {string} [options.baseDir] - Base directory for curriculum content (defaults to CURRICULUM_DIR)
* @param {string} [options.i18nBaseDir] - Base directory for i18n content (defaults to I18N_CURRICULUM_DIR)
* @param {string} [options.structureDir] - Directory for curriculum structure (defaults to STRUCTURE_DIR)
* @returns {Object} Object containing all relevant directory paths for the language
* @throws {AssertionError} When required i18n directories don't exist for non-English languages
*/
function getLanguageConfig(
lang,
{ baseDir, i18nBaseDir, structureDir } = {
baseDir: CURRICULUM_DIR,
i18nBaseDir: I18N_CURRICULUM_DIR,
structureDir: STRUCTURE_DIR
}
) {
const contentDir = path.resolve(baseDir, 'challenges', 'english');
const i18nContentDir = path.resolve(i18nBaseDir, 'challenges', lang);
const blockContentDir = path.resolve(contentDir, 'blocks');
const i18nBlockContentDir = path.resolve(i18nContentDir, 'blocks');
const blockStructureDir = path.resolve(structureDir, 'blocks');
const dictionariesDir = path.resolve(baseDir, 'dictionaries');
const i18nDictionariesDir = path.resolve(i18nBaseDir, 'dictionaries');
if (lang !== 'english') {
assert(
fs.existsSync(i18nContentDir),
`i18n content directory does not exist: ${i18nContentDir}`
);
assert(
fs.existsSync(i18nBlockContentDir),
`i18n block content directory does not exist: ${i18nBlockContentDir}`
);
assert(
fs.existsSync(i18nDictionariesDir),
`i18n dictionaries directory does not exist: ${i18nDictionariesDir}`
);
}
debug(`Using content directory: ${contentDir}`);
debug(`Using i18n content directory: ${i18nContentDir}`);
debug(`Using block content directory: ${blockContentDir}`);
debug(`Using i18n block content directory: ${i18nBlockContentDir}`);
debug(`Using dictionaries directory: ${dictionariesDir}`);
debug(`Using i18n dictionaries directory: ${i18nDictionariesDir}`);
return {
contentDir,
i18nContentDir,
blockContentDir,
i18nBlockContentDir,
blockStructureDir,
dictionariesDir,
i18nDictionariesDir
};
}
/**
* Gets the appropriate content directory path for a given language
* @param {string} lang - The language code (e.g., 'english', 'spanish', etc.)
* @returns {string} Path to the content directory for the specified language
*/
function getContentDir(lang) {
const { contentDir, i18nContentDir } = getLanguageConfig(lang);
return lang === 'english' ? contentDir : i18nContentDir;
}
const getCurriculumStructure = () => {
const curriculumPath = path.resolve(STRUCTURE_DIR, 'curriculum.json');
if (!fs.existsSync(curriculumPath)) {
throw new Error(`Curriculum file not found: ${curriculumPath}`);
}
return JSON.parse(fs.readFileSync(curriculumPath, 'utf8'));
};
/**
* Builds an array of superblock structures from a curriculum object
* @param {string[]} superblocks - Array of superblock filename strings
* @returns {Array<Object>} Array of superblock structure objects with filename, name, and blocks
* @throws {Error} When a superblock file is not found
*/
function addSuperblockStructure(superblocks) {
debug(`Building structure for ${superblocks.length} superblocks`);
const superblockStructures = superblocks.map(superblockFilename => {
const superblockName = superBlockNames[superblockFilename];
if (!superblockName) {
throw new Error(`Superblock name not found for ${superblockFilename}`);
}
return {
name: superblockName,
blocks: transformSuperBlock(getSuperblockStructure(superblockFilename), {
showComingSoon: process.env.SHOW_UPCOMING_CHANGES === 'true'
})
};
});
debug(
`Successfully built ${superblockStructures.length} superblock structures`
);
return superblockStructures;
}
function getSuperblockStructure(superblock) {
const superblockPath = path.resolve(
STRUCTURE_DIR,
'superblocks',
`${superblock}.json`
);
return JSON.parse(fs.readFileSync(superblockPath, 'utf8'));
}
function getBlockStructure(block) {
const blockPath = path.resolve(STRUCTURE_DIR, 'blocks', `${block}.json`);
try {
return JSON.parse(fs.readFileSync(blockPath, 'utf8'));
} catch {
console.warn('block missing', block);
}
}
function addBlockStructure(
superblocks,
_getBlockStructure = getBlockStructure
) {
return superblocks.map(superblock => ({
...superblock,
blocks: superblock.blocks.map((block, index) => ({
...block,
..._getBlockStructure(block.dashedName),
order: index,
superBlock: superblock.name
}))
}));
}
async function buildCurriculum(lang, filters) {
const contentDir = getContentDir(lang);
const builder = new SuperblockCreator({
blockCreator: getBlockCreator(lang, !isEmpty(filters))
});
const curriculum = getCurriculumStructure();
if (isEmpty(curriculum.superblocks))
throw Error('No superblocks found in curriculum.json');
if (isEmpty(curriculum.certifications))
throw Error('No certifications found in curriculum.json');
debug(`Found ${curriculum.superblocks.length} superblocks to build`);
debug(`Found ${curriculum.certifications.length} certifications to build`);
const superblockList = addBlockStructure(
addSuperblockStructure(curriculum.superblocks)
);
const fullSuperblockList = applyFilters(superblockList, filters);
const fullCurriculum = { certifications: { blocks: {} } };
for (const superblock of fullSuperblockList) {
fullCurriculum[superblock.name] =
await builder.processSuperblock(superblock);
}
for (const cert of curriculum.certifications) {
const certPath = path.resolve(contentDir, 'certifications', `${cert}.yml`);
if (!fs.existsSync(certPath)) {
throw Error(`Certification file not found: ${certPath}`);
}
debug(`=== Processing certification ${cert} ===`);
fullCurriculum.certifications.blocks[cert] = buildCertification(certPath);
}
return fullCurriculum;
}
module.exports = {
addBlockStructure,
buildCurriculum,
getContentDir,
getBlockCreator,
getBlockStructure,
getSuperblockStructure,
createCommentMap
};

View File

@@ -0,0 +1,105 @@
const path = require('node:path');
const { createCommentMap, addBlockStructure } = require('./build-curriculum');
describe('createCommentMap', () => {
const dictionaryDir = path.resolve(__dirname, '__fixtures__', 'dictionaries');
const incompleteDictDir = path.resolve(
__dirname,
'__fixtures__',
'incomplete-dicts'
);
it('returns an object', () => {
expect(typeof createCommentMap(dictionaryDir, dictionaryDir)).toBe(
'object'
);
});
it('fallback to the untranslated string', () => {
expect.assertions(2);
const commentMap = createCommentMap(incompleteDictDir, incompleteDictDir);
expect(commentMap['To be translated one'].spanish).toEqual(
'Spanish translation one'
);
expect(commentMap['To be translated two'].spanish).toEqual(
'To be translated two'
);
});
it('returns an object with an expected form', () => {
expect.assertions(4);
const expectedIds = [
'To be translated one',
'To be translated two',
'Not translated one',
'Not translated two'
];
const map = createCommentMap(dictionaryDir, dictionaryDir);
expect(Object.keys(map)).toEqual(expect.arrayContaining(expectedIds));
const mapValue = map['To be translated one'];
expect(Object.keys(mapValue)).toEqual(
expect.arrayContaining(['chinese', 'spanish'])
);
expect(typeof mapValue.chinese).toBe('string');
expect(typeof mapValue.spanish).toBe('string');
});
it('returns an object with expected values', () => {
expect.assertions(9);
const expectedIds = [
'To be translated one',
'To be translated two',
'Not translated one',
'Not translated two'
];
const map = createCommentMap(dictionaryDir, dictionaryDir);
expect(Object.keys(map)).toEqual(expect.arrayContaining(expectedIds));
const translatedOne = map['To be translated one'];
expect(translatedOne.chinese).toBe('Chinese translation one');
expect(translatedOne.spanish).toBe('Spanish translation one');
const translatedTwo = map['To be translated two'];
expect(translatedTwo.chinese).toBe('Chinese translation two');
expect(translatedTwo.spanish).toBe('Spanish translation two');
const untranslatedOne = map['Not translated one'];
expect(untranslatedOne.chinese).toBe('Not translated one');
expect(untranslatedOne.spanish).toBe('Not translated one');
const untranslatedTwo = map['Not translated two'];
expect(untranslatedTwo.chinese).toBe('Not translated two');
expect(untranslatedTwo.spanish).toBe('Not translated two');
});
});
describe('addBlockStructure', () => {
it('should override the order and superblock coming from getBlockStructure', () => {
const mockGetBlockStructure = () => ({
order: 5,
superBlock: 'no'
});
const result = addBlockStructure(
[{ name: 'yes', blocks: [{ a: 1 }, { b: 1 }] }],
mockGetBlockStructure
);
expect(result).toEqual([
{
name: 'yes',
blocks: [
{ a: 1, order: 0, superBlock: 'yes' },
{ b: 1, order: 1, superBlock: 'yes' }
]
}
]);
});
});

View File

@@ -0,0 +1,486 @@
const fs = require('fs');
const path = require('path');
const { isEmpty } = require('lodash');
const debug = require('debug')('fcc:build-superblock');
const { parseMD } = require('../tools/challenge-parser/parser');
const { createPoly } = require('../shared/utils/polyvinyl');
const { isAuditedSuperBlock } = require('../shared/utils/is-audited');
const {
translateCommentsInChallenge
} = require('../tools/challenge-parser/translation-parser');
const { getSuperOrder } = require('./utils');
const duplicates = xs => xs.filter((x, i) => xs.indexOf(x) !== i);
const createValidator = throwOnError => fn => {
try {
fn();
} catch (error) {
if (throwOnError) {
throw error;
} else {
console.error(error.message);
}
}
};
/**
* Validates challenges against meta.json challengeOrder
* @param {Array<object>} foundChallenges - Array of challenge objects
* @param {object} meta - Meta object with challengeOrder array
* @throws {Error} If validation fails (missing challenges, duplicates, etc.)
*/
function validateChallenges(foundChallenges, meta, throwOnError) {
const metaChallengeIds = new Set(meta.challengeOrder.map(c => c.id));
const foundChallengeIds = new Set(foundChallenges.map(c => c.id));
const throwOrLog = createValidator(throwOnError);
throwOrLog(() => {
const missingFromMeta = Array.from(foundChallengeIds).filter(
id => !metaChallengeIds.has(id)
);
if (missingFromMeta.length > 0)
throw Error(
`Challenges found in directory but missing from meta: ${missingFromMeta.join(', ')}`
);
});
throwOrLog(() => {
const missingFromFiles = Array.from(metaChallengeIds).filter(
id => !foundChallengeIds.has(id)
);
if (missingFromFiles.length > 0)
throw Error(
`Challenges in meta but missing files with id(s): ${missingFromFiles.join(', ')}`
);
});
throwOrLog(() => {
const duplicateIds = duplicates(foundChallenges.map(c => c.id));
if (duplicateIds.length > 0)
throw Error(
`Duplicate challenges found in found challenges with id(s): ${duplicateIds.join(', ')}`
);
});
throwOrLog(() => {
const duplicateMetaIds = duplicates(meta.challengeOrder.map(c => c.id));
if (duplicateMetaIds.length > 0)
throw Error(
`Duplicate challenges found in meta with id(s): ${duplicateMetaIds.join(', ')}`
);
});
throwOrLog(() => {
const duplicateTitles = duplicates(foundChallenges.map(c => c.title));
if (duplicateTitles.length > 0)
throw Error(
`Duplicate titles found in found challenges with title(s): ${duplicateTitles.join(', ')} in block ${meta.dashedName}`
);
});
throwOrLog(() => {
const duplicateMetaTitles = duplicates(
meta.challengeOrder.map(c => c.title)
);
if (duplicateMetaTitles.length > 0)
throw Error(
`Duplicate titles found in meta with title(s): ${duplicateMetaTitles.join(', ')}`
);
});
}
/**
* Builds a block object from challenges and meta data
* @param {Array<object>} foundChallenges - Array of challenge objects
* @param {object} meta - Meta object with name, dashedName, and challengeOrder
* @returns {object} Block object with ordered challenges
*/
function buildBlock(foundChallenges, meta) {
const challenges = meta.challengeOrder.map(challengeInfo => {
const challenge = foundChallenges.find(c => c.id === challengeInfo.id);
if (!challenge) {
throw Error(
`Challenge ${challengeInfo.id} (${challengeInfo.title}) not found in block`
);
}
return challenge;
});
return {
challenges,
meta
};
}
/**
* Adds the meta information to a challenge
* @param {object} challenge - The challenge object
* @param {object} meta - The meta information object
* @returns {object} The challenge object with added meta information
*/
function addMetaToChallenge(challenge, meta) {
const challengeOrderIndex = meta.challengeOrder.findIndex(
({ id }) => id === challenge.id
);
const isLastChallengeInBlock =
meta.challengeOrder.length - 1 === challengeOrderIndex;
// Add basic meta properties
challenge.block = meta.dashedName;
challenge.blockType = meta.blockType;
challenge.blockLayout = meta.blockLayout;
challenge.hasEditableBoundaries = !!meta.hasEditableBoundaries;
challenge.order = meta.order;
// Ensure required properties exist
if (!challenge.description) challenge.description = '';
if (!challenge.instructions) challenge.instructions = '';
if (!challenge.questions) challenge.questions = [];
// Set superblock-related properties
challenge.superBlock = meta.superBlock;
challenge.superOrder = meta.superOrder;
challenge.challengeOrder = challengeOrderIndex;
challenge.isLastChallengeInBlock = isLastChallengeInBlock;
challenge.isPrivate = challenge.isPrivate || meta.isPrivate;
challenge.required = (meta.required || []).concat(challenge.required || []);
challenge.template = meta.template;
challenge.helpCategory = challenge.helpCategory || meta.helpCategory;
challenge.usesMultifileEditor = !!meta.usesMultifileEditor;
challenge.disableLoopProtectTests = !!meta.disableLoopProtectTests;
challenge.disableLoopProtectPreview = !!meta.disableLoopProtectPreview;
// Add chapter and module if present in meta
if (meta.chapter) challenge.chapter = meta.chapter;
if (meta.module) challenge.module = meta.module;
// Handle certification field for legacy support
const dupeCertifications = [
{
certification: 'responsive-web-design',
dupe: '2022/responsive-web-design'
}
];
const hasDupe = dupeCertifications.find(
cert => cert.dupe === meta.superBlock
);
challenge.certification = hasDupe ? hasDupe.certification : meta.superBlock;
return challenge;
}
/**
* Converts challenge files to polyvinyl objects with seed property
* @param {Array<object>} files - Array of challenge file objects
* @returns {Array<object>} Array of polyvinyl objects with seed property
*/
function challengeFilesToPolys(files) {
return files.reduce((challengeFiles, challengeFile) => {
return [
...challengeFiles,
{
...createPoly(challengeFile),
seed: challengeFile.contents.slice(0)
}
];
}, []);
}
/**
* Fixes challenge properties by converting files to polyvinyl objects
* @param {object} challenge - The challenge object to fix
* @returns {object} The challenge object with fixed properties
*/
function fixChallengeProperties(challenge) {
const fixedChallenge = {
...challenge
};
if (challenge.challengeFiles) {
fixedChallenge.challengeFiles = challengeFilesToPolys(
challenge.challengeFiles
);
}
if (challenge.solutions?.length) {
// The test runner needs the solutions to be arrays of polyvinyls so it
// can sort them correctly.
fixedChallenge.solutions = challenge.solutions.map(challengeFilesToPolys);
}
return fixedChallenge;
}
/**
* Finalizes a challenge by fixing properties and adding meta information
* @param {object} challenge - The challenge object to finalize
* @param {object} meta - The meta information object
* @returns {object} The finalized challenge object
*/
function finalizeChallenge(challenge, meta) {
return addMetaToChallenge(fixChallengeProperties(challenge), meta);
}
class BlockCreator {
/**
* @param {object} options - Options object
* @param {string} options.blockContentDir - Directory containing block content files
* @param {string} options.blockStructureDir - Directory containing block structure files (meta
* .json)
* @param {string} options.i18nBlockContentDir - Directory containing i18n block content files
* @param {string} options.lang - Language code for the block content
* @param {object} options.commentTranslations - Translations for comments in challenges
* @constructor
* @description Initializes the BlockCreator with directories for block content and structure.
* This class is responsible for reading block directories, parsing challenges, and validating them
* against the meta information.
*/
constructor({
blockContentDir,
blockStructureDir,
i18nBlockContentDir,
lang,
commentTranslations,
skipValidation
}) {
this.blockContentDir = blockContentDir;
this.blockStructureDir = blockStructureDir;
this.i18nBlockContentDir = i18nBlockContentDir;
this.lang = lang;
this.commentTranslations = commentTranslations;
this.skipValidation = skipValidation;
}
/**
* Creates a challenge from a markdown file
* @param {object} options - Options object
* @param {string} options.filename - Name of the challenge file
* @param {string} options.block - Name of the block
* @param {object} options.meta - Meta information for the block
* @param {boolean} options.isAudited - Whether the block is audited for i18n
* @param {Function} parser - Parser function to use (defaults to parseMD)
* @returns {Promise<object>} The finalized challenge object
*/
async createChallenge(
{ filename, block, meta, isAudited },
parser = parseMD
) {
debug(
`Creating challenge from file: ${filename} in block: ${block}, using lang: ${this.lang}`
);
const englishPath = path.resolve(this.blockContentDir, block, filename);
const i18nPath = path.resolve(this.i18nBlockContentDir, block, filename);
const langUsed =
isAudited && fs.existsSync(i18nPath) ? this.lang : 'english';
const challengePath = langUsed === 'english' ? englishPath : i18nPath;
const challenge = translateCommentsInChallenge(
await parser(challengePath),
langUsed,
this.commentTranslations
);
challenge.translationPending = this.lang !== 'english' && !isAudited;
return finalizeChallenge(challenge, meta);
}
/**
* Reads and builds all challenges from a block directory
* @param {string} block - Name of the block
* @param {object} meta - Meta object for the block
* @param {boolean} isAudited - Whether the block is audited for i18n
* @returns {Promise<Array<object>>} Array of challenge objects
*/
async readBlockChallenges(block, meta, isAudited) {
const blockDir = path.resolve(this.blockContentDir, block);
const challengeFiles = fs
.readdirSync(blockDir)
.filter(file => file.endsWith('.md'));
return await Promise.all(
challengeFiles.map(filename =>
this.createChallenge({ filename, block, meta, isAudited })
)
);
}
/**
* Gets meta information for a block from its JSON file
* @param {string} blockName - Name of the block
* @returns {object} The meta information object for the block
* @throws {Error} If meta file is not found
*/
getMetaForBlock(blockName) {
// Read meta.json for this block
const metaPath = path.resolve(this.blockStructureDir, `${blockName}.json`);
if (!fs.existsSync(metaPath)) {
throw new Error(
`Meta file not found for block ${blockName}: ${metaPath}`
);
}
// Not all "meta information" can be found in the meta.json.
const rawMeta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
debug(
`Meta file indicates ${rawMeta.challengeOrder.length} challenges should exist`
);
return rawMeta;
}
async processBlock(block, { superBlock, order }) {
const blockName = block.dashedName;
debug(`Processing block ${blockName} in superblock ${superBlock}`);
// Check if block directory exists
const blockContentDir = path.resolve(this.blockContentDir, blockName);
if (!fs.existsSync(blockContentDir)) {
throw Error(`Block directory not found: ${blockContentDir}`);
}
if (
block.isUpcomingChange &&
process.env.SHOW_UPCOMING_CHANGES !== 'true'
) {
debug(`Ignoring upcoming block ${blockName}`);
return null;
}
const superOrder = getSuperOrder(superBlock);
const meta = {
...block,
superOrder,
superBlock,
order,
...(block.chapter && { chapter: block.chapter }),
...(block.module && { module: block.module })
};
const isAudited = isAuditedSuperBlock(this.lang, superBlock);
// Read challenges from directory
const foundChallenges = await this.readBlockChallenges(
blockName,
meta,
isAudited
);
debug(`Found ${foundChallenges.length} challenge files in directory`);
// Log found challenges
foundChallenges.forEach(challenge => {
debug(`Found challenge: ${challenge.title} (${challenge.id})`);
});
const throwOnError = this.lang === 'english';
// Validate challenges against meta
if (!this.skipValidation)
validateChallenges(foundChallenges, meta, throwOnError);
// Build the block object
const blockResult = buildBlock(foundChallenges, meta);
debug(
`Completed block "${meta.name}" with ${blockResult.challenges.length} challenges (${blockResult.challenges.filter(c => !c.missing).length} built successfully)`
);
return blockResult;
}
}
class SuperblockCreator {
/**
* @param {object} options - Options object
* @param {BlockCreator} options.blockCreator - Instance of BlockCreator
*/
constructor({ blockCreator }) {
this.blockCreator = blockCreator;
}
async processSuperblock({ blocks, name }) {
const superBlock = { blocks: {} };
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const blockResult = await this.blockCreator.processBlock(block, {
superBlock: name,
order: i
});
if (blockResult) {
superBlock.blocks[block.dashedName] = blockResult;
}
}
debug(
`Completed parsing superblock. Total blocks: ${Object.keys(superBlock.blocks).length}`
);
return superBlock;
}
}
/**
* Transforms superblock data to extract blocks array
* @param {object} superblockData - The superblock data object
* @returns {object[]} Array of block objects with dashedName, chapter, and module properties
*/
function transformSuperBlock(
superblockData,
{ showComingSoon } = { showComingSoon: false }
) {
let blocks = [];
// Handle simple blocks array format
if (superblockData.blocks) {
blocks = superblockData.blocks.map(dashedName => ({
dashedName
}));
}
// Handle nested chapters/modules/blocks format
else if (superblockData.chapters) {
for (const chapter of superblockData.chapters) {
if (chapter.comingSoon && !showComingSoon) {
continue;
}
if (chapter.modules) {
for (const module of chapter.modules) {
if (module.comingSoon && !showComingSoon) {
continue;
}
if (module.blocks) {
for (const block of module.blocks) {
blocks.push({
dashedName: block,
chapter: chapter.dashedName,
module: module.dashedName
});
}
}
}
}
}
}
if (isEmpty(blocks)) {
throw Error(`No blocks found in superblock data`);
}
const blockNames = blocks.map(block => block.dashedName);
debug(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
return blocks;
}
module.exports = {
SuperblockCreator,
BlockCreator,
addMetaToChallenge,
validateChallenges,
buildBlock,
finalizeChallenge,
transformSuperBlock,
fixChallengeProperties
};

View File

@@ -0,0 +1,586 @@
const { isPoly } = require('../shared/utils/polyvinyl');
const {
validateChallenges,
buildBlock,
transformSuperBlock,
addMetaToChallenge,
fixChallengeProperties,
SuperblockCreator,
finalizeChallenge
} = require('./build-superblock');
const dummyFullStackSuperBlock = {
chapters: [
{
dashedName: 'chapter-1',
modules: [
{
dashedName: 'module-1',
blocks: ['block-1', 'block-2']
},
{
dashedName: 'module-2',
blocks: ['block-3', 'block-4']
}
]
},
{
dashedName: 'chapter-2',
modules: [
{
dashedName: 'module-3',
blocks: ['block-5', 'block-6']
}
]
}
]
};
// Includes "comingSoon" modules and chapters
const dummyUnfinishedSuperBlock = {
chapters: [
{
dashedName: 'chapter-1',
modules: [
{
comingSoon: true,
dashedName: 'module-1',
blocks: ['block-1', 'block-2']
},
{
dashedName: 'module-2',
blocks: ['block-3', 'block-4']
}
]
},
{
dashedName: 'chapter-2',
comingSoon: true,
modules: [
{
dashedName: 'module-3',
blocks: ['block-5', 'block-6']
}
]
}
]
};
const dummyBlockMeta = {
name: 'Test Block',
blockLayout: 'challenge-list',
blockType: 'workshop',
isUpcomingChange: false,
dashedName: 'test-block',
superBlock: 'responsive-web-design',
order: 1,
superOrder: 2,
usesMultifileEditor: true,
hasEditableBoundaries: false,
disableLoopProtectTests: true,
template: 'html/css',
required: [
{
link: 'https://example.com/style.css',
raw: false,
src: 'style.css'
}
],
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
],
helpCategory: 'HTML-CSS'
};
const dummyChallenge = {
challengeFiles: [
{
spuriousProp: '1',
name: 'file1',
ext: 'js',
history: [],
contents: 'console.log("Hello")',
// head and tail should not be required, but they currently are
head: '',
tail: ''
},
{
spuriousProp: '2',
name: 'file2',
ext: 'css',
history: [],
contents: 'body { background: red; }',
head: '',
tail: ''
}
]
};
const expectedChallengeProperties = {
id: expect.any(String),
challengeOrder: expect.any(Number),
isLastChallengeInBlock: expect.any(Boolean),
block: dummyBlockMeta.dashedName,
blockLayout: dummyBlockMeta.blockLayout,
blockType: dummyBlockMeta.blockType,
hasEditableBoundaries: dummyBlockMeta.hasEditableBoundaries,
order: dummyBlockMeta.order,
description: '',
instructions: '',
questions: [],
superOrder: dummyBlockMeta.superOrder,
certification: dummyBlockMeta.superBlock,
superBlock: dummyBlockMeta.superBlock,
required: dummyBlockMeta.required,
template: dummyBlockMeta.template,
helpCategory: dummyBlockMeta.helpCategory,
usesMultifileEditor: dummyBlockMeta.usesMultifileEditor,
disableLoopProtectTests: dummyBlockMeta.disableLoopProtectTests,
disableLoopProtectPreview: false
};
describe('buildSuperblock pure functions', () => {
describe('validateChallenges', () => {
test('should not throw when all challenges match meta', () => {
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
]
};
expect(() =>
validateChallenges(foundChallenges, meta, true)
).not.toThrow();
});
test('should throw when challenges are missing from meta', () => {
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' },
{ id: '3', title: 'Challenge 3' } // Extra challenge
];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Challenges found in directory but missing from meta: 3'
);
});
test('should throw when challenge files are missing', () => {
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' } // Missing file
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Challenges in meta but missing files with id(s): 2'
);
});
test('should throw when multiple challenges are missing from meta', () => {
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' },
{ id: '3', title: 'Challenge 3' },
{ id: '4', title: 'Challenge 4' }
];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Challenges found in directory but missing from meta: 3, 4'
);
});
test('should throw when multiple challenge files are missing', () => {
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' },
{ id: '3', title: 'Challenge 3' }
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Challenges in meta but missing files with id(s): 2, 3'
);
});
test('should throw if there are duplicate challenges in the meta', () => {
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '1', title: 'Challenge 2' }
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Duplicate challenges found in meta with id(s): 1'
);
});
test('should throw if there are duplicate found challenges', () => {
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '1', title: 'Challenge 2' }
];
const meta = {
challengeOrder: [{ id: '1', title: 'Challenge 1' }]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Duplicate challenges found in found challenges with id(s): 1'
);
});
test('should throw if there are duplicate titles in the meta', () => {
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 1' } // Duplicate title
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Duplicate titles found in meta with title(s): Challenge 1'
);
});
test('should throw if there are duplicate titles in the found challenges', () => {
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 1' } // Duplicate title
];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
]
};
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
'Duplicate titles found in found challenges with title(s): Challenge 1'
);
});
test('should log errors for duplicate titles in meta if shouldThrow is false', () => {
jest.spyOn(console, 'error');
const foundChallenges = [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
];
const meta = {
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 1' } // Duplicate title
]
};
validateChallenges(foundChallenges, meta, false);
expect(console.error).toHaveBeenCalledWith(
'Duplicate titles found in meta with title(s): Challenge 1'
);
});
});
describe('buildBlock', () => {
test('should build block with ordered challenges', () => {
const foundChallenges = [
{ id: '2', title: 'Challenge 2' },
{ id: '1', title: 'Challenge 1' }
];
const meta = {
name: 'Test Block',
dashedName: 'test-block',
challengeOrder: [
{ id: '1', title: 'Challenge 1' },
{ id: '2', title: 'Challenge 2' }
]
};
const result = buildBlock(foundChallenges, meta);
expect(result.challenges).toHaveLength(2);
expect(result.challenges[0].id).toBe('1'); // First in order
expect(result.challenges[1].id).toBe('2'); // Second in order
});
test('should throw if challenges are missing', () => {
const foundChallenges = [{ id: '2', title: 'Challenge 2' }];
const meta = {
name: 'Test Block',
dashedName: 'test-block',
challengeOrder: [
{ id: '1', title: 'Challenge 1' }, // Missing
{ id: '2', title: 'Challenge 2' }
]
};
expect(() => buildBlock(foundChallenges, meta)).toThrow(
'Challenge 1 (Challenge 1) not found in block'
);
});
test('should return the passed in meta', () => {
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
const meta = {
name: 'Test Block',
dashedName: 'test-block',
challengeOrder: [{ id: '1', title: 'Challenge 1' }]
};
const result = buildBlock(foundChallenges, meta);
expect(result.meta).toEqual(meta);
});
});
describe('addMetaToChallenge', () => {
test('should add meta properties to challenge', () => {
const challenge = { id: '1' };
addMetaToChallenge(challenge, dummyBlockMeta);
expect(challenge).toEqual(expectedChallengeProperties);
});
test('should add chapter and module properties when present in meta', () => {
const challenge = { id: '1' };
const metaWithChapterAndModule = {
...dummyBlockMeta,
chapter: 'chapter-1',
module: 'module-1'
};
addMetaToChallenge(challenge, metaWithChapterAndModule);
expect(challenge).toMatchObject({
...expectedChallengeProperties,
chapter: 'chapter-1',
module: 'module-1'
});
});
});
describe('finalizeChallenge', () => {
it('should add meta properties', async () => {
const challenge = finalizeChallenge(
{
id: '1'
},
{ challengeOrder: [{ id: '1' }] }
);
expect(challenge).toMatchObject({
id: '1',
isLastChallengeInBlock: true
});
});
});
describe('fixChallengeProperties', () => {
test("should ensure all challengeFiles are 'polyvinyls'", () => {
dummyChallenge.challengeFiles.forEach(file => {
expect(isPoly(file)).toBe(false);
});
const fixedChallenge = fixChallengeProperties(dummyChallenge);
expect(fixedChallenge.challengeFiles).toHaveLength(2);
fixedChallenge.challengeFiles.forEach(file =>
expect(isPoly(file)).toBe(true)
);
const seeds = fixedChallenge.challengeFiles.map(file => file.seed);
expect(seeds[0]).toBe(dummyChallenge.challengeFiles[0].contents);
expect(seeds[1]).toBe(dummyChallenge.challengeFiles[1].contents);
});
test("should ensure all the solutions are arrays of 'polyvinyls'", () => {
const challengeWithSolutions = {
...dummyChallenge,
solutions: [dummyChallenge.challengeFiles]
};
const fixedChallenge = fixChallengeProperties(challengeWithSolutions);
expect(fixedChallenge.solutions).toHaveLength(1);
fixedChallenge.solutions.forEach(solution => {
expect(Array.isArray(solution)).toBe(true);
solution.forEach(file => expect(isPoly(file)).toBe(true));
});
});
});
describe('transformSuperBlock', () => {
test('should return the blocks array when valid superblock data is provided', () => {
const superblockData = {
blocks: ['basic-html-and-html5', 'basic-css', 'applied-visual-design']
};
const result = transformSuperBlock(superblockData);
expect(result).toEqual([
{ dashedName: 'basic-html-and-html5' },
{ dashedName: 'basic-css' },
{ dashedName: 'applied-visual-design' }
]);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
});
test('should return single block when superblock has one block', () => {
const superblockData = {
blocks: ['basic-html-and-html5']
};
const result = transformSuperBlock(superblockData);
expect(result).toEqual([{ dashedName: 'basic-html-and-html5' }]);
expect(result).toHaveLength(1);
});
test('should throw an error when the blocks array is empty', () => {
const superblockData = {
blocks: []
};
expect(() => transformSuperBlock(superblockData)).toThrow(
'No blocks found in superblock data'
);
});
test('should handle superblock data with other properties', () => {
const superblockData = {
name: 'Responsive Web Design',
dashedName: 'responsive-web-design',
blocks: ['basic-html-and-html5', 'basic-css'],
otherProperty: 'should be ignored'
};
const result = transformSuperBlock(superblockData);
expect(result).toEqual([
{ dashedName: 'basic-html-and-html5' },
{ dashedName: 'basic-css' }
]);
expect(result).toHaveLength(2);
});
test('should transform superblocks with nested chapters and modules', () => {
const result = transformSuperBlock(dummyFullStackSuperBlock);
expect(result).toEqual([
{ dashedName: 'block-1', chapter: 'chapter-1', module: 'module-1' },
{ dashedName: 'block-2', chapter: 'chapter-1', module: 'module-1' },
{ dashedName: 'block-3', chapter: 'chapter-1', module: 'module-2' },
{ dashedName: 'block-4', chapter: 'chapter-1', module: 'module-2' },
{ dashedName: 'block-5', chapter: 'chapter-2', module: 'module-3' },
{ dashedName: 'block-6', chapter: 'chapter-2', module: 'module-3' }
]);
expect(result).toHaveLength(6);
});
test("should omit 'comingSoon' modules and chapters", () => {
const result = transformSuperBlock(dummyUnfinishedSuperBlock);
expect(result).toEqual([
{ dashedName: 'block-3', chapter: 'chapter-1', module: 'module-2' },
{ dashedName: 'block-4', chapter: 'chapter-1', module: 'module-2' }
]);
expect(result).toHaveLength(2);
});
test("should NOT omit 'comingSoon' modules and chapters if showComingSoon is true", () => {
const result = transformSuperBlock(dummyUnfinishedSuperBlock, {
showComingSoon: true
});
expect(result).toEqual([
{ dashedName: 'block-1', chapter: 'chapter-1', module: 'module-1' },
{ dashedName: 'block-2', chapter: 'chapter-1', module: 'module-1' },
{ dashedName: 'block-3', chapter: 'chapter-1', module: 'module-2' },
{ dashedName: 'block-4', chapter: 'chapter-1', module: 'module-2' },
{ dashedName: 'block-5', chapter: 'chapter-2', module: 'module-3' },
{ dashedName: 'block-6', chapter: 'chapter-2', module: 'module-3' }
]);
expect(result).toHaveLength(6);
});
});
});
describe('SuperblockCreator class', () => {
describe('processSuperblock', () => {
test('should process blocks using the provided processing function', async () => {
const mockProcessBlockFn = jest
.fn()
.mockResolvedValueOnce('Block 1')
.mockResolvedValueOnce('Block 2')
.mockResolvedValueOnce(null);
const mockBlockCreator = {
processBlock: mockProcessBlockFn
};
const blocks = [
{ dashedName: 'block-1' },
{ dashedName: 'block-2' },
{ dashedName: 'block-3' }
];
const parser = new SuperblockCreator({
blockCreator: mockBlockCreator
});
const result = await parser.processSuperblock({
blocks,
name: 'test-superblock'
});
expect(mockProcessBlockFn).toHaveBeenCalledTimes(3);
expect(result).toEqual({
blocks: {
'block-1': 'Block 1',
'block-2': 'Block 2'
}
});
});
});
});

View File

@@ -1,12 +0,0 @@
{
"name": "Build a Color Picker App",
"isUpcomingChange": false,
"usesMultifileEditor": true,
"dashedName": "lab-color-picker",
"superBlock": "full-stack-developer",
"challengeOrder": [{ "id": "67bf4350777ac6ffdc027745", "title": "Build a Color Picker App" }],
"helpCategory": "JavaScript",
"blockType": "lab",
"blockLayout": "link",
"disableLoopProtectPreview": true
}

Some files were not shown because too many files have changed in this diff Show More