gestion_table_tournante_libre/scripts/banc/lancer.js

349 lines
16 KiB
JavaScript
Raw Normal View History

// © 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']);
/** 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<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();