« Calcule l'hypoténuse BC du triangle rectangle en A. AB = 12 cm, AC = 9 cm, BC = ? » L'élève lit l'énoncé, regarde le schéma… et le schéma dit AB = 3 cm. Pas le même triangle. Les valeurs de l'énoncé sont tirées au hasard, mais le schéma est statique. C'est le genre de bug qui ne crashe rien mais qui rend un outil éducatif inutilisable.
Radar College est une plateforme de quizz pour le Brevet des collèges. Après avoir posé l'architecture React et migré en TypeScript, il restait un chantier de fond : passer de questions statiques à des questions paramétriques — avec des schémas SVG qui suivent les valeurs générées. Et surtout, trouver un moyen de tester tout ça sans devenir fou.
Le problème des questions statiques
La version initiale avait ~300 questions codées en dur. Chaque question : un énoncé texte, 4 options, un indice. Ça marche, mais au bout de 3 passages l'élève reconnaît les questions. La randomisation ne portait que sur l'ordre de pioche et l'ordre des réponses — pas sur les valeurs numériques.
Pour les maths et la physique, c'est rédhibitoire. Un élève qui retient « la réponse c'est 25 cm » n'a rien appris. Il faut que les nombres changent à chaque tentative — et que les distracteurs (les mauvaises réponses) soient calculés à partir de la bonne pour rester plausibles. Pas de valeurs aléatoires décorrélées : les mauvaises réponses doivent correspondre à des erreurs typiques (oubli de la racine carrée, inversion de signe, addition au lieu de multiplication).
Anatomie d'un générateur
Chaque question paramétrique est une fonction gen(rnd) qui reçoit un PRNG seedé
et retourne un objet { q, options, correct, hint }. Le PRNG est un
Mulberry32 :
32 bits, déterministe, rapide. La même seed produit toujours la même question — ce qui permet
de stocker uniquement la seed dans l'historique et de reconstruire l'énoncé exact pour la relecture.
// Générateur Pythagore — hypoténuse à calculer
{ 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); // multiplicateur 1..3
const [a, b, c] = [a0*k, b0*k, c0*k];
return {
q: <>Calcule l'hypoténuse 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.`,
};
}}
Trois choses à noter. D'abord, le pool de triplets pythagoriciens garantit que l'hypoténuse
est un entier — pas de √41 ≈ 6.403 dans un QCM de collège. Ensuite, le
multiplicateur k donne des valeurs variées sans quitter les entiers. Enfin,
les distracteurs ne sont pas aléatoires : a+b (erreur classique : additionner
au lieu de Pythagore), a²+b² (oubli de la racine), |a-b|
(soustraction par réflexe).
Un kit SVG qui suit les valeurs
Le composant <TriangleRectangle> dans l'exemple ci-dessus n'est pas
décoratif. C'est un composant React qui reçoit les valeurs en props et affiche le schéma
correspondant — avec les côtés labelisés, l'angle droit marqué, et un « ? » sur la mesure
à trouver.
// _svg-kit.tsx — Triangle rectangle paramétrique
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 (hypoténuse)';
return (
<svg viewBox="0 0 250 160" role="img"
aria-label="Triangle rectangle en 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" /> {/* angle droit */}
<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>
);
}
Le même pattern s'applique à tout le kit : ConfigThales (6 props pour les
segments AM, AB, AN, AC, MN, BC), TriangleTrigo (angle, côtés opposé/adjacent/hypoténuse),
GrapheAffine (pente, ordonnée à l'origine). Quand le générateur tire des valeurs
aléatoires, il les passe au composant SVG — le schéma montre toujours les mêmes nombres que l'énoncé.
Circuits électriques et volumes 3D
Le kit ne s'arrête pas à la géométrie. Pour la physique-chimie 4e, les questions sur l'électricité nécessitent des schémas de circuits. Plutôt que des images PNG statiques, j'ai construit des primitives SVG composables :
// Primitives : Fil, Pile, Resistance, Lampe, Amperemetre, Voltmetre
// Compositions : CircuitSerie, CircuitParallele, CircuitCourtCircuit…
function CircuitSerie() {
return (
<svg viewBox="0 0 240 115" role="img"
aria-label="Circuit en série">
<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">
même I partout · U = U₁ + U₂
</text>
</svg>
);
}
Un <Fil> trace un polyline entre des points. Une <Resistance>
dessine un rectangle avec un label optionnel. Un <Mesureur> dessine un cercle
avec une lettre dedans — « A » pour l'ampèremètre, « V » pour le voltmètre. Les compositions
assemblent ces briques pour créer des circuits complets avec les formules annotées.
Pour les volumes (maths 5e), même approche : des composants Cube3D,
Pave3D, Cylindre3D, Sphere3D, Cone3D
en perspective cavalière. Et pour les transformations géométriques (maths 4e) :
SymetrieAxiale, SymetrieCentrale, Translation
avec une figure F stylisée et son image. Au total, 20 composants SVG dans un seul fichier
_svg-kit.tsx de 300 lignes.
Le piège des distracteurs dupliqués
Quand les distracteurs sont calculés, il y a un cas vicieux : le distracteur peut tomber sur la même valeur que la bonne réponse. Exemple : un carré de côté 4, aire = 16, périmètre = 16. Si le distracteur est « périmètre au lieu d'aire », on a deux fois 16 dans les options.
Le premier réflexe serait de re-tirer les valeurs. Mais avec un PRNG seedé, on ne peut pas
jeter des tirages — ça casse le déterminisme. La solution : un Set de valeurs
déjà utilisées, et un bump systématique sur les collisions.
// Pattern anti-doublons dans chaque 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);
}
Sauf que ce pattern a lui-même un bug. Si la borne est franchie (v <= 0
et on décrémente), la boucle while tourne à l'infini. Ce n'est pas théorique :
ça m'est arrivé sur 7 générateurs quand certaines seeds produisaient des valeurs proches de zéro.
La correction : vérifier la direction du bump et inverser si on s'éloigne de l'espace valide.
200 seeds par gen, en pre-commit
Avec 358 générateurs, il est impossible de vérifier manuellement que chaque combinaison de
valeurs produit un QCM valide. J'ai écrit un script Node (test-generators.js)
qui charge chaque fichier de quizz via Babel, exécute chaque gen sur 200 seeds, et vérifie :
- 4 options présentes
correctdans [0, 3]- Aucun doublon dans les options (après stringification et francisation des décimales)
- Déterminisme : même seed → même sortie (vérifié sur 5 seeds sentinelles)
- Variabilité : au moins 10 sorties distinctes sur 200 seeds
// test-generators.js — extrait
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`);
// Doublons
const strings = out.options.map(serializeNode);
if (new Set(strings).size !== strings.length)
issues.push(`seed ${seed} : doublons → [${strings.join(' | ')}]`);
// Variabilité
outputs.add(strings.join('¤') + '|' + out.correct);
}
if (outputs.size < 10)
issues.push(`variabilité faible : ${outputs.size}/200`);
return { quizKey, key: q.key, issues, uniqueOutputs: outputs.size };
}
Le script distingue les bugs (doublons, correct hors range, options manquantes) et les warnings (variabilité faible). Seuls les bugs font échouer le pre-commit. La variabilité faible est affichée mais ne bloque pas — certains générateurs ont un espace d'entrée naturellement étroit (un QCM sur « vrai ou faux, ce triangle est rectangle » n'a que deux sorties possibles par design).
Résultat du dernier run : 358/358 générateurs, 0 bug, 8 warnings variabilité.
Le pre-commit passe sans --no-verify.
La sérialisation JSX, la surprise du test
Le plus gros défi technique du script de test n'était pas les vérifications — c'était
la sérialisation. Les options d'un QCM ne sont pas toujours des strings. Une fraction
s'affiche avec <F n={3} d={4} />, une puissance avec
<sup>, un symbole mathématique avec <M>.
Pour comparer deux options, il faut les réduire à du texte.
// Sérialiser un ReactNode compilé (JSX → createElement → objet)
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 '';
}
Le script ne monte pas de DOM. Il shim React.createElement pour retourner
des objets plats, puis descend récursivement dans les props.children. Les
composants SVG du kit sont stubés — un <TriangleRectangle ab="12 cm" />
se sérialise en une string plate, suffisante pour détecter les doublons sans monter de VDOM.
La francisation qui casse les comparaisons
En France, on écrit 3,5 — pas 3.5. L'app applique une passe de francisation sur les options
avant affichage. Problème : le test doit reproduire exactement cette passe, sinon un doublon
post-francisation passe inaperçu. Exemple : les options "3.50 cm" et
"3.5 cm" sont différentes en JS, mais identiques après francisation
("3,50 cm" vs "3,5 cm"… non, toujours différentes dans ce cas).
Le vrai piège, c'est "3.0" et "3" qui deviennent tous les deux
"3" après .replace(/\.0$/, '').
Le script applique donc la même regex de francisation que app.tsx avant de
comparer. C'est une copie exacte — pas une réimplémentation, pas un port, une copie
ligne par ligne. Toute divergence entre le test et l'app produirait des faux négatifs,
et c'est exactement le genre de bug qui passerait inaperçu pendant des mois.
Conclusion
Le truc que je n'avais pas anticipé, c'est que construire des questions paramétriques est un problème de combinatoire contrainte, pas de génération aléatoire. Le hasard est la partie facile. La partie dure, c'est de garantir que chaque combinaison de valeurs produit un QCM valide — pas de doublons, pas de division par zéro, pas de résultat négatif quand le contexte est une longueur, pas de schéma qui contredit l'énoncé.
Le pre-commit qui teste 200 seeds par gen a attrapé 13 bugs de doublons et 7 boucles infinies que je n'aurais jamais trouvés manuellement. Le coût : un script de 180 lignes et 4 secondes de plus par commit. Le retour sur investissement est absurde.