diff --git a/src/App.jsx b/src/App.jsx index 588178f..87ccfd3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -184,6 +184,7 @@ export default function App() { const [res, setRes] = useState([]); const [chart, setChart] = useState(false); const [rule, setRule] = useState(false); + const [pickPrep, setPickPrep] = useState(null); const [learnStep, setLearnStep] = useState(0); const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null}); const [access, setAccess] = useState(null); @@ -239,7 +240,7 @@ export default function App() { const lvObj = LEVELS.find(l => l.id === id); if (lvObj?.tab) setTab(lvObj.tab); 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 => { @@ -257,7 +258,9 @@ export default function App() { const doSubmit = useCallback((overrideInput) => { const userAns = (overrideInput ?? inp).trim(); 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); // Dismiss mobile keyboard so feedback is visible try { iref.current?.blur(); } catch(e){} @@ -274,7 +277,7 @@ export default function App() { const doNext = useCallback(() => { 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); setQ(sessionRef.current.next()); }, [res, lv]); @@ -288,6 +291,17 @@ export default function App() { if (chart) { if (e.key === 'Escape') setChart(false); return; } if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); 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) { const idx = ['1','2','3','4'].indexOf(e.key); if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; } @@ -691,18 +705,47 @@ export default function App() { )} - {/* PRACTICE: contractions */} + {/* PRACTICE: contractions — shown fused, taken apart in two taps */} {scr === 'practice' && q?.type === 'contraction' && ( -
-
Contract the preposition and article
-
- {q.prep} + {q.art} = ? +
+
What is this a contraction of?
+
+ {q.ans}
-
+ +
+ {rev + ? + {q.prep} + {q.art} + + : pickPrep + ? <> + + + + ? + + : ? + ?} +
+ +
+ + {!rev ? ( - +
+
+ {pickPrep ? 'Now the article' : 'Which preposition?'} +
+ { if (pickPrep === null) setPickPrep(w); else doSubmit(`${pickPrep} ${w}`); }} + /> + {pickPrep && !IS_TOUCH && ( +
+ backspace to change the preposition +
+ )} +
) : 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 ( +
+ {words.map((w, i) => ( + + ))} +
+ ); +} + // One-tap correct answer: no solution shown, just a tick before auto-skip. function QuickTick() { return ( @@ -1239,16 +1298,29 @@ function ChunkFeedback({ 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 (
{ok - ? <>✓ {q.prep} + {q.art} = {q.ans} - : <>✗ {inp} → {q.ans}} -
-
- {q.prep} + {q.art} = {q.ans} + ? <>✓ {q.ans} = {q.prep} + {q.art} + : <>✗ {gotPrep} + {gotArt} → {q.prep} + {q.art}}
+ {!ok && ( +
+ {q.ans} = {q.prep} + {q.art} + {gotPrep !== q.prep && <> — the preposition is {q.prep}, not {gotPrep}.} +
+ )} + {endingRule && ( +
+ Ending -{ending} always means{' '} + {endingRule}. That is the whole trick: + -s → das, -m → dem, -r → der. +
+ )}
{q.pref ? 'Preferred — use this contraction in both speech and writing.' @@ -1261,7 +1333,7 @@ function ContractionFeedback({ q, inp, ok }) { valueFor={(g, c) => DEF[g][c]} focusG={null} isCorrect={(g, c, val) => normAns(val) === normAns(q.art)} - isWrong={null} + isWrong={ok ? null : (g, c, val) => !!gotArt && normAns(val) === normAns(gotArt)} />
); @@ -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', ...(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' }, ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 }, ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 }, diff --git a/src/engine.js b/src/engine.js index 8343801..f4538ee 100644 --- a/src/engine.js +++ b/src/engine.js @@ -688,10 +688,10 @@ const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`; // ═══════════════════════════════════════════ // 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 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); } @@ -876,12 +876,25 @@ export function genPronounQ(level, state = newState()) { 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()) { const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr); state.last.contr = 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, - 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 diff --git a/src/engine.test.js b/src/engine.test.js index 274651e..0bd87bf 100644 --- a/src/engine.test.js +++ b/src/engine.test.js @@ -3,7 +3,7 @@ import { DEF, INDEF, PREPS, PREPS_BY_CASE, COMMON_PREPS, RARE_PREPS, ALL_PREPS, NOUNS, nounForm, LEVELS, CONTRACTIONS, CONTRACTABLE, CHUNK_PREPS, POSS_ENDINGS, combinePoss, PRONOUNS, VERBS, PREP_FRAMES, - createSession, gen, genContractionContextQ, newState, buildChunkChoices, PREPS_BY_CASE as _PBC, + createSession, gen, genContractionContextQ, genContractionQ, newState, buildChunkChoices, bumpStreak, currentStreak, weekDots, } from './engine.js'; @@ -278,6 +278,54 @@ describe('contractions', () => { expect(correct).toBe('an die'); }); + it('Kontraktionen is asked backwards: the fused form is given, you take it apart', () => { + const state = newState(); + const seenForms = new Set(); + for (let i = 0; i < 300; i++) { + const q = genContractionQ(state); + const c = CONTRACTIONS.find(x => x.form === q.ans); + expect(c, `${q.ans} is a real contraction`).toBeTruthy(); + seenForms.add(q.ans); + + // the question is graded on the two parts, not the form + expect(q.answerKey).toBe(`${c.prep} ${c.art}`); + + expect(q.prepChoices).toContain(c.prep); + expect(q.prepChoices.length).toBe(6); + expect(new Set(q.prepChoices).size).toBe(6); + + expect(q.artChoices).toContain(c.art); + expect(q.artChoices.length).toBe(4); + expect(new Set(q.artChoices).size).toBe(4); + + // the old flaw: offering whole contraction forms meant only one option + // could start with the given preposition. Nothing on offer is a form now. + const forms = new Set(CONTRACTIONS.map(x => x.form)); + for (const w of [...q.prepChoices, ...q.artChoices]) expect(forms.has(w)).toBe(false); + } + expect(seenForms.size).toBe(CONTRACTIONS.length); + }); + + it('the article step is a real discrimination, never answerable by elimination', () => { + const state = newState(); + for (let i = 0; i < 300; i++) { + const q = genContractionQ(state); + // at least one wrong article that a careless learner would plausibly pick + const wrong = q.artChoices.filter(a => a !== q.art); + expect(wrong.length).toBe(3); + expect(wrong.every(a => ['das','dem','der','die','den'].includes(a))).toBe(true); + } + }); + + it('the -s/-m/-r ending always predicts the article', () => { + const byEnding = { s:'das', m:'dem', r:'der' }; + for (const c of CONTRACTIONS) { + const expected = byEnding[c.form.slice(-1)]; + expect(expected, `${c.form} has an unexpected ending`).toBeTruthy(); + expect(c.art, `${c.form} = ${c.prep} + ${c.art}`).toBe(expected); + } + }); + it('dedicated Kontraktionen level always yields a preferred contraction', () => { const state = newState(); for (let i = 0; i < 200; i++) { @@ -339,7 +387,7 @@ describe('pronoun questions', () => { }); 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)) { expect(q.choices.length).toBe(4); expect(new Set(q.choices).size).toBe(4);