Kontraktionen: ask it backwards, as a two-tap word cloud
"hinter + das = ?" with options am / im / uebers / hinters was no test at
all — only one option could start with "hinter", so you never had to know
which article was fused in. The whole level was answerable on the prefix.
Flipped it. The fused form is given and you take it apart:
- The form is shown large ("im"), and you pick the preposition from a word
cloud, then the article from a second one. Graded on both parts.
- The preposition step is a real question for the truncated forms — im vs
am, vom, zum/zur — which are exactly the ones that get mixed up.
- The article step is the point: -s means das, -m means dem, -r means der.
Distractors are always real articles (das/dem/der/die/den), never whole
contraction forms, so elimination on shape no longer works.
- Feedback names which half was wrong and states the ending rule, then
highlights the article in the definite-article table.
- The chosen preposition chip is tappable to change before committing;
backspace does the same on desktop. On reveal the line resolves to the
correct pair.
Tests: the old flaw is pinned shut (nothing on offer is ever a contraction
form), both choice sets are checked, and a data test asserts the -s/-m/-r
ending predicts the article for all 16 contractions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
+94
-17
@@ -184,6 +184,7 @@ export default function App() {
|
|||||||
const [res, setRes] = useState([]);
|
const [res, setRes] = useState([]);
|
||||||
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 [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);
|
||||||
@@ -239,7 +240,7 @@ export default function App() {
|
|||||||
const lvObj = LEVELS.find(l => l.id === id);
|
const lvObj = LEVELS.find(l => l.id === id);
|
||||||
if (lvObj?.tab) setTab(lvObj.tab);
|
if (lvObj?.tab) setTab(lvObj.tab);
|
||||||
sessionRef.current = createSession(id);
|
sessionRef.current = createSession(id);
|
||||||
setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice');
|
setLv(id); setRes([]); setInp(''); setRev(false); setPickPrep(null); setQ(sessionRef.current.next()); setScr('practice');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCheckout = plan => {
|
const openCheckout = plan => {
|
||||||
@@ -257,7 +258,9 @@ export default function App() {
|
|||||||
const doSubmit = useCallback((overrideInput) => {
|
const doSubmit = useCallback((overrideInput) => {
|
||||||
const userAns = (overrideInput ?? inp).trim();
|
const userAns = (overrideInput ?? inp).trim();
|
||||||
if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; }
|
if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; }
|
||||||
const correct = userAns.toLowerCase() === q.ans.toLowerCase();
|
// Two-part answers (contractions) grade against "prep art", not the form.
|
||||||
|
const target = q.answerKey ?? q.ans;
|
||||||
|
const correct = userAns.toLowerCase() === target.toLowerCase();
|
||||||
if (overrideInput !== undefined) setInp(overrideInput);
|
if (overrideInput !== undefined) setInp(overrideInput);
|
||||||
// Dismiss mobile keyboard so feedback is visible
|
// Dismiss mobile keyboard so feedback is visible
|
||||||
try { iref.current?.blur(); } catch(e){}
|
try { iref.current?.blur(); } catch(e){}
|
||||||
@@ -274,7 +277,7 @@ export default function App() {
|
|||||||
|
|
||||||
const doNext = useCallback(() => {
|
const doNext = useCallback(() => {
|
||||||
if (res.length >= ROUND) { setScr('summary'); return; }
|
if (res.length >= ROUND) { setScr('summary'); return; }
|
||||||
setInp(''); setRev(false); setOk(false);
|
setInp(''); setRev(false); setOk(false); setPickPrep(null);
|
||||||
if (!sessionRef.current) sessionRef.current = createSession(lv);
|
if (!sessionRef.current) sessionRef.current = createSession(lv);
|
||||||
setQ(sessionRef.current.next());
|
setQ(sessionRef.current.next());
|
||||||
}, [res, lv]);
|
}, [res, lv]);
|
||||||
@@ -288,6 +291,17 @@ export default function App() {
|
|||||||
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; }
|
||||||
if (scr !== 'practice') return;
|
if (scr !== 'practice') return;
|
||||||
|
if (!rev && q?.type === 'contraction') {
|
||||||
|
const cloud = pickPrep === null ? q.prepChoices : q.artChoices;
|
||||||
|
const idx = ['1','2','3','4','5','6'].indexOf(e.key);
|
||||||
|
if (idx >= 0 && cloud[idx]) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (pickPrep === null) setPickPrep(cloud[idx]);
|
||||||
|
else submitRef.current(`${pickPrep} ${cloud[idx]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && pickPrep !== null) { e.preventDefault(); setPickPrep(null); return; }
|
||||||
|
}
|
||||||
if (!rev && q?.choices) {
|
if (!rev && q?.choices) {
|
||||||
const idx = ['1','2','3','4'].indexOf(e.key);
|
const idx = ['1','2','3','4'].indexOf(e.key);
|
||||||
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
|
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
|
||||||
@@ -691,18 +705,47 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PRACTICE: contractions */}
|
{/* PRACTICE: contractions — shown fused, taken apart in two taps */}
|
||||||
{scr === 'practice' && q?.type === 'contraction' && (
|
{scr === 'practice' && q?.type === 'contraction' && (
|
||||||
<div style={{textAlign:'center', paddingTop:30}}>
|
<div style={{textAlign:'center', paddingTop:20}}>
|
||||||
<div style={{fontSize:13,color:'#999',marginBottom:12}}>Contract the preposition and article</div>
|
<div style={{fontSize:13,color:'#999',marginBottom:10}}>What is this a contraction of?</div>
|
||||||
<div style={{fontSize:34, fontWeight:700, color:'#1a1a1a', margin:'4px 0 6px'}}>
|
<div style={{fontSize:40, fontWeight:700, color:'#1a1a1a', margin:'4px 0 10px', letterSpacing:'-0.5px'}}>
|
||||||
{q.prep} <span style={{color:'#ccc'}}>+</span> {q.art} <span style={{color:'#ccc'}}>=</span> <span style={{color:'#ccc'}}>?</span>
|
{q.ans}
|
||||||
</div>
|
</div>
|
||||||
<div style={{fontSize:12, color:'#aaa', marginBottom:22}}>
|
|
||||||
|
<div style={{fontSize:22, fontWeight:600, marginBottom:14, minHeight:30}}>
|
||||||
|
{rev
|
||||||
|
? <span style={{color: ok ? GREEN : RED}}>
|
||||||
|
{q.prep} <span style={{color:'#ccc', margin:'0 4px'}}>+</span> {q.art}
|
||||||
|
</span>
|
||||||
|
: pickPrep
|
||||||
|
? <>
|
||||||
|
<button style={S.chosenChip} onClick={()=>setPickPrep(null)} title="tap to change">{pickPrep}</button>
|
||||||
|
<span style={{color:'#ccc', margin:'0 8px'}}>+</span>
|
||||||
|
<span style={{color:'#ccc'}}>?</span>
|
||||||
|
</>
|
||||||
|
: <span style={{color:'#ccc'}}>? <span style={{margin:'0 8px'}}>+</span> ?</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
<span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
|
||||||
</div>
|
</Hideable>
|
||||||
|
|
||||||
{!rev ? (
|
{!rev ? (
|
||||||
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
<div>
|
||||||
|
<div style={S.cloudStep}>
|
||||||
|
{pickPrep ? 'Now the article' : 'Which preposition?'}
|
||||||
|
</div>
|
||||||
|
<WordCloud
|
||||||
|
words={pickPrep === null ? q.prepChoices : q.artChoices}
|
||||||
|
onPick={w => { if (pickPrep === null) setPickPrep(w); else doSubmit(`${pickPrep} ${w}`); }}
|
||||||
|
/>
|
||||||
|
{pickPrep && !IS_TOUCH && (
|
||||||
|
<div style={{...S.hintText, marginTop:12}}>
|
||||||
|
<kbd style={S.kbd}>backspace</kbd> to change the preposition
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : quickTick ? (
|
) : quickTick ? (
|
||||||
<QuickTick/>
|
<QuickTick/>
|
||||||
) : (
|
) : (
|
||||||
@@ -967,6 +1010,22 @@ function AnswerGrid({ choices, onPick }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A loose cloud of word chips. Sizes vary a little so it reads as a cloud
|
||||||
|
// rather than a list, but every chip keeps a full-height tap target.
|
||||||
|
function WordCloud({ words, onPick }) {
|
||||||
|
return (
|
||||||
|
<div style={S.cloud}>
|
||||||
|
{words.map((w, i) => (
|
||||||
|
<button key={w} style={{...S.cloudChip, fontSize: 16 + (w.length > 4 ? 2 : 4)}}
|
||||||
|
onClick={()=>onPick(w)}>
|
||||||
|
{w}
|
||||||
|
{!IS_TOUCH && <sub style={S.cloudKbd}>{i+1}</sub>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// One-tap correct answer: no solution shown, just a tick before auto-skip.
|
// One-tap correct answer: no solution shown, just a tick before auto-skip.
|
||||||
function QuickTick() {
|
function QuickTick() {
|
||||||
return (
|
return (
|
||||||
@@ -1239,16 +1298,29 @@ function ChunkFeedback({ q, inp, ok }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContractionFeedback({ q, inp, ok }) {
|
function ContractionFeedback({ q, inp, ok }) {
|
||||||
|
const [gotPrep, gotArt] = (inp || '').split(' ');
|
||||||
|
const ending = q.ans.slice(-1);
|
||||||
|
const endingRule = {s:'das', m:'dem', r:'der'}[ending];
|
||||||
return (
|
return (
|
||||||
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
|
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
|
||||||
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
|
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
|
||||||
{ok
|
{ok
|
||||||
? <>✓ {q.prep} + {q.art} = <span style={{color:GREEN}}>{q.ans}</span></>
|
? <>✓ <span style={{color:GREEN}}>{q.ans}</span> = {q.prep} + {q.art}</>
|
||||||
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
|
: <>✗ <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{gotPrep} + {gotArt}</span> <span style={{color:'#999', fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.prep} + {q.art}</span></>}
|
||||||
</div>
|
|
||||||
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
|
|
||||||
{q.prep} + {q.art} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!ok && (
|
||||||
|
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.5}}>
|
||||||
|
<strong>{q.ans}</strong> = {q.prep} + {q.art}
|
||||||
|
{gotPrep !== q.prep && <> — the preposition is <strong>{q.prep}</strong>, not {gotPrep}.</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endingRule && (
|
||||||
|
<div style={{fontSize:12, color:'#888', marginBottom:8, lineHeight:1.5}}>
|
||||||
|
Ending <strong style={{color:'#1a1a1a'}}>-{ending}</strong> always means{' '}
|
||||||
|
<strong style={{color:'#1a1a1a'}}>{endingRule}</strong>. That is the whole trick:
|
||||||
|
-s → das, -m → dem, -r → der.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{fontSize:12, color: q.pref ? GREEN : '#d97706', lineHeight:1.5}}>
|
<div style={{fontSize:12, color: q.pref ? GREEN : '#d97706', lineHeight:1.5}}>
|
||||||
{q.pref
|
{q.pref
|
||||||
? 'Preferred — use this contraction in both speech and writing.'
|
? 'Preferred — use this contraction in both speech and writing.'
|
||||||
@@ -1261,7 +1333,7 @@ function ContractionFeedback({ q, inp, ok }) {
|
|||||||
valueFor={(g, c) => DEF[g][c]}
|
valueFor={(g, c) => DEF[g][c]}
|
||||||
focusG={null}
|
focusG={null}
|
||||||
isCorrect={(g, c, val) => normAns(val) === normAns(q.art)}
|
isCorrect={(g, c, val) => normAns(val) === normAns(q.art)}
|
||||||
isWrong={null}
|
isWrong={ok ? null : (g, c, val) => !!gotArt && normAns(val) === normAns(gotArt)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1561,6 +1633,11 @@ const S = {
|
|||||||
choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s',
|
choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s',
|
||||||
...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
|
...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
|
||||||
|
|
||||||
|
cloud: { display:'flex', flexWrap:'wrap', gap:10, justifyContent:'center', alignItems:'center', maxWidth:360, margin:'0 auto' },
|
||||||
|
cloudChip: { position:'relative', padding:'14px 20px', borderRadius:999, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', fontWeight:700, cursor:'pointer', fontFamily:'inherit', minHeight:52, transition:'all 0.12s' },
|
||||||
|
cloudKbd: { position:'absolute', top:4, right:8, fontSize:9, color:'#ccc', fontWeight:600 },
|
||||||
|
cloudStep: { fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:0.8, color:'#bbb', marginBottom:12 },
|
||||||
|
chosenChip: { padding:'6px 16px', borderRadius:999, border:`1.5px solid ${GREEN}`, background:'#f3ffe8', color:GREEN, fontSize:20, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
|
||||||
ruleBtn: { marginLeft:8, background:'none', border:'1px solid #ddd', borderRadius:999, padding:'3px 10px', color:'#888', fontSize:11, fontWeight:600, cursor:'pointer', fontFamily:'inherit' },
|
ruleBtn: { marginLeft:8, background:'none', border:'1px solid #ddd', borderRadius:999, padding:'3px 10px', color:'#888', fontSize:11, fontWeight:600, cursor:'pointer', fontFamily:'inherit' },
|
||||||
ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 },
|
ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 },
|
||||||
ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 },
|
ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 },
|
||||||
|
|||||||
+16
-3
@@ -688,10 +688,10 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`;
|
|||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
// The answer plus 3 distractors drawn from a pool, shuffled.
|
// The answer plus 3 distractors drawn from a pool, shuffled.
|
||||||
function pickChoices(ans, pool) {
|
function pickChoices(ans, pool, n = 4) {
|
||||||
const rem = [...new Set(pool)].filter(a => a !== ans);
|
const rem = [...new Set(pool)].filter(a => a !== ans);
|
||||||
const picked = [];
|
const picked = [];
|
||||||
while (picked.length < 3 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]);
|
while (picked.length < n - 1 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]);
|
||||||
return [ans, ...picked].sort(() => Math.random() - 0.5);
|
return [ans, ...picked].sort(() => Math.random() - 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,12 +876,25 @@ export function genPronounQ(level, state = newState()) {
|
|||||||
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation, choices:pronounChoices(pron, ans)};
|
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation, choices:pronounChoices(pron, ans)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every preposition that fuses, and every article that shows up inside a
|
||||||
|
// contraction plus two that never do — so "which article is in there?" can't be
|
||||||
|
// answered by elimination.
|
||||||
|
export const CONTRACTION_PREPS = [...new Set(CONTRACTIONS.map(c => c.prep))];
|
||||||
|
const CONTRACTION_ARTS = ['das','dem','der','die','den'];
|
||||||
|
|
||||||
|
// Asked the other way round: the fused form is GIVEN and you take it apart.
|
||||||
|
// Asking "hinter + das = ?" was no test at all — only one option could start
|
||||||
|
// with "hinter". Going backwards, the real question survives: -s is das, -m is
|
||||||
|
// dem, -r is der, and am/ans, im/ins, zum/zur are the pairs that actually cost
|
||||||
|
// you marks.
|
||||||
export function genContractionQ(state = newState()) {
|
export function genContractionQ(state = newState()) {
|
||||||
const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr);
|
const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr);
|
||||||
state.last.contr = form;
|
state.last.contr = form;
|
||||||
const c = CONTRACTIONS.find(x => x.form === form);
|
const c = CONTRACTIONS.find(x => x.form === form);
|
||||||
return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c,
|
return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c,
|
||||||
choices:pickChoices(c.form, CONTRACTIONS.map(x=>x.form))};
|
answerKey: `${c.prep} ${c.art}`,
|
||||||
|
prepChoices: pickChoices(c.prep, CONTRACTION_PREPS, 6),
|
||||||
|
artChoices: pickChoices(c.art, CONTRACTION_ARTS, 4)};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the 4 choices for a fused prep+article question: always a BLEND of
|
// Build the 4 choices for a fused prep+article question: always a BLEND of
|
||||||
|
|||||||
+50
-2
@@ -3,7 +3,7 @@ import {
|
|||||||
DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS,
|
DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS,
|
||||||
NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
|
NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS,
|
||||||
POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
|
POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES,
|
||||||
createSession, gen, genContractionContextQ, newState, buildChunkChoices, PREPS_BY_CASE as _PBC,
|
createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices,
|
||||||
bumpStreak, currentStreak, weekDots,
|
bumpStreak, currentStreak, weekDots,
|
||||||
} from './engine.js';
|
} from './engine.js';
|
||||||
|
|
||||||
@@ -278,6 +278,54 @@ describe('contractions', () => {
|
|||||||
expect(correct).toBe('an die');
|
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', () => {
|
it('dedicated Kontraktionen level always yields a preferred contraction', () => {
|
||||||
const state = newState();
|
const state = newState();
|
||||||
for (let i = 0; i < 200; i++) {
|
for (let i = 0; i < 200; i++) {
|
||||||
@@ -339,7 +387,7 @@ describe('pronoun questions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('one-tap choices on every question type', () => {
|
describe('one-tap choices on every question type', () => {
|
||||||
it.each([11, 12, 13, 14, 15, 16, 17, 19])('level %s: 4 unique choices containing the answer', (id) => {
|
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)) {
|
for (const q of runSession(id, 200)) {
|
||||||
expect(q.choices.length).toBe(4);
|
expect(q.choices.length).toBe(4);
|
||||||
expect(new Set(q.choices).size).toBe(4);
|
expect(new Set(q.choices).size).toBe(4);
|
||||||
|
|||||||
Reference in New Issue
Block a user