Files
Beug/src/engine.test.js
T
georgeandClaude Opus 5 0dfc2955db Enumerate every possible sentence, review it, and lock it with a snapshot
"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
2026-09-18 07:24:29 +00:00

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