Fix blank screen on the pronoun levels

Opening Singular Pronomen rendered nothing at all. The console said
"Cannot read properties of undefined (reading 'w')".

My regression, from the commit that added the "why Regens?" explainer. That
button is rendered on the screen shared by the article, contraction,
possessive AND pronoun questions, and its visibility test calls
nounForm(q.noun, q.cas) — but pronoun questions have no noun. The read threw,
React unmounted the tree, and the whole page went blank rather than losing
just that one line.

Guarded the access. Then swept all 32 levels across the six tabs in a real
browser: every one renders with no page errors.

Also wrote the contract down, since this is the second field a screen has
dereferenced without checking. engine.test.js now asserts which question
types carry a noun and which deliberately do not, that pronoun questions stay
noun-less (so the guard keeps being necessary rather than quietly redundant),
and that every question of every type has an answer and a blank to fill. A
new question type that lands on a shared screen without the fields that
screen reads is now a test failure instead of a blank page.

170 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
2026-10-05 06:16:28 +00:00
co-authored by Claude Opus 5
parent eae889c52b
commit b96f3d3d0e
2 changed files with 57 additions and 1 deletions
+2 -1
View File
@@ -606,7 +606,8 @@ export default function App() {
{q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}</>} {q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}</>}
</Hideable> </Hideable>
{nounForm(q.noun, q.cas) !== q.noun.w && ( {/* Pronoun questions have no noun at all, so this must be guarded. */}
{q.noun && nounForm(q.noun, q.cas) !== q.noun.w && (
<div style={{...S.secondaryContext, marginTop:-10}}> <div style={{...S.secondaryContext, marginTop:-10}}>
<button style={{...S.ruleBtn, marginLeft:0}} onClick={()=>setNounInfo(q)}> <button style={{...S.ruleBtn, marginLeft:0}} onClick={()=>setNounInfo(q)}>
ⓘ why {nounForm(q.noun, q.cas)}? ⓘ why {nounForm(q.noun, q.cas)}?
+55
View File
@@ -942,3 +942,58 @@ describe('no repeats within a round', () => {
} }
}); });
}); });
// ── What the screens are allowed to assume ──────────────────
// A question that is missing a field its screen dereferences does not fail
// gracefully — React unmounts and the learner gets a blank page. That has now
// happened twice, so the contract is written down here rather than left
// implicit in the JSX.
describe('question field contract', () => {
// The article / possessive / pronoun screen is shared by four types. Only
// pronoun questions have no noun, which is why every noun access on that
// screen has to be guarded.
const NEEDS_NOUN = ['article','chunk','poss','wechselContext','adjective','adjClass','verbSentence'];
const NO_NOUN = ['pronoun','prep','contraction','verbCase'];
it('every question carries the fields its screen reads', () => {
for (const lv of LEVELS) {
for (const q of runSession(lv.id, 120)) {
expect([...NEEDS_NOUN, ...NO_NOUN], `unknown question type ${q.type}`).toContain(q.type);
if (NEEDS_NOUN.includes(q.type)) {
expect(q.noun, `${q.type} (level ${lv.id}) has no noun`).toBeTruthy();
expect(q.noun.g, `${q.type} noun has no gender`).toBeTruthy();
expect(q.noun.w).toBeTruthy();
expect(q.noun.en).toBeTruthy();
}
// every type needs something to show and something to grade
expect(q.ans, `${q.type} (level ${lv.id}) has no answer`).toBeTruthy();
if (q.sentence) expect(q.sentence).toContain('_____');
}
}
});
it('pronoun questions have a pronoun and deliberately no noun', () => {
for (const id of [14, 15, 16, 17]) {
for (const q of runSession(id, 120)) {
expect(q.pron, 'pronoun question has no pron').toBeTruthy();
expect(q.pron.nom).toBeTruthy();
expect(q.pron.en).toBeTruthy();
expect(q.pron.tag).toBeTruthy();
// this is the one the blank screen came from
expect(q.noun, 'pronoun questions must stay noun-less so the guard is real').toBeUndefined();
}
}
});
it('verbCase questions carry the verb, not a noun', () => {
for (const id of [30, 31]) {
for (const q of runSession(id, 120)) {
expect(q.verb?.de).toBeTruthy();
expect(q.verb?.en).toBeTruthy();
expect(q.noun).toBeUndefined();
}
}
});
});