gestion_table_tournante_libre/scripts/mutation/mutants.test.js
Mathieu Benoit 52bc5f0bc8 [ADD] mutation: engine mutant generator on Vite's parser
The engine's tests must be shown to fail when its code is wrong (spec
14.13). The generator parses each module of src/moteur with Vite's parseAst
and yields one mutant per site and operator: arithmetic, boundary,
negation, logic, update, numbers, booleans, branches, ternaries, min/max.
Strings, comments, templates and imports are never mutated, sites follow
source order, and a mutant key survives an unrelated line added above it.
2576 mutants on src/moteur, enumerated in tens of milliseconds.
Checked: tests red first; 3201 node tests green from the index alone.

--- FR ---

[ADD] mutation : générateur de mutants du moteur, analyseur de Vite

Il faut montrer que les épreuves du moteur échouent quand son code est faux
(spec 14.13). Le générateur lit chaque module de src/moteur par parseAst de
Vite et rend un mutant par site et par opérateur : arithmétique, frontière,
négation, logique, mise à jour, nombres, booléens, branches, ternaires,
min/max. Chaînes, commentaires, gabarits et imports ne sont jamais mutés,
les sites suivent l'ordre du source, et une clé de mutant survit à une
ligne sans rapport ajoutée au-dessus. 2576 mutants sur src/moteur, énumérés
en quelques dizaines de millisecondes.
Vérifié : rouge d'abord ; 3201 node verts depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 04:52:30 -04:00

