"Calculate the hypotenuse BC of the right triangle at A. AB = 12 cm, AC = 9 cm, BC = ?" The student reads the question, looks at the diagram… and the diagram says AB = 3 cm. Different triangle. The values in the question are randomly generated, but the diagram is static. This kind of bug doesn't crash anything — it just makes an educational tool unusable.
Radar College is a quiz platform for French middle school exams. After building the React architecture and migrating to TypeScript, one major challenge remained: moving from static questions to parametric ones — with SVG diagrams that match the generated values. And more importantly, finding a way to test all of it without going insane.
The static question problem
The initial version had ~300 hardcoded questions. Each one: a text prompt, 4 options, a hint. It works, but after 3 attempts the student recognizes the questions. Randomization only affected the pick order and answer shuffling — not the actual numerical values.
For math and physics, this is a dealbreaker. A student who memorizes "the answer is 25 cm" hasn't learned anything. The numbers need to change on every attempt — and the distractors (wrong answers) need to be computed from the correct answer to stay plausible. No random garbage: wrong answers should correspond to typical mistakes (forgetting the square root, flipping a sign, adding instead of multiplying).
Anatomy of a generator
Each parametric question is a gen(rnd) function that receives a seeded PRNG
and returns an object { q, options, correct, hint }. The PRNG is a
Mulberry32:
32-bit, deterministic, fast. The same seed always produces the same question — which means
we only store the seed in the history and reconstruct the exact problem for review.
// Pythagorean theorem generator — compute the hypotenuse
{ key:'pyt-1', gen: (rnd) => {
const TRIPLETS = [[3,4,5],[5,12,13],[8,15,17],[7,24,25],[6,8,10]];
const [a0, b0, c0] = TRIPLETS[Math.floor(rnd() * TRIPLETS.length)];
const k = 1 + Math.floor(rnd() * 3); // multiplier 1..3
const [a, b, c] = [a0*k, b0*k, c0*k];
return {
q: <>Calculate the hypotenuse BC:
<TriangleRectangle ab={`${a} cm`} ac={`${b} cm`} bc="?" /></>,
options: [`${c} cm`, `${a+b} cm`, `${a*a+b*b} cm`, `${Math.abs(a-b)} cm`],
correct: 0,
hint: `BC² = ${a}² + ${b}² = ${a*a+b*b} → BC = ${c} cm.`,
};
}}
Three things to note. First, the pool of Pythagorean triplets guarantees the hypotenuse
is an integer — no √41 ≈ 6.403 in a middle school quiz. Second, the
multiplier k gives varied values without leaving integer territory. Third,
the distractors aren't random: a+b (classic mistake: adding instead of
Pythagoras), a²+b² (forgot the square root), |a-b|
(subtraction by reflex).
An SVG kit that follows the values
The <TriangleRectangle> component above isn't decorative. It's a React
component that receives values as props and renders the matching diagram — with labeled
sides, a marked right angle, and a "?" on the measurement to find.
// _svg-kit.tsx — Parametric right triangle
function TriangleRectangle({ ab, ac, bc }: {
ab?: string | number;
ac?: string | number;
bc?: string | number;
} = {}) {
const lAB = ab !== undefined ? `AB = ${ab}` : 'AB';
const lAC = ac !== undefined ? `AC = ${ac}` : 'AC';
const lBC = bc !== undefined ? `BC = ${bc}` : 'BC (hypotenuse)';
return (
<svg viewBox="0 0 250 160" role="img"
aria-label="Right triangle at A">
<polygon points="50,30 50,130 200,130"
fill="rgba(199,138,29,0.08)"
stroke="currentColor" strokeWidth={1.8} />
<rect x={50} y={118} width={12} height={12}
stroke="currentColor" /> {/* right angle */}
<text x={40} y={85} fill="#b45309">{lAB}</text>
<text x={125} y={148} fill="#b45309">{lAC}</text>
<text x={135} y={72} fill="#b45309">{lBC}</text>
</svg>
);
}
The same pattern applies across the kit: ConfigThales (6 props for segments
AM, AB, AN, AC, MN, BC), TriangleTrigo (angle, opposite/adjacent/hypotenuse
sides), GrapheAffine (slope, y-intercept). When the generator picks random
values, it passes them to the SVG component — the diagram always shows the same numbers
as the question.
Electrical circuits and 3D volumes
The kit goes beyond geometry. For 8th-grade physics, electricity questions need circuit diagrams. Instead of static PNG images, I built composable SVG primitives:
// Primitives: Fil, Pile, Resistance, Lampe, Amperemetre, Voltmetre
// Compositions: CircuitSerie, CircuitParallele, CircuitCourtCircuit…
function CircuitSerie() {
return (
<svg viewBox="0 0 240 115" role="img"
aria-label="Series circuit">
<Fil points={[[30,85],[30,30],[210,30],[210,85],[30,85]]} />
<Pile cx={30} cy={58} />
<Resistance cx={100} cy={30} label="R₁" />
<Resistance cx={170} cy={30} label="R₂" />
<text x={120} y={105} fill="#b45309">
same I everywhere · U = U₁ + U₂
</text>
</svg>
);
}
A <Fil> draws a polyline between points. A <Resistance>
draws a rectangle with an optional label. A <Mesureur> draws a circle
with a letter inside — "A" for ammeter, "V" for voltmeter. Compositions assemble these
building blocks into complete circuits with annotated formulas.
For volumes (7th-grade math), same approach: Cube3D, Pave3D,
Cylindre3D, Sphere3D, Cone3D components in
cavalier perspective. And for geometric transformations (8th-grade math):
SymetrieAxiale, SymetrieCentrale, Translation
with a stylized F figure and its image. In total, 20 SVG components in a single
_svg-kit.tsx file — 300 lines.
The duplicate distractor trap
When distractors are computed, there's a nasty edge case: a distractor can land on the same value as the correct answer. Example: a square with side 4, area = 16, perimeter = 16. If the distractor is "perimeter instead of area", you get 16 twice in the options.
The first instinct would be to re-roll the values. But with a seeded PRNG, you can't
discard rolls — it breaks determinism. The solution: a Set of already-used
values, and a systematic bump on collisions.
// Anti-duplicate pattern in every gen
const good = computeAnswer(a, b);
const used = new Set([good]);
const opts = [good];
for (const distractor of [wrongSign, wrongFormula, wrongOp]) {
let v = distractor;
while (used.has(v)) v += (v >= 0 ? 1 : -1);
used.add(v);
opts.push(v);
}
Except this pattern has its own bug. If the boundary is crossed (v <= 0
and we're decrementing), the while loop runs forever. Not theoretical:
it happened on 7 generators when certain seeds produced values near zero. The fix:
check the bump direction and reverse if heading out of valid space.
200 seeds per gen, in pre-commit
With 358 generators, manually checking that every value combination produces a valid quiz
is impossible. I wrote a Node script (test-generators.js) that loads each
quiz file through Babel, runs every gen across 200 seeds, and verifies:
- 4 options present
correctin [0, 3]- No duplicates in options (after stringification and French decimal formatting)
- Determinism: same seed → same output (verified on 5 sentinel seeds)
- Variability: at least 10 distinct outputs across 200 seeds
// test-generators.js — excerpt
function testQuestion(quizKey, domainKey, q) {
const issues = [];
if (typeof q.gen !== 'function') return null;
const SEEDS = 200;
const outputs = new Set();
for (let i = 0; i < SEEDS; i++) {
const seed = (i * 2654435761) >>> 0; // Knuth multiplicative
const out = q.gen(mulberry32(seed));
// 4 options
if (out.options.length !== 4)
issues.push(`seed ${seed}: ${out.options.length} options`);
// Duplicates
const strings = out.options.map(serializeNode);
if (new Set(strings).size !== strings.length)
issues.push(`seed ${seed}: duplicates → [${strings.join(' | ')}]`);
// Variability
outputs.add(strings.join('¤') + '|' + out.correct);
}
if (outputs.size < 10)
issues.push(`low variability: ${outputs.size}/200`);
return { quizKey, key: q.key, issues, uniqueOutputs: outputs.size };
}
The script distinguishes bugs (duplicates, correct out of range, missing options) from warnings (low variability). Only bugs fail the pre-commit. Low variability is displayed but doesn't block — some generators have a naturally narrow input space (a "true or false, is this triangle right-angled" quiz only has two possible outputs by design).
Last run result: 358/358 generators, 0 bugs, 8 variability warnings.
Pre-commit passes without --no-verify.
JSX serialization, the testing surprise
The biggest technical challenge in the test script wasn't the checks — it was serialization.
Quiz options aren't always strings. A fraction renders with <F n={3} d={4} />,
an exponent with <sup>, a math symbol with <M>.
To compare two options, you need to reduce them to text.
// Serialize a compiled ReactNode (JSX → createElement → object)
function serializeNode(n) {
if (n == null || n === false) return '';
if (typeof n === 'string' || typeof n === 'number') return String(n);
if (Array.isArray(n)) return n.map(serializeNode).join('');
if (typeof n === 'object' && n.props) {
const children = n.props.children;
if (children == null)
return `<${typeof n.type === 'string' ? n.type : 'C'}/>`;
return serializeNode(children);
}
return '';
}
The script doesn't mount any DOM. It shims React.createElement to return
plain objects, then walks down props.children recursively. The SVG kit
components are stubbed — a <TriangleRectangle ab="12 cm" /> serializes
to a flat string, enough to detect duplicates without mounting a virtual DOM.
French formatting that breaks comparisons
In France, we write 3,5 — not 3.5. The app applies a Frenchification pass on options
before display. Problem: the test must reproduce this exact pass, otherwise a post-formatting
duplicate goes unnoticed. Example: "3.0" and "3" both become
"3" after .replace(/\.0$/, '').
The script applies the same regex as app.tsx before comparing. It's an
exact copy — not a reimplementation, not a port, a line-by-line copy. Any divergence
between the test and the app would produce false negatives, and that's exactly the kind
of bug that would go unnoticed for months.
Conclusion
The thing I didn't anticipate is that building parametric questions is a constrained combinatorics problem, not a random generation one. Randomness is the easy part. The hard part is guaranteeing that every value combination produces a valid quiz — no duplicates, no division by zero, no negative result when the context is a length, no diagram contradicting the question.
The pre-commit hook that tests 200 seeds per gen caught 13 duplicate bugs and 7 infinite loops I would never have found manually. The cost: a 180-line script and 4 extra seconds per commit. The return on investment is absurd.