Add testable question engine, streak fixes, mobile case grid
- New question-generation engine (shuffle-bag preposition selection, per-preposition sentence frames, genitive noun forms) in src/engine.js - Streak fixes: local-date handling, lapse display - Footer copy: "Get in touch" - 2x2 mobile case grid - Vitest suite (60 tests) covering the engine Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d30350e7e2
commit
d30e213104
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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, newState, buildChunkChoices,
|
||||
bumpStreak, currentStreak,
|
||||
} 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());
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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('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);
|
||||
const expected = p[{nominative:'nom',accusative:'acc',dative:'dat'}[q.cas]];
|
||||
const want = q.cas === 'nominative' ? expected.charAt(0).toUpperCase() + expected.slice(1) : expected;
|
||||
expect(q.ans).toBe(want);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user