gestion_table_tournante_libre/scripts/banc/lancer.js
Mathieu Benoit 91a0cea4cb [ADD] quality page: walkthrough captures, edge cases, contrast guards
The quality page is now photographed and guarded like the other
screens. The walkthrough captures each view and the seven edge cases
where a sentence replaces a chart; the transversal guards measure the
page's marks, keyboard access and tooltips, a dotted reference line
against the chart background and a run point against what its trace
covers. Proved and remaining shares differ by outline, not colour
alone; tooltips keep clear of the sticky banner; rank axes print whole
numbers. The two bench thresholds of the page are measured.
Checked: node 5096, navigateur 1137, node-long 128; captures published.

--- FR ---

[ADD] page de qualité : parcours, cas limites, gardes du contraste

La page de qualité est désormais photographiée et gardée comme les autres
écrans. Le parcours photographie chaque vue et les sept cas limites où une
phrase remplace un graphique ; les gardes transversales mesurent ses
marques, son clavier et ses infobulles, un repère pointillé contre le fond
du graphique, un point de relevé contre ce que son tracé recouvre. Prouvé
et reste se distinguent par le contour, pas par la seule couleur ; les
infobulles évitent le bandeau collant ; les rangs s'écrivent en entiers.
Les deux seuils de la page sont mesurés au banc.
Vérifié : node 5096, navigateur 1137, longues 128 ; captures publiées.

Assisted-by: Claude Opus 5.5
2026-10-12 16:38:04 -04:00

355 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'orchestrateur du banc de mesure (§ 19.10), que lancent npm run banc et
// make banc. Son déroulé :
//
// 1. effacer les sorties du navigateur (scripts/banc/sorties/<nom>.json,
// chacune par son nom), pour qu'aucune sortie d'un passage précédent
// ne se lise comme une mesure de celui-ci ;
// 2. relever le coût du plafond a priori sous node (plafond.js) ;
// 3. jouer le projet banc de Vitest (vitest.banc.config.js), qui écrit
// les sorties ; une épreuve tombée — un contrôle du harnais, une
// mesure qui se refuse — arrête le passage sans relevé ;
// 4. lire les sorties, et assembler le relevé : la configuration de la
// machine, puis chaque mesure attendue, une fois, dans l'ordre du
// registre, avec le ralentissement sous lequel elle s'est prise ; une
// mesure manquante arrête le passage sans relevé ;
// 5. juger : une mesure de texte prise hors de la police embarquée
// arrête le passage sans relevé ; un verdict « non tenu » s'écrit au
// relevé, qui est une donnée, et rend la sortie non nulle ;
// 6. écrire scripts/banc/releve.json, puis imprimer le tableau des
// constantes, les valeurs proposées face à celles du registre et les
// écarts de leur accord.
//
// Le banc écrit un relevé, jamais le registre : src/geometrie/constantes.js
// ne se lit ici que pour l'ordre et les valeurs, et une valeur s'adopte à la
// main (releve.js). La configuration du relevé est la liste fermée du
// contrat — cadre, facteur d'échelle, versions, nombre de processeurs
// logiques, ralentissement, police — et ne nomme pas la machine : ni nom
// d'hôte, ni modèle de processeur, ni utilisateur, ni chemin, ni date
// (contrainte 27). Code de sortie : 0 quand le relevé est écrit et que
// chaque verdict est tenu, 1 sinon.
import { spawnSync } from 'node:child_process';
import { readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CONSTANTES } from '../../src/geometrie/constantes.js';
import { optionsBanc, projetBanc } from './projet.js';
import { accordRegistre, nombre, tableauConstantes, verifierReleve } from './releve.js';
/** La racine du projet, d'où se résolvent les chemins relatifs. */
export const RACINE = fileURLToPath(new URL('../../', import.meta.url));
/** Le relevé, relatif à la racine. */
export const CHEMIN_RELEVE = 'scripts/banc/releve.json';
// Le dossier des sorties du navigateur, que navigateur.js écrit.
const DOSSIER_SORTIES = 'scripts/banc/sorties';
/** Les sorties que les épreuves du banc écrivent, par nom de fichier sans
* extension : le harnais, puis chaque mesure. */
export const SORTIES_NAVIGATEUR = Object.freeze(['harnais', 'temoin', 'sieges_lisibles', 'temps_image', 'parallele', 'qualite']);
/** Les gardes du banc : des mesures hors du registre, qui ne proposent pas
* de constante mais disent si une hypothèse tient, par ordre de nom. */
export const GARDES = Object.freeze(['DUREE_GENERATION_PAR_DEFAUT', 'TEMPS_IMAGE_LISTES']);
/** Les mesures qu'un relevé porte : chaque constante du banc du registre,
* dans son ordre, puis les gardes. Une constante du banc ajoutée au
* registre est attendue d'office, et manque tant que le banc ne la relève
* pas. */
export const ATTENDUES = Object.freeze([...CONSTANTES.filter((c) => c.proprietaire === 'banc').map((c) => c.nom), ...GARDES]);
/** La police embarquée, sous laquelle se prend toute mesure de texte. */
export const FAMILLE_EMBARQUEE = 'Noto Sans';
// Les mesures qui lisent le rendu du texte, et nomment donc leur police :
// le seuil de la matrice en est, sa colonne de noms prenant la largeur
// rendue du nom de référence.
const MESURES_DE_TEXTE = Object.freeze([
'SEUIL_SIEGES_LISIBLES',
'TOLERANCE_CHAINE_TEMOIN',
'SEUIL_MATRICE_PARTICIPANTS',
'LARGEUR_TRONCATURE_NOM_PX',
]);
// La machine de référence du banc : le poste, son processeur ralenti ×4 par
// le navigateur. C'est le ralentissement de la configuration du relevé ;
// chaque mesure porte en plus celui sous lequel elle s'est prise, que cite
// la configuration de sa constante.
const RALENTISSEMENT_REFERENCE = 4;
// Les constantes du banc que la machine de référence relève sans les
// proposer : ρ y reste à son défaut de travail. Leur mesure s'imprime pour
// information, jamais « à adopter ».
const RELEVEES_SANS_ADOPTION = Object.freeze(['RALENTISSEMENT_PLEINE_CHARGE']);
// Les champs de sommet qu'une sortie peut porter en plus de ses mesures, et
// que le relevé reprend tels quels.
const CHAMPS_REPRIS = Object.freeze(['plafondAPriori', 'parallele']);
const cheminSortie = (nom) => `${DOSSIER_SORTIES}/${nom}.json`;
const estAbsent = (erreur) => erreur?.code === 'ENOENT';
/**
* La configuration du relevé : le cadre du projet banc au facteur 1, les
* versions du navigateur et de Node, le nombre des processeurs logiques, le
* ralentissement de la machine de référence, la police embarquée. Du
* système, seul availableParallelism se lit : ni nom d'hôte, ni modèle de
* processeur, ni utilisateur, ni dossier.
*
* @param {{systeme: Pick<typeof os, 'availableParallelism'>,
* node: string, navigateur: string|null}} options
* @returns {import('./releve.js').ConfigurationReleve}
*/
export function configurationDuBanc({ systeme, node, navigateur }) {
return {
cadre: { ...projetBanc().provide.cadreBanc },
facteurEchelle: 1,
navigateur,
node,
processeursLogiques: systeme.availableParallelism(),
ralentissementCpu: RALENTISSEMENT_REFERENCE,
police: FAMILLE_EMBARQUEE,
};
}
// Le rang d'une mesure dans le relevé : son rang au registre, puis, pour
// une garde, après toutes les constantes, par ordre de nom.
function rangeur(constantes) {
const rang = (nom) => {
const auRegistre = constantes.findIndex((c) => c.nom === nom);
return auRegistre >= 0 ? auRegistre : constantes.length;
};
return (a, b) => rang(a.nom) - rang(b.nom) || (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
}
/**
* Le relevé d'un passage : la configuration, les mesures de toutes les
* sorties — chaque attendue une fois, rangées dans l'ordre du registre reçu
* puis les gardes par ordre de nom —, et plafondAPriori et parallele quand
* une sortie les porte. Une mesure sans ralentissement propre reçoit celui
* de la configuration de sa sortie, quand celle-ci en porte un : en copie,
* les sorties reçues restent intactes. Les sorties se lisent dans l'ordre
* de la table ; une sortie sans mesures (le harnais) n'apporte rien.
*
* @param {{configuration: import('./releve.js').ConfigurationReleve,
* sorties: Map<string, {mesures?: object[], plafondAPriori?: object[], parallele?: object[]}>,
* attendues: ReadonlyArray<string>,
* constantes?: ReadonlyArray<import('../../src/geometrie/constantes.js').Constante>}} options
* @returns {import('./releve.js').Releve}
* @throws {TypeError} des sorties qui ne sont pas une Map
* @throws {Error} une mesure attendue absente, une mesure en double ou
* inattendue, un champ repris porté par deux sorties, ou un relevé que
* verifierReleve refuse — le message nomme chaque faute
*/
export function assembler({ configuration, sorties, attendues, constantes = CONSTANTES }) {
if (!(sorties instanceof Map)) throw new TypeError('sorties du banc : une Map attendue');
const mesures = [];
const repris = new Map();
for (const [nomSortie, sortie] of sorties) {
const ralentissementSortie = sortie.configuration?.ralentissementCpu;
for (const mesure of sortie.mesures ?? []) {
if (!attendues.includes(mesure.nom)) throw new Error(`mesure inattendue : ${mesure.nom} (sortie ${nomSortie})`);
if (mesures.some((m) => m.nom === mesure.nom)) throw new Error(`mesure en double : ${mesure.nom} (sortie ${nomSortie})`);
const herite = ralentissementSortie !== undefined && !Object.hasOwn(mesure, 'ralentissementCpu');
mesures.push(herite ? { ...mesure, ralentissementCpu: ralentissementSortie } : mesure);
}
for (const champ of CHAMPS_REPRIS) {
if (!Object.hasOwn(sortie, champ)) continue;
if (repris.has(champ)) throw new Error(`${champ} : porté par deux sorties, ${repris.get(champ).sortie} et ${nomSortie}`);
repris.set(champ, { sortie: nomSortie, valeur: sortie[champ] });
}
}
const absentes = attendues.filter((nom) => !mesures.some((m) => m.nom === nom));
if (absentes.length > 0) throw new Error(`mesure attendue absente : ${absentes.join(', ')}`);
const releve = { format: 1, configuration, mesures: [...mesures].sort(rangeur(constantes)) };
for (const champ of CHAMPS_REPRIS) if (repris.has(champ)) releve[champ] = repris.get(champ).valeur;
const fautes = verifierReleve(releve, constantes);
if (fautes.length > 0) throw new Error(`relevé refusé : ${fautes.map(({ chemin, faute }) => `${chemin} : ${faute}`).join(' ; ')}`);
return releve;
}
/**
* Le jugement d'un relevé assemblé. bloquants : chaque mesure de texte
* prise sous une autre police que la police embarquée, ou qui n'en nomme
* aucune — le relevé ne s'écrit pas. nonTenus : chaque mesure au verdict
* « nonTenu », avec son critère — le relevé s'écrit, la sortie est non
* nulle. Dans l'ordre des mesures.
*
* @param {import('./releve.js').Releve} releve
* @returns {{bloquants: string[], nonTenus: string[]}}
*/
export function jugement(releve) {
const bloquants = [];
const nonTenus = [];
for (const mesure of releve.mesures) {
if (MESURES_DE_TEXTE.includes(mesure.nom) && mesure.police !== FAMILLE_EMBARQUEE) {
bloquants.push(`${mesure.nom} mesurée sous la police « ${mesure.police ?? 'inconnue'} », et non sous ${FAMILLE_EMBARQUEE}`);
}
if (mesure.verdict === 'nonTenu') nonTenus.push(`${mesure.nom} : verdict non tenu — ${mesure.critere}`);
}
return { bloquants, nonTenus };
}
// Le relevé committé, lu sur le disque.
const releveCommite = (racine = RACINE) => JSON.parse(readFileSync(join(racine, CHEMIN_RELEVE), 'utf8'));
/**
* Le tableau des constantes (§ 19.8, § 19.10) : tableauConstantes sur le
* registre et un relevé, par défaut le relevé committé. Une ligne par
* constante, sous un en-tête et son séparateur.
*
* @param {{releve?: import('./releve.js').Releve,
* constantes?: ReadonlyArray<import('../../src/geometrie/constantes.js').Constante>}} [options]
* @returns {string}
*/
export function regionConstantes({ releve = releveCommite(), constantes = CONSTANTES } = {}) {
return tableauConstantes(constantes, releve);
}
// Une valeur et son unité, à la française.
const avecUnite = (valeur, unite) => (valeur === null ? 'aucune valeur' : `${nombre(valeur)} ${unite}`);
// Ce qu'un passage imprime après l'écriture du relevé : le tableau, chaque
// constante du banc face à sa valeur proposée — égale, à adopter, ou
// relevée sans adoption —, chaque garde, puis l'accord du registre avec le
// relevé.
function imprimer(releve, sortie) {
sortie('');
sortie(regionConstantes({ releve }));
sortie('');
sortie('Valeurs proposées par le banc (à adopter à la main dans src/geometrie/constantes.js) :');
for (const constante of CONSTANTES) {
if (constante.proprietaire !== 'banc') continue;
const mesure = releve.mesures.find((m) => m.nom === constante.nom);
let suite = mesure.valeurProposee === constante.valeur ? 'égale' : 'à adopter';
if (RELEVEES_SANS_ADOPTION.includes(constante.nom)) suite = 'relevé, non adopté : défaut de travail sous la machine de référence';
sortie(
`- ${constante.nom} : registre ${avecUnite(constante.valeur, constante.unite)}, ` +
`relevé ${avecUnite(mesure.valeurProposee, constante.unite)} — ${suite}`,
);
}
sortie('Gardes :');
for (const mesure of releve.mesures) {
if (!GARDES.includes(mesure.nom)) continue;
const verdict = mesure.verdict === undefined ? '' : `, verdict ${mesure.verdict === 'tenu' ? 'tenu' : 'non tenu'}`;
sortie(`- ${mesure.nom} : ${avecUnite(mesure.valeurProposee, mesure.unite)}${verdict}`);
}
const ecarts = accordRegistre(CONSTANTES, releve);
if (ecarts.length === 0) sortie('Accord du registre avec le relevé : aucun écart.');
else {
sortie('Accord du registre avec le relevé :');
for (const { nom, faute } of ecarts) sortie(`- ${nom} : ${faute}`);
}
sortie('La région « constantes » de README.md se met à jour par node scripts/documentation.js engendrer.');
}
// Le relevé du plafond a priori, par le script lui-même : son JSON imprimé.
function plafondParScript(racine) {
const resultat = spawnSync(process.execPath, [join(racine, 'scripts/banc/plafond.js')], {
cwd: racine,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
});
if (resultat.status !== 0) throw new Error(`plafond.js : code ${resultat.status}`);
return JSON.parse(resultat.stdout);
}
// Le projet banc de Vitest, sorties à l'écran ; rend son code de sortie.
function vitestDuBanc(racine) {
const resultat = spawnSync(process.execPath, [join(racine, 'node_modules/vitest/vitest.mjs'), 'run', '--config', 'vitest.banc.config.js'], {
cwd: racine,
stdio: 'inherit',
});
return resultat.status ?? 1;
}
// La version du Chromium du banc, lancé par Playwright sous les options du
// projet banc : « Chromium <version> ».
async function versionNavigateur() {
const { chromium } = await import('playwright');
const { launchOptions = {} } = optionsBanc(process.env);
const navigateur = await chromium.launch({ ...launchOptions, headless: true });
try {
return `Chromium ${navigateur.version()}`;
} finally {
await navigateur.close();
}
}
/**
* Un passage du banc, selon le déroulé de l'en-tête. Chaque effet se reçoit,
* pour que les épreuves le remplacent ; par défaut, ceux de la machine.
*
* @param {{racine?: string, mesurerPlafond?: () => object[], lancerVitest?: () => number,
* lireNavigateur?: () => Promise<string>, systeme?: typeof os, node?: string,
* effacer?: (chemin: string) => void, lire?: (chemin: string) => string,
* ecrire?: (chemin: string, texte: string) => void, sortie?: (ligne: string) => void}} [effets]
* @returns {Promise<0|1>}
*/
export async function executer({
racine = RACINE,
mesurerPlafond = () => plafondParScript(racine),
lancerVitest = () => vitestDuBanc(racine),
lireNavigateur = versionNavigateur,
systeme = os,
node = process.version,
effacer = (chemin) => rmSync(join(racine, chemin), { force: true }),
lire = (chemin) => readFileSync(join(racine, chemin), 'utf8'),
ecrire = (chemin, texte) => writeFileSync(join(racine, chemin), texte),
sortie = (ligne) => process.stdout.write(`${ligne}\n`),
} = {}) {
for (const nom of SORTIES_NAVIGATEUR) effacer(cheminSortie(nom));
let releve;
try {
const plafondAPriori = mesurerPlafond();
const code = lancerVitest();
if (code !== 0) {
sortie(`Une épreuve du banc est tombée (code ${code}) : un contrôle du harnais, ou une mesure refusée. Aucun relevé écrit.`);
return 1;
}
const sorties = new Map([['plafond', { plafondAPriori }]]);
for (const nom of SORTIES_NAVIGATEUR) {
let texte;
try {
texte = lire(cheminSortie(nom));
} catch (erreur) {
if (estAbsent(erreur)) continue;
throw erreur;
}
sorties.set(nom, JSON.parse(texte));
}
const configuration = configurationDuBanc({ systeme, node, navigateur: await lireNavigateur() });
releve = assembler({ configuration, sorties, attendues: ATTENDUES });
} catch (erreur) {
sortie(`Aucun relevé écrit : ${erreur.message}`);
return 1;
}
const { bloquants, nonTenus } = jugement(releve);
if (bloquants.length > 0) {
for (const motif of bloquants) sortie(motif);
sortie('Aucun relevé écrit : une mesure de texte hors de la police embarquée ne mesure pas le logiciel.');
return 1;
}
ecrire(CHEMIN_RELEVE, `${JSON.stringify(releve, null, 2)}\n`);
sortie(`Relevé écrit : ${CHEMIN_RELEVE}`);
imprimer(releve, sortie);
for (const motif of nonTenus) sortie(motif);
return nonTenus.length > 0 ? 1 : 0;
}
// Vrai quand ce fichier est le script que Node a lancé, et non un module
// importé.
function estLanceDirectement() {
if (process.argv[1] === undefined) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (estLanceDirectement()) process.exitCode = await executer();