"Wir wohnen bei der Schwester. / We live at the sister." was two bugs at
once, and both were the same shape: a template applied to a pairing nobody
had ever read.
Root cause: sentences are built combinatorially (frames x verbs x nouns x
subjects). The space is ~2900 sentences and only a sample had ever been
looked at, so every fix so far has been whack-a-mole on whichever bad one
happened to surface.
The fix is structural, not another special case:
- composeFrame() is now pure, and enumerateCorpus() walks every frame,
verb, noun and case for every consumer (fill-the-article, ein-Woerter,
possessive, Wechsel im Kontext, fused). src/corpus.txt is that full
enumeration, checked in and reviewed.
- Two tests make it foolproof rather than decorative:
* the snapshot test fails if generation drifts from corpus.txt, so no
new sentence can reach a learner unreviewed. Regenerate with
`npm run corpus` and the diff IS the review.
* a coverage test runs the real generators over every sentence level
and asserts each question it produces has a key in the corpus — so
the file is provably the live output, not a parallel implementation.
Two more assert no un-fused contraction and no German leaking into an
English line.
Bugs the audit then found and fixed:
- zu/von/bei + a person means "at/to/from their PLACE". English needs the
-'s ("at the doctor's"), and with a bare definite article German only
allows roles you visit (beim Arzt), not family — "bei der Schwester" is
wrong, "bei meiner Schwester" is right, so family frames are now
possessive-only. Added Aerztin/Lehrerin/Freundin so the role case has a
non-fusing feminine form.
- "zu dem Arzt", "in dem Haus": a fill-the-article sentence may no longer
show a two-word form German obligatorily fuses. Those pairings belong to
the Kontraktionen questions, which already teach them. "zu" therefore
leaves the Dativ lesson entirely, the way "bis" already had.
- mein Mann / meine Frau mean my husband / my wife, not my man / my woman.
- "mein Maedchen" is not idiomatic and was reachable from the possessive
templates; the coverage test caught it.
- You cannot drive into a kitchen, a cellar or a library: the "in" motion
frame is split into drivable places and on-foot-only places.
- "stellt den Stuhl an die Wand" is "against the wall", not "to the wall";
haengen/schreiben/kleben take "on"; wohnen am See is "by the lake".
Also: questions now carry the verb and case they were built from, the
article redraw is depth-bounded so a data mistake degrades to one odd
question instead of hanging, and level 3's advertised prep list matches
what it can actually generate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
582 lines
24 KiB
JavaScript
582 lines
24 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|