// © 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/.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']); /** 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. const MESURES_DE_TEXTE = Object.freeze(['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN']); // 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, * 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, * attendues: ReadonlyArray, * constantes?: ReadonlyArray}} 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}} [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 ». 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, 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();