gestion_table_tournante_libre/scripts/banc/lancer.test.js

378 lines
17 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'orchestrateur du banc (§ 19.10, § 14.1, § 19.8), sous node
// et sans navigateur : l'assemblage du relevé, qui refuse une mesure
// manquante et range les mesures dans l'ordre du registre ; la
// configuration, liste fermée du contrat, qui ne nomme pas la machine ;
// le jugement qui rend la sortie non nulle ; la région des constantes ; le
// déroulé de executer, sur des effets factices. Les machines, les versions
// et les valeurs sont inventées ; le relevé committé n'est lu que pour sa
// conformité.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CONSTANTES } from '../../src/geometrie/constantes.js';
import { projetBanc } from './projet.js';
import { verifierReleve } from './releve.js';
import releveCommite from './releve.json' with { type: 'json' };
import {
ATTENDUES,
CHEMIN_RELEVE,
FAMILLE_EMBARQUEE,
GARDES,
SORTIES_NAVIGATEUR,
assembler,
configurationDuBanc,
executer,
jugement,
regionConstantes,
} from './lancer.js';
// Un système d'exploitation inventé, au nom d'hôte, au dossier personnel, à
// l'utilisateur et au modèle de processeur reconnaissables : aucun ne doit
// atteindre le relevé.
const HOTE_INVENTE = 'poste-quenouille-7';
const UTILISATEUR_INVENTE = 'quenouille';
const DOSSIER_INVENTE = '/home/quenouille';
const MODELE_INVENTE = 'Processeur Quenouille 6 cœurs';
const systemeInvente = () => ({
hostname: () => HOTE_INVENTE,
homedir: () => DOSSIER_INVENTE,
userInfo: () => ({ username: UTILISATEUR_INVENTE, homedir: DOSSIER_INVENTE }),
type: () => 'Linux',
arch: () => 'x64',
cpus: () => Array.from({ length: 6 }, () => ({ model: MODELE_INVENTE })),
availableParallelism: () => 6,
});
const configuration = () => configurationDuBanc({ systeme: systemeInvente(), node: 'v26.10.0', navigateur: 'Chromium 140.0' });
// Les sorties d'un passage du banc, de valeurs inventées : une mesure par
// constante du banc et par garde, rangées dans un ordre qui n'est pas celui
// du registre. Comme celles du navigateur, les mesures de texte et la garde
// du temps par image portent leur ralentissement ; celles du parallèle n'en
// portent pas, et leur sortie le dit dans sa configuration.
const sorties = () =>
new Map([
['plafond', { plafondAPriori: [{ cle: 'grande', N: 260, T: 33, R: 4, msPlafonds: 0.07, msDiagnostic: 0.9 }] }],
[
'parallele',
{
format: 1,
configuration: {
cadre: { largeur: 1366, hauteur: 700 },
facteurEchelle: 1,
navigateur: null,
node: null,
processeursLogiques: 6,
ralentissementCpu: 1,
police: null,
},
mesures: [
{ nom: 'RALENTISSEMENT_PLEINE_CHARGE', unite: '×', valeurProposee: 2.5, critere: 't(L) / t(1)', courbe: [] },
{ nom: 'DUREE_GENERATION_PAR_DEFAUT', unite: 'ms', valeurProposee: 600, critere: 'médiane de 3 générations' },
],
parallele: [{ coeurs: 1, ralentissement: 1 }],
},
],
[
'temps_image',
{
format: 1,
mesures: [{ nom: 'TEMPS_IMAGE_LISTES', unite: 'ms', valeurProposee: null, critere: 'p95 sous 33 ms', courbe: [], verdict: 'tenu', ralentissementCpu: 4 }],
},
],
[
'temoin',
{
format: 1,
mesures: [{ nom: 'TOLERANCE_CHAINE_TEMOIN', unite: 'rapport', valeurProposee: 0.02, critere: 'quart du repli', police: 'Noto Sans', ralentissementCpu: 1 }],
},
],
[
'sieges_lisibles',
{
format: 1,
mesures: [{ nom: 'SEUIL_SIEGES_LISIBLES', unite: 'places', valeurProposee: 17, critere: 'liste dans le plan', police: 'Noto Sans', ralentissementCpu: 1 }],
},
],
['harnais', { format: 1, releves: [{ controle: 'cadre' }] }],
]);
const noms = (releve) => releve.mesures.map(({ nom }) => nom);
const DU_BANC = CONSTANTES.filter((c) => c.proprietaire === 'banc').map((c) => c.nom);
describe('lancer : les mesures attendues', () => {
test('chaque constante du banc du registre est attendue, dans l’ordre du registre, puis les gardes, par ordre de nom', () => {
assert.ok(DU_BANC.length > 0);
assert.deepEqual([...ATTENDUES], [...DU_BANC, ...GARDES]);
assert.deepEqual([...GARDES], ['DUREE_GENERATION_PAR_DEFAUT', 'TEMPS_IMAGE_LISTES']);
assert.ok(GARDES.every((nom) => !CONSTANTES.some((c) => c.nom === nom)));
});
});
describe('lancer : assembler (§ 19.10)', () => {
test('un relevé conforme : les mesures dans l’ordre du registre, puis les gardes ; plafond et parallèle repris', () => {
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
assert.deepEqual(verifierReleve(releve), []);
assert.deepEqual(noms(releve), [
'SEUIL_SIEGES_LISIBLES',
'TOLERANCE_CHAINE_TEMOIN',
'RALENTISSEMENT_PLEINE_CHARGE',
'DUREE_GENERATION_PAR_DEFAUT',
'TEMPS_IMAGE_LISTES',
]);
assert.deepEqual(Object.keys(releve), ['format', 'configuration', 'mesures', 'plafondAPriori', 'parallele']);
assert.equal(releve.plafondAPriori[0].cle, 'grande');
assert.deepEqual(releve.parallele, [{ coeurs: 1, ralentissement: 1 }]);
assert.equal(releve.mesures[0].police, 'Noto Sans');
});
test('l’ordre des mesures ne dépend ni de l’ordre des sorties ni de celui des attendues', () => {
const inverse = new Map([...sorties()].reverse());
const attendues = [...ATTENDUES].reverse();
const releve = assembler({ configuration: configuration(), sorties: inverse, attendues });
assert.deepEqual(noms(releve), [...DU_BANC, ...GARDES]);
});
test('l’ordre des mesures est celui du registre reçu : un registre renversé les renverse, les gardes restant après', () => {
const renverse = [...CONSTANTES].reverse();
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES, constantes: renverse });
assert.deepEqual(noms(releve), [...[...DU_BANC].reverse(), ...GARDES]);
});
test('chaque mesure porte le ralentissement sous lequel elle s’est prise : le sien, sinon celui de la configuration de sa sortie', () => {
const recues = sorties();
const releve = assembler({ configuration: configuration(), sorties: recues, attendues: ATTENDUES });
assert.deepEqual(
releve.mesures.map(({ nom, ralentissementCpu }) => [nom, ralentissementCpu]),
[
['SEUIL_SIEGES_LISIBLES', 1],
['TOLERANCE_CHAINE_TEMOIN', 1],
['RALENTISSEMENT_PLEINE_CHARGE', 1],
['DUREE_GENERATION_PAR_DEFAUT', 1],
['TEMPS_IMAGE_LISTES', 4],
],
);
assert.equal(releve.configuration.ralentissementCpu, 4);
assert.ok(!Object.hasOwn(recues.get('parallele').mesures[0], 'ralentissementCpu'), 'les sorties reçues restent intactes');
const sansConfiguration = sorties();
delete sansConfiguration.get('parallele').configuration;
const autre = assembler({ configuration: configuration(), sorties: sansConfiguration, attendues: ATTENDUES });
assert.ok(!Object.hasOwn(autre.mesures[2], 'ralentissementCpu'));
});
test('une sortie manquante se refuse en nommant chaque mesure qu’elle portait', () => {
const manque = sorties();
manque.delete('parallele');
assert.throws(
() => assembler({ configuration: configuration(), sorties: manque, attendues: ATTENDUES }),
/mesure attendue absente : RALENTISSEMENT_PLEINE_CHARGE, DUREE_GENERATION_PAR_DEFAUT/,
);
const vide = new Map();
assert.throws(() => assembler({ configuration: configuration(), sorties: vide, attendues: ATTENDUES }), /absente : SEUIL_SIEGES_LISIBLES/);
});
test('une mesure en double, une mesure inattendue, un champ de sommet deux fois porté se refusent', () => {
const double = sorties();
double.set('autre', { mesures: [{ nom: 'SEUIL_SIEGES_LISIBLES', unite: 'places', valeurProposee: 3, critere: 'x' }] });
assert.throws(() => assembler({ configuration: configuration(), sorties: double, attendues: ATTENDUES }), /en double : SEUIL_SIEGES_LISIBLES/);
const inattendue = sorties();
inattendue.set('autre', { mesures: [{ nom: 'TEMPS_INVENTE', unite: 'ms', valeurProposee: 1, critere: 'x' }] });
assert.throws(() => assembler({ configuration: configuration(), sorties: inattendue, attendues: ATTENDUES }), /inattendue : TEMPS_INVENTE/);
const deuxParalleles = sorties();
deuxParalleles.set('autre', { parallele: [] });
assert.throws(() => assembler({ configuration: configuration(), sorties: deuxParalleles, attendues: ATTENDUES }), /parallele : porté par deux sorties/);
});
test('un relevé que verifierReleve refuse ne s’assemble pas : la faute est nommée', () => {
const fautive = sorties();
fautive.get('temoin').mesures[0].critere = 'relevé sous /home/quenouille/banc';
assert.throws(
() => assembler({ configuration: configuration(), sorties: fautive, attendues: ATTENDUES }),
/mesures\[1\]\.critere : chemin de compte/,
);
});
test('des sorties qui ne sont pas une table se refusent', () => {
assert.throws(() => assembler({ configuration: configuration(), sorties: {}, attendues: ATTENDUES }), TypeError);
});
});
describe('lancer : la configuration ne nomme pas la machine (contrainte 27)', () => {
test('ni le nom d’hôte, ni l’utilisateur, ni le dossier personnel, ni le modèle du processeur injectés n’y paraissent', () => {
const texte = JSON.stringify(configuration());
for (const nom of [HOTE_INVENTE, UTILISATEUR_INVENTE, DOSSIER_INVENTE, MODELE_INVENTE]) assert.ok(!texte.includes(nom), nom);
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
for (const nom of [HOTE_INVENTE, MODELE_INVENTE]) assert.ok(!JSON.stringify(releve).includes(nom), nom);
});
test('la liste fermée du contrat : cadre du projet banc, facteur, versions, processeurs logiques, ralenti ×4, police embarquée', () => {
assert.deepEqual(configuration(), {
cadre: projetBanc().provide.cadreBanc,
facteurEchelle: 1,
navigateur: 'Chromium 140.0',
node: 'v26.10.0',
processeursLogiques: 6,
ralentissementCpu: 4,
police: FAMILLE_EMBARQUEE,
});
assert.equal(FAMILLE_EMBARQUEE, 'Noto Sans');
});
});
describe('lancer : le jugement qui rend la sortie non nulle', () => {
const releve = () => assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
test('un relevé tenu sous la police embarquée : aucun motif', () => {
assert.deepEqual(jugement(releve()), { bloquants: [], nonTenus: [] });
});
test('une mesure de texte sous une autre police, ou sans police nommée : motif bloquant', () => {
const r = releve();
r.mesures[0].police = 'DejaVu Sans';
delete r.mesures[1].police;
assert.deepEqual(jugement(r).bloquants, [
'SEUIL_SIEGES_LISIBLES mesurée sous la police « DejaVu Sans », et non sous Noto Sans',
'TOLERANCE_CHAINE_TEMOIN mesurée sous la police « inconnue », et non sous Noto Sans',
]);
});
test('un verdict non tenu : motif non bloquant, qui nomme la mesure et son critère', () => {
const r = releve();
r.mesures[4].verdict = 'nonTenu';
assert.deepEqual(jugement(r), { bloquants: [], nonTenus: ['TEMPS_IMAGE_LISTES : verdict non tenu — p95 sous 33 ms'] });
});
});
describe('lancer : la région des constantes (§ 19.8)', () => {
test('autant de lignes que le registre, sous un en-tête et son séparateur', () => {
const lignes = regionConstantes().split('\n');
assert.equal(lignes.length, CONSTANTES.length + 2);
CONSTANTES.forEach((c, i) => assert.ok(lignes[i + 2].startsWith(`| \`${c.nom}\` |`), c.nom));
});
test('elle lit le relevé reçu : la valeur proposée paraît', () => {
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
const ligne = regionConstantes({ releve }).split('\n').find((l) => l.startsWith('| `SEUIL_SIEGES_LISIBLES` |'));
assert.match(ligne, /\| 17 places \|/);
});
test('chaque constante du banc cite le ralentissement de sa mesure : à ×1, « processeur non ralenti », jamais ×4', () => {
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
const lignes = regionConstantes({ releve }).split('\n');
for (const nom of DU_BANC) {
const ligne = lignes.find((l) => l.startsWith(`| \`${nom}\` |`));
assert.match(ligne, /, processeur non ralenti, police Noto Sans \|$/, nom);
assert.doesNotMatch(ligne, /ralenti ×4/, nom);
}
});
});
describe('lancer : executer, sur des effets factices', () => {
// Un passage factice : chaque effet se note dans journal, dans l'ordre.
// fichiers tient les sorties que le navigateur aurait écrites.
function passage({ codeVitest = 0, fichiers = sorties(), plafond = sorties().get('plafond').plafondAPriori } = {}) {
const journal = [];
const ecrits = new Map();
const lignes = [];
const effets = {
mesurerPlafond: () => {
journal.push('plafond');
return plafond;
},
lancerVitest: () => {
journal.push('vitest');
return codeVitest;
},
lireNavigateur: async () => 'Chromium 140.0',
systeme: systemeInvente(),
node: 'v26.10.0',
effacer: (chemin) => journal.push(`effacer ${chemin}`),
lire: (chemin) => {
const nom = /^scripts\/banc\/sorties\/([a-z_]+)\.json$/.exec(chemin)?.[1];
if (nom === undefined || !fichiers.has(nom)) throw Object.assign(new Error(`absent : ${chemin}`), { code: 'ENOENT' });
return JSON.stringify(fichiers.get(nom));
},
ecrire: (chemin, texte) => ecrits.set(chemin, texte),
sortie: (ligne) => lignes.push(ligne),
};
return { effets, journal, ecrits, lignes };
}
test('le déroulé : sorties effacées, mesures node, banc navigateur ; le relevé écrit, le tableau et les écarts imprimés ; code 0', async () => {
const p = passage();
assert.equal(await executer(p.effets), 0);
assert.deepEqual(p.journal, [...SORTIES_NAVIGATEUR.map((nom) => `effacer scripts/banc/sorties/${nom}.json`), 'plafond', 'vitest']);
assert.deepEqual([...p.ecrits.keys()], [CHEMIN_RELEVE]);
const ecrit = p.ecrits.get(CHEMIN_RELEVE);
assert.ok(ecrit.endsWith('}\n'));
const releve = JSON.parse(ecrit);
assert.deepEqual(verifierReleve(releve), []);
assert.deepEqual(noms(releve), [...ATTENDUES]);
const texte = p.lignes.join('\n');
assert.ok(texte.includes(regionConstantes({ releve })));
assert.match(texte, /SEUIL_SIEGES_LISIBLES : registre 16 places, relevé 17 places — à adopter/);
assert.match(
texte,
/RALENTISSEMENT_PLEINE_CHARGE : registre 2 ×, relevé 2,5 × — relevé, non adopté : défaut de travail sous la machine de référence/,
);
assert.doesNotMatch(texte, /RALENTISSEMENT_PLEINE_CHARGE : .*à adopter/);
assert.match(texte, /Accord du registre avec le relevé : aucun écart/);
});
test('une épreuve du banc tombée (un contrôle du harnais) : code 1, aucun relevé écrit', async () => {
const p = passage({ codeVitest: 1 });
assert.equal(await executer(p.effets), 1);
assert.equal(p.ecrits.size, 0);
assert.match(p.lignes.join('\n'), /épreuve du banc est tombée/);
});
test('une sortie manquante : code 1, aucun relevé écrit, la mesure nommée', async () => {
const fichiers = sorties();
fichiers.delete('temoin');
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(p.ecrits.size, 0);
assert.match(p.lignes.join('\n'), /absente : TOLERANCE_CHAINE_TEMOIN/);
});
test('un verdict non tenu : le relevé s’écrit, qui le porte, et le code vaut 1', async () => {
const fichiers = sorties();
fichiers.get('temps_image').mesures[0].verdict = 'nonTenu';
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(JSON.parse(p.ecrits.get(CHEMIN_RELEVE)).mesures[4].verdict, 'nonTenu');
assert.match(p.lignes.join('\n'), /TEMPS_IMAGE_LISTES : verdict non tenu/);
});
test('une mesure de texte hors de la police embarquée : code 1, aucun relevé écrit', async () => {
const fichiers = sorties();
fichiers.get('temoin').mesures[0].police = 'DejaVu Sans';
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(p.ecrits.size, 0);
assert.match(p.lignes.join('\n'), /sous la police « DejaVu Sans »/);
});
});
describe('lancer : le relevé committé', () => {
test('il est conforme, et porte chaque mesure attendue dans l’ordre du registre', () => {
assert.deepEqual(verifierReleve(releveCommite), []);
assert.deepEqual(noms(releveCommite), [...ATTENDUES]);
for (const champ of ['navigateur', 'node', 'processeursLogiques']) {
assert.notEqual(releveCommite.configuration[champ] ?? null, null, champ);
}
assert.deepEqual(Object.keys(releveCommite.configuration), [
'cadre',
'facteurEchelle',
'navigateur',
'node',
'processeursLogiques',
'ralentissementCpu',
'police',
]);
for (const mesure of releveCommite.mesures) assert.ok(Number.isFinite(mesure.ralentissementCpu), mesure.nom);
});
});