/** * ExamRunner — Preact island driving the interactive practice exam (#112-UI) * and the front-matter assessment test (#113). * * Architecture: a CONTROLLER over server-rendered question cards, not a * client-side renderer. MDX stems can't serialize into island props, so the * .astro side renders every card statically (QuestionCard.astro: stem, options * as radio inputs named `exam-`, answer behind
) and this island * receives only the pure manifest — the exact `ExamQuestion` shape * sampleExam/scoreExam consume. The island samples a form client-side, hides * the cards that aren't in it, collects the checked radios on submit, scores * with the SAME engine the node:test suite verifies, and renders the * score/per-domain/weak-domain readout. No JS → the static bank with *
reveals is untouched. * * DOM contract with the .astro side (QuestionCard inside a [data-exam-root]): * [data-exam-root] wrapper section; gains * data-exam-phase="active|review" * [data-question-id=""] one card per question; toggled via * the `hidden` attribute; gains * data-exam-result="correct|incorrect" * input[name="exam-"]:checked the reader's chosen option id * details.question-reveal force-opened on review * * Hydrated with `client:idle`. Theme via CSS tokens only (no canvas — no * book:theme:change listener needed). */ import { useRef, useState } from 'preact/hooks'; import { sampleExam, scoreExam, type ExamQuestion, type ExamResult, } from '../src/lib/exam-engine'; import { spreadBlueprint, type RoutingChapter } from '../src/lib/exam-manifest'; interface Props { /** Scoreable MCQ pool (buildExamManifest output) — ids/domains/options only. */ manifest: ExamQuestion[]; /** practice: domain-agnostic sampling, weak domains anchor to #domain- on * the same page. assessment: cross-domain spread blueprint, weak domains * route to chapters via `domainRouting`. */ mode: 'practice' | 'assessment'; /** Default form size (clamped to the pool; reader can adjust before start). */ count?: number; /** Weak-domain threshold passed to scoreExam (default 0.7). */ passMark?: number; /** Assessment mode: domain → chapters carrying its questions (deriveDomainRouting). */ domainRouting?: Record; /** Assessment mode: href of the practice bank when that route is enabled, else null. */ practiceExamHref?: string | null; } type Phase = 'idle' | 'active' | 'review'; export default function ExamRunner({ manifest, mode, count, passMark = 0.7, domainRouting = {}, practiceExamHref = null, }: Props) { const poolSize = manifest.length; const domainCount = new Set(manifest.map((q) => q.domain)).size; // Assessment floors at one question per domain (see start()); the default // and the input's min respect that so the UI can't request a starved form. const minCount = mode === 'assessment' ? Math.max(1, domainCount) : 1; const defaultCount = Math.min( Math.max(count ?? (mode === 'assessment' ? 12 : 10), minCount), poolSize, ); const [phase, setPhase] = useState('idle'); const [requested, setRequested] = useState(defaultCount); const [form, setForm] = useState([]); const [result, setResult] = useState(null); const ref = useRef(null); function requireRoot(): HTMLElement { // Fail loud (house invariant): a silently dead Start button is the worst // failure mode. The throw surfaces as an uncaught console error. const r = ref.current?.closest('[data-exam-root]'); if (!r) { throw new Error( 'ExamRunner: no [data-exam-root] ancestor — mount the island inside the ' + 'wrapper that contains its QuestionCards (see the DOM contract in ExamRunner.tsx).', ); } return r; } function cards(r: HTMLElement): HTMLElement[] { // Array.from, not spread — the dts tsconfig lib lacks DOM.Iterable. return Array.from(r.querySelectorAll('[data-question-id]')); } function radios(r: HTMLElement): HTMLInputElement[] { return Array.from(r.querySelectorAll('input[type="radio"]')); } function start(): void { const r = requireRoot(); // Assessment mode floors at one question per domain — a "cross-domain" // form that silently drops late-book domains would betray its own point // (spreadBlueprint's quota order starves the tail otherwise). const n = Math.max(minCount, Math.min(requested, poolSize)); const sampled = mode === 'assessment' ? sampleExam(manifest, spreadBlueprint(manifest, n)) : sampleExam(manifest, { count: n }); const inForm = new Set(sampled.map((q) => q.id)); const allCards = cards(r); // Fail loud on manifest/DOM drift: a sampled question with no rendered // card would be invisible yet scored incorrect — silently wrong results. const cardIds = new Set(allCards.map((c) => c.dataset.questionId)); const missing = sampled.filter((q) => !cardIds.has(q.id)); if (missing.length > 0) { throw new Error( `ExamRunner: manifest/DOM drift — no rendered card for question(s): ` + `${missing.map((q) => q.id).join(', ')}.`, ); } for (const card of allCards) { card.hidden = !inForm.has(card.dataset.questionId ?? ''); card.removeAttribute('data-exam-result'); const reveal = card.querySelector('details.question-reveal'); if (reveal) reveal.open = false; } for (const input of radios(r)) { input.checked = false; } r.setAttribute('data-exam-phase', 'active'); setForm(sampled); setResult(null); setPhase('active'); } function submit(): void { const r = requireRoot(); const answers: Record = {}; for (const q of form) { // CSS.escape: a question id containing a quote would otherwise break // the selector and throw a DOMException mid-submit (frozen exam). const checked = r.querySelector( `input[name="exam-${CSS.escape(q.id)}"]:checked`, ); if (checked) answers[q.id] = checked.value; } for (const q of form) { const card = r.querySelector( `[data-question-id="${CSS.escape(q.id)}"]`, ); if (!card) { // start() already guards drift; defense in depth, same loud failure. throw new Error(`ExamRunner: no rendered card for question "${q.id}".`); } const right = q.options.some((o) => o.correct === true && o.id === answers[q.id]); card.setAttribute('data-exam-result', right ? 'correct' : 'incorrect'); const reveal = card.querySelector('details.question-reveal'); if (reveal) reveal.open = true; } r.setAttribute('data-exam-phase', 'review'); setResult(scoreExam(form, answers, passMark)); setPhase('review'); } function reset(): void { const r = requireRoot(); for (const card of cards(r)) { card.hidden = false; card.removeAttribute('data-exam-result'); } for (const input of radios(r)) { input.checked = false; } r.removeAttribute('data-exam-phase'); setForm([]); setResult(null); setPhase('idle'); } if (poolSize === 0) { return (

No auto-scoreable (multiple-choice) questions available — free-response and cloze items can't be machine-scored.

); } return (
{phase === 'idle' && (

{mode === 'assessment' ? 'Take a cross-domain assessment: a sampled form spread over every exam domain, scored with a weak-domain readout routing you to the chapters to (re)read.' : 'Take a scored practice exam: a random form sampled from the bank below, with a per-domain score readout.'}

)} {phase === 'active' && (

{form.length} question{form.length === 1 ? '' : 's'} below — answers stay hidden until you submit.

)} {phase === 'review' && result && (

{result.pct}% — {result.correct} of {result.total} correct

{result.byDomain.map((d) => ( ))}
Domain Score
{d.domain} {d.correct}/{d.total} {result.weakDomains.includes(d.domain) && ( — review )}
{result.weakDomains.length > 0 && (

{mode === 'assessment' ? 'Weak domains — start with these chapters:' : 'Weak domains — review these sections of the bank:'}

    {result.weakDomains.map((domain) => (
  • {domain} {mode === 'practice' && ( <> {' '} — jump to {domain} questions )} {mode === 'assessment' && (domainRouting[domain]?.length ?? 0) > 0 && ( <> {' — '} {domainRouting[domain]!.map((ch, i) => ( <> {i > 0 && ', '} {ch.href ? ( chapter {ch.label} ) : ( chapter {ch.label} )} ))} )} {mode === 'assessment' && practiceExamHref && ( <> {' '} (practice more) )}
  • ))}
)}
)}
); }