add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Dutch and Korean (explained in English) and English (explained in Dutch) from A1 to C1, with a Hangul level for Korean. Lessons with notes, words and sentences generate multiple-choice, typing, sentence building, cloze, listening and matching exercises. Endless challenges: a seeded daily challenge, sprint, survival, mixed practice, level tests, spaced-repetition reviews and generated drills (numbers, clock times, de/het, plurals, Dutch and Korean conjugation, particles, Hangul reading, irregular verbs). Progress is stored in localStorage with export/import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
85
src/lib/language-learner/content.test.ts
Normal file
85
src/lib/language-learner/content.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allEntries, allLessons } from './course';
|
||||
import { lessonSession, reviewExercise } from './exercises';
|
||||
import { seeded } from './random';
|
||||
import { optionKey as normalize } from './text';
|
||||
import en from './courses/en';
|
||||
import ko from './courses/ko';
|
||||
import nl from './courses/nl';
|
||||
import { drills as nlDrills } from './drills/nl';
|
||||
import { drills as koDrills } from './drills/ko';
|
||||
import { drills as enDrills } from './drills/en';
|
||||
import type { Exercise } from './types';
|
||||
|
||||
const courses = { nl, ko, en };
|
||||
const drillSets = { nl: nlDrills, ko: koDrills, en: enDrills };
|
||||
|
||||
function checkExercise(ex: Exercise) {
|
||||
if (ex.type === 'choose') {
|
||||
expect(ex.options).toContain(ex.answer);
|
||||
expect(
|
||||
new Set(ex.options.map(normalize)).size,
|
||||
`${ex.key}: duplicate options ${ex.options}`
|
||||
).toBe(ex.options.length);
|
||||
expect(ex.options.length).toBeGreaterThanOrEqual(2);
|
||||
expect(ex.options.length, ex.key).toBeLessThanOrEqual(4);
|
||||
}
|
||||
if (ex.type === 'type') expect(ex.accept.length).toBeGreaterThan(0);
|
||||
if (ex.type === 'build') expect(ex.tiles.length).toBeGreaterThan(0);
|
||||
if (ex.type === 'match') {
|
||||
expect(new Set(ex.pairs.map((p) => normalize(p.gloss))).size, ex.key).toBe(ex.pairs.length);
|
||||
expect(new Set(ex.pairs.map((p) => normalize(p.target))).size, ex.key).toBe(ex.pairs.length);
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(Object.entries(courses))('%s course', (code, course) => {
|
||||
it('has unique lesson ids and content in every lesson', () => {
|
||||
const ids = allLessons(course).map((l) => l.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const lesson of allLessons(course)) {
|
||||
expect(lesson.words.length, lesson.id).toBeGreaterThanOrEqual(4);
|
||||
const ids = [...lesson.words, ...lesson.sentences].map((e) => e.id);
|
||||
expect(
|
||||
ids.filter((id, i) => ids.indexOf(id) !== i),
|
||||
lesson.id
|
||||
).toEqual([]);
|
||||
expect(lesson.notes.length, lesson.id).toBeGreaterThan(50);
|
||||
for (const drill of lesson.drills) {
|
||||
expect(drill.wrong.map(normalize), `${lesson.id}: ${drill.prompt}`).not.toContain(
|
||||
normalize(drill.answer)
|
||||
);
|
||||
expect(new Set(drill.wrong.map(normalize)).size, `${lesson.id}: ${drill.prompt}`).toBe(
|
||||
drill.wrong.length
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('generates valid lesson sessions', () => {
|
||||
for (const lesson of allLessons(course))
|
||||
for (const opts of [
|
||||
{ typing: true, audio: true },
|
||||
{ typing: false, audio: false }
|
||||
]) {
|
||||
const session = lessonSession(course, lesson, seeded(lesson.id), opts);
|
||||
expect(session.length, lesson.id).toBeGreaterThan(lesson.words.length);
|
||||
session.forEach(checkExercise);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates review exercises for every entry', () => {
|
||||
const pool = allEntries(course);
|
||||
const rng = seeded(code);
|
||||
for (const entry of pool)
|
||||
checkExercise(reviewExercise(entry, pool, rng, { typing: true, audio: true }, rng()));
|
||||
});
|
||||
|
||||
it('generates endless drills at every level', () => {
|
||||
const rng = seeded(`drills-${code}`);
|
||||
for (const drill of drillSets[code as keyof typeof drillSets])
|
||||
for (let level = 1; level <= 5; level++)
|
||||
for (let i = 0; i < 60; i++)
|
||||
for (const typing of [true, false])
|
||||
checkExercise(drill.make({ rng, level, opts: { typing, audio: true }, course }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user