gestion_table_tournante_libre/test/rapporteur.js
Mathieu Benoit 3028fca2ed [FIX] interface: ghost labels, keyboard, valid HTML, closed data-* list
The drag ghost names what releasing at the point changes, floor included.
Escape on a seat returns to its table, a refused Tab stays put, the legend
toggle exposes aria-expanded, and the header markup is valid HTML. Every
data-* attribute the interface sets belongs to one closed list that a guard
reads. The tree guards follow helpers a test imports, and the reporter says
the whole series stays watched under coverage on a small machine.
Checked: every new test red first; 3001 node, 635 browser and 63 long tests
green from the index alone.

--- FR ---

[FIX] interface : fantôme nommé, clavier, HTML valide, data-* fermés

Le fantôme du glisser nomme ce que relâcher au point modifie, sol compris.
Échap sur un siège remonte à sa table, un Tab refusé reste en place, la
bascule de la légende expose aria-expanded, et la tête est du HTML valide.
Chaque attribut data-* que l'interface pose appartient à une liste fermée
qu'une garde lit. Les gardes de l'arbre suivent les aides qu'une épreuve
importe, et le rapporteur dit la série entière surveillée sous couverture.
Vérifié : chaque épreuve rouge d'abord ; 3001 node, 635 navigateur et 63
longues vertes depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 04:02:03 -04:00

193 lines
8.4 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. Le bilan finit sur le mode que la machine a fait retenir
// (test/machine.js) : un développeur sait ainsi sur quelle série sa relance a
// porté.
import { coeursDisponibles, ligneMachine, reglagesSeries } from './machine.js';
// 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 3,7 s sur 2, où elle se joue en threads
// (test/machine.js).
['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);
this.#journal.log(ligneMachine(reglagesSeries(coeursDisponibles(), { couverture: process.argv.includes('--coverage') })));
if (raison === 'interrupted') return;
for (const message of refus) this.#journal.error(message);
if (refus.length > 0) process.exitCode = 1;
}
}