344 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'énumération des mutants (§ 14.13) : chaque opérateur sur un
// extrait inventé, les décalages en unités UTF-16, ce qui n'est jamais muté,
// la clé stable, et le plancher du moteur réel.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseAst } from 'vite';
import { describe, test } from '../../test/lanceur.js';
import { appliquer, mutants, OPERATEURS } from './mutants.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
const EXTRAIT = 'scripts/mutation/extrait.js';
// Les sources mutées d'un extrait, pour un opérateur, dans l'ordre du source.
const mutes = (source, operateur) =>
mutants(source, EXTRAIT)
.filter((m) => m.operateur === operateur)
.map((m) => appliquer(source, m));
// Vrai quand (ligne, colonne) ne décroît jamais d'un mutant au suivant.
const ordonne = (liste) =>
liste.every((m, i) => i === 0 || liste[i - 1].ligne < m.ligne || (liste[i - 1].ligne === m.ligne && liste[i - 1].colonne <= m.colonne));
describe('OPERATEURS', () => {
test('la liste est figée et nomme chaque opérateur une fois', () => {
assert.deepEqual(OPERATEURS, [
'arithmetique',
'frontiere',
'negation',
'logique',
'retrait-non',
'mise-a-jour',
'nombre-plus-un',
'nombre-zero',
'booleen',
'si-vrai',
'si-faux',
'ternaire-vrai',
'ternaire-faux',
'min-max',
'corps-si',
]);
assert.ok(Object.isFrozen(OPERATEURS));
});
});
describe('chaque opérateur sur un extrait minimal', () => {
test('arithmetique : + et - s’échangent, * et / aussi, % devient *', () => {
const source = 'y = a + b;\ny = a - b;\ny = a * b;\ny = a / b;\ny = a % b;\n';
assert.deepEqual(mutes(source, 'arithmetique'), [
'y = a - b;\ny = a - b;\ny = a * b;\ny = a / b;\ny = a % b;\n',
'y = a + b;\ny = a + b;\ny = a * b;\ny = a / b;\ny = a % b;\n',
'y = a + b;\ny = a - b;\ny = a / b;\ny = a / b;\ny = a % b;\n',
'y = a + b;\ny = a - b;\ny = a * b;\ny = a * b;\ny = a % b;\n',
'y = a + b;\ny = a - b;\ny = a * b;\ny = a / b;\ny = a * b;\n',
]);
});
test('arithmetique : une concaténation avec une chaîne n’est pas une addition', () => {
assert.deepEqual(mutes("s = 'a' + b;\ns = b + 'a';\n", 'arithmetique'), []);
});
test('frontiere : < et <= s’échangent, > et >= aussi', () => {
assert.deepEqual(mutes('p = a < b; p = a <= b; p = a > b; p = a >= b;', 'frontiere'), [
'p = a <= b; p = a <= b; p = a > b; p = a >= b;',
'p = a < b; p = a < b; p = a > b; p = a >= b;',
'p = a < b; p = a <= b; p = a >= b; p = a >= b;',
'p = a < b; p = a <= b; p = a > b; p = a > b;',
]);
});
test('negation : l’égalité et chaque comparaison prennent leur contraire', () => {
const source = 'p = a === b; p = a !== b; p = a == b; p = a != b; p = a < b; p = a <= b; p = a > b; p = a >= b;';
const remplacements = mutants(source, EXTRAIT)
.filter((m) => m.operateur === 'negation')
.map((m) => `${m.original}→${m.remplacement}`);
assert.deepEqual(remplacements, ['===→!==', '!==→===', '==→!=', '!=→==', '<→>=', '<=→>', '>→<=', '>=→<']);
});
test('logique : && et || s’échangent, ?? devient &&, entre parenthèses', () => {
assert.deepEqual(mutes('p = a && b; p = a || b; p = a ?? b;', 'logique'), [
'p = ((a )||( b)); p = a || b; p = a ?? b;',
'p = a && b; p = ((a )&&( b)); p = a ?? b;',
'p = a && b; p = a || b; p = ((a )&&( b));',
]);
});
test('logique : la parenthèse garde l’arbre quand la priorité change', () => {
// a && b && c se lit (a && b) && c : sans parenthèse, le nœud de gauche muté
// en || se relirait a || (b && c).
const source = 'p = a && b && c;';
const interieur = mutants(source, EXTRAIT).find((m) => m.operateur === 'logique' && m.original === 'a && b');
assert.equal(appliquer(source, interieur), 'p = ((a )||( b)) && c;');
// Un ?? sous un ?? ne se mêle pas à && sans parenthèse : la source mutée se lit.
for (const m of mutants('p = a ?? b ?? c;', EXTRAIT)) {
parseAst(appliquer('p = a ?? b ?? c;', m));
}
});
test('retrait-non : le ! part, son argument reste', () => {
assert.deepEqual(mutes('p = !a; q = !(b && c);', 'retrait-non'), ['p = a; q = !(b && c);', 'p = !a; q = (b && c);']);
});
test('mise-a-jour : ++ et -- s’échangent, += et -= aussi', () => {
assert.deepEqual(mutes('i++; --j; k += c; k -= c;', 'mise-a-jour'), [
'i--; --j; k += c; k -= c;',
'i++; ++j; k += c; k -= c;',
'i++; --j; k -= c; k -= c;',
'i++; --j; k += c; k += c;',
]);
});
test('nombre-plus-un et nombre-zero : n devient n + 1, puis 0 s’il ne l’est pas déjà', () => {
const source = 'n = 3; n = 0.5; n = 0; n = 0x10;';
assert.deepEqual(mutes(source, 'nombre-plus-un'), [
'n = 4; n = 0.5; n = 0; n = 0x10;',
'n = 3; n = 1.5; n = 0; n = 0x10;',
'n = 3; n = 0.5; n = 1; n = 0x10;',
'n = 3; n = 0.5; n = 0; n = 17;',
]);
assert.deepEqual(mutes(source, 'nombre-zero'), [
'n = 0; n = 0.5; n = 0; n = 0x10;',
'n = 3; n = 0; n = 0; n = 0x10;',
'n = 3; n = 0.5; n = 0; n = 0;',
]);
});
test('nombre : une clé de propriété n’est pas une valeur', () => {
assert.deepEqual(mutants('o = { 1: a };', EXTRAIT), []);
});
test('booleen : true et false s’échangent', () => {
assert.deepEqual(mutes('v = true; w = false;', 'booleen'), ['v = false; w = false;', 'v = true; w = true;']);
});
test('si-vrai et si-faux : le test d’un if devient constant, sauf s’il l’est déjà', () => {
const source = 'if (a > b) f();\nif (true) g();\n';
assert.deepEqual(mutes(source, 'si-vrai'), ['if (true) f();\nif (true) g();\n']);
assert.deepEqual(mutes(source, 'si-faux'), ['if (false) f();\nif (true) g();\n', 'if (a > b) f();\nif (false) g();\n']);
});
test('ternaire-vrai et ternaire-faux : le test du ternaire devient constant', () => {
assert.deepEqual(mutes('v = a || b ? c : d;', 'ternaire-vrai'), ['v = true ? c : d;']);
assert.deepEqual(mutes('v = a || b ? c : d;', 'ternaire-faux'), ['v = false ? c : d;']);
});
test('min-max : Math.min et Math.max s’échangent, rien d’autre ne s’appelle min', () => {
assert.deepEqual(mutes('v = Math.min(a, b); w = Math.max(a, b); x = o.min(a);', 'min-max'), [
'v = Math.max(a, b); w = Math.max(a, b); x = o.min(a);',
'v = Math.min(a, b); w = Math.min(a, b); x = o.min(a);',
]);
});
test('corps-si : le corps d’un if se vide, l’autre branche reste', () => {
const source = 'if (a) { f(); } else { g(); }\nif (b) h();\nif (c) {}\n';
assert.deepEqual(mutes(source, 'corps-si'), [
'if (a) {} else { g(); }\nif (b) h();\nif (c) {}\n',
'if (a) { f(); } else { g(); }\nif (b) {}\nif (c) {}\n',
]);
});
});
describe('décalages', () => {
test('un commentaire accentué avant le site : appliquer coupe au bon endroit, en unités UTF-16', () => {
// é, « » et une clé de sol hors du plan multilingue de base, qui compte
// deux unités UTF-16 et quatre octets.
const source = '// Côté « été » \u{1D11E}\nexport const x = a < b;\n';
const [site] = mutants(source, EXTRAIT).filter((m) => m.operateur === 'frontiere');
const indice = source.indexOf('<');
assert.equal(indice, 38);
assert.equal(site.debut, indice);
assert.equal(site.fin, indice + 1);
assert.equal(site.ligne, 2);
assert.equal(site.colonne, 20);
assert.equal(appliquer(source, site), '// Côté « été » \u{1D11E}\nexport const x = a <= b;\n');
});
test('un commentaire entre les opérandes qui porte l’opérateur n’est pas le site', () => {
const source = 'y = a /* é - < */ - b;';
const sites = mutants(source, EXTRAIT).filter((m) => m.operateur === 'arithmetique');
assert.equal(sites.length, 1);
assert.equal(sites[0].debut, source.lastIndexOf('-'));
assert.equal(appliquer(source, sites[0]), 'y = a /* é - < */ + b;');
});
test('un commentaire de ligne entre les opérandes qui porte l’opérateur n’est pas le site', () => {
const source = 'y = a // note - <\n - b;';
const sites = mutants(source, EXTRAIT).filter((m) => m.operateur === 'arithmetique');
assert.equal(sites.length, 1);
assert.equal(sites[0].debut, source.lastIndexOf('-'));
assert.equal(appliquer(source, sites[0]), 'y = a // note - <\n + b;');
parseAst(appliquer(source, sites[0]));
});
test('appliquer refuse un mutant que le source ne porte plus à sa place', () => {
const source = 'y = a + b;';
const [site] = mutants(source, EXTRAIT);
assert.throws(() => appliquer('y = a - b;', site), /ne porte pas/);
});
});
describe('ce qui n’est jamais muté', () => {
test('ni chaîne, ni commentaire, ni gabarit, ni import', () => {
const source = [
"import { a } from './a.js';",
"export { b } from './b.js';",
"const s = 'a < b + 1 && true';",
'// c < d + 1 && true',
'/* e > 2 */',
'const t = `${a + 1} < 2 ${true}`;',
'const u = String.raw`${a - 1}`;',
"const v = import('./c.js');",
'',
].join('\n');
assert.deepEqual(mutants(source, EXTRAIT), []);
});
test('un import dynamique dont l’argument porte des sites reste sans mutant', () => {
// Hors de import(…), le ternaire, i + 1 et le 1 en donneraient.
assert.deepEqual(mutants("const m = import(a ? './x.js' : './y.js');\n", EXTRAIT), []);
assert.deepEqual(mutants("const n = import('./p' + (i + 1) + '.js');\n", EXTRAIT), []);
assert.ok(mutants("const m = a ? './x.js' : './y.js';\n", EXTRAIT).length > 0);
assert.ok(mutants("const n = './p' + (i + 1) + '.js';\n", EXTRAIT).length > 0);
});
test('un source sans site rend []', () => {
assert.deepEqual(mutants('', EXTRAIT), []);
assert.deepEqual(mutants("export const NOM = 'x';\n", EXTRAIT), []);
});
test('types.js et les épreuves sont exclus, quel que soit leur contenu', () => {
assert.ok(mutants('p = a < b;', EXTRAIT).length > 0);
assert.deepEqual(mutants('p = a < b;', 'src/moteur/types.js'), []);
assert.deepEqual(mutants('p = a < b;', 'src/moteur/plafond.test.js'), []);
assert.deepEqual(mutants('p = a < b;', 'src/moteur/plafond.long.test.js'), []);
});
});
describe('la clé', () => {
const SOURCE = 'function f(a, b) {\n if (a < b) return a + 2;\n return b + 1;\n}\nconst g = (a) => a + 1;\nconst h = (a) => a + 1;\n';
test('chaque mutant a sa clé, et deux sites identiques se distinguent par leur rang', () => {
const cles = mutants(SOURCE, EXTRAIT).map((m) => m.cle);
assert.equal(new Set(cles).size, cles.length);
const jumeaux = mutants(SOURCE, EXTRAIT).filter((m) => m.operateur === 'arithmetique' && m.ligne >= 5);
assert.equal(jumeaux.length, 2);
assert.match(jumeaux[0].cle, /:0$/);
assert.match(jumeaux[1].cle, /:1$/);
});
test('la clé porte le fichier et l’opérateur', () => {
assert.equal(mutants(SOURCE, EXTRAIT).length, 17);
for (const m of mutants(SOURCE, EXTRAIT)) {
assert.ok(m.cle.startsWith(`${EXTRAIT}:${m.operateur}:`), m.cle);
}
const autre = mutants(SOURCE, 'scripts/mutation/autre.js').map((m) => m.cle);
assert.equal(autre.filter((cle) => mutants(SOURCE, EXTRAIT).some((m) => m.cle === cle)).length, 0);
});
test('une ligne sans rapport ajoutée au-dessus ne change aucune clé', () => {
const avant = mutants(SOURCE, EXTRAIT);
// Le 1 et le 2 ajoutés précèdent ceux de SOURCE : une clé prise sur le
// seul littéral verrait leur rang glisser.
const apres = mutants(`let k = 1;\nconst z = y * 2; // é\n${SOURCE}`, EXTRAIT).filter((m) => m.ligne > 2);
assert.deepEqual(
apres.map(({ cle, operateur, original, remplacement, ligne }) => ({ cle, operateur, original, remplacement, ligne })),
avant.map(({ cle, operateur, original, remplacement, ligne }) => ({ cle, operateur, original, remplacement, ligne: ligne + 2 })),
);
});
test('deux énumérations du même source sont identiques', () => {
assert.ok(mutants(SOURCE, EXTRAIT).length > 0);
assert.deepEqual(mutants(SOURCE, EXTRAIT), mutants(SOURCE, EXTRAIT));
});
});
describe('l’ordre du source', () => {
test('les mutants se rangent par place du jeton muté, quel que soit le parcours de l’arbre', () => {
// Le if se visite avant le < de son test, et le test avant le corps.
const source = 'if (a < b) y = a + 2;\n';
const liste = mutants(source, EXTRAIT);
assert.ok(ordonne(liste));
assert.deepEqual(
liste.map((m) => `${m.colonne}:${m.operateur}`),
['5:si-vrai', '5:si-faux', '7:frontiere', '7:negation', '12:corps-si', '18:arithmetique', '20:nombre-plus-un', '20:nombre-zero'],
);
});
test('deux mutants d’un même site se rangent dans l’ordre d’OPERATEURS, non dans celui du parcours', () => {
// Le if se visite avant son test : sans départage, si-vrai passerait devant.
const operateurs = (source, colonne) =>
mutants(source, EXTRAIT)
.filter((m) => m.colonne === colonne)
.map((m) => m.operateur);
assert.deepEqual(operateurs('p = a < b;', 7), ['frontiere', 'negation']);
assert.deepEqual(operateurs('if (0) f();', 5), ['nombre-plus-un', 'si-vrai', 'si-faux']);
assert.deepEqual(operateurs('if (!a) f();', 5), ['retrait-non', 'si-vrai', 'si-faux']);
});
});
describe('le moteur réel', () => {
// Les modules du moteur, hors épreuves, dans l'ordre des noms.
const dossier = join(RACINE, 'src/moteur');
const modules = readdirSync(dossier)
.filter((nom) => nom.endsWith('.js'))
.sort()
.map((nom) => ({ fichier: `src/moteur/${nom}`, source: readFileSync(join(dossier, nom), 'utf8') }));
test('il rend au moins 1 000 mutants, de clés distinctes, chacun à sa place dans le source', () => {
const tous = modules.flatMap(({ fichier, source }) =>
mutants(source, fichier).map((m) => {
assert.equal(source.slice(m.debut, m.fin), m.original, m.cle);
assert.notEqual(m.remplacement, m.original, m.cle);
return m;
}),
);
assert.ok(tous.length >= 1000, `${tous.length} mutants`);
for (const { fichier, source } of modules) assert.ok(ordonne(mutants(source, fichier)), fichier);
assert.equal(new Set(tous.map((m) => m.cle)).size, tous.length);
assert.ok(tous.every((m) => !m.fichier.endsWith('types.js') && !m.fichier.endsWith('.test.js')));
});
// Relire chaque source mutée reparse le module entier par mutant, des
// milliers de fois : se relisent le premier mutant de chaque opérateur dans
// chaque module, et tous ceux de logique, les seuls qui réécrivent un nœud
// entier.
test('une source mutée du moteur se lit encore, pour chaque opérateur de chaque module', () => {
const operateurs = new Set();
for (const { fichier, source } of modules) {
const vus = new Set();
for (const m of mutants(source, fichier)) {
if (vus.has(m.operateur) && m.operateur !== 'logique') continue;
vus.add(m.operateur);
operateurs.add(m.operateur);
assert.doesNotThrow(() => parseAst(appliquer(source, m)), m.cle);
}
}
assert.deepEqual([...operateurs].sort(), [...OPERATEURS].sort());
});
});