"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.
Key takeaway
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.