chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
import Joi from 'joi';
|
||||
import joiObjectId from 'joi-objectid';
|
||||
|
||||
Joi.objectId = joiObjectId(Joi);
|
||||
|
||||
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
|
||||
import {
|
||||
chapterBasedSuperBlocks,
|
||||
languageSuperBlocks,
|
||||
SuperBlocks
|
||||
} from '@freecodecamp/shared/config/curriculum';
|
||||
import {
|
||||
availableCharacters,
|
||||
availableBackgrounds,
|
||||
availableAudios,
|
||||
availableAlignments
|
||||
} from './scene-assets.js';
|
||||
|
||||
const slugRE = new RegExp('^[a-z0-9-]+$');
|
||||
const slugWithSlashRE = new RegExp('^[a-z0-9-/]+$');
|
||||
|
||||
const fileJoi = Joi.object().keys({
|
||||
fileKey: Joi.string(),
|
||||
ext: Joi.string(),
|
||||
name: Joi.string(),
|
||||
editableRegionBoundaries: [Joi.array().items(Joi.number())],
|
||||
path: Joi.string(),
|
||||
error: Joi.valid(null),
|
||||
seed: Joi.string().allow(''),
|
||||
contents: Joi.string().allow(''),
|
||||
id: Joi.string().allow(''),
|
||||
history: Joi.array().items(Joi.string().allow(''))
|
||||
});
|
||||
|
||||
const prerequisitesJoi = Joi.object().keys({
|
||||
id: Joi.objectId().required(),
|
||||
title: Joi.string().required()
|
||||
});
|
||||
|
||||
const positionJoi = Joi.object().keys({
|
||||
x: Joi.number().required().strict(),
|
||||
y: Joi.number().required().strict(),
|
||||
z: Joi.number().required().strict()
|
||||
});
|
||||
|
||||
const setupCharacterJoi = Joi.object().keys({
|
||||
character: Joi.string()
|
||||
.valid(...availableCharacters)
|
||||
.required(),
|
||||
position: positionJoi.required(),
|
||||
opacity: Joi.number().strict()
|
||||
});
|
||||
|
||||
const setupAudioJoi = Joi.object().keys({
|
||||
filename: Joi.string()
|
||||
.valid(...availableAudios)
|
||||
.required(),
|
||||
startTime: Joi.number().required().strict(),
|
||||
startTimestamp: Joi.number().strict(),
|
||||
finishTimestamp: Joi.number().strict()
|
||||
});
|
||||
|
||||
const setupJoi = Joi.object().keys({
|
||||
background: Joi.string()
|
||||
.valid(...availableBackgrounds)
|
||||
.required(),
|
||||
characters: Joi.array().items(setupCharacterJoi).min(1).required(),
|
||||
audio: setupAudioJoi.required(),
|
||||
alwaysShowDialogue: Joi.boolean()
|
||||
});
|
||||
|
||||
const DialogueJoi = Joi.object().keys({
|
||||
text: Joi.string().required(),
|
||||
align: Joi.string().valid(...availableAlignments)
|
||||
});
|
||||
|
||||
const commandJoi = Joi.object().keys({
|
||||
background: Joi.string().valid(...availableBackgrounds),
|
||||
character: Joi.string()
|
||||
.valid(...availableCharacters)
|
||||
.required(),
|
||||
position: positionJoi,
|
||||
opacity: Joi.number().strict(),
|
||||
startTime: Joi.number().required().strict(),
|
||||
finishTime: Joi.number().strict(),
|
||||
dialogue: DialogueJoi
|
||||
});
|
||||
|
||||
const questionJoi = Joi.object().keys({
|
||||
text: Joi.string().required(),
|
||||
answers: Joi.array()
|
||||
.items(
|
||||
Joi.object().keys({
|
||||
answer: Joi.string().required(),
|
||||
feedback: Joi.string().allow(null),
|
||||
audioId: Joi.string().allow(null)
|
||||
})
|
||||
)
|
||||
.required()
|
||||
.unique('answer'),
|
||||
solution: Joi.number().min(1).max(Joi.ref('..answers.length')).required()
|
||||
});
|
||||
|
||||
const quizJoi = Joi.object().keys({
|
||||
questions: Joi.array()
|
||||
.items(
|
||||
Joi.object().keys({
|
||||
text: Joi.string().required(),
|
||||
distractors: Joi.array()
|
||||
.items(
|
||||
Joi.valid(Joi.ref('...answer')).forbidden(),
|
||||
Joi.string().required()
|
||||
)
|
||||
.length(3)
|
||||
.required()
|
||||
.unique(),
|
||||
answer: Joi.string().required(),
|
||||
audioData: Joi.object().keys({
|
||||
audio: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
startTimestamp: Joi.number(),
|
||||
finishTimestamp: Joi.number()
|
||||
}),
|
||||
transcript: Joi.array().items(
|
||||
Joi.object({
|
||||
character: Joi.string().required(),
|
||||
text: Joi.string().required()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
.custom((value, helpers) => {
|
||||
return value.length === 10 || value.length === 20
|
||||
? value
|
||||
: helpers.error('array.invalidLength');
|
||||
})
|
||||
.messages({
|
||||
'array.invalidLength': 'Quiz must have exactly 10 or 20 questions.'
|
||||
})
|
||||
.required()
|
||||
});
|
||||
|
||||
export const schema = Joi.object().keys({
|
||||
block: Joi.string().regex(slugRE).required(),
|
||||
blockId: Joi.objectId(),
|
||||
blockLabel: Joi.when('superBlock', {
|
||||
is: [...chapterBasedSuperBlocks],
|
||||
then: Joi.valid(
|
||||
'workshop',
|
||||
'lab',
|
||||
'lecture',
|
||||
'review',
|
||||
'quiz',
|
||||
'exam',
|
||||
'warm-up',
|
||||
'learn',
|
||||
'practice'
|
||||
).required(),
|
||||
otherwise: Joi.optional()
|
||||
}),
|
||||
blockLayout: Joi.valid(
|
||||
'challenge-list',
|
||||
'challenge-grid',
|
||||
'dialogue-grid',
|
||||
'link',
|
||||
'project-list',
|
||||
'legacy-challenge-list',
|
||||
'legacy-link',
|
||||
'legacy-challenge-grid'
|
||||
).required(),
|
||||
challengeOrder: Joi.number(),
|
||||
chapter: Joi.string().when('superBlock', {
|
||||
is: chapterBasedSuperBlocks,
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional()
|
||||
}),
|
||||
certification: Joi.string().regex(slugWithSlashRE),
|
||||
isExam: Joi.boolean(),
|
||||
challengeType: Joi.number().min(0).max(31).required(),
|
||||
// TODO: require this only for normal challenges, not certs
|
||||
dashedName: Joi.string().regex(slugRE),
|
||||
demoType: Joi.string().valid('onClick', 'onLoad'),
|
||||
description: Joi.when('challengeType', {
|
||||
is: [
|
||||
challengeTypes.step,
|
||||
challengeTypes.video,
|
||||
challengeTypes.multipleChoice,
|
||||
challengeTypes.fillInTheBlank,
|
||||
challengeTypes.review
|
||||
],
|
||||
then: Joi.string().allow(''),
|
||||
otherwise: Joi.string().required()
|
||||
}),
|
||||
disableLoopProtectTests: Joi.boolean().required(),
|
||||
disableLoopProtectPreview: Joi.boolean().required(),
|
||||
explanation: Joi.when('challengeType', {
|
||||
is: [challengeTypes.multipleChoice, challengeTypes.fillInTheBlank],
|
||||
then: Joi.string()
|
||||
}),
|
||||
challengeFiles: Joi.array().items(fileJoi),
|
||||
// TODO: Consider renaming to something else. Stuff show.tsx knows how to render in order
|
||||
nodules: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
type: Joi.valid('paragraph', 'interactiveEditor').required(),
|
||||
files: Joi.when('type', {
|
||||
is: ['interactiveEditor'],
|
||||
then: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
ext: Joi.string().required(),
|
||||
name: Joi.string().required(),
|
||||
contents: Joi.string().required(),
|
||||
contentsHtml: Joi.string().required()
|
||||
})
|
||||
),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
contents: Joi.when('type', {
|
||||
is: ['paragraph'],
|
||||
then: Joi.string().required(),
|
||||
otherwise: Joi.forbidden()
|
||||
})
|
||||
})
|
||||
),
|
||||
hasEditableBoundaries: Joi.boolean(),
|
||||
helpCategory: Joi.valid(
|
||||
'JavaScript',
|
||||
'HTML-CSS',
|
||||
'Python',
|
||||
'Backend Development',
|
||||
'C-Sharp',
|
||||
'English',
|
||||
'Odin',
|
||||
'Euler',
|
||||
'Rosetta',
|
||||
'Chinese Curriculum',
|
||||
'Spanish Curriculum',
|
||||
'General'
|
||||
).required(),
|
||||
isLastChallengeInBlock: Joi.boolean().required(),
|
||||
videoUrl: Joi.string().allow(''),
|
||||
fillInTheBlank: Joi.object().keys({
|
||||
sentence: Joi.string().required(),
|
||||
blanks: Joi.array()
|
||||
.items(
|
||||
Joi.object().keys({
|
||||
answer: Joi.string().required(),
|
||||
feedback: Joi.string().allow(null)
|
||||
})
|
||||
)
|
||||
.required()
|
||||
}),
|
||||
forumTopicId: Joi.number(),
|
||||
id: Joi.objectId().required(),
|
||||
lang: Joi.string().when('superBlock', {
|
||||
is: languageSuperBlocks,
|
||||
then: Joi.valid('en-US', 'es', 'zh-CN').required(),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
inputType: Joi.when('challengeType', {
|
||||
is: challengeTypes.fillInTheBlank,
|
||||
then: Joi.when('superBlock', {
|
||||
is: Joi.valid(SuperBlocks.A1Chinese, SuperBlocks.A2Chinese),
|
||||
then: Joi.string().valid('pinyin-tone', 'pinyin-to-hanzi').required(),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
instructions: Joi.string().when('challengeType', {
|
||||
is: [challengeTypes.pythonProject, challengeTypes.codeAllyCert],
|
||||
then: Joi.string().min(1).required(),
|
||||
otherwise: Joi.string().allow('')
|
||||
}),
|
||||
module: Joi.string().when('superBlock', {
|
||||
is: chapterBasedSuperBlocks,
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional()
|
||||
}),
|
||||
msTrophyId: Joi.when('challengeType', {
|
||||
is: [challengeTypes.msTrophy],
|
||||
then: Joi.string().required()
|
||||
}),
|
||||
notes: Joi.string().allow(''),
|
||||
order: Joi.number(),
|
||||
prerequisites: Joi.when('challengeType', {
|
||||
is: [challengeTypes.exam],
|
||||
then: Joi.array().items(prerequisitesJoi)
|
||||
}),
|
||||
// video challenges only:
|
||||
videoId: Joi.when('challengeType', {
|
||||
is: [challengeTypes.video],
|
||||
then: Joi.string().required()
|
||||
}),
|
||||
videoLocaleIds: Joi.when('challengeType', {
|
||||
is: challengeTypes.video,
|
||||
then: Joi.object().keys({
|
||||
espanol: Joi.string(),
|
||||
italian: Joi.string(),
|
||||
portuguese: Joi.string()
|
||||
})
|
||||
}),
|
||||
bilibiliIds: Joi.when('challengeType', {
|
||||
is: challengeTypes.video,
|
||||
then: Joi.object().keys({
|
||||
aid: Joi.number().required(),
|
||||
bvid: Joi.string().required(),
|
||||
cid: Joi.number().required()
|
||||
})
|
||||
}),
|
||||
questions: Joi.when('challengeType', {
|
||||
is: [
|
||||
challengeTypes.video,
|
||||
challengeTypes.multipleChoice,
|
||||
challengeTypes.theOdinProject
|
||||
],
|
||||
then: Joi.array().items(questionJoi).min(1).required(),
|
||||
otherwise: Joi.array().length(0)
|
||||
}),
|
||||
quizzes: Joi.when('challengeType', {
|
||||
is: challengeTypes.quiz,
|
||||
then: Joi.array().items(quizJoi).min(1).required(),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
required: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
link: Joi.string(),
|
||||
raw: Joi.bool(),
|
||||
src: Joi.string(),
|
||||
crossDomain: Joi.bool()
|
||||
})
|
||||
),
|
||||
assignments: Joi.when('challengeType', {
|
||||
is: challengeTypes.dialogue,
|
||||
then: Joi.array().items(Joi.string()).required(),
|
||||
otherwise: Joi.array().items(Joi.string())
|
||||
}),
|
||||
saveSubmissionToDB: Joi.bool(),
|
||||
scene: Joi.object().keys({
|
||||
setup: setupJoi.required(),
|
||||
commands: Joi.array()
|
||||
.items(commandJoi)
|
||||
.unique(
|
||||
(a, b) =>
|
||||
a.dialogue &&
|
||||
b.dialogue &&
|
||||
!(
|
||||
(a.startTime < b.startTime &&
|
||||
a.finishTime < b.finishTime &&
|
||||
a.finishTime <= b.startTime) ||
|
||||
(b.startTime < a.startTime &&
|
||||
b.finishTime < a.finishTime &&
|
||||
b.finishTime <= a.startTime)
|
||||
)
|
||||
)
|
||||
.messages({
|
||||
'array.unique': 'Dialogues must not have overlapping times.'
|
||||
})
|
||||
}),
|
||||
// This is only to be used for dynamic client updates.
|
||||
sourceLocation: Joi.string(),
|
||||
solutions: Joi.array().items(Joi.array().items(fileJoi).min(1)),
|
||||
superBlock: Joi.string().regex(slugWithSlashRE),
|
||||
superOrder: Joi.number(),
|
||||
suborder: Joi.number(),
|
||||
hooks: Joi.object().keys({
|
||||
beforeAll: Joi.string().allow(''),
|
||||
beforeEach: Joi.string().allow(''),
|
||||
afterEach: Joi.string().allow(''),
|
||||
afterAll: Joi.string().allow('')
|
||||
}),
|
||||
tests: Joi.array()
|
||||
.items(
|
||||
// public challenges
|
||||
Joi.object().keys({
|
||||
id: Joi.string().allow(''),
|
||||
text: Joi.string().required(),
|
||||
testString: Joi.string().allow('').required()
|
||||
}),
|
||||
// our tests used in certification verification
|
||||
Joi.object().keys({
|
||||
id: Joi.string().required(),
|
||||
title: Joi.string().required()
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
template: Joi.string().allow(''),
|
||||
title: Joi.string().required(),
|
||||
transcript: Joi.when('challengeType', {
|
||||
is: [challengeTypes.generic, challengeTypes.video],
|
||||
then: Joi.string()
|
||||
}),
|
||||
translationPending: Joi.bool().required(),
|
||||
url: Joi.when('challengeType', {
|
||||
is: [challengeTypes.codeAllyPractice, challengeTypes.codeAllyCert],
|
||||
then: Joi.string().required()
|
||||
}),
|
||||
usesMultifileEditor: Joi.boolean()
|
||||
});
|
||||
|
||||
export const challengeSchemaValidator = () => {
|
||||
return challenge => schema.validate(challenge);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { schema } from './challenge-schema.js';
|
||||
|
||||
describe('challenge schema', () => {
|
||||
it('should not be changed without informing the mobile team', () => {
|
||||
expect(schema.describe()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const blocksSchema = Joi.object().pattern(
|
||||
/./,
|
||||
Joi.object().keys({ challenges: Joi.array().required() })
|
||||
);
|
||||
|
||||
const superblocks = [
|
||||
'certifications',
|
||||
'responsive-web-design',
|
||||
'javascript-algorithms-and-data-structures',
|
||||
'front-end-development-libraries',
|
||||
'data-visualization',
|
||||
'back-end-development-and-apis',
|
||||
'quality-assurance',
|
||||
'scientific-computing-with-python',
|
||||
'data-analysis-with-python',
|
||||
'information-security',
|
||||
'machine-learning-with-python',
|
||||
'coding-interview-prep',
|
||||
'relational-database',
|
||||
'javascript-algorithms-and-data-structures-v8',
|
||||
'semantic-html',
|
||||
'a2-professional-chinese',
|
||||
'b1-english-for-developers',
|
||||
'dev-playground',
|
||||
'python-for-everybody',
|
||||
'a2-professional-spanish',
|
||||
'basic-html',
|
||||
'a2-english-for-developers',
|
||||
'rosetta-code',
|
||||
'foundational-c-sharp-with-microsoft',
|
||||
'college-algebra-with-python',
|
||||
'project-euler',
|
||||
'2022/responsive-web-design',
|
||||
'the-odin-project',
|
||||
'introduction-to-algorithms-and-data-structures',
|
||||
'introduction-to-precalculus',
|
||||
'html-and-accessibility',
|
||||
'computer-basics',
|
||||
'basic-css',
|
||||
'design-for-developers',
|
||||
'absolute-and-relative-units',
|
||||
'pseudo-classes-and-elements',
|
||||
'css-colors',
|
||||
'styling-forms',
|
||||
'css-box-model',
|
||||
'css-flexbox',
|
||||
'css-typography',
|
||||
'css-and-accessibility',
|
||||
'css-positioning',
|
||||
'attribute-selectors',
|
||||
'responsive-design',
|
||||
'css-variables',
|
||||
'css-grid',
|
||||
'css-animations',
|
||||
'learn-oop-with-python',
|
||||
'learn-rag-mcp-fundamentals',
|
||||
'introduction-to-bash',
|
||||
'introduction-to-sql-and-postgresql',
|
||||
'learn-bash-scripting',
|
||||
'learn-sql-and-bash',
|
||||
'introduction-to-nano',
|
||||
'introduction-to-git-and-github',
|
||||
'introduction-to-variables-and-strings-in-javascript',
|
||||
'introduction-to-booleans-and-numbers-in-javascript',
|
||||
'introduction-functions-in-javascript',
|
||||
'introduction-to-arrays-in-javascript',
|
||||
'introduction-to-objects-in-javascript',
|
||||
'introduction-to-loops-in-javascript',
|
||||
'javascript-fundamentals-review',
|
||||
'introduction-to-higher-order-functions-and-callbacks-in-javascript',
|
||||
'learn-dom-manipulation-and-events-with-javascript',
|
||||
'introduction-to-javascript-and-accessibility',
|
||||
'learn-javascript-debugging',
|
||||
'learn-basic-regex-with-javascript',
|
||||
'introduction-to-dates-in-javascript',
|
||||
'learn-audio-and-video-events-with-javascript',
|
||||
'introduction-to-maps-and-sets-in-javascript',
|
||||
'learn-localstorage-and-crud-operations-with-javascript',
|
||||
'introduction-to-javascript-classes',
|
||||
'learn-recursion-with-javascript',
|
||||
'introduction-to-functional-programming-with-javascript',
|
||||
'introduction-to-asynchronous-javascript',
|
||||
'learn-data-visualization-with-d3',
|
||||
'introduction-to-python-basics',
|
||||
'learn-python-loops-and-sequences',
|
||||
'learn-python-dictionaries-and-sets',
|
||||
'learn-error-handling-in-python',
|
||||
'learn-python-classes-and-objects',
|
||||
'introduction-to-oop-in-python',
|
||||
'introduction-to-linear-data-structures-in-python',
|
||||
'learn-algorithms-in-python',
|
||||
'learn-graphs-and-trees-in-python',
|
||||
'learn-dynamic-programming-in-python'
|
||||
];
|
||||
|
||||
const schema = Joi.object().keys(
|
||||
superblocks.reduce((acc, superblock) => {
|
||||
acc[superblock] = Joi.object()
|
||||
.keys({
|
||||
blocks: blocksSchema
|
||||
})
|
||||
.required();
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
exports.curriculumSchemaValidator = curriculum => {
|
||||
return schema.validate(curriculum, {
|
||||
abortEarly: false
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const blockIntroSchema = Joi.object()
|
||||
.keys({
|
||||
title: Joi.string().trim().min(1).required(),
|
||||
intro: Joi.array().items(Joi.string().allow('')).required()
|
||||
})
|
||||
.unknown(true);
|
||||
|
||||
const superBlockIntroSchema = Joi.object()
|
||||
.keys({
|
||||
title: Joi.string().trim().min(1).required(),
|
||||
intro: Joi.array().items(Joi.string().allow('')).required(),
|
||||
blocks: Joi.object().pattern(Joi.string(), blockIntroSchema).required()
|
||||
})
|
||||
.unknown(true);
|
||||
|
||||
function createIntroSchema(expectedBlocksBySuperblock) {
|
||||
return Joi.object()
|
||||
.unknown(true)
|
||||
.custom((intros, helpers) => {
|
||||
for (const [superblock, blocks] of Object.entries(
|
||||
expectedBlocksBySuperblock
|
||||
)) {
|
||||
const superBlockIntro = intros[superblock];
|
||||
|
||||
if (!superBlockIntro) {
|
||||
return helpers.error('any.custom', {
|
||||
message: `Missing intro.json entry for superblock "${superblock}"`
|
||||
});
|
||||
}
|
||||
|
||||
const { error: superBlockError } =
|
||||
superBlockIntroSchema.validate(superBlockIntro);
|
||||
if (superBlockError) {
|
||||
return helpers.error('any.custom', {
|
||||
message: `Invalid intro.json shape for superblock "${superblock}": ${superBlockError.message}`
|
||||
});
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const blockIntro = superBlockIntro.blocks?.[block];
|
||||
if (!blockIntro) {
|
||||
return helpers.error('any.custom', {
|
||||
message: `Missing intro.json block title entry for "${superblock}/${block}"`
|
||||
});
|
||||
}
|
||||
|
||||
const { error: blockError } = blockIntroSchema.validate(blockIntro);
|
||||
if (blockError) {
|
||||
return helpers.error('any.custom', {
|
||||
message: `Invalid intro.json block entry for "${superblock}/${block}": ${blockError.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intros;
|
||||
}, 'intro block coverage validation')
|
||||
.messages({
|
||||
'any.custom': '{{#message}}'
|
||||
});
|
||||
}
|
||||
|
||||
exports.validateIntroSchema = (intros, expectedBlocksBySuperblock) => {
|
||||
return createIntroSchema(expectedBlocksBySuperblock).validate(intros, {
|
||||
abortEarly: false
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { addSuperblockStructure } from '../src/build-curriculum.js';
|
||||
import { getCurriculumStructure } from '../src/file-handler.js';
|
||||
import introSchema from './intro-schema.js';
|
||||
|
||||
const { validateIntroSchema } = introSchema;
|
||||
|
||||
const introPath = path.resolve(
|
||||
import.meta.dirname,
|
||||
'../../client/i18n/locales/english/intro.json'
|
||||
);
|
||||
|
||||
const intros = JSON.parse(readFileSync(introPath, 'utf8'));
|
||||
|
||||
function getExpectedBlocksBySuperblock() {
|
||||
const { superblocks } = getCurriculumStructure();
|
||||
|
||||
return addSuperblockStructure(superblocks, true).reduce(
|
||||
(expected, { name, blocks }) => {
|
||||
expected[name] = blocks.map(({ dashedName }) => dashedName);
|
||||
return expected;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
describe('intro schema', () => {
|
||||
it('includes block titles for every curriculum block', () => {
|
||||
const result = validateIntroSchema(intros, getExpectedBlocksBySuperblock());
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails if a block title entry is missing', () => {
|
||||
const expectedBlocksBySuperblock = getExpectedBlocksBySuperblock();
|
||||
const [superblock, blocks] = Object.entries(expectedBlocksBySuperblock)[0];
|
||||
const block = blocks[0];
|
||||
const introsWithoutBlock = structuredClone(intros);
|
||||
|
||||
delete introsWithoutBlock[superblock].blocks[block];
|
||||
|
||||
const result = validateIntroSchema(introsWithoutBlock, {
|
||||
[superblock]: [block]
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain(`${superblock}/${block}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const slugRE = new RegExp('^[a-z0-9-]+$');
|
||||
|
||||
const schema = Joi.object()
|
||||
.keys({
|
||||
blockLayout: Joi.valid(
|
||||
'challenge-list',
|
||||
'challenge-grid',
|
||||
'dialogue-grid',
|
||||
'link',
|
||||
'project-list',
|
||||
'legacy-challenge-list',
|
||||
'legacy-link',
|
||||
'legacy-challenge-grid'
|
||||
).required(),
|
||||
blockLabel: Joi.valid(
|
||||
'workshop',
|
||||
'lab',
|
||||
'lecture',
|
||||
'review',
|
||||
'quiz',
|
||||
'exam',
|
||||
'warm-up',
|
||||
'learn',
|
||||
'practice'
|
||||
),
|
||||
isUpcomingChange: Joi.boolean().required(),
|
||||
dashedName: Joi.string().regex(slugRE).required(),
|
||||
usesMultifileEditor: Joi.boolean(),
|
||||
hasEditableBoundaries: Joi.boolean(),
|
||||
disableLoopProtectTests: Joi.boolean(),
|
||||
disableLoopProtectPreview: Joi.boolean(),
|
||||
template: Joi.string(),
|
||||
required: Joi.array()
|
||||
.items(
|
||||
Joi.object().keys({
|
||||
link: Joi.string(),
|
||||
raw: Joi.bool(),
|
||||
src: Joi.string()
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.message(
|
||||
`'required' must contain at least one item or not exist. Add an item or delete the property`
|
||||
),
|
||||
challengeOrder: Joi.array()
|
||||
.items(
|
||||
Joi.object().keys({
|
||||
id: Joi.string().required(),
|
||||
title: Joi.string().required()
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.required(),
|
||||
helpCategory: Joi.valid(
|
||||
'JavaScript',
|
||||
'HTML-CSS',
|
||||
'Python',
|
||||
'Backend Development',
|
||||
'C-Sharp',
|
||||
'English',
|
||||
'Odin',
|
||||
'Euler',
|
||||
'Rosetta',
|
||||
'Chinese Curriculum',
|
||||
'Spanish Curriculum',
|
||||
'General'
|
||||
).required()
|
||||
})
|
||||
// this makes sure there is no unknown key in the object
|
||||
.unknown(false);
|
||||
|
||||
exports.validateMetaSchema = meta => {
|
||||
return schema.validate(meta, { abortEarly: false });
|
||||
};
|
||||
@@ -0,0 +1,354 @@
|
||||
const availableCharacters = [
|
||||
// English
|
||||
'Alice',
|
||||
'Amy',
|
||||
'Anna',
|
||||
'Bob',
|
||||
'Brian',
|
||||
'Candidate',
|
||||
'David',
|
||||
'Delivery Man',
|
||||
'Expert',
|
||||
'Jake',
|
||||
'James',
|
||||
'Jessica',
|
||||
'Jim',
|
||||
'Josh',
|
||||
'Linda',
|
||||
'Lisa',
|
||||
'Maria',
|
||||
'Mark',
|
||||
'Riker',
|
||||
'Sarah',
|
||||
'Second Candidate',
|
||||
'Sophie',
|
||||
'Tom',
|
||||
|
||||
// Spanish
|
||||
'Ángela',
|
||||
'Camila',
|
||||
'Carlos',
|
||||
'Elena',
|
||||
'Esteban',
|
||||
'Joaquín',
|
||||
'Julieta',
|
||||
'Luis',
|
||||
'Luna',
|
||||
'Marisol',
|
||||
'Mateo',
|
||||
'Noelia',
|
||||
'René',
|
||||
'Sebastián',
|
||||
'Diego',
|
||||
'Valeria',
|
||||
|
||||
// Chinese
|
||||
'Chen Na',
|
||||
'Huang Jingyi',
|
||||
'Li Hong',
|
||||
'Li Ping',
|
||||
'Lin Yating',
|
||||
'Liu Ming',
|
||||
'Wang Hua',
|
||||
'Zhang Wei',
|
||||
'Zhou Jia',
|
||||
'Zhou Yongjie'
|
||||
];
|
||||
|
||||
const availableBackgrounds = [
|
||||
'cafe.png',
|
||||
'chaos.png',
|
||||
'company1-boardroom.png',
|
||||
'company1-breakroom.png',
|
||||
'company1-center.png',
|
||||
'company1-dining.png',
|
||||
'company1-lydia-cubicle.png',
|
||||
'company1-parking.png',
|
||||
'company1-reception.png',
|
||||
'company1-roof.png',
|
||||
'company2-boardroom.png',
|
||||
'company2-breakroom.png',
|
||||
'company2-center.png',
|
||||
'company2-dining.png',
|
||||
'company2-lydia-cubicle.png',
|
||||
'company2-parking.png',
|
||||
'company2-reception.png',
|
||||
'company2-roof.png',
|
||||
'company3-boardmeeting.png',
|
||||
'company3-breakroom.png',
|
||||
'company3-center.png',
|
||||
'company3-dining.png',
|
||||
'company3-lydia-cubicle.png',
|
||||
'company3-parking.png',
|
||||
'company3-reception.png',
|
||||
'company3-roof.png',
|
||||
'cubicle.png',
|
||||
'desk.png',
|
||||
'hacker-space-cafe.png',
|
||||
'hacker-space.png',
|
||||
'interview-room1.png',
|
||||
'interview-room2.png',
|
||||
'interview-room3.png',
|
||||
'living-room.png'
|
||||
];
|
||||
|
||||
const availableAudios = [
|
||||
'1.1-1.mp3',
|
||||
'1.1-2.mp3',
|
||||
'1.1-3.mp3',
|
||||
'1.1-4.mp3',
|
||||
'1.1-5.mp3',
|
||||
'1.2-1.mp3',
|
||||
'1.2-2.mp3',
|
||||
'1.2-3.mp3',
|
||||
'1.2-4.mp3',
|
||||
'1.2-5.mp3',
|
||||
'1.3-1.mp3',
|
||||
'1.3-2.mp3',
|
||||
'1.3-3.mp3',
|
||||
'1.3-4.mp3',
|
||||
'1.3-5.mp3',
|
||||
'2.1-1.mp3',
|
||||
'2.1-2.mp3',
|
||||
'2.1-3.mp3',
|
||||
'2.1-4.mp3',
|
||||
'2.1-5.mp3',
|
||||
'2.2-1.mp3',
|
||||
'2.2-2.mp3',
|
||||
'2.2-3.mp3',
|
||||
'2.2-4.mp3',
|
||||
'2.2-5.mp3',
|
||||
'2.3-1.mp3',
|
||||
'2.3-2.mp3',
|
||||
'2.3-3.mp3',
|
||||
'2.3-4.mp3',
|
||||
'2.3-5.mp3',
|
||||
'3.1-1.mp3',
|
||||
'3.1-2.mp3',
|
||||
'3.1-3.mp3',
|
||||
'3.1-4.mp3',
|
||||
'3.1-5.mp3',
|
||||
'3.2-1.mp3',
|
||||
'3.2-2.mp3',
|
||||
'3.2-3.mp3',
|
||||
'3.2-4.mp3',
|
||||
'3.2-5.mp3',
|
||||
'3.3-1.mp3',
|
||||
'3.3-2.mp3',
|
||||
'3.3-3.mp3',
|
||||
'3.3-4.mp3',
|
||||
'3.3-5.mp3',
|
||||
'4.1-1.mp3',
|
||||
'4.1-2.mp3',
|
||||
'4.1-3.mp3',
|
||||
'4.1-4.mp3',
|
||||
'4.1-5.mp3',
|
||||
'4.2-1.mp3',
|
||||
'4.2-2.mp3',
|
||||
'4.2-3.mp3',
|
||||
'4.2-4.mp3',
|
||||
'4.2-5.mp3',
|
||||
'4.3-1.mp3',
|
||||
'4.3-2.mp3',
|
||||
'4.3-3.mp3',
|
||||
'4.3-4.mp3',
|
||||
'4.3-5.mp3',
|
||||
'5.1-1.mp3',
|
||||
'5.1-2.mp3',
|
||||
'5.1-3.mp3',
|
||||
'5.2-1.mp3',
|
||||
'5.2-2.mp3',
|
||||
'5.2-3.mp3',
|
||||
'5.3-1.mp3',
|
||||
'5.3-2.mp3',
|
||||
'5.3-3.mp3',
|
||||
'6.1-1.mp3',
|
||||
'6.1-2.mp3',
|
||||
'6.1-3.mp3',
|
||||
'6.2-1.mp3',
|
||||
'6.2-2.mp3',
|
||||
'6.2-3.mp3',
|
||||
'6.3-1.mp3',
|
||||
'6.3-2.mp3',
|
||||
'6.3-3.mp3',
|
||||
'7.1-1.mp3',
|
||||
'7.1-2.mp3',
|
||||
'7.1-3.mp3',
|
||||
'7.2-1.mp3',
|
||||
'7.2-2.mp3',
|
||||
'7.2-3.mp3',
|
||||
'7.3-1.mp3',
|
||||
'7.3-2.mp3',
|
||||
'7.3-3.mp3',
|
||||
'8.1-1.mp3',
|
||||
'8.1-2.mp3',
|
||||
'8.1-3.mp3',
|
||||
'8.2-1.mp3',
|
||||
'8.2-2.mp3',
|
||||
'8.2-3.mp3',
|
||||
'8.3-1.mp3',
|
||||
'8.3-2.mp3',
|
||||
'8.3-3.mp3',
|
||||
'9.1-1.mp3',
|
||||
'9.1-2.mp3',
|
||||
'9.1-3.mp3',
|
||||
'9.2-1.mp3',
|
||||
'9.2-2.mp3',
|
||||
'9.2-3.mp3',
|
||||
'9.3-1.mp3',
|
||||
'9.3-2.mp3',
|
||||
'9.3-3.mp3',
|
||||
'B1_1-1.mp3',
|
||||
'B1_1-2.mp3',
|
||||
'B1_1-3.mp3',
|
||||
'B1_2-1.mp3',
|
||||
'B1_2-2.mp3',
|
||||
'B1_2-3.mp3',
|
||||
'B1_3-1.mp3',
|
||||
'B1_3-2.mp3',
|
||||
'B1_3-3.mp3',
|
||||
'B1_4-1.mp3',
|
||||
'B1_4-2.mp3',
|
||||
'B1_4-3.mp3',
|
||||
'B1_4-4.mp3',
|
||||
'B1_5-1.mp3',
|
||||
'B1_5-2.mp3',
|
||||
'B1_5-3.mp3',
|
||||
'B1_6-1.mp3',
|
||||
'B1_6-2.mp3',
|
||||
'B1_6-3.mp3',
|
||||
'B1_7-1.mp3',
|
||||
'B1_7-2.mp3',
|
||||
'B1_7-3.mp3',
|
||||
'B1_8-1.mp3',
|
||||
'B1_8-2.mp3',
|
||||
'B1_8-3.mp3',
|
||||
'B1_9-1.mp3',
|
||||
'B1_9-2.mp3',
|
||||
'B1_9-3.mp3',
|
||||
'B1_10-1.mp3',
|
||||
'B1_10-2.mp3',
|
||||
'B1_10-3.mp3',
|
||||
'B1_11-1.mp3',
|
||||
'B1_11-2.mp3',
|
||||
'B1_11-3.mp3',
|
||||
'B1_12-1.mp3',
|
||||
'B1_12-2.mp3',
|
||||
'B1_12-3.mp3',
|
||||
'B1_13-1.mp3',
|
||||
'B1_13-2.mp3',
|
||||
'B1_13-3.mp3',
|
||||
'B1_14-1.mp3',
|
||||
'B1_14-2.mp3',
|
||||
'B1_14-3.mp3',
|
||||
'B1_15-1.mp3',
|
||||
'B1_15-2.mp3',
|
||||
'B1_15-3.mp3',
|
||||
'B1_16-1.mp3',
|
||||
'B1_16-2.mp3',
|
||||
'B1_16-3.mp3',
|
||||
'B1_17-1.mp3',
|
||||
'B1_17-2.mp3',
|
||||
'B1_17-3.mp3',
|
||||
'B1_18-1.mp3',
|
||||
'B1_18-2.mp3',
|
||||
'B1_18-3.mp3',
|
||||
'B1_19-1.mp3',
|
||||
'B1_19-2.mp3',
|
||||
'B1_19-3.mp3',
|
||||
'B1_20-1.mp3',
|
||||
'B1_20-2.mp3',
|
||||
'B1_20-3.mp3',
|
||||
'B1_21-1.mp3',
|
||||
'B1_21-2.mp3',
|
||||
'B1_21-3.mp3',
|
||||
'B1_22-1.mp3',
|
||||
'B1_22-2.mp3',
|
||||
'B1_22-3.mp3',
|
||||
'B1_23-1.mp3',
|
||||
'B1_23-2.mp3',
|
||||
'B1_23-3.mp3',
|
||||
'B1_24-1.mp3',
|
||||
'B1_24-2.mp3',
|
||||
'B1_24-3.mp3',
|
||||
'ES_A1_spanish_greetings_in_the_morning.mp3',
|
||||
'ES_A1_spanish_greetings_in_the_afternoon.mp3',
|
||||
'ES_A1_spanish_greetings_in_the_evening.mp3',
|
||||
'ES_A1_spanish_greetings_camila_practice.mp3',
|
||||
'ES_A1_spanish_meet_luna.mp3',
|
||||
'ES_A1_spanish_meet_mateo.mp3',
|
||||
'ES_A1_spanish_meet_julieta.mp3',
|
||||
'ES_A1_spanish_meet_mateo_practice.mp3',
|
||||
'ES_A1_spanish_introducing_yourself_vocabulary.mp3',
|
||||
'ES_A1_spanish_meet_angela_and_basti.mp3',
|
||||
'ES_A1_spanish_meet_camila_and_mateo.mp3',
|
||||
'ES_A1_first_questions_numbers.mp3',
|
||||
'ES_A1_first_questions_warmup.mp3',
|
||||
'ES_A1_alphabet.mp3',
|
||||
'ES_A1_alphabet_practice.mp3',
|
||||
'ES_A1_andescommerce_company_profile.mp3',
|
||||
'ES_A1_basic_personal_details_numbers_10_29.mp3',
|
||||
'ES_A1_basic_personal_details_numbers_mixed.mp3',
|
||||
'ES_A1_basti_asks_luna_about_carlos_dialogue.mp3',
|
||||
'ES_A1_basti_personal_details.mp3',
|
||||
'ES_A1_diego_mini_biography.mp3',
|
||||
'ES_A1_esteban_asks_angela_about_luis_dialogue.mp3',
|
||||
'ES_A1_camila_asks_basti_about_marisol_dialogue.mp3',
|
||||
'ES_A1_carlos_workplace_profile.mp3',
|
||||
'ES_A1_company_departments_and_employees.mp3',
|
||||
'ES_A1_company_details_fill_in.mp3',
|
||||
'ES_A1_company_facts_short.mp3',
|
||||
'ES_A1_describing_a_company_basics_vocabulary.mp3',
|
||||
'ES_A1_describing_company_and_people_numbers_practice.mp3',
|
||||
'ES_A1_elena_asks_esteban_about_diego_dialogue.mp3',
|
||||
'ES_A1_elena_talks_about_her_company.mp3',
|
||||
'ES_A1_esteban_personal_records_dialogue_with_angela.mp3',
|
||||
'ES_A1_describing_company_and_people_numbers_21_100.mp3',
|
||||
'ES_A1_joaquin_workplace_profile.mp3',
|
||||
'ES_A1_julieta_personal_details.mp3',
|
||||
'ES_A1_luis_workplace_profile.mp3',
|
||||
'ES_A1_luna_personal_data_form.mp3',
|
||||
'ES_A1_marisol_mini_biography.mp3',
|
||||
'ES_A1_mateo_asks_julieta_about_valeria_dialogue.mp3',
|
||||
'ES_A1_mateo_camila_phone_number_dialogue.mp3',
|
||||
'ES_A1_mateo_email_dialogue_with_camila.mp3',
|
||||
'ES_A1_noelia_mini_biography.mp3',
|
||||
'ES_A1_personal_details_just_questions.mp3',
|
||||
'ES_A1_questions_about_joaquin_workplace_profile.mp3',
|
||||
'ES_A1_questions_about_noelia_mini_biography.mp3',
|
||||
'ES_A1_sebastian_asks_elena_about_her_company.mp3',
|
||||
'ES_A1_spanish_fundamentals_numbers_0_9.mp3',
|
||||
'ES_A1_spanish_fundamentals_numbers_mixed.mp3',
|
||||
'ES_A1_valeria_mini_biography.mp3',
|
||||
'ES_A1_vowels.mp3',
|
||||
'ES_A1_vowels_aiu.mp3',
|
||||
'ES_A1_vowels_oiu.mp3',
|
||||
'ES_A1_vowels_uae.mp3',
|
||||
'ES_A1_warm_up_describing_people_at_work.mp3',
|
||||
'ES_A1_what_each_department_does.mp3',
|
||||
'ES_A1_what_the_company_does.mp3',
|
||||
'ZH_A1_greetings_and_introductions_wanghua.mp3',
|
||||
'ZH_A1_greetings_and_introductions_liuming.mp3',
|
||||
'ZH_A1_greetings_and_introductions_chenna.mp3',
|
||||
'ZH_A1_greetings_and_introductions_dialogue.mp3',
|
||||
'ZH_A1_greetings_and_introductions_warm_up.mp3',
|
||||
'ZH_A1_chinese_fundamentals_hello_world.mp3',
|
||||
'ZH_A1_pinyin_single_finals.mp3',
|
||||
'ZH_A1_pinyin_initials.mp3',
|
||||
'ZH_A1_pinyin_compound_finals.mp3',
|
||||
'ZH_A1_pinyin_nasal_finals.mp3',
|
||||
'ZH_A1_pinyin_special_spelling_rules.mp3',
|
||||
'ZH_A1_pinyin_practice.mp3',
|
||||
'ZH_A1_numbers_and_personal_information_numbers_0_10.mp3',
|
||||
'ZH_A1_numbers_and_personal_information_numbers_11_19.mp3',
|
||||
'ZH_A1_numbers_and_personal_information_numbers_20_99.mp3'
|
||||
];
|
||||
|
||||
const availableAlignments = ['left', 'center', 'right'];
|
||||
|
||||
module.exports = {
|
||||
availableCharacters,
|
||||
availableBackgrounds,
|
||||
availableAudios,
|
||||
availableAlignments
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const slugRE = new RegExp('^[a-z0-9-]+$');
|
||||
|
||||
const schema = Joi.object()
|
||||
.keys({
|
||||
chapters: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
dashedName: Joi.string().regex(slugRE).required(),
|
||||
comingSoon: Joi.boolean().optional(),
|
||||
chapterType: Joi.valid('exam').optional(),
|
||||
modules: Joi.array()
|
||||
.items(
|
||||
Joi.object().keys({
|
||||
moduleType: Joi.valid('review', 'exam').optional(),
|
||||
comingSoon: Joi.boolean().optional(),
|
||||
dashedName: Joi.string().regex(slugRE).required(),
|
||||
blocks: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
dashedName: Joi.string().regex(slugRE).required()
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
.required()
|
||||
})
|
||||
)
|
||||
})
|
||||
// this makes sure there is no unknown key in the object
|
||||
.unknown(false);
|
||||
|
||||
exports.assertSuperBlockStructure = structure => Joi.assert(structure, schema);
|
||||
Reference in New Issue
Block a user