import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS, NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS, POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES, createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices, bumpStreak, currentStreak, weekDots, enumerateCorpus, corpusToText, corpusKeyFor, } from './engine.js'; const runSession = (levelId, n) => { const s = createSession(levelId); return Array.from({ length: n }, () => s.next()); }; // ── Grammar data ──────────────────────────────────────────── describe('declension tables', () => { it('definite articles are correct', () => { expect(DEF.masculine.accusative).toBe('den'); expect(DEF.masculine.dative).toBe('dem'); expect(DEF.feminine.dative).toBe('der'); expect(DEF.neuter.genitive).toBe('des'); expect(DEF.plural.dative).toBe('den'); }); it('every masculine/neuter noun has a genitive form ending in -s', () => { for (const n of NOUNS) { if (n.g === 'masculine' || n.g === 'neuter') { expect(n.gen, `${n.w} is missing its genitive form`).toBeTruthy(); expect(n.gen.endsWith('s'), `${n.gen} should end in -s`).toBe(true); } } }); it('nounForm applies the genitive only for genitive case', () => { const haus = NOUNS.find(n => n.w === 'Haus'); expect(nounForm(haus, 'genitive')).toBe('Hauses'); expect(nounForm(haus, 'dative')).toBe('Haus'); const frau = NOUNS.find(n => n.w === 'Frau'); expect(nounForm(frau, 'genitive')).toBe('Frau'); }); it('every frame references real verbs and nouns', () => { const nounNames = new Set(NOUNS.map(n => n.w)); const checkFrames = frames => { for (const f of frames) { f.verbs.forEach(v => expect(VERBS[v], `unknown verb ${v}`).toBeTruthy()); f.nouns.forEach(n => expect(nounNames.has(n), `unknown noun ${n}`).toBe(true)); } }; for (const [prep, f] of Object.entries(PREP_FRAMES)) { expect(PREPS[prep], `frame for unknown prep ${prep}`).toBeTruthy(); if (Array.isArray(f)) checkFrames(f); else { checkFrames(f.motion); checkFrames(f.location); } } }); it('every sentence-level preposition has a frame', () => { for (const lv of LEVELS) { if (lv.type === 'article' && lv.preps) { for (const p of lv.preps) expect(PREP_FRAMES[p], `no frame for ${p} (level ${lv.id})`).toBeTruthy(); } } }); }); // ── Article questions ─────────────────────────────────────── describe('article questions', () => { const articleLevels = LEVELS.filter(l => l.type === 'article').map(l => l.id); it.each(articleLevels)('level %s: answer is always among 4 unique choices', (id) => { for (const q of runSession(id, 200)) { expect(q.choices.length).toBe(4); expect(new Set(q.choices).size).toBe(4); expect(q.choices).toContain(q.ans); expect(q.sentence).toContain('_____'); } }); it('genitive sentences use the genitive noun form (des Hauses, not des Haus)', () => { for (const q of runSession(5, 200)) { if (q.type !== 'article') continue; expect(q.cas).toBe('genitive'); expect(q.sentence).toContain(nounForm(q.noun, 'genitive')); if (q.noun.gen) expect(q.sentence).not.toContain(` ${q.noun.w}.`); } }); it('the answer matches the declension table', () => { for (const q of runSession(6, 300)) { if (q.type === 'article') expect(q.ans).toBe(DEF[q.noun.g][q.cas]); } }); it('never repeats a preposition twice in a row', () => { for (const id of [2, 3, 4, 5]) { const qs = runSession(id, 200); for (let i = 1; i < qs.length; i++) { if (qs[i].prepKey && qs[i-1].prepKey) { expect(qs[i].prepKey, `level ${id} repeated ${qs[i].prepKey} at ${i}`).not.toBe(qs[i-1].prepKey); } } } }); it('covers every preposition of the level within one bag cycle', () => { const lv = LEVELS.find(l => l.id === 3); const seen = new Set(runSession(3, lv.preps.length).map(q => q.prepKey)); expect([...seen].sort()).toEqual([...lv.preps].sort()); }); it('ein-Wörter never pairs an ein-word with a mass/definite-only noun', () => { for (const q of runSession(7, 400)) { expect(q.noun.noIndef, `„ein ${q.noun.w}" should never be generated`).toBeFalsy(); } }); it('ein-Wörter never uses definite-idiom frames (nach, seit) or all-mass preps (trotz)', () => { for (const q of runSession(7, 400)) { expect(['nach', 'seit', 'trotz']).not.toContain(q.prepKey); } }); it('ein-Wörter translations say a/an, matching the German article', () => { for (const q of runSession(7, 400)) { const usesA = q.translation.includes(` a ${q.noun.en}`) || q.translation.includes(` an ${q.noun.en}`); expect(usesA, `"${q.translation}" should use a/an for „${q.ans} ${q.noun.w}"`).toBe(true); expect(q.translation).not.toContain(` the ${q.noun.en}`); } }); it('definite levels still translate with "the"', () => { for (const q of runSession(2, 200)) { expect(q.translation).toContain(` the ${q.noun.en}`); } }); }); // ── Preposition case quiz ─────────────────────────────────── describe('prep case quiz (levels 8/9/10)', () => { it('every common prep appears within 16 draws — zu, nach, bei included', () => { const seen = new Set(runSession(8, COMMON_PREPS.length).map(q => q.prepKey)); expect([...seen].sort()).toEqual([...COMMON_PREPS].sort()); }); it('every prep appears within one cycle on levels 9 and 10', () => { expect(new Set(runSession(9, RARE_PREPS.length).map(q => q.prepKey)).size).toBe(RARE_PREPS.length); expect(new Set(runSession(10, ALL_PREPS.length).map(q => q.prepKey)).size).toBe(ALL_PREPS.length); }); it('never shows the same preposition twice in a row', () => { const qs = runSession(10, 300); for (let i = 1; i < qs.length; i++) expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey); }); it('answers match PREPS and examples are well-formed', () => { for (const q of runSession(10, 300)) { expect(q.ans).toBe(PREPS[q.prepKey].c); expect(q.example.startsWith(q.prepKey) || q.exampleFull).toBe(true); } }); }); // ── Wechsel im Kontext ────────────────────────────────────── describe('wechsel in context (level 18)', () => { it('neben appears (was underrepresented)', () => { const seen = new Set(runSession(18, 9).map(q => q.prepKey)); expect(seen.has('neben')).toBe(true); expect(seen.size).toBe(9); }); it('never repeats prep back-to-back and rarely repeats the verb', () => { const qs = runSession(18, 300); let verbRepeats = 0; for (let i = 1; i < qs.length; i++) { expect(qs[i].prepKey).not.toBe(qs[i-1].prepKey); if (qs[i].verb === qs[i-1].verb) verbRepeats++; } expect(verbRepeats).toBeLessThanOrEqual(6); // only possible via single-verb frames }); it('case follows motion/location and the sentence article matches', () => { for (const q of runSession(18, 300)) { expect(q.ans).toBe(q.isMotion ? 'accusative' : 'dative'); expect(q.sentence).toContain(` ${q.prepKey} `); expect(q.sentence.endsWith('.')).toBe(true); } }); it('blanks the article so the verb is the only evidence', () => { for (const q of runSession(18, 300)) { expect(q.sentence).toContain('_____'); expect(q.article).toBe(DEF[q.noun.g][q.ans]); // the blanked sentence must never leak the answer article next to the prep expect(q.sentence).not.toContain(` ${q.prepKey} ${q.article} `); } }); it('the solved sentence contracts exactly where German contracts', () => { for (const q of runSession(18, 400)) { const fused = CONTRACTABLE[q.prepKey]?.[q.article] || null; expect(q.contraction).toBe(fused); expect(q.answerSentence).not.toContain('_____'); if (fused) expect(q.answerSentence).toContain(` ${fused} `); else expect(q.answerSentence).toContain(` ${q.prepKey} ${q.article} `); } }); it('drills far more than the handful of cats and lamps it used to', () => { const qs = runSession(18, 400); expect(new Set(qs.map(q => q.verb)).size).toBeGreaterThanOrEqual(20); expect(new Set(qs.map(q => q.noun.w)).size).toBeGreaterThanOrEqual(25); // no single sentence should dominate the round const counts = {}; for (const q of qs) counts[q.answerSentence] = (counts[q.answerSentence] || 0) + 1; expect(Math.max(...Object.values(counts))).toBeLessThanOrEqual(12); }); it('teaches that movement alone is not Akkusativ', () => { const qs = runSession(18, 600); // motion verbs used with a dative "arena" reading const arena = qs.filter(q => ['schwimmen','tanzen','spielen','fliegen'].includes(q.verb) && q.ans === 'dative'); expect(arena.length).toBeGreaterThan(0); // ankommen is the lexical exception: always dative const ank = qs.filter(q => q.verb === 'ankommen'); expect(ank.length).toBeGreaterThan(0); for (const q of ank) expect(q.ans).toBe('dative'); }); it('the put/be verb pairs land on the case their transitivity demands', () => { // Unambiguously transitive "put it there" verbs vs unambiguously stative ones. // hängen and stecken are deliberately excluded — see the next test. const PUT = ['legen','stellen','setzen','werfen','schieben','packen','tragen','kleben','schreiben']; const BE = ['liegen','stehen','sitzen','wohnen','leben','schlafen','warten','arbeiten','sein']; for (const q of runSession(18, 600)) { if (PUT.includes(q.verb)) expect(q.ans, `${q.answerSentence}`).toBe('accusative'); if (BE.includes(q.verb)) expect(q.ans, `${q.answerSentence}`).toBe('dative'); } }); it('hängen and stecken appear in BOTH cases — only the direct object decides', () => { const qs = runSession(18, 800); for (const v of ['hängen', 'stecken']) { const mine = qs.filter(q => q.verb === v); expect(mine.some(q => q.ans === 'accusative'), `${v} never showed transitive`).toBe(true); expect(mine.some(q => q.ans === 'dative'), `${v} never showed intransitive`).toBe(true); // transitive = something is being moved, so the sentence carries an object // before the preposition; intransitive sentences start with that thing. for (const q of mine) { const beforePrep = q.answerSentence.split(` ${q.prepKey} `)[0]; const fusedHead = q.contraction ? q.answerSentence.split(` ${q.contraction} `)[0] : beforePrep; const head = q.contraction ? fusedHead : beforePrep; const hasObject = /\b(das|den|die|dem)\b/.test(head); expect(hasObject, `${q.answerSentence} (${q.ans})`).toBe(q.ans === 'accusative'); } } }); }); // ── Chunk / contraction questions ─────────────────────────── describe('contractions', () => { it('buildChunkChoices always contains the correct form, 4 unique options', () => { for (const c of CONTRACTIONS.filter(x => x.pref)) { const { correct, choices } = buildChunkChoices(c.prep, c.art); expect(correct).toBe(c.form); expect(choices).toContain(c.form); expect(new Set(choices).size).toBe(4); } }); it('non-contracting articles keep the two-word form', () => { const { correct, contracts } = buildChunkChoices('an', 'die'); expect(contracts).toBe(false); expect(correct).toBe('an die'); }); it('Kontraktionen is asked backwards: the fused form is given, you take it apart', () => { const state = newState(); const seenForms = new Set(); for (let i = 0; i < 300; i++) { const q = genContractionQ(state); const c = CONTRACTIONS.find(x => x.form === q.ans); expect(c, `${q.ans} is a real contraction`).toBeTruthy(); seenForms.add(q.ans); // the question is graded on the two parts, not the form expect(q.answerKey).toBe(`${c.prep} ${c.art}`); expect(q.prepChoices).toContain(c.prep); expect(q.prepChoices.length).toBe(6); expect(new Set(q.prepChoices).size).toBe(6); expect(q.artChoices).toContain(c.art); expect(q.artChoices.length).toBe(4); expect(new Set(q.artChoices).size).toBe(4); // the old flaw: offering whole contraction forms meant only one option // could start with the given preposition. Nothing on offer is a form now. const forms = new Set(CONTRACTIONS.map(x => x.form)); for (const w of [...q.prepChoices, ...q.artChoices]) expect(forms.has(w)).toBe(false); } expect(seenForms.size).toBe(CONTRACTIONS.length); }); it('the article step is a real discrimination, never answerable by elimination', () => { const state = newState(); for (let i = 0; i < 300; i++) { const q = genContractionQ(state); // at least one wrong article that a careless learner would plausibly pick const wrong = q.artChoices.filter(a => a !== q.art); expect(wrong.length).toBe(3); expect(wrong.every(a => ['das','dem','der','die','den'].includes(a))).toBe(true); } }); it('the -s/-m/-r ending always predicts the article', () => { const byEnding = { s:'das', m:'dem', r:'der' }; for (const c of CONTRACTIONS) { const expected = byEnding[c.form.slice(-1)]; expect(expected, `${c.form} has an unexpected ending`).toBeTruthy(); expect(c.art, `${c.form} = ${c.prep} + ${c.art}`).toBe(expected); } }); it('dedicated Kontraktionen level always yields a preferred contraction', () => { const state = newState(); for (let i = 0; i < 200; i++) { const q = genContractionContextQ(state); expect(q.contracts).toBe(true); expect(q.pref).toBe(true); const c = CONTRACTIONS.find(x => x.form === q.ans); expect(c, `answer ${q.ans} is a known contraction`).toBeTruthy(); expect(DEF[q.noun.g][q.cas]).toBe(c.art); expect(q.choices).toContain(q.ans); } }); it('mixed definite level routes contractable preps to chunk questions', () => { const qs = runSession(6, 300); for (const q of qs) { if (q.prepKey && CHUNK_PREPS.has(q.prepKey)) expect(q.type).toBe('chunk'); } expect(qs.some(q => q.type === 'chunk')).toBe(true); }); }); // ── Possessives & pronouns ────────────────────────────────── describe('possessive questions', () => { it.each([11, 12, 13])('level %s: answer matches stem + ending, genitive nouns inflected', (id) => { const lv = LEVELS.find(l => l.id === id); for (const q of runSession(id, 300)) { expect(lv.possessives).toContain(q.stem); expect(q.ans).toBe(combinePoss(q.stem, POSS_ENDINGS[q.noun.g][q.cas])); expect(lv.cases).toContain(q.cas); expect(q.sentence).toContain('_____'); if (q.cas === 'genitive') expect(q.sentence).toContain(nounForm(q.noun, 'genitive')); } }); it('level 13 reaches the genitive', () => { expect(runSession(13, 300).some(q => q.cas === 'genitive')).toBe(true); }); it('dative helfen template only targets people', () => { for (const q of runSession(11, 400)) { if (q.sentence.startsWith('Ich helfe')) { expect(['masculine','feminine','neuter'].includes(q.noun.g)).toBe(true); expect(q.noun.en).not.toBe('table'); expect(q.noun.en).not.toBe('week'); } } }); }); describe('pronoun questions', () => { it.each([14, 15, 16, 17])('level %s: answer matches the pronoun table', (id) => { for (const q of runSession(id, 200)) { const p = PRONOUNS.find(x => x.key === q.pronKey); expect(q.ans).toBe(p[{accusative:'acc',dative:'dat'}[q.cas]]); } }); }); describe('one-tap choices on every question type', () => { it.each([11, 12, 13, 14, 15, 16, 17])('level %s: 4 unique choices containing the answer', (id) => { for (const q of runSession(id, 200)) { expect(q.choices.length).toBe(4); expect(new Set(q.choices).size).toBe(4); expect(q.choices).toContain(q.ans); } }); it('possessive choices all share the question stem', () => { for (const q of runSession(13, 200)) { const root = q.stem === 'euer' ? 'eu' : q.stem; for (const c of q.choices) expect(c.startsWith(root), `${c} should be a form of ${q.stem}`).toBe(true); } }); }); // ── Nominative is never tested ────────────────────────────── describe('no nominative anywhere', () => { it('no level tests the nominative — you already know der/die/das', () => { expect(LEVELS.some(l => l.name === 'Nominativ')).toBe(false); for (const lv of LEVELS) { if (lv.cases) expect(lv.cases, `level ${lv.id} still lists nominative`).not.toContain('nominative'); for (const q of runSession(lv.id, 150)) { if (q.cas) expect(q.cas, `level ${lv.id} generated a nominative question`).not.toBe('nominative'); expect(q.ans).not.toBe('nominative'); } } }); }); // ── Daily practice (per-tab mixed sessions) ───────────────── describe('daily practice', () => { const dailies = LEVELS.filter(l => l.daily); it('every tab has exactly one daily level', () => { expect(dailies.map(l => l.tab).sort()).toEqual(['article', 'poss', 'prep', 'pronoun']); }); it.each(dailies.map(l => [l.id, l.tab]))('daily %s mixes every lesson type of the %s tab', (id, tab) => { const expected = new Set(LEVELS.filter(l => l.tab === tab && !l.daily).map(l => l.type)); const seen = new Set(runSession(id, 80).map(q => q.type)); // Mixed definite-article lessons legitimately emit chunk questions too. for (const t of seen) expect([...expected, 'chunk'], `unexpected type ${t} in ${tab} daily`).toContain(t); for (const t of expected) expect(seen, `${tab} daily never produced a ${t} question`).toContain(t); }); it('daily questions always carry an answer', () => { for (const d of dailies) { for (const q of runSession(d.id, 100)) { expect(q.ans).toBeTruthy(); } } }); }); // ── Every level generates without crashing ────────────────── describe('all levels', () => { it.each(LEVELS.map(l => [l.id]))('level %s generates 100 questions cleanly', (id) => { for (const q of runSession(id, 100)) { expect(q).toBeTruthy(); expect(q.ans).toBeTruthy(); expect(q.type).toBeTruthy(); } }); }); // ── Streaks (multi-day simulation) ────────────────────────── describe('streaks', () => { it('grows by one per consecutive day, no matter how many rounds per day', () => { let st = { streak: 0, ld: null }; st = bumpStreak(st, '2026-07-01'); expect(st.streak).toBe(1); st = bumpStreak(st, '2026-07-01'); // second round same day st = bumpStreak(st, '2026-07-01'); expect(st.streak).toBe(1); st = bumpStreak(st, '2026-07-02'); expect(st.streak).toBe(2); st = bumpStreak(st, '2026-07-03'); st = bumpStreak(st, '2026-07-03'); expect(st.streak).toBe(3); }); it('resets after a missed day', () => { let st = { streak: 5, ld: '2026-07-01' }; st = bumpStreak(st, '2026-07-04'); expect(st.streak).toBe(1); }); it('survives month and year boundaries', () => { let st = bumpStreak({ streak: 0, ld: null }, '2026-01-31'); st = bumpStreak(st, '2026-02-01'); expect(st.streak).toBe(2); st = { streak: 9, ld: '2026-12-31' }; st = bumpStreak(st, '2027-01-01'); expect(st.streak).toBe(10); }); it('records practice days and prunes old ones', () => { let st = bumpStreak({ streak: 0, ld: null }, '2026-07-01'); expect(st.days['2026-07-01']).toBe(true); st = bumpStreak(st, '2026-07-01'); // same day, already recorded expect(Object.keys(st.days).length).toBe(1); st = bumpStreak(st, '2026-07-02'); expect(st.days['2026-07-01']).toBe(true); expect(st.days['2026-07-02']).toBe(true); st = bumpStreak(st, '2026-07-20'); // far in the future → old days pruned expect(st.days['2026-07-01']).toBeUndefined(); expect(st.days['2026-07-20']).toBe(true); }); it('weekDots reports the last 7 days, today last', () => { const st = { days: { '2026-07-10': true, '2026-07-12': true, '2026-07-14': true } }; const dots = weekDots(st, '2026-07-14'); expect(dots.length).toBe(7); expect(dots[0].date).toBe('2026-07-08'); expect(dots[6].date).toBe('2026-07-14'); expect(dots[6].isToday).toBe(true); expect(dots.map(d => d.practiced)).toEqual([false, false, true, false, true, false, true]); }); it('displays 0 once the streak has lapsed, without needing a new answer', () => { const st = { streak: 7, ld: '2026-07-01' }; expect(currentStreak(st, '2026-07-01')).toBe(7); expect(currentStreak(st, '2026-07-02')).toBe(7); // still alive: practiced yesterday expect(currentStreak(st, '2026-07-03')).toBe(0); // lapsed expect(currentStreak({}, '2026-07-03')).toBe(0); expect(currentStreak(null, '2026-07-03')).toBe(0); }); }); // ── The sentence corpus ───────────────────────────────────── // The engine builds sentences combinatorially, so most of what it can say has // never been read by anyone. These two tests close that hole: the whole space // is enumerated into src/corpus.txt and reviewed, and nothing the app actually // generates is allowed to fall outside it. describe('sentence corpus', () => { const corpusPath = new URL('./corpus.txt', import.meta.url); const onDisk = readFileSync(corpusPath, 'utf8'); it('matches the reviewed snapshot in src/corpus.txt', () => { // If this fails, generation changed. Read the diff — every added or edited // line is a sentence a learner will see — then regenerate with: // npm run corpus expect(corpusToText(enumerateCorpus())).toBe(onDisk); }); it('every sentence the app generates is in the corpus', () => { const known = new Set(enumerateCorpus().map(r => r.key)); const sentenceLevels = LEVELS .filter(l => ['article', 'wechselContext', 'poss'].includes(l.type) || l.type === 'chunk') .map(l => l.id); const escaped = new Set(); for (const id of sentenceLevels) { for (const q of runSession(id, 600)) { const key = corpusKeyFor(q); if (key && !known.has(key)) escaped.add(`level ${id}: ${key} — ${q.sentence}`); } } expect([...escaped]).toEqual([]); }); it('never shows a two-word form German would fuse (zu dem Arzt, in dem Haus)', () => { for (const r of enumerateCorpus()) { if (r.key.startsWith('CHUNK')) continue; // fused questions own that job for (const [prep, arts] of Object.entries(CONTRACTABLE)) { for (const art of Object.keys(arts)) { expect(r.de.includes(` ${prep} ${art} `), `${r.de} should be "${arts[art]}"`).toBe(false); } } } }); it('no English translation contains a raw German word or an empty slot', () => { const germanish = /[äöüßÄÖÜ]|\b(der|die|das|dem|den|des|ein|eine|einem|einen|mein|meine)\b/; for (const r of enumerateCorpus()) { expect(germanish.test(r.en), `leaked German in: ${r.en}`).toBe(false); expect(r.en).not.toContain('undefined'); expect(r.en).not.toContain(' '); expect(r.de).not.toContain('undefined'); expect(r.de).not.toContain(' '); expect(r.de.endsWith('.')).toBe(true); expect(r.en.endsWith('.')).toBe(true); } }); });