add language learner project
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:
2026-09-24 17:34:56 +00:00
parent afb1f191df
commit 438623d857
69 changed files with 12669 additions and 4 deletions

View 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 }));
});
});