SVG Diagrams and Parametric Generators: Testing 358 Questions Across 200 Seeds
Source: Dev.to
“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:
,
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.`,
};
}}
Enter fullscreen mode
Exit fullscreen mode
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 “ 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 (
{/* right angle */}
{lAB}
{lAC}
{lBC}
);
}
Enter fullscreen mode
Exit fullscreen mode
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 (
same I everywhere · U = U₁ + U₂
);
}
Enter fullscreen mode
Exit fullscreen mode
A draws a polyline between points. A draws a rectangle with an optional label. A “ 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);
}
Enter fullscreen mode
Exit fullscreen mode
Except this pattern has its own bug. If the boundary is crossed (`v >> 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 `, an exponent with , a math symbol with . 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 ``;
return serializeNode(children);
}
return '';
}
Enter fullscreen mode
Exit fullscreen mode
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 “ 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.