gestion_table_tournante_libre/test/rapporteur.js
Mathieu Benoit 4795114093 [ADD] tests: budget reporter, one import parser, key-order guard
The time budget of § 14.14 had no enforcement: a reporter now prints the
total and the ten slowest tests, and fails a run whose node project
exceeds 30 s or node-long 60 s. Two guards parsed imports with two
patterns, one blind past an apostrophe in a comment; both now use one
parser anchored on « from ». The determinism guard refuses key-order
walks, and one test binds the application name across its three
declarations. The end-to-end generations drop to 100 000 moves.

Checked: each ceiling, the apostrophe case and a key walk are shown
failing before the guard that catches them.

--- FR ---

[ADD] épreuves : rapporteur de budget, imports, ordre des clés

Le budget de temps du § 14.14 n'avait aucune garde : un rapporteur
imprime désormais la durée totale et les dix épreuves les plus lentes,
et fait échouer une exécution dont le projet node dépasse 30 s ou
node-long 60 s. Deux gardes lisaient les imports par deux motifs, l'un
aveugle après une apostrophe en commentaire ; les deux passent par un
seul analyseur ancré sur « from ». La garde de déterminisme refuse les
parcours de clés, et une épreuve lie le nom de l'application à ses
trois déclarations. Les générations de bout en bout passent à 100 000.

Vérifié : chaque plafond, le cas de l'apostrophe et un parcours de clés
sont montrés en échec avant la garde qui les attrape.

Assisted-by: Claude Opus 5.5
2026-10-06 04:23:13 -04:00

189 lines
8 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Rapporteur des durées de Vitest, déclaré dans vitest.config.js à côté du
// rapporteur par défaut (§ 14.14). À la fin de chaque exécution, en série
// simple comme à chaque relance sous surveillance, il imprime la durée
// totale, la durée au mur de chaque projet et les dix épreuves les plus
// lentes. Un projet qui dépasse son plafond fait sortir le lanceur en code 1,
// et l'erreur le nomme.
//
// La durée d'un projet est celle de l'exécution, au mur, moins le temps où
// seuls d'autres projets ont un fichier en cours. Un fichier est en cours du
// moment où un processus de Vitest le prend (onTestModuleQueued) à celui où
// sa dernière épreuve finit (onTestModuleEnd) ; le démarrage du processus
// qui le prend, avant, n'est en cours pour aucun projet, et compte donc à
// chacun. Un projet qui tourne seul dure ainsi toute l'exécution, comme la
// durée qu'affiche Vitest ; avec d'autres, le temps où il attend un
// processus occupé ailleurs ne lui est pas compté, si bien que son plafond
// ne dépend pas des projets lancés avec lui. Un fichier que le rapporteur
// n'a pas vu prendre rend la durée de son projet inconnue, et un projet à
// plafond refuse alors au lieu de passer sans mesure (§ 14.2). Une exécution
// interrompue imprime son bilan sans rien refuser : ses durées sont
// partielles.
// Plafond au mur de chaque projet, en millisecondes, par nom d'exécution du
// projet : celui de vitest.config.js, que Vitest fait suivre du navigateur
// entre parenthèses pour un projet navigateur. Le refus est généreux
// (§ 14.14) : un plafond calé sur la mesure échouerait sur une machine
// chargée, et un test capricieux se désactive.
export const PLAFONDS = new Map([
// La série node surveillée : trente secondes (§ 14.14). Seule, elle dure
// 1,8 s au mur sur 16 cœurs et 6,6 s sur 2 ; la commande entière, lanceur
// compris, prend 2,5 s sur 16 cœurs.
['node', 30_000],
// Posé sur la mesure de node-long : seule, 17 s au mur sur 16 cœurs et
// 28 s sur un seul. Le plafond laisse un facteur 2,1 au cas le plus lent.
['node-long', 60_000],
]);
// Nombre d'épreuves que nomme le bilan, des plus lentes.
const PLUS_LENTES = 10;
// Durée en secondes, deux décimales, virgule décimale.
const secondes = (ms) => `${(ms / 1000).toFixed(2).replace('.', ',')} s`;
// Longueur, en millisecondes, de la réunion d'intervalles { debut, fin }
// donnés dans n'importe quel ordre.
export function dureeReunie(intervalles) {
let total = 0;
let finReunie = -Infinity;
for (const { debut, fin } of [...intervalles].sort((a, b) => a.debut - b.debut)) {
if (fin > finReunie) {
total += fin - Math.max(debut, finReunie);
finReunie = fin;
}
}
return total;
}
// Les n épreuves les plus lentes parmi des { duree, projet, fichier, nom },
// de la plus lente à la plus rapide ; à durée égale, par fichier puis par
// nom, comparés par points de code.
export function plusLentes(epreuves, n) {
const avant = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
return [...epreuves]
.sort((a, b) => b.duree - a.duree || avant(a.fichier, b.fichier) || avant(a.nom, b.nom))
.slice(0, n);
}
// Bilan d'une exécution : ses lignes et ses refus. duree est celle de
// l'exécution ; projets associe à chaque nom de projet { duree, nonSuivis },
// nonSuivis listant les fichiers dont la durée manque ; epreuves porte chaque
// épreuve exécutée, { duree, projet, fichier, nom }. Les projets s'impriment
// triés par nom. Un projet refuse quand sa durée dépasse son plafond, ou
// quand il a un plafond et un fichier non suivi.
export function bilan({ duree, projets, epreuves, plafonds = PLAFONDS }) {
const lignes = [`Durée totale : ${secondes(duree)}`];
const refus = [];
for (const nom of [...projets.keys()].sort()) {
const { duree: dureeProjet, nonSuivis } = projets.get(nom);
const plafond = plafonds.get(nom);
const mesure =
nonSuivis.length === 0
? `${secondes(dureeProjet)} au mur`
: `durée inconnue, ${nonSuivis.length} fichier${nonSuivis.length > 1 ? 's' : ''} non suivi${nonSuivis.length > 1 ? 's' : ''}`;
lignes.push(` ${nom} : ${mesure}${plafond === undefined ? '' : `, plafond ${secondes(plafond)}`}`);
if (plafond === undefined) continue;
if (nonSuivis.length > 0) {
refus.push(
`Le projet ${nom} a un plafond de ${secondes(plafond)}, et sa durée ne se mesure pas : ` +
`le rapporteur n'a pas suivi ${nonSuivis.join(', ')} (§ 14.14).`,
);
} else if (dureeProjet > plafond) {
refus.push(
`Le projet ${nom} dure ${secondes(dureeProjet)} au mur, au-delà de son plafond de ${secondes(plafond)} (§ 14.14).`,
);
}
}
if (epreuves.length === 0) {
lignes.push('Aucune épreuve exécutée.');
} else {
const lentes = plusLentes(epreuves, PLUS_LENTES);
lignes.push(`Épreuves les plus lentes (${lentes.length} sur ${epreuves.length}) :`);
for (const { duree: dureeEpreuve, projet, fichier, nom } of lentes) {
lignes.push(`${String(Math.round(dureeEpreuve)).padStart(8)} ms ${projet} ${fichier} > ${nom}`);
}
}
return { lignes, refus };
}
// Le rapporteur que Vitest construit, sans option ; une épreuve lui passe sa
// propre horloge, en millisecondes, et ses propres plafonds.
export default class Rapporteur {
#horloge;
#plafonds;
#journal;
#debut;
// Intervalle { debut, fin } de chaque fichier pris pendant l'exécution
// courante, par identifiant de fichier ; fin reste undefined jusqu'à la
// fin du fichier.
#intervalles = new Map();
constructor({ horloge = () => performance.now(), plafonds = PLAFONDS } = {}) {
this.#horloge = horloge;
this.#plafonds = plafonds;
}
onInit(vitest) {
this.#journal = vitest.logger;
}
onTestRunStart() {
this.#debut = this.#horloge();
this.#intervalles = new Map();
}
onTestModuleQueued(module) {
this.#intervalles.set(module.id, { debut: this.#horloge(), fin: undefined });
}
onTestModuleEnd(module) {
const intervalle = this.#intervalles.get(module.id);
if (intervalle !== undefined) intervalle.fin = this.#horloge();
}
// Un fichier pris et jamais fini est en cours jusqu'à la fin de
// l'exécution. Le temps où seuls d'autres projets ont un fichier en cours
// vaut la réunion de tous les fichiers moins celle du projet : la durée
// du projet est donc celle de l'exécution, moins la première, plus la
// seconde.
onTestRunEnd(modules, _erreurs, raison) {
const fin = this.#horloge();
const duree = fin - this.#debut;
const parProjet = new Map();
const tous = [];
const epreuves = [];
for (const module of modules) {
const projet = module.project.name;
if (!parProjet.has(projet)) parProjet.set(projet, { intervalles: [], nonSuivis: [] });
const suivi = parProjet.get(projet);
const intervalle = this.#intervalles.get(module.id);
if (intervalle === undefined) {
suivi.nonSuivis.push(module.relativeModuleId);
} else {
const enCours = { debut: intervalle.debut, fin: intervalle.fin ?? fin };
suivi.intervalles.push(enCours);
tous.push(enCours);
}
for (const epreuve of module.children.allTests()) {
const diagnostic = epreuve.diagnostic();
if (diagnostic !== undefined) {
epreuves.push({ duree: diagnostic.duration, projet, fichier: module.relativeModuleId, nom: epreuve.fullName });
}
}
}
const horsDeTout = duree - dureeReunie(tous);
const projets = new Map(
[...parProjet].map(([projet, { intervalles, nonSuivis }]) => [
projet,
{ duree: horsDeTout + dureeReunie(intervalles), nonSuivis },
]),
);
const { lignes, refus } = bilan({ duree, projets, epreuves, plafonds: this.#plafonds });
for (const ligne of lignes) this.#journal.log(ligne);
if (raison === 'interrupted') return;
for (const message of refus) this.#journal.error(message);
if (refus.length > 0) process.exitCode = 1;
}
}