Add the Adjectives section, and explain why the noun itself changes
Two gaps, both visible from one screenshot of the Genitiv level. 1. Adjective endings were half-present and entirely untested. STRONG and WEAK existed in engine.js with correct data, but only as the S/W columns of a reference chart — nothing generated them, nothing tested them, and the MIXED table (the one "in seinem eigenen Kontext" actually needs) did not exist at all. There was not a single adjective in the codebase. 2. Nothing explained why "Regen" becomes "Regens" after trotz. New Adjectives tab, six lessons plus Daily Practice: Schwach after der/diese/jede — only -e and -en Gemischt after ein/kein/mein — weak plus three exceptions Stark no determiner — the adjective carries the case Welche Tabelle? der-word, ein-word or nothing? (the meta-skill) Nach Präposition the full chain: preposition -> case -> table Alles gemischt all three tables, including Genitiv The three tables are built on one idea: the case has to be signalled once in the noun phrase, and whoever can show it does. MIXED is therefore not hand-typed — it is DERIVED from POSS_ENDINGS as "weak, except in the three slots where the ein-word has no ending of its own, where it borrows the strong one". A test asserts that relationship as well as all 48 cells, so the rule cannot silently rot. Two info panels: - "Which ending table?" — the one-signal idea, the three tables, plus the two traps: strong genitive masc/neut is -en not -es (the noun already carries the -(e)s), and predicate adjectives take no ending at all. - "Why Regens?" — appears wherever the case changes the noun itself: the -es/-s split, that feminines and plurals never change, the dative plural -n, and a note on the N-declension nouns Beug does not have yet. Notable decisions: - Nominative is now tested, for adjectives only. der gute Mann vs ein guter Mann is the clearest illustration of the whole weak/mixed split and it exists only in the nominative, so the app-wide "no nominative" rule is explicitly scoped to non-adjective levels rather than quietly dropped. - The tab bar measured 369px of content in a 350px bar with four tabs, so a fifth could not fit. The row now scrolls instead of squeezing labels. - Adjectives are gated like Possessives and Pronouns. The corpus caught its keep again. Adjective sentences are enumerated into corpus.txt keyed on the semantic pairing (frame x adjective x noun), which keeps it at ~2000 lines instead of exploding over every determiner and case. Reading it surfaced, and this commit fixes: "I live in the old lake" (places split into buildings/settlements/landscape), "I play with the blue sofa", "I like the bad kitchen" (frames that assert liking now exclude negative adjectives), "I buy the good time" and "I cook with the good money" (pools narrowed to what you can buy and cook with), grosser/kleiner of a person rendering as "big/small" rather than "tall/short", and "in dem alten Hotel" failing to fuse into "im alten Hotel". 130 tests pass; 12000 generated questions across all 30 levels. Known gap, stated in the rule panel: Beug has no plural nouns, so the plural column of all three tables is shown for reference but not drilled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
+284
-6
@@ -3,6 +3,7 @@ import {
|
|||||||
DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C,
|
DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C,
|
||||||
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
|
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
|
||||||
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
|
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
|
||||||
|
nounForm, ADJ_ENDINGS, ADJ_CLASS_LABEL, ADJ_CLASS_HINT, DER_ENDINGS,
|
||||||
} from './engine.js';
|
} from './engine.js';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -58,12 +59,15 @@ const TABS = [
|
|||||||
{id:'article', label:'Articles'},
|
{id:'article', label:'Articles'},
|
||||||
{id:'poss', label:'Possessives'},
|
{id:'poss', label:'Possessives'},
|
||||||
{id:'pronoun', label:'Pronouns'},
|
{id:'pronoun', label:'Pronouns'},
|
||||||
|
{id:'adj', label:'Adjectives'},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Daily Practice pseudo-levels are always free: unlocking one requires
|
// Daily Practice pseudo-levels are always free: unlocking one requires
|
||||||
// passing every lesson in its tab, so gated tabs stay gated anyway.
|
// passing every lesson in its tab, so gated tabs stay gated anyway.
|
||||||
const DAILY_IDS = LEVELS.filter(l => l.daily).map(l => l.id);
|
const DAILY_IDS = LEVELS.filter(l => l.daily).map(l => l.id);
|
||||||
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18, ...DAILY_IDS]);
|
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18, ...DAILY_IDS]);
|
||||||
|
const ADJ_GENDERS = ['masculine','feminine','neuter','plural'];
|
||||||
|
const ADJ_CASES = ['nominative','accusative','dative','genitive'];
|
||||||
const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
|
const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
|
||||||
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
|
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
|
||||||
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
|
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
|
||||||
@@ -185,6 +189,7 @@ export default function App() {
|
|||||||
const [chart, setChart] = useState(false);
|
const [chart, setChart] = useState(false);
|
||||||
const [rule, setRule] = useState(false);
|
const [rule, setRule] = useState(false);
|
||||||
const [pickPrep, setPickPrep] = useState(null);
|
const [pickPrep, setPickPrep] = useState(null);
|
||||||
|
const [nounInfo, setNounInfo] = useState(null);
|
||||||
const [learnStep, setLearnStep] = useState(0);
|
const [learnStep, setLearnStep] = useState(0);
|
||||||
const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null});
|
const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null});
|
||||||
const [access, setAccess] = useState(null);
|
const [access, setAccess] = useState(null);
|
||||||
@@ -287,6 +292,7 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = e => {
|
const h = e => {
|
||||||
|
if (nounInfo) { if (e.key === 'Escape') setNounInfo(null); return; }
|
||||||
if (rule) { if (e.key === 'Escape') setRule(false); return; }
|
if (rule) { if (e.key === 'Escape') setRule(false); return; }
|
||||||
if (chart) { if (e.key === 'Escape') setChart(false); return; }
|
if (chart) { if (e.key === 'Escape') setChart(false); return; }
|
||||||
if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; }
|
if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; }
|
||||||
@@ -339,7 +345,7 @@ export default function App() {
|
|||||||
|
|
||||||
const rc = res.filter(Boolean).length;
|
const rc = res.filter(Boolean).length;
|
||||||
const cur = LEVELS.find(l=>l.id===lv);
|
const cur = LEVELS.find(l=>l.id===lv);
|
||||||
const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab));
|
const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type === 'adjective' || q?.type === 'adjClass') ? 'adj' : (q?.type || tab));
|
||||||
const nextInTab = nextLevelInTab(lv);
|
const nextInTab = nextLevelInTab(lv);
|
||||||
const quickActive = !!cur?.daily || !!st.auto;
|
const quickActive = !!cur?.daily || !!st.auto;
|
||||||
const quickTick = quickActive && rev && ok;
|
const quickTick = quickActive && rev && ok;
|
||||||
@@ -598,6 +604,14 @@ export default function App() {
|
|||||||
{q.type === 'poss' && <> · <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative}</span> {q.noun.w}</>}
|
{q.type === 'poss' && <> · <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative}</span> {q.noun.w}</>}
|
||||||
</Hideable>
|
</Hideable>
|
||||||
|
|
||||||
|
{nounForm(q.noun, q.cas) !== q.noun.w && (
|
||||||
|
<div style={{...S.secondaryContext, marginTop:-10}}>
|
||||||
|
<button style={{...S.ruleBtn, marginLeft:0}} onClick={()=>setNounInfo(q)}>
|
||||||
|
ⓘ why {nounForm(q.noun, q.cas)}?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!rev ? (
|
{!rev ? (
|
||||||
q.choices ? (
|
q.choices ? (
|
||||||
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
||||||
@@ -754,6 +768,72 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PRACTICE: adjective endings */}
|
||||||
|
{scr === 'practice' && (q?.type === 'adjective' || q?.type === 'adjClass') && (
|
||||||
|
<div style={{textAlign:'center', paddingTop:10}}>
|
||||||
|
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>
|
||||||
|
{q.sentence.split('_____').map((part,i,a)=>(
|
||||||
|
<span key={i}>
|
||||||
|
{part}
|
||||||
|
{i < a.length-1 && (
|
||||||
|
<span style={{...S.blank, minWidth:56,
|
||||||
|
borderBottomColor: rev?(ok?GREEN:RED):'#bbb',
|
||||||
|
color: rev?(ok?GREEN:RED):'#1a1a1a',
|
||||||
|
}}>
|
||||||
|
{rev ? ADJ_ENDINGS[q.cls][q.noun.g][q.cas] : ' '}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
|
||||||
|
{q.translation}
|
||||||
|
</Hideable>
|
||||||
|
|
||||||
|
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
|
||||||
|
<span style={{color: GENDER_C[q.noun.g], fontWeight:700}}>{DEF[q.noun.g].nominative}</span>
|
||||||
|
{' '}{q.noun.w}
|
||||||
|
<span style={S.keyInfoMute}> ({q.noun.en})</span>
|
||||||
|
</Hideable>
|
||||||
|
|
||||||
|
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
|
||||||
|
<span style={{color: CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
|
||||||
|
{q.prepKey && <> · {q.prepKey}</>}
|
||||||
|
{q.type === 'adjective' && <> · {ADJ_CLASS_LABEL[q.cls]}</>}
|
||||||
|
</Hideable>
|
||||||
|
|
||||||
|
<div style={S.secondaryContext}>
|
||||||
|
{q.type === 'adjClass' ? 'Which ending table?' : 'Which ending?'}
|
||||||
|
<button style={S.ruleBtn} onClick={()=>setRule('adj')}>ⓘ Rule</button>
|
||||||
|
{nounForm(q.noun, q.cas) !== q.noun.w && (
|
||||||
|
<button style={S.ruleBtn} onClick={()=>setNounInfo(q)}>ⓘ why {nounForm(q.noun, q.cas)}?</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!rev ? (
|
||||||
|
q.type === 'adjClass' ? (
|
||||||
|
<div style={{display:'grid', gap:10, maxWidth:330, margin:'8px auto 0'}}>
|
||||||
|
{q.choices.map((c,i) => (
|
||||||
|
<button key={c} style={{...S.choiceBtn, borderColor:'#ddd', textAlign:'left', padding:'13px 16px'}}
|
||||||
|
onClick={()=>doSubmit(c)}>
|
||||||
|
<span style={{fontWeight:700}}>{ADJ_CLASS_LABEL[c]}</span>
|
||||||
|
<span style={{fontSize:12, color:'#999'}}> — {ADJ_CLASS_HINT[c]}</span>
|
||||||
|
{!IS_TOUCH && <kbd style={{...S.kbdSmall, marginLeft:8}}>{i+1}</kbd>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AnswerGrid choices={q.choices.map(c => '-' + c)} onPick={c => doSubmit(c.slice(1))}/>
|
||||||
|
)
|
||||||
|
) : quickTick ? (
|
||||||
|
<QuickTick/>
|
||||||
|
) : (
|
||||||
|
<AdjectiveFeedback q={q} inp={inp} ok={ok} onRule={()=>setRule('adj')}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{scr === 'practice' && (
|
{scr === 'practice' && (
|
||||||
<div style={S.bottomBar}>
|
<div style={S.bottomBar}>
|
||||||
<div style={S.bottomBarInner}>
|
<div style={S.bottomBarInner}>
|
||||||
@@ -823,8 +903,9 @@ export default function App() {
|
|||||||
{scr !== 'practice' && <FeatureRequestFooter />}
|
{scr !== 'practice' && <FeatureRequestFooter />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)} onRule={()=>{ setChart(false); setRule(true); }}/>}
|
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)} onRule={kind => { setChart(false); setRule(kind || true); }}/>}
|
||||||
{rule && <WechselRuleModal onClose={()=>setRule(false)}/>}
|
{rule && (rule === 'adj' ? <AdjRuleModal onClose={()=>setRule(false)}/> : <WechselRuleModal onClose={()=>setRule(false)}/>)}
|
||||||
|
{nounInfo && <NounEndingModal q={nounInfo} onClose={()=>setNounInfo(null)}/>}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
|
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
|
||||||
@@ -834,6 +915,7 @@ export default function App() {
|
|||||||
html,body{margin:0;background:#f7f7f7}
|
html,body{margin:0;background:#f7f7f7}
|
||||||
#root{min-height:100%}
|
#root{min-height:100%}
|
||||||
input{scroll-margin-top:60px;scroll-margin-bottom:80px}
|
input{scroll-margin-top:60px;scroll-margin-bottom:80px}
|
||||||
|
::-webkit-scrollbar{display:none}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1165,6 +1247,183 @@ function WechselContextFeedback({ q, inp, ok, onRule }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdjectiveFeedback({ q, inp, ok, onRule }) {
|
||||||
|
const ending = ADJ_ENDINGS[q.cls][q.noun.g][q.cas];
|
||||||
|
const isClassQ = q.type === 'adjClass';
|
||||||
|
const gotLabel = isClassQ ? (ADJ_CLASS_LABEL[inp] || inp) : '-' + inp;
|
||||||
|
const wantLabel = isClassQ ? ADJ_CLASS_LABEL[q.cls] : '-' + ending;
|
||||||
|
return (
|
||||||
|
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:430}}>
|
||||||
|
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
|
||||||
|
{ok
|
||||||
|
? <>✓ <span style={{color:GREEN}}>{wantLabel}</span></>
|
||||||
|
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{gotLabel}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{wantLabel}</span></>}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:15, fontWeight:600, color:'#1a1a1a', marginBottom:10, lineHeight:1.45}}>
|
||||||
|
{q.answerSentence}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.55}}>
|
||||||
|
{q.det
|
||||||
|
? <>„{q.det}" is {q.cls === 'weak' ? 'a der-word' : 'an ein-word'} → <strong style={{color:'#1a1a1a'}}>{ADJ_CLASS_LABEL[q.cls]}</strong></>
|
||||||
|
: <>No determiner → the adjective has to carry the case → <strong style={{color:'#1a1a1a'}}>Stark</strong></>}
|
||||||
|
{' · '}<span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span>
|
||||||
|
{' '}<span style={{color:GENDER_C[q.noun.g]}}>{q.noun.g}</span>
|
||||||
|
{' → '}<strong style={{color:GREEN}}>-{ending}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop:6, fontSize:11, color:'#aaa', marginBottom:2}}>
|
||||||
|
{ADJ_CLASS_LABEL[q.cls]} — {ADJ_CLASS_HINT[q.cls]}
|
||||||
|
</div>
|
||||||
|
<AdjTable cls={q.cls} g={q.noun.g} cas={q.cas} wrong={!ok && !isClassQ ? inp : null}/>
|
||||||
|
<button style={{...S.ruleBtn, marginLeft:0, marginTop:12}} onClick={onRule}>ⓘ All three tables</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdjTable({ cls, g, cas, wrong }) {
|
||||||
|
return (
|
||||||
|
<table style={S.stripTbl}>
|
||||||
|
<thead><tr>
|
||||||
|
<th style={{...S.stripH, textAlign:'left'}}></th>
|
||||||
|
{ADJ_GENDERS.map(x => <th key={x} style={{...S.stripH, color:GENDER_C[x], opacity: x===g?1:0.5}}>{x.slice(0,4)}.</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ADJ_CASES.map(c => (
|
||||||
|
<tr key={c}>
|
||||||
|
<td style={{...S.stripD, textAlign:'left', fontSize:11, fontWeight:600, color:CASE_C[c]}}>{CL[c]}</td>
|
||||||
|
{ADJ_GENDERS.map(x => {
|
||||||
|
const val = ADJ_ENDINGS[cls][x][c];
|
||||||
|
const hit = x === g && c === cas;
|
||||||
|
const bad = !hit && wrong && x === g && val === wrong;
|
||||||
|
return <td key={x} style={{...S.stripD, borderRadius:4,
|
||||||
|
background: hit ? GREEN+'22' : bad ? RED+'22' : 'transparent',
|
||||||
|
fontWeight: (hit||bad) ? 700 : 400,
|
||||||
|
color: hit ? GREEN : bad ? RED : (x===g ? '#555' : '#c8c8c8')}}>-{val}</td>;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why the noun itself changed — the last surviving case ending on German nouns.
|
||||||
|
function NounEndingModal({ q, onClose }) {
|
||||||
|
const changed = nounForm(q.noun, q.cas);
|
||||||
|
return (
|
||||||
|
<div style={S.overlay} onClick={onClose}>
|
||||||
|
<div style={S.modal} onClick={e=>e.stopPropagation()}>
|
||||||
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
|
||||||
|
<div style={{fontSize:17,fontWeight:700}}>Why „{changed}"?</div>
|
||||||
|
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={S.ruleLead}>
|
||||||
|
German normally leaves the noun alone and lets the article do the work. The
|
||||||
|
genitive is the one surviving exception: <strong>masculine and neuter nouns
|
||||||
|
add -s or -es in the genitive singular</strong>. So <em>{q.noun.w}</em> becomes
|
||||||
|
<strong> {changed}</strong>.
|
||||||
|
</div>
|
||||||
|
<div style={S.ruleEx}>
|
||||||
|
{DEF[q.noun.g].genitive} <strong>{changed}</strong> — {q.noun.en}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>-es or just -s?</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
<strong>-es</strong> after one-syllable nouns, and after -s, -ß, -x, -z:<br/>
|
||||||
|
<em>des Hauses, des Mannes, des Kindes, des Platzes</em>
|
||||||
|
</div>
|
||||||
|
<div style={{...S.ruleBody, marginTop:8}}>
|
||||||
|
<strong>-s</strong> after longer nouns — especially those ending in -el, -en, -er —
|
||||||
|
and after foreign words:<br/>
|
||||||
|
<em>des Regens, des Vaters, des Lehrers, des Autos, des Hotels</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>Feminine nouns never change</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
<em>der Frau, der Stadt, der Nacht</em> — the article alone marks the case.
|
||||||
|
Same for plurals: <em>der Kinder</em>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>The only other one left</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
The dative plural adds -n if the plural doesn't already end in one:
|
||||||
|
<em> mit den Kindern, zwischen den Bäumen</em>.
|
||||||
|
</div>
|
||||||
|
<div style={{...S.ruleBody, marginTop:8, color:'#999', fontSize:12}}>
|
||||||
|
There is also a small group of masculine nouns — <em>der Junge, der Student,
|
||||||
|
der Herr, der Mensch</em> — that take -n in every case except the nominative.
|
||||||
|
None of them appear in Beug yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button style={{...S.nextBtn, width:'100%', marginTop:16}} onClick={onClose}>
|
||||||
|
Got it{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three adjective tables and the single idea underneath them.
|
||||||
|
function AdjRuleModal({ onClose }) {
|
||||||
|
const rows = [
|
||||||
|
['weak', 'After a der-word', 'der · dieser · jeder · welcher · alle',
|
||||||
|
'The determiner already shows the case, so the adjective goes limp — only -e or -en.',
|
||||||
|
'der gut\u00ADe Mann · den guten Mann'],
|
||||||
|
['mixed', 'After an ein-word', 'ein · kein · mein · dein · sein · ihr · unser · euer',
|
||||||
|
'Same as weak, EXCEPT the three slots where the ein-word has no ending of its own. There the adjective steps in and takes the strong ending.',
|
||||||
|
'ein guter Mann · ein gutes Kind'],
|
||||||
|
['strong', 'No determiner', '— nothing in front —',
|
||||||
|
'Nothing else can signal the case, so the adjective carries the full load.',
|
||||||
|
'guter Wein · kaltes Wasser'],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={S.overlay} onClick={onClose}>
|
||||||
|
<div style={S.modal} onClick={e=>e.stopPropagation()}>
|
||||||
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
|
||||||
|
<div style={{fontSize:17,fontWeight:700}}>Which ending table?</div>
|
||||||
|
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleLead}>
|
||||||
|
One idea holds the whole system together: <strong>the case and gender have to be
|
||||||
|
signalled exactly once in the noun phrase.</strong> Whoever is able to show it,
|
||||||
|
shows it — and whoever is left over relaxes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.map(([cls, title, words, body, ex]) => (
|
||||||
|
<div key={cls} style={{...S.ruleCard, borderLeft:`3px solid ${cls==='weak'?CASE_C.dative:cls==='mixed'?CASE_C.accusative:CASE_C.genitive}`}}>
|
||||||
|
<div style={{fontWeight:700, fontSize:13, marginBottom:2}}>
|
||||||
|
{ADJ_CLASS_LABEL[cls]} — {title}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:11, color:'#999', marginBottom:6}}>{words}</div>
|
||||||
|
<div style={S.ruleBody}>{body}</div>
|
||||||
|
<div style={{...S.ruleEx, marginBottom:0}}>{ex}</div>
|
||||||
|
<AdjTable cls={cls} g={null} cas={null} wrong={null}/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={S.ruleH}>Two things worth pinning down</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
<strong>Strong genitive masc/neut is -en, not -es</strong> — <em>trotz schlechten
|
||||||
|
Wetters</em>. The noun already carries the -(e)s, so the adjective doesn't repeat it.
|
||||||
|
</div>
|
||||||
|
<div style={{...S.ruleBody, marginTop:10}}>
|
||||||
|
<strong>After the verb, an adjective takes no ending at all</strong> —
|
||||||
|
<em> Das Haus ist groß</em>, never <em>großes</em>. An adjective only declines
|
||||||
|
when it stands in front of its noun.
|
||||||
|
</div>
|
||||||
|
<div style={{...S.ruleBody, marginTop:10, color:'#999', fontSize:12}}>
|
||||||
|
Beug has no plural nouns yet, so the plural column is shown for reference but is
|
||||||
|
not drilled.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button style={{...S.nextBtn, width:'100%', marginTop:16}} onClick={onClose}>
|
||||||
|
Got it{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The wohin/wo rule, the trap, the verb pairs, and the one real exception.
|
// The wohin/wo rule, the trap, the verb pairs, and the one real exception.
|
||||||
function WechselRuleModal({ onClose }) {
|
function WechselRuleModal({ onClose }) {
|
||||||
const PAIRS = [
|
const PAIRS = [
|
||||||
@@ -1453,7 +1712,7 @@ function ChartModal({ section, q, onClose, onRule }) {
|
|||||||
<strong>Wechsel</strong>: Akkusativ when the phrase is where something ends up
|
<strong>Wechsel</strong>: Akkusativ when the phrase is where something ends up
|
||||||
(wohin?), Dativ when it is where the action happens (wo?) — movement alone does
|
(wohin?), Dativ when it is where the action happens (wo?) — movement alone does
|
||||||
not decide it.
|
not decide it.
|
||||||
{onRule && <button style={{...S.ruleBtn, marginLeft:0, marginTop:8, display:'block'}} onClick={onRule}>ⓘ Wohin oder wo?</button>}
|
{onRule && <button style={{...S.ruleBtn, marginLeft:0, marginTop:8, display:'block'}} onClick={()=>onRule()}>ⓘ Wohin oder wo?</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1469,6 +1728,23 @@ function ChartModal({ section, q, onClose, onRule }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === 'adj' && (
|
||||||
|
<div style={{marginBottom:18}}>
|
||||||
|
<div style={S.chartH}>Adjective Endings</div>
|
||||||
|
<div style={{fontSize:11, color:'#999', marginBottom:10, lineHeight:1.5}}>
|
||||||
|
The case is signalled once. If the determiner shows it, the adjective goes limp.
|
||||||
|
</div>
|
||||||
|
{['weak','mixed','strong'].map(cls => (
|
||||||
|
<div key={cls} style={{marginBottom:14}}>
|
||||||
|
<div style={{fontSize:12, fontWeight:700, marginBottom:2}}>{ADJ_CLASS_LABEL[cls]}</div>
|
||||||
|
<div style={{fontSize:11, color:'#999'}}>{ADJ_CLASS_HINT[cls]}</div>
|
||||||
|
<AdjTable cls={cls} g={q?.noun?.g} cas={q?.cas} wrong={null}/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{onRule && <button style={{...S.ruleBtn, marginLeft:0}} onClick={()=>onRule('adj')}>ⓘ Which ending table?</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'pronoun' && (
|
{section === 'pronoun' && (
|
||||||
<div style={{marginBottom:18}}>
|
<div style={{marginBottom:18}}>
|
||||||
<div style={S.chartH}>Personal Pronouns</div>
|
<div style={S.chartH}>Personal Pronouns</div>
|
||||||
@@ -1584,8 +1860,10 @@ const S = {
|
|||||||
pillBtnOn: { border:`1.5px solid ${GREEN}`, color:GREEN, background:'#f3ffe8' },
|
pillBtnOn: { border:`1.5px solid ${GREEN}`, color:GREEN, background:'#f3ffe8' },
|
||||||
chartsPill: { position:'absolute', right:20, bottom:2, borderRadius:999, padding:'5px 13px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, border:'1.5px solid #d5d5d5', color:'#777', background:'#fff' },
|
chartsPill: { position:'absolute', right:20, bottom:2, borderRadius:999, padding:'5px 13px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, border:'1.5px solid #d5d5d5', color:'#777', background:'#fff' },
|
||||||
|
|
||||||
tabs: { display:'flex', gap:0, borderBottom:'1px solid #eee', marginTop:6, marginBottom:12 },
|
// Five tabs no longer fit across a phone, so the row scrolls instead of
|
||||||
tab: { flex:1, background:'none', border:'none', padding:'12px 8px', cursor:'pointer', color:'#999', fontSize:13, fontWeight:600, fontFamily:'inherit', borderBottomWidth:2, borderBottomStyle:'solid', borderBottomColor:'transparent', marginBottom:-1, transition:'all 0.15s' },
|
// squeezing the labels. The scrollbar itself is hidden (see the <style> block).
|
||||||
|
tabs: { display:'flex', gap:0, borderBottom:'1px solid #eee', marginTop:6, marginBottom:12, overflowX:'auto', scrollbarWidth:'none' },
|
||||||
|
tab: { flex:'0 0 auto', background:'none', border:'none', padding:'12px 8px', cursor:'pointer', color:'#999', fontSize:13, fontWeight:600, fontFamily:'inherit', borderBottomWidth:2, borderBottomStyle:'solid', borderBottomColor:'transparent', marginBottom:-1, transition:'all 0.15s' },
|
||||||
tabActive: { color:'#1a1a1a', borderBottomColor:GREEN },
|
tabActive: { color:'#1a1a1a', borderBottomColor:GREEN },
|
||||||
|
|
||||||
hero: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 22px', textAlign:'left' },
|
hero: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 22px', textAlign:'left' },
|
||||||
|
|||||||
+2067
File diff suppressed because it is too large
Load Diff
+306
@@ -168,6 +168,14 @@ export const NOUNS = [
|
|||||||
{w:'Dorf',g:'neuter',en:'village',gen:'Dorfes'},
|
{w:'Dorf',g:'neuter',en:'village',gen:'Dorfes'},
|
||||||
{w:'Bild',g:'neuter',en:'picture',gen:'Bildes'},
|
{w:'Bild',g:'neuter',en:'picture',gen:'Bildes'},
|
||||||
{w:'Wasser',g:'neuter',en:'water',gen:'Wassers',noIndef:true},
|
{w:'Wasser',g:'neuter',en:'water',gen:'Wassers',noIndef:true},
|
||||||
|
{w:'Brot',g:'neuter',en:'bread',gen:'Brotes',noIndef:true},
|
||||||
|
{w:'Bier',g:'neuter',en:'beer',gen:'Bieres',noIndef:true},
|
||||||
|
{w:'Geld',g:'neuter',en:'money',gen:'Geldes',noIndef:true},
|
||||||
|
{w:'Kaffee',g:'masculine',en:'coffee',gen:'Kaffees',noIndef:true},
|
||||||
|
{w:'Tee',g:'masculine',en:'tea',gen:'Tees',noIndef:true},
|
||||||
|
{w:'Wein',g:'masculine',en:'wine',gen:'Weines',noIndef:true},
|
||||||
|
{w:'Milch',g:'feminine',en:'milk',noIndef:true},
|
||||||
|
{w:'Zeit',g:'feminine',en:'time',noIndef:true},
|
||||||
];
|
];
|
||||||
const NOUN_BY_NAME = Object.fromEntries(NOUNS.map(n => [n.w, n]));
|
const NOUN_BY_NAME = Object.fromEntries(NOUNS.map(n => [n.w, n]));
|
||||||
export const nounForm = (noun, cas) =>
|
export const nounForm = (noun, cas) =>
|
||||||
@@ -220,6 +228,174 @@ export const POSS_ENDINGS = {
|
|||||||
};
|
};
|
||||||
export const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending;
|
export const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// ADJECTIVE ENDINGS
|
||||||
|
// Three tables, chosen by what stands in front of the adjective. The idea the
|
||||||
|
// whole system rests on (the "one signal" principle American courses teach):
|
||||||
|
// the case/gender signal has to appear ONCE in the noun phrase. If the
|
||||||
|
// determiner already carries it, the adjective goes limp (-e/-en). If there is
|
||||||
|
// no determiner, the adjective has to carry it itself.
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
// der-word endings: der/die/das, dieser, jeder, welcher, jener, alle.
|
||||||
|
// Note genitive masc/neut is -es here but -en on an adjective — the one place
|
||||||
|
// the two tables part company.
|
||||||
|
export const DER_ENDINGS = {
|
||||||
|
masculine:{nominative:'er',accusative:'en',dative:'em',genitive:'es'},
|
||||||
|
feminine: {nominative:'e', accusative:'e', dative:'er',genitive:'er'},
|
||||||
|
neuter: {nominative:'es',accusative:'es',dative:'em',genitive:'es'},
|
||||||
|
plural: {nominative:'e', accusative:'e', dative:'en',genitive:'er'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADJ_GENDERS = ['masculine','feminine','neuter','plural'];
|
||||||
|
const ADJ_CASES = ['nominative','accusative','dative','genitive'];
|
||||||
|
|
||||||
|
// STRONG/WEAK already exist above as single signal letters; as adjective
|
||||||
|
// endings they are those letters with a leading -e where needed.
|
||||||
|
const ending = letter => (letter === 'e' ? 'e' : 'e' + letter);
|
||||||
|
export const ADJ_STRONG = Object.fromEntries(ADJ_GENDERS.map(g =>
|
||||||
|
[g, Object.fromEntries(ADJ_CASES.map(c => [c, ending(STRONG[g][c])]))]));
|
||||||
|
export const ADJ_WEAK = Object.fromEntries(ADJ_GENDERS.map(g =>
|
||||||
|
[g, Object.fromEntries(ADJ_CASES.map(c => [c, ending(WEAK[g][c])]))]));
|
||||||
|
|
||||||
|
// MIXED is not a third thing to memorise: it IS weak, except in the three
|
||||||
|
// slots where the ein-word itself has no ending (ein_ guter, ein_ gutes,
|
||||||
|
// ein_ gutes). There the adjective picks up the strong ending instead.
|
||||||
|
// Derived from POSS_ENDINGS so the rule is enforced, never duplicated.
|
||||||
|
export const ADJ_MIXED = Object.fromEntries(ADJ_GENDERS.map(g =>
|
||||||
|
[g, Object.fromEntries(ADJ_CASES.map(c =>
|
||||||
|
[c, POSS_ENDINGS[g][c] === '' ? ADJ_STRONG[g][c] : ADJ_WEAK[g][c]]))]));
|
||||||
|
|
||||||
|
export const ADJ_ENDINGS = { strong: ADJ_STRONG, weak: ADJ_WEAK, mixed: ADJ_MIXED };
|
||||||
|
export const ADJ_CLASS_LABEL = { weak:'Schwach', mixed:'Gemischt', strong:'Stark' };
|
||||||
|
export const ADJ_CLASS_HINT = {
|
||||||
|
weak: 'after a der-word — it already shows the case',
|
||||||
|
mixed: 'after an ein-word — sometimes it shows nothing',
|
||||||
|
strong:'no determiner — the adjective must show the case',
|
||||||
|
};
|
||||||
|
// Every distinct ending, so wrong answers are always plausible endings.
|
||||||
|
export const ADJ_ALL_ENDINGS = ['e','en','er','es','em'];
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// ADJECTIVES
|
||||||
|
// Adjectives declare which kinds of noun they may describe, the same
|
||||||
|
// discipline the preposition frames use — "ein grünes Kind" has to be
|
||||||
|
// impossible to build, not merely unlikely to come up.
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
export const ADJ_NOUN_GROUPS = {
|
||||||
|
people: ['Mann','Frau','Arzt','Ärztin','Freund','Freundin','Lehrer','Lehrerin',
|
||||||
|
'Bruder','Schwester','Vater','Mutter','Tochter','Kind','Mädchen'],
|
||||||
|
animals: ['Hund','Katze'],
|
||||||
|
things: ['Buch','Auto','Tisch','Stuhl','Fenster','Tür','Bett','Sofa','Schrank',
|
||||||
|
'Regal','Tasche','Bild','Zug'],
|
||||||
|
// Buildings you can be inside; settlements you can live in; landscape you
|
||||||
|
// cannot ("Ich wohne in dem alten See" was reachable before this split).
|
||||||
|
buildings: ['Haus','Hotel','Museum','Büro','Restaurant','Kirche','Bibliothek',
|
||||||
|
'Supermarkt','Schule','Bahnhof','Zimmer','Küche','Keller','Wohnung'],
|
||||||
|
settlements:['Stadt','Dorf'],
|
||||||
|
landscape: ['Park','Garten','Wald','See','Fluss','Berg','Meer','Wiese','Brücke','Straße'],
|
||||||
|
mass: ['Wasser','Milch','Brot','Kaffee','Tee','Bier','Wein','Geld','Zeit','Musik','Arbeit'],
|
||||||
|
weather: ['Wetter','Regen'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// groups = broad compatibility; nouns = an explicit list where the adjective is
|
||||||
|
// fussy and a whole group would let nonsense through.
|
||||||
|
// enPeople: German groß/klein mean tall/short of a person.
|
||||||
|
// neg: excluded from frames that assert liking ("I like the bad kitchen").
|
||||||
|
export const ADJECTIVES = [
|
||||||
|
{de:'groß', en:'big', enPeople:'tall', groups:['people','animals','things','buildings','settlements','landscape']},
|
||||||
|
{de:'klein', en:'small', enPeople:'short', groups:['people','animals','things','buildings','settlements','landscape']},
|
||||||
|
{de:'alt', en:'old', groups:['people','animals','things','buildings','settlements']},
|
||||||
|
{de:'neu', en:'new', groups:['things','buildings']},
|
||||||
|
{de:'jung', en:'young', groups:['people','animals']},
|
||||||
|
{de:'schön', en:'beautiful', groups:['things','buildings','settlements','landscape'], nouns:['Musik','Wetter']},
|
||||||
|
{de:'gut', en:'good', groups:['things','buildings','people','mass','weather']},
|
||||||
|
{de:'schlecht', en:'bad', neg:true, groups:['things','buildings','mass','weather']},
|
||||||
|
{de:'freundlich', en:'friendly', groups:['people','animals']},
|
||||||
|
{de:'müde', en:'tired', neg:true, groups:['people','animals']},
|
||||||
|
{de:'krank', en:'sick', neg:true, groups:['people','animals']},
|
||||||
|
{de:'interessant', en:'interesting', nouns:['Buch','Bild','Museum','Arbeit','Musik','Konzert']},
|
||||||
|
{de:'wichtig', en:'important', nouns:['Buch','Arbeit','Zeit','Geld','Konzert']},
|
||||||
|
{de:'billig', en:'cheap', nouns:['Auto','Buch','Tasche','Zug','Hotel','Restaurant','Kaffee','Bier','Wein','Bild']},
|
||||||
|
{de:'kalt', en:'cold', nouns:['Wasser','Milch','Kaffee','Tee','Bier','Wetter','Zimmer','Keller','Haus','Nacht']},
|
||||||
|
{de:'warm', en:'warm', nouns:['Wasser','Milch','Kaffee','Tee','Brot','Wetter','Zimmer','Haus']},
|
||||||
|
{de:'laut', en:'loud', neg:true, nouns:['Musik','Stadt','Straße','Restaurant','Hund','Konzert','Kind']},
|
||||||
|
{de:'ruhig', en:'quiet', nouns:['Zimmer','Dorf','Garten','Wald','Straße','Hund','Katze','Kind']},
|
||||||
|
{de:'lang', en:'long', nouns:['Straße','Brücke','Fluss','Buch','Nacht','Woche','Arbeit']},
|
||||||
|
{de:'rot', en:'red', nouns:['Auto','Buch','Tasche','Tür','Sofa','Bild','Wein','Katze']},
|
||||||
|
{de:'grün', en:'green', nouns:['Auto','Buch','Tasche','Tür','Sofa','Wiese','Wald','Tee']},
|
||||||
|
{de:'schwarz', en:'black', nouns:['Auto','Buch','Tasche','Tür','Sofa','Hund','Katze','Kaffee','Tee','Bild']},
|
||||||
|
{de:'weiß', en:'white', nouns:['Auto','Haus','Tür','Sofa','Tasche','Hund','Katze','Milch','Brot','Wein']},
|
||||||
|
{de:'blau', en:'blue', nouns:['Auto','Buch','Tasche','Tür','Sofa','See','Meer']},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const adjNouns = adj => [...new Set([
|
||||||
|
...(adj.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]),
|
||||||
|
...(adj.nouns || []),
|
||||||
|
])];
|
||||||
|
const PEOPLE_SET = new Set(ADJ_NOUN_GROUPS.people);
|
||||||
|
export const adjEn = (adj, noun) => (adj.enPeople && PEOPLE_SET.has(noun.w)) ? adj.enPeople : adj.en;
|
||||||
|
|
||||||
|
export const DER_WORD_DETS = [
|
||||||
|
{stem:'d', en:'the', definite:true},
|
||||||
|
{stem:'dies', en:'this'},
|
||||||
|
{stem:'jed', en:'every'},
|
||||||
|
];
|
||||||
|
export const EIN_WORD_DETS = [
|
||||||
|
{stem:'ein', en:'a'},
|
||||||
|
{stem:'kein', en:'no'},
|
||||||
|
{stem:'mein', en:'my', poss:true},
|
||||||
|
{stem:'dein', en:'your', poss:true},
|
||||||
|
{stem:'sein', en:'his', poss:true},
|
||||||
|
{stem:'ihr', en:'her', poss:true},
|
||||||
|
{stem:'unser', en:'our', poss:true},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function determinerFor(cls, det, g, cas) {
|
||||||
|
if (cls === 'strong') return '';
|
||||||
|
if (cls === 'weak') return det.definite ? DEF[g][cas] : det.stem + DER_ENDINGS[g][cas];
|
||||||
|
return combinePoss(det.stem, POSS_ENDINGS[g][cas]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentence carriers. `prep` is kept OUT of dePre so it can fuse with the
|
||||||
|
// article (in + dem -> im). deCap/enCap mark which sentence the noun phrase opens.
|
||||||
|
// noNeg drops adjectives that would contradict the frame ("I like the bad hotel").
|
||||||
|
export const ADJ_FRAMES = [
|
||||||
|
{cas:'nominative', dePre:'', dePost:' gefällt mir.', enPre:'I like ', enPost:'.', deCap:true, noNeg:true,
|
||||||
|
groups:['people','animals','things','buildings','settlements','landscape'],
|
||||||
|
nouns:['Musik','Wetter','Wein','Kaffee','Brot']},
|
||||||
|
{cas:'nominative', dePre:'', dePost:' ist da.', enPre:'', enPost:' is there.', deCap:true, enCap:true,
|
||||||
|
groups:['people','animals','things']},
|
||||||
|
{cas:'accusative', dePre:'Ich sehe ', dePost:'.', enPre:'I see ', enPost:'.',
|
||||||
|
groups:['people','animals','things','buildings','settlements','landscape']},
|
||||||
|
{cas:'accusative', dePre:'Ich kaufe ', dePost:'.', enPre:'I buy ', enPost:'.',
|
||||||
|
groups:['things'], nouns:['Brot','Wein','Bier','Kaffee','Tee','Milch']},
|
||||||
|
{cas:'accusative', dePre:'Ich mag ', dePost:'.', enPre:'I like ', enPost:'.', noNeg:true,
|
||||||
|
groups:['people','animals','things','buildings','settlements','landscape'],
|
||||||
|
nouns:['Musik','Kaffee','Tee','Wein','Bier','Brot']},
|
||||||
|
{cas:'dative', dePre:'Ich helfe ', dePost:'.', enPre:'I help ', enPost:'.',
|
||||||
|
groups:['people','animals']},
|
||||||
|
{cas:'dative', dePre:'Ich spiele ', prep:'mit', dePost:'.', enPre:'I play with ', enPost:'.',
|
||||||
|
groups:['animals']},
|
||||||
|
{cas:'dative', dePre:'Ich wohne ', prep:'in', dePost:'.', enPre:'I live in ', enPost:'.',
|
||||||
|
groups:['buildings','settlements']},
|
||||||
|
{cas:'dative', dePre:'Ich koche ', prep:'mit', dePost:'.', enPre:'I cook with ', enPost:'.',
|
||||||
|
nouns:['Wasser','Milch','Brot','Wein']},
|
||||||
|
{cas:'dative', dePre:'Ich warte ', prep:'vor', dePost:'.', enPre:'I wait in front of ', enPost:'.',
|
||||||
|
groups:['buildings']},
|
||||||
|
{cas:'genitive', dePre:'Ich arbeite ', prep:'trotz', dePost:'.', enPre:'I work despite ', enPost:'.',
|
||||||
|
nouns:['Wetter','Regen','Musik','Arbeit'], classes:['weak','strong']},
|
||||||
|
{cas:'genitive', dePre:'Ich bleibe ', prep:'wegen', dePost:'.', enPre:'I stay because of ', enPost:'.',
|
||||||
|
nouns:['Wetter','Regen','Musik'], classes:['weak','strong']},
|
||||||
|
];
|
||||||
|
ADJ_FRAMES.forEach((f, i) => { f.id = i; });
|
||||||
|
const MASS_LIKE = new Set([...ADJ_NOUN_GROUPS.mass, ...ADJ_NOUN_GROUPS.weather]);
|
||||||
|
const framePool = f => [...new Set([
|
||||||
|
...(f.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]),
|
||||||
|
...(f.nouns || []),
|
||||||
|
])];
|
||||||
|
|
||||||
export const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'};
|
export const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'};
|
||||||
export const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'};
|
export const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'};
|
||||||
export const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'};
|
export const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'};
|
||||||
@@ -324,6 +500,17 @@ export const LEVELS = [
|
|||||||
{id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
|
{id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
|
||||||
{id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, Akkusativ & Dativ', pronouns:ALL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
|
{id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, Akkusativ & Dativ', pronouns:ALL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
|
||||||
{id:17.5, tab:'pronoun', name:'Daily Practice', desc:'Every pronoun lesson, mixed · one-tap', daily:true},
|
{id:17.5, tab:'pronoun', name:'Daily Practice', desc:'Every pronoun lesson, mixed · one-tap', daily:true},
|
||||||
|
|
||||||
|
// Adjective endings. Nominative IS tested here, unlike everywhere else in the
|
||||||
|
// app: der gute Mann vs ein guter Mann is the clearest illustration of the
|
||||||
|
// whole weak/mixed split, and skipping it would gut the section.
|
||||||
|
{id:20, tab:'adj', name:'Schwach', desc:'After der, diese, jede — only -e and -en', classes:['weak'], cases:['nominative','accusative','dative'], type:'adjective'},
|
||||||
|
{id:21, tab:'adj', name:'Gemischt', desc:'After ein, kein, mein — weak plus three exceptions', classes:['mixed'], cases:['nominative','accusative','dative'], type:'adjective'},
|
||||||
|
{id:22, tab:'adj', name:'Stark', desc:'No determiner — the adjective carries the case', classes:['strong'], cases:['nominative','accusative','dative'], type:'adjective'},
|
||||||
|
{id:23, tab:'adj', name:'Welche Tabelle?', desc:'der-word, ein-word or nothing at all?', classes:['weak','mixed','strong'], cases:['nominative','accusative','dative'], type:'adjClass'},
|
||||||
|
{id:24, tab:'adj', name:'Nach Präposition', desc:'The full chain: preposition → case → table', classes:['weak','mixed','strong'], cases:['accusative','dative','genitive'], type:'adjective', prepOnly:true},
|
||||||
|
{id:25, tab:'adj', name:'Alles gemischt', desc:'All three tables, including Genitiv', classes:['weak','mixed','strong'], cases:['nominative','accusative','dative','genitive'], type:'adjective'},
|
||||||
|
{id:25.5, tab:'adj', name:'Daily Practice', desc:'Every adjective lesson, mixed · one-tap', daily:true},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Contractions (preposition + article). pref:true = preferred even in writing.
|
// Contractions (preposition + article). pref:true = preferred even in writing.
|
||||||
@@ -1014,8 +1201,97 @@ export function genContractionContextQ(state = newState()) {
|
|||||||
return makeChunkQ(c.prep, cas, tw, state, genders);
|
return makeChunkQ(c.prep, cas, tw, state, genders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build one adjective question. Order matters: the class picks the determiner,
|
||||||
|
// the determiner constrains the nouns ("ein Wasser" is not a thing), the noun
|
||||||
|
// picks the adjective.
|
||||||
|
function buildAdjective(lv, state) {
|
||||||
|
for (let attempt = 0; attempt < 16; attempt++) {
|
||||||
|
const cls = drawBag(state, 'adjcls', lv.classes, state.last.adjcls);
|
||||||
|
let frames = ADJ_FRAMES.filter(f =>
|
||||||
|
lv.cases.includes(f.cas) &&
|
||||||
|
(!f.classes || f.classes.includes(cls)) &&
|
||||||
|
(!lv.prepOnly || f.prep));
|
||||||
|
// Strong only occurs where German really drops the determiner: mass nouns.
|
||||||
|
if (cls === 'strong') frames = frames.filter(f => framePool(f).some(n => MASS_LIKE.has(n)));
|
||||||
|
if (!frames.length) continue;
|
||||||
|
|
||||||
|
const frame = pick(frames);
|
||||||
|
const detObj = cls === 'strong' ? null : pick(cls === 'weak' ? DER_WORD_DETS : EIN_WORD_DETS);
|
||||||
|
|
||||||
|
let pool = framePool(frame);
|
||||||
|
if (cls === 'strong') pool = pool.filter(n => MASS_LIKE.has(n));
|
||||||
|
// "ein Wasser" is not a thing, but "kein Geld" and "meine Zeit" are.
|
||||||
|
if (detObj?.stem === 'ein') pool = pool.filter(n => !NOUN_BY_NAME[n].noIndef);
|
||||||
|
if (!pool.length) continue;
|
||||||
|
|
||||||
|
let candidates = ADJECTIVES.filter(a => adjNouns(a).some(n => pool.includes(n)));
|
||||||
|
if (frame.noNeg) candidates = candidates.filter(a => !a.neg);
|
||||||
|
if (!candidates.length) continue;
|
||||||
|
|
||||||
|
const adj = pickAvoid(candidates, state.last.adj);
|
||||||
|
const nounName = pickAvoid(adjNouns(adj).filter(n => pool.includes(n)), state.last.noun);
|
||||||
|
state.last.adj = adj; state.last.noun = nounName; state.last.adjcls = cls;
|
||||||
|
|
||||||
|
const noun = NOUN_BY_NAME[nounName];
|
||||||
|
const cas = frame.cas;
|
||||||
|
const det = determinerFor(cls, detObj, noun.g, cas);
|
||||||
|
const ending = ADJ_ENDINGS[cls][noun.g][cas];
|
||||||
|
const nounDe = nounForm(noun, cas);
|
||||||
|
|
||||||
|
// Fuse the preposition with a definite article the way German really does.
|
||||||
|
const fused = frame.prep && det ? CONTRACTABLE[frame.prep]?.[det] : null;
|
||||||
|
const lead = !frame.prep ? (det ? det + ' ' : '')
|
||||||
|
: fused ? fused + ' '
|
||||||
|
: `${frame.prep} ${det ? det + ' ' : ''}`;
|
||||||
|
|
||||||
|
let blanked = `${lead}${adj.de}_____ ${nounDe}`;
|
||||||
|
let solved = `${lead}${adj.de}${ending} ${nounDe}`;
|
||||||
|
if (frame.deCap) { blanked = cap(blanked); solved = cap(solved); }
|
||||||
|
|
||||||
|
let enDet = cls === 'strong' ? '' : (detObj.definite ? 'the' : detObj.en);
|
||||||
|
const adjE = adjEn(adj, noun);
|
||||||
|
if (enDet === 'a' && /^[aeiou]/i.test(adjE)) enDet = 'an';
|
||||||
|
const nounEn = detObj?.poss ? possNounEn(noun) : noun.en;
|
||||||
|
let enPhrase = `${enDet}${enDet ? ' ' : ''}${adjE} ${nounEn}`;
|
||||||
|
if (frame.enCap) enPhrase = cap(enPhrase);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cls, cas, frame, adj, noun, det, detObj, ending, fused,
|
||||||
|
sentence: `${frame.dePre}${blanked}${frame.dePost}`,
|
||||||
|
answerSentence: `${frame.dePre}${solved}${frame.dePost}`,
|
||||||
|
translation: `${frame.enPre}${enPhrase}${frame.enPost}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genAdjectiveQ(level, state = newState()) {
|
||||||
|
const lv = LEVELS.find(l => l.id === level);
|
||||||
|
const b = buildAdjective(lv, state);
|
||||||
|
return {
|
||||||
|
type:'adjective', cls:b.cls, cas:b.cas, frameId:b.frame.id, prepKey:b.frame.prep || null,
|
||||||
|
adj:b.adj, noun:b.noun, det:b.det, ans:b.ending,
|
||||||
|
sentence:b.sentence, answerSentence:b.answerSentence, translation:b.translation,
|
||||||
|
choices: pickChoices(b.ending, ADJ_ALL_ENDINGS, 4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The meta-skill: name the table before you can pick the ending.
|
||||||
|
export function genAdjClassQ(level, state = newState()) {
|
||||||
|
const lv = LEVELS.find(l => l.id === level);
|
||||||
|
const b = buildAdjective(lv, state);
|
||||||
|
return {
|
||||||
|
type:'adjClass', cls:b.cls, cas:b.cas, frameId:b.frame.id, prepKey:b.frame.prep || null,
|
||||||
|
adj:b.adj, noun:b.noun, det:b.det, ending:b.ending, ans:b.cls,
|
||||||
|
sentence:b.sentence, answerSentence:b.answerSentence, translation:b.translation,
|
||||||
|
choices:['weak','mixed','strong'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function gen(level, state = newState()) {
|
export function gen(level, state = newState()) {
|
||||||
const lv = LEVELS.find(l=>l.id===level);
|
const lv = LEVELS.find(l=>l.id===level);
|
||||||
|
if (lv.type==='adjective') return genAdjectiveQ(level, state);
|
||||||
|
if (lv.type==='adjClass') return genAdjClassQ(level, state);
|
||||||
if (lv.type==='chunk') return genContractionContextQ(state);
|
if (lv.type==='chunk') return genContractionContextQ(state);
|
||||||
if (lv.type==='contraction') return genContractionQ(state);
|
if (lv.type==='contraction') return genContractionQ(state);
|
||||||
if (lv.type==='prep') return genPrepQ(level, state);
|
if (lv.type==='prep') return genPrepQ(level, state);
|
||||||
@@ -1118,6 +1394,34 @@ export function enumerateCorpus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adjective sentences. Keyed on the SEMANTIC pairing — frame x adjective x
|
||||||
|
// noun — because that is what carries the risk ("kalter Kaffee" fine,
|
||||||
|
// "kalte Mutter" not). The determiner, class and ending are mechanical and
|
||||||
|
// covered exhaustively by the ending-table tests instead, which keeps this
|
||||||
|
// from exploding into hundreds of thousands of lines.
|
||||||
|
for (const frame of ADJ_FRAMES) {
|
||||||
|
const pool = framePool(frame);
|
||||||
|
for (const adj of ADJECTIVES) {
|
||||||
|
if (frame.noNeg && adj.neg) continue;
|
||||||
|
for (const n of adjNouns(adj)) {
|
||||||
|
if (!pool.includes(n)) continue;
|
||||||
|
const noun = NOUN_BY_NAME[n];
|
||||||
|
const cas = frame.cas;
|
||||||
|
const det = DEF[noun.g][cas];
|
||||||
|
const ending = ADJ_ENDINGS.weak[noun.g][cas];
|
||||||
|
const fused = frame.prep ? CONTRACTABLE[frame.prep]?.[det] : null;
|
||||||
|
const lead = !frame.prep ? det + ' ' : fused ? fused + ' ' : `${frame.prep} ${det} `;
|
||||||
|
let solved = `${lead}${adj.de}${ending} ${nounForm(noun, cas)}`;
|
||||||
|
if (frame.deCap) solved = cap(solved);
|
||||||
|
let enPhrase = `the ${adjEn(adj, noun)} ${noun.en}`;
|
||||||
|
if (frame.enCap) enPhrase = cap(enPhrase);
|
||||||
|
add({ key: `ADJ|${frame.id}|${adj.de}|${n}`,
|
||||||
|
de: `${frame.dePre}${solved}${frame.dePost}`,
|
||||||
|
en: `${frame.enPre}${enPhrase}${frame.enPost}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Possessive sentences with no preposition at all.
|
// Possessive sentences with no preposition at all.
|
||||||
for (const t of POSS_ACC_TEMPLATES) {
|
for (const t of POSS_ACC_TEMPLATES) {
|
||||||
for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) {
|
for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) {
|
||||||
@@ -1145,6 +1449,8 @@ export function corpusKeyFor(q) {
|
|||||||
if (q.type === 'wechselContext') return `WECH|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
if (q.type === 'wechselContext') return `WECH|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
||||||
if (q.type === 'chunk') return `CHUNK|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
if (q.type === 'chunk') return `CHUNK|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
||||||
if (q.type === 'article') return `${q.artType === 'indefinite' ? 'INDEF' : 'ART'}|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
if (q.type === 'article') return `${q.artType === 'indefinite' ? 'INDEF' : 'ART'}|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
||||||
|
if (q.type === 'adjective' || q.type === 'adjClass')
|
||||||
|
return `ADJ|${q.frameId}|${q.adj.de}|${n}`;
|
||||||
if (q.type === 'poss') return q.prepKey
|
if (q.type === 'poss') return q.prepKey
|
||||||
? `POSS|${q.prepKey}|${q.cas}|${q.verb}|${n}`
|
? `POSS|${q.prepKey}|${q.cas}|${q.verb}|${n}`
|
||||||
: `POSSTPL|-|${q.cas}|${q.verb}|${n}`;
|
: `POSSTPL|-|${q.cas}|${q.verb}|${n}`;
|
||||||
|
|||||||
+174
-2
@@ -7,6 +7,9 @@ import {
|
|||||||
createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices,
|
createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices,
|
||||||
bumpStreak, currentStreak, weekDots,
|
bumpStreak, currentStreak, weekDots,
|
||||||
enumerateCorpus, corpusToText, corpusKeyFor,
|
enumerateCorpus, corpusToText, corpusKeyFor,
|
||||||
|
ADJ_ENDINGS, ADJ_STRONG, ADJ_WEAK, ADJ_MIXED, DER_ENDINGS, ADJECTIVES,
|
||||||
|
ADJ_FRAMES, ADJ_NOUN_GROUPS, adjNouns, determinerFor, DER_WORD_DETS,
|
||||||
|
EIN_WORD_DETS, ADJ_ALL_ENDINGS,
|
||||||
} from './engine.js';
|
} from './engine.js';
|
||||||
|
|
||||||
const runSession = (levelId, n) => {
|
const runSession = (levelId, n) => {
|
||||||
@@ -408,16 +411,28 @@ describe('one-tap choices on every question type', () => {
|
|||||||
// ── Nominative is never tested ──────────────────────────────
|
// ── Nominative is never tested ──────────────────────────────
|
||||||
|
|
||||||
describe('no nominative anywhere', () => {
|
describe('no nominative anywhere', () => {
|
||||||
|
// Adjectives are the deliberate exception: der gute Mann vs ein guter Mann is
|
||||||
|
// the clearest illustration of the weak/mixed split, and it only exists in the
|
||||||
|
// nominative. Everywhere else the original rule still holds.
|
||||||
|
const ADJ_TYPES = ['adjective', 'adjClass'];
|
||||||
|
const isAdjLevel = lv => ADJ_TYPES.includes(lv.type) || lv.tab === 'adj';
|
||||||
|
|
||||||
it('no level tests the nominative — you already know der/die/das', () => {
|
it('no level tests the nominative — you already know der/die/das', () => {
|
||||||
expect(LEVELS.some(l => l.name === 'Nominativ')).toBe(false);
|
expect(LEVELS.some(l => l.name === 'Nominativ')).toBe(false);
|
||||||
for (const lv of LEVELS) {
|
for (const lv of LEVELS.filter(l => !isAdjLevel(l))) {
|
||||||
if (lv.cases) expect(lv.cases, `level ${lv.id} still lists nominative`).not.toContain('nominative');
|
if (lv.cases) expect(lv.cases, `level ${lv.id} still lists nominative`).not.toContain('nominative');
|
||||||
for (const q of runSession(lv.id, 150)) {
|
for (const q of runSession(lv.id, 150)) {
|
||||||
|
if (ADJ_TYPES.includes(q.type)) continue; // daily rounds mix tabs
|
||||||
if (q.cas) expect(q.cas, `level ${lv.id} generated a nominative question`).not.toBe('nominative');
|
if (q.cas) expect(q.cas, `level ${lv.id} generated a nominative question`).not.toBe('nominative');
|
||||||
expect(q.ans).not.toBe('nominative');
|
expect(q.ans).not.toBe('nominative');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adjective levels DO reach the nominative — that is where weak and mixed differ', () => {
|
||||||
|
const cases = new Set(runSession(25, 400).map(q => q.cas));
|
||||||
|
expect(cases.has('nominative')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Daily practice (per-tab mixed sessions) ─────────────────
|
// ── Daily practice (per-tab mixed sessions) ─────────────────
|
||||||
@@ -426,7 +441,7 @@ describe('daily practice', () => {
|
|||||||
const dailies = LEVELS.filter(l => l.daily);
|
const dailies = LEVELS.filter(l => l.daily);
|
||||||
|
|
||||||
it('every tab has exactly one daily level', () => {
|
it('every tab has exactly one daily level', () => {
|
||||||
expect(dailies.map(l => l.tab).sort()).toEqual(['article', 'poss', 'prep', 'pronoun']);
|
expect(dailies.map(l => l.tab).sort()).toEqual(['adj', '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) => {
|
it.each(dailies.map(l => [l.id, l.tab]))('daily %s mixes every lesson type of the %s tab', (id, tab) => {
|
||||||
@@ -579,3 +594,160 @@ describe('sentence corpus', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Adjective endings ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('adjective ending tables', () => {
|
||||||
|
// Hand-written reference. If the engine and this disagree, one of them is
|
||||||
|
// wrong — and it is much more likely to be the engine.
|
||||||
|
const REF = {
|
||||||
|
strong: { masculine:['er','en','em','en'], feminine:['e','e','er','er'],
|
||||||
|
neuter:['es','es','em','en'], plural:['e','e','en','er'] },
|
||||||
|
weak: { masculine:['e','en','en','en'], feminine:['e','e','en','en'],
|
||||||
|
neuter:['e','e','en','en'], plural:['en','en','en','en'] },
|
||||||
|
mixed: { masculine:['er','en','en','en'], feminine:['e','e','en','en'],
|
||||||
|
neuter:['es','es','en','en'], plural:['en','en','en','en'] },
|
||||||
|
};
|
||||||
|
const CASES = ['nominative','accusative','dative','genitive'];
|
||||||
|
const GENDERS = ['masculine','feminine','neuter','plural'];
|
||||||
|
|
||||||
|
it.each(['strong','weak','mixed'])('%s: all 16 cells match the reference', (cls) => {
|
||||||
|
for (const g of GENDERS) {
|
||||||
|
CASES.forEach((c, i) => {
|
||||||
|
expect(ADJ_ENDINGS[cls][g][c], `${cls} ${g} ${c}`).toBe(REF[cls][g][i]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed IS weak, except exactly where the ein-word has no ending', () => {
|
||||||
|
const gaps = new Set(['masculine|nominative','neuter|nominative','neuter|accusative']);
|
||||||
|
for (const g of GENDERS) for (const c of CASES) {
|
||||||
|
const key = `${g}|${c}`;
|
||||||
|
if (gaps.has(key)) {
|
||||||
|
expect(ADJ_MIXED[g][c], `${key} should borrow the strong ending`).toBe(ADJ_STRONG[g][c]);
|
||||||
|
expect(ADJ_MIXED[g][c]).not.toBe(ADJ_WEAK[g][c]);
|
||||||
|
} else {
|
||||||
|
expect(ADJ_MIXED[g][c], `${key} should follow weak`).toBe(ADJ_WEAK[g][c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strong genitive masc/neut is -en, not -es — the noun already carries the -(e)s', () => {
|
||||||
|
expect(ADJ_STRONG.masculine.genitive).toBe('en');
|
||||||
|
expect(ADJ_STRONG.neuter.genitive).toBe('en');
|
||||||
|
// and that is the ONLY place strong parts company with the der-word endings
|
||||||
|
for (const g of GENDERS) for (const c of CASES) {
|
||||||
|
const differs = ADJ_STRONG[g][c] !== DER_ENDINGS[g][c];
|
||||||
|
const expected = (g === 'masculine' || g === 'neuter') && c === 'genitive';
|
||||||
|
expect(differs, `${g} ${c}`).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weak only ever produces -e or -en', () => {
|
||||||
|
for (const g of GENDERS) for (const c of CASES) {
|
||||||
|
expect(['e','en']).toContain(ADJ_WEAK[g][c]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every der-word and ein-word declines from the right table', () => {
|
||||||
|
for (const det of DER_WORD_DETS) {
|
||||||
|
if (det.definite) continue;
|
||||||
|
for (const g of GENDERS) for (const c of CASES) {
|
||||||
|
expect(determinerFor('weak', det, g, c)).toBe(det.stem + DER_ENDINGS[g][c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const det of EIN_WORD_DETS) {
|
||||||
|
for (const g of GENDERS) for (const c of CASES) {
|
||||||
|
const d = determinerFor('mixed', det, g, c);
|
||||||
|
expect(d.startsWith(det.stem) || det.stem === 'euer').toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(determinerFor('strong', null, 'masculine', 'dative')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adjective questions', () => {
|
||||||
|
const ADJ_LEVELS = [20, 21, 22, 23, 24, 25];
|
||||||
|
|
||||||
|
it.each(ADJ_LEVELS)('level %s: the answer always comes from the table', (id) => {
|
||||||
|
for (const q of runSession(id, 300)) {
|
||||||
|
const ending = ADJ_ENDINGS[q.cls][q.noun.g][q.cas];
|
||||||
|
if (q.type === 'adjClass') expect(q.ans).toBe(q.cls);
|
||||||
|
else expect(q.ans).toBe(ending);
|
||||||
|
expect(q.answerSentence.toLowerCase()).toContain((q.adj.de + ending).toLowerCase());
|
||||||
|
expect(q.sentence).toContain('_____');
|
||||||
|
expect(q.answerSentence).not.toContain('_____');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(ADJ_LEVELS)('level %s: 4 plausible choices, never noise', (id) => {
|
||||||
|
for (const q of runSession(id, 200)) {
|
||||||
|
expect(new Set(q.choices).size).toBe(q.choices.length);
|
||||||
|
expect(q.choices).toContain(q.ans);
|
||||||
|
if (q.type === 'adjective') {
|
||||||
|
expect(q.choices.length).toBe(4);
|
||||||
|
for (const c of q.choices) expect(ADJ_ALL_ENDINGS).toContain(c);
|
||||||
|
} else {
|
||||||
|
expect(q.choices.sort()).toEqual(['mixed','strong','weak']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each level drills the class it claims to', () => {
|
||||||
|
for (const id of ADJ_LEVELS) {
|
||||||
|
const lv = LEVELS.find(l => l.id === id);
|
||||||
|
for (const q of runSession(id, 200)) {
|
||||||
|
expect(lv.classes, `level ${id}`).toContain(q.cls);
|
||||||
|
expect(lv.cases, `level ${id}`).toContain(q.cas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the determiner shown really belongs to the class claimed', () => {
|
||||||
|
for (const q of runSession(25, 400)) {
|
||||||
|
if (q.cls === 'strong') { expect(q.det).toBe(''); continue; }
|
||||||
|
expect(q.det).toBeTruthy();
|
||||||
|
const der = [...DER_WORD_DETS].some(d =>
|
||||||
|
d.definite ? Object.values(DEF).some(row => Object.values(row).includes(q.det))
|
||||||
|
: q.det.startsWith(d.stem));
|
||||||
|
const ein = EIN_WORD_DETS.some(d => q.det.startsWith(d.stem) || (d.stem === 'euer' && q.det.startsWith('eur')));
|
||||||
|
if (q.cls === 'weak') expect(der, `${q.det} should be a der-word`).toBe(true);
|
||||||
|
if (q.cls === 'mixed') expect(ein, `${q.det} should be an ein-word`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strong only appears with mass nouns — German does not drop the article otherwise', () => {
|
||||||
|
const massLike = new Set([...ADJ_NOUN_GROUPS.mass, ...ADJ_NOUN_GROUPS.weather]);
|
||||||
|
for (const q of runSession(25, 400)) {
|
||||||
|
if (q.cls === 'strong') expect(massLike.has(q.noun.w), `strong "${q.answerSentence}"`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Nach Präposition" always goes through a preposition, and the case follows it', () => {
|
||||||
|
for (const q of runSession(24, 300)) {
|
||||||
|
expect(q.prepKey).toBeTruthy();
|
||||||
|
const p = PREPS[q.prepKey];
|
||||||
|
if (p.c !== 'wechsel') expect(q.cas).toBe(p.c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never pairs an adjective with a noun it cannot describe', () => {
|
||||||
|
for (const id of ADJ_LEVELS) {
|
||||||
|
for (const q of runSession(id, 300)) {
|
||||||
|
expect(adjNouns(q.adj), `${q.adj.de} + ${q.noun.w}`).toContain(q.noun.w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every adjective names real nouns and every frame a real group', () => {
|
||||||
|
const known = new Set(NOUNS.map(n => n.w));
|
||||||
|
for (const a of ADJECTIVES) {
|
||||||
|
expect(adjNouns(a).length, `${a.de} describes nothing`).toBeGreaterThan(0);
|
||||||
|
for (const n of adjNouns(a)) expect(known.has(n), `${a.de} → unknown noun ${n}`).toBe(true);
|
||||||
|
}
|
||||||
|
for (const f of ADJ_FRAMES) {
|
||||||
|
for (const g of (f.groups || [])) expect(ADJ_NOUN_GROUPS[g], `unknown group ${g}`).toBeTruthy();
|
||||||
|
for (const n of (f.nouns || [])) expect(known.has(n), `unknown noun ${n}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user