diff --git a/src/engine.js b/src/engine.js index fbe8ceb..4783d7a 100644 --- a/src/engine.js +++ b/src/engine.js @@ -89,6 +89,9 @@ export function wechselExample(prepKey, isMotion) { } // gen: genitive noun form for masculine/neuter (des Mannes, des Hauses) +// noIndef: never combine with ein-words — mass nouns („eines Wetters" is not +// German) and nouns whose frames only read naturally with a definite article +// (kinship terms, temporal Nacht/Woche: „während einer Woche" is off). export const NOUNS = [ {w:'Hund',g:'masculine',en:'dog',gen:'Hundes'}, {w:'Mann',g:'masculine',en:'man',gen:'Mannes'}, @@ -99,24 +102,24 @@ export const NOUNS = [ {w:'Baum',g:'masculine',en:'tree',gen:'Baumes'}, {w:'Garten',g:'masculine',en:'garden',gen:'Gartens'}, {w:'Stuhl',g:'masculine',en:'chair',gen:'Stuhls'}, - {w:'Bruder',g:'masculine',en:'brother',gen:'Bruders'}, + {w:'Bruder',g:'masculine',en:'brother',gen:'Bruders',noIndef:true}, {w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'}, - {w:'Vater',g:'masculine',en:'father',gen:'Vaters'}, + {w:'Vater',g:'masculine',en:'father',gen:'Vaters',noIndef:true}, {w:'Zug',g:'masculine',en:'train',gen:'Zuges'}, - {w:'Regen',g:'masculine',en:'rain',gen:'Regens'}, + {w:'Regen',g:'masculine',en:'rain',gen:'Regens',noIndef:true}, {w:'Frau',g:'feminine',en:'woman'}, {w:'Katze',g:'feminine',en:'cat'}, {w:'Schule',g:'feminine',en:'school'}, {w:'Stadt',g:'feminine',en:'city'}, {w:'Straße',g:'feminine',en:'street'}, {w:'Tür',g:'feminine',en:'door'}, - {w:'Schwester',g:'feminine',en:'sister'}, - {w:'Mutter',g:'feminine',en:'mother'}, - {w:'Tochter',g:'feminine',en:'daughter'}, - {w:'Musik',g:'feminine',en:'music'}, - {w:'Nacht',g:'feminine',en:'night'}, - {w:'Woche',g:'feminine',en:'week'}, - {w:'Arbeit',g:'feminine',en:'work'}, + {w:'Schwester',g:'feminine',en:'sister',noIndef:true}, + {w:'Mutter',g:'feminine',en:'mother',noIndef:true}, + {w:'Tochter',g:'feminine',en:'daughter',noIndef:true}, + {w:'Musik',g:'feminine',en:'music',noIndef:true}, + {w:'Nacht',g:'feminine',en:'night',noIndef:true}, + {w:'Woche',g:'feminine',en:'week',noIndef:true}, + {w:'Arbeit',g:'feminine',en:'work',noIndef:true}, {w:'Brücke',g:'feminine',en:'bridge'}, {w:'Kind',g:'neuter',en:'child',gen:'Kindes'}, {w:'Haus',g:'neuter',en:'house',gen:'Hauses'}, @@ -128,7 +131,7 @@ export const NOUNS = [ {w:'Museum',g:'neuter',en:'museum',gen:'Museums'}, {w:'Büro',g:'neuter',en:'office',gen:'Büros'}, {w:'Restaurant',g:'neuter',en:'restaurant',gen:'Restaurants'}, - {w:'Wetter',g:'neuter',en:'weather',gen:'Wetters'}, + {w:'Wetter',g:'neuter',en:'weather',gen:'Wetters',noIndef:true}, {w:'Dach',g:'neuter',en:'roof',gen:'Daches'}, {w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'}, ]; @@ -480,7 +483,7 @@ function framesFor(prepKey, tw) { // Resolve a frame into concrete sentence parts. Returns null if the // preposition has no usable frame for the given constraints. -function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly } = {}) { +function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly, indefOnly } = {}) { let frames = framesFor(prepKey, tw); if (possOnly) { frames = frames @@ -488,6 +491,15 @@ function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly } = {}) .map(f => ({...f, nouns: f.nouns.filter(n => POSSESSABLE.has(n))})) .filter(f => f.nouns.length); } + if (indefOnly) { + // ein-word questions: drop frames whose English determiner is a baked-in + // definite idiom (nach der Schule → "after school", seit → "since that…"), + // and drop noIndef nouns — the remaining pairings read naturally with a/an. + frames = frames + .filter(f => f.enDet === undefined) + .map(f => ({...f, nouns: f.nouns.filter(n => !NOUN_BY_NAME[n].noIndef)})) + .filter(f => f.nouns.length); + } if (genderFilter) { frames = frames .map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))})) @@ -550,24 +562,35 @@ function articleChoices(ans, artType) { export function genArticleQ(level, state = newState()) { const lv = LEVELS.find(l=>l.id===level); - const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); - state.last.prep = prepKey; - const pc = PREPS[prepKey].c; - let tw = null, cas; - if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } - else cas = pc; + const indef = lv.artType === 'indefinite'; + // Draw preps until one yields a usable frame. With ein-words, mass nouns and + // definite idioms can rule a preposition out entirely (e.g. trotz, whose + // nouns are all uncountable — „trotz eines Wetters" is not German). + let prepKey, cas, tw = null, r = null; + for (let i = 0; i <= lv.preps.length && !r; i++) { + prepKey = drawBag(state, 'prep', lv.preps, state.last.prep); + const pc = PREPS[prepKey].c; + if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } + else { tw = null; cas = pc; } - // Mixed definite lessons: contraction-capable prep → fused "chunk" question. - if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) { - return makeChunkQ(prepKey, cas, tw, state); + // Mixed definite lessons: contraction-capable prep → fused "chunk" question. + if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) { + state.last.prep = prepKey; + return makeChunkQ(prepKey, cas, tw, state); + } + + r = realizeFrame(prepKey, cas, tw, state, { indefOnly: indef }); } + if (!r) r = realizeFrame(prepKey, cas, tw, state); // hard fallback; unreachable with current data + state.last.prep = prepKey; - const r = realizeFrame(prepKey, cas, tw, state); - const tbl = lv.artType === 'indefinite' ? INDEF : DEF; + const tbl = indef ? INDEF : DEF; const ans = tbl[r.noun.g][cas]; + // ein-words translate as a/an, never the frame's default "the". + const detEn = indef ? (/^[aeiou]/i.test(r.noun.en) ? 'an' : 'a') : r.enDet; const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`; - const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`; - const rule = pc === 'wechsel' + const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, detEn)}${r.postEn}.`; + const rule = PREPS[prepKey].c === 'wechsel' ? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`) : `„${prepKey}" → always ${CL[cas]}`; return {type:'article', noun:r.noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices:articleChoices(ans, lv.artType)}; diff --git a/src/engine.test.js b/src/engine.test.js index 4872a01..b49d76d 100644 --- a/src/engine.test.js +++ b/src/engine.test.js @@ -109,6 +109,32 @@ describe('article questions', () => { 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 ───────────────────────────────────