gestion_table_tournante_libre/scripts/mutation/mutants.test.js

345 lines
15 KiB
JavaScript
Raw Normal View History

// © 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());
});
});