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
344 lines
15 KiB
JavaScript
344 lines
15 KiB
JavaScript
// © 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());
|
||
});
|
||
});
|