[ADD] bench: make banc orchestrator, first reading and constants table

`make banc` runs the node and browser measures, assembles releve.json in
the registry's order and fails when a measure is missing, a harness
check falls, a text measure is not in Noto Sans, or the time per image
is not held. It prints values to adopt and never edits constantes.js; ρ
is reported but stays at its working default on the reference machine.
Each bench constant cites the CPU slowdown of its own measure. The
reading names the machine class only: identifying keys, accented or
camelCase, and account paths are refused.
Checked: node 3836, browser 758, node-long 101; make banc exits 0.

--- FR ---

[ADD] banc : orchestrateur make banc, premier relevé, constantes

`make banc` lance les mesures node et navigateur, assemble releve.json
dans l'ordre du registre et échoue sur une mesure manquante, un contrôle
du harnais tombé, une mesure de texte hors Noto Sans ou un temps par
image non tenu. Il imprime les valeurs à adopter sans toucher
constantes.js ; ρ est relevé mais reste à son défaut de travail. Chaque
constante du banc cite le ralentissement de sa propre mesure. Le relevé
ne nomme que la classe de machine : clés identifiantes, accentuées ou
camelCase, et chemins de compte sont refusés.
Vérifié : node 3836, navigateur 758, longues 101 ; make banc sort en 0.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 15:31:25 -04:00
parent cb89060e34
commit c69c0f320b
8 changed files with 2488 additions and 79 deletions

View file

@ -117,26 +117,26 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a
<!-- engendre:debut constantes -->
| Constante | Valeur | Unité | Propriétaire | Statut | Protocole |
|---|---|---|---|---|---|
| `SEUIL_GLISSER_PX` | 4 | px | manuel | défaut de travail | vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre pointerdown et pointerup, × 1,5, écrit dans ce registre |
| `DUREE_PRESSION_LONGUE_MS` | 500 | ms | manuel | défaut de travail | à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, sur dix essais, écrite dans ce registre |
| `SEUIL_SIEGES_LISIBLES` | 16 | places | banc | défaut de travail | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre |
| `K_LISTES` | 0,3 | px/cm | manuel | défaut de travail | une personne qui n'a pas écrit le logiciel lit les listes de la grande démonstration sur la machine de référence à k décroissant ; le plus petit k où elle les lit sans erreur, écrit dans ce registre |
| `LARGEUR_BLOC_NOMS_CM` | 500 | cm | manuel | défaut de travail | relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre |
| `HAUTEUR_LIGNE` | — | cm | execution | mesurée | mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des listes ; aucune valeur n'est écrite dans ce registre |
| `ZONE_SILENCE_MM` | 8 | mm | manuel | défaut de travail | sur chaque imprimante visée : une planche de filets imprimée, son décalage mesuré à la règle, plus l'erreur de dix coupes ; puis sa marge non imprimable et le biais d'entraînement sur dix feuilles. La plus grande des deux bornes, arrondie au millimètre supérieur, écrite dans ce registre |
| `HAUTEUR_CAPITALE_NOM_MAX_MM` | 9 | mm | manuel | défaut de travail | impression réelle d'une planche ; lecture à un mètre par une personne qui n'a pas écrit le logiciel ; la hauteur retenue, écrite dans ce registre |
| `HAUTEUR_CAPITALE_NOM_PLANCHER_MM` | 4 | mm | manuel | défaut de travail | impression réelle d'une planche à hauteurs décroissantes ; lecture à un mètre par une personne qui n'a pas écrit le logiciel ; la plus petite lue sans erreur, écrite dans ce registre |
| `CORPS_TOURS_PT` | 14 | pt | manuel | défaut de travail | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre |
| `CORPS_TOURS_PLANCHER_PT` | 10 | pt | manuel | défaut de travail | impression réelle d'une planche à corps décroissants ; lecture en main par une personne qui n'a pas écrit le logiciel ; le plus petit corps lu sans erreur, écrit dans ce registre |
| `CORPS_APPARTENANCE_PT` | 12 | pt | manuel | défaut de travail | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre |
| `CORPS_PIED_PT` | 8 | pt | manuel | défaut de travail | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre |
| `CORPS_MIN_IMPRESSION_MM` | 2,5 | mm | manuel | défaut de travail | page d'essai à corps décroissants imprimée sur l'imprimante visée, lue à bout de bras ; le plus petit corps lu sans erreur, écrit dans ce registre |
| `MARGE_PAGE_MM` | 10 | mm | manuel | défaut de travail | marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue à la règle) ; la plus grande, écrite dans ce registre |
| `HAUTEUR_BANDE_IMPRESSION` | — | mm | execution | mesurée | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre |
| `TOLERANCE_CHAINE_TEMOIN` | 0,01 | rapport | banc | défaut de travail | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre |
| `RALENTISSEMENT_PLEINE_CHARGE` | 2 | × | banc | défaut de travail | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre |
| Constante | Ce qu’elle mesure | Valeur | Statut | Propriétaire | Protocole | Valeur proposée par le banc | Configuration du relevé |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `SEUIL_GLISSER_PX` | déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement | 4 px | défaut de travail | manuel | vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre pointerdown et pointerup, × 1,5, écrit dans ce registre | — | pavé tactile de la machine de référence |
| `DUREE_PRESSION_LONGUE_MS` | durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne | 500 ms | défaut de travail | manuel | à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, sur dix essais, écrite dans ce registre | — | écran tactile de la machine de référence |
| `SEUIL_SIEGES_LISIBLES` | plus grand nombre de places d'une table dont la liste de noms reste lisible | 16 places | défaut de travail | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre | 18 places | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `K_LISTES` | échelle sous laquelle le plan retire les listes de noms | 0,3 px/cm | défaut de travail | manuel | une personne qui n'a pas écrit le logiciel lit les listes de la grande démonstration sur la machine de référence à k décroissant ; le plus petit k où elle les lit sans erreur, écrit dans ce registre | — | grande démonstration, 33 tables avec listes, machine de référence, cadre de 1 366 × 700 px |
| `LARGEUR_BLOC_NOMS_CM` | largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège | 500 cm | défaut de travail | manuel | relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre | — | grande démonstration au zoom par défaut |
| `HAUTEUR_LIGNE` | hauteur d'une ligne d'une liste de noms, dans le dessin | relevée à l’exécution | mesurée | exécution | mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des listes ; aucune valeur n'est écrite dans ce registre | — | police calculée des listes, relevée à chaque montage du plan |
| `ZONE_SILENCE_MM` | distance en deçà de laquelle aucun texte ni aucune marque ne s'approche d'un bord de badge | 8 mm | défaut de travail | manuel | sur chaque imprimante visée : une planche de filets imprimée, son décalage mesuré à la règle, plus l'erreur de dix coupes ; puis sa marge non imprimable et le biais d'entraînement sur dix feuilles. La plus grande des deux bornes, arrondie au millimètre supérieur, écrite dans ce registre | — | imprimantes visées, chaque papier des sorties, massicot et ciseaux |
| `HAUTEUR_CAPITALE_NOM_MAX_MM` | hauteur de capitale du nom sur le badge, au plus grand corps que la mise en page lui donne | 9 mm | défaut de travail | manuel | impression réelle d'une planche ; lecture à un mètre par une personne qui n'a pas écrit le logiciel ; la hauteur retenue, écrite dans ce registre | — | badge A6 imprimé, police embarquée, lecture à un mètre |
| `HAUTEUR_CAPITALE_NOM_PLANCHER_MM` | hauteur de capitale du nom sous laquelle la mise en page ne descend pas : un nom plus long replie | 4 mm | défaut de travail | manuel | impression réelle d'une planche à hauteurs décroissantes ; lecture à un mètre par une personne qui n'a pas écrit le logiciel ; la plus petite lue sans erreur, écrite dans ce registre | — | badge A6 imprimé, police embarquée, lecture à un mètre |
| `CORPS_TOURS_PT` | corps des lignes de tour du badge | 14 pt | défaut de travail | manuel | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre | — | badge A6 imprimé, police embarquée, lecture en main |
| `CORPS_TOURS_PLANCHER_PT` | corps sous lequel les lignes de tour ne descendent pas | 10 pt | défaut de travail | manuel | impression réelle d'une planche à corps décroissants ; lecture en main par une personne qui n'a pas écrit le logiciel ; le plus petit corps lu sans erreur, écrit dans ce registre | — | badge A6 imprimé, police embarquée, lecture en main |
| `CORPS_APPARTENANCE_PT` | corps de la ligne d'appartenance du badge | 12 pt | défaut de travail | manuel | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre | — | badge A6 imprimé, police embarquée, lecture en main |
| `CORPS_PIED_PT` | corps du pied du badge : nom et date de l'événement, horodatage du placement | 8 pt | défaut de travail | manuel | impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; le corps retenu, écrit dans ce registre | — | badge A6 imprimé, police embarquée, lecture en main |
| `CORPS_MIN_IMPRESSION_MM` | hauteur de caractère du plan imprimé en deçà de laquelle le plan se découpe en pages | 2,5 mm | défaut de travail | manuel | page d'essai à corps décroissants imprimée sur l'imprimante visée, lue à bout de bras ; le plus petit corps lu sans erreur, écrit dans ce registre | — | imprimante visée, chaque papier des sorties, lecture à bout de bras |
| `MARGE_PAGE_MM` | marge de chaque bord d'une page imprimée par l'aperçu | 10 mm | défaut de travail | manuel | marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue à la règle) ; la plus grande, écrite dans ce registre | — | imprimantes visées, chaque papier des sorties |
| `HAUTEUR_BANDE_IMPRESSION` | hauteur de ce que porte une feuille imprimée hors de sa zone de contenu : pied, mentions, barre, cartouche | relevée à l’exécution | mesurée | exécution | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre | — | rendu de l'aperçu, relevé à chaque ouverture |
| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,01 rapport | défaut de travail | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `RALENTISSEMENT_PLEINE_CHARGE` | ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1) | 2 × | défaut de travail | banc | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | 2,79 × | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
<!-- engendre:fin constantes -->

348
scripts/banc/lancer.js Normal file
View file

@ -0,0 +1,348 @@
// © 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();

377
scripts/banc/lancer.test.js Normal file
View file

@ -0,0 +1,377 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'orchestrateur du banc (§ 19.10, § 14.1, § 19.8), sous node
// et sans navigateur : l'assemblage du relevé, qui refuse une mesure
// manquante et range les mesures dans l'ordre du registre ; la
// configuration, liste fermée du contrat, qui ne nomme pas la machine ;
// le jugement qui rend la sortie non nulle ; la région des constantes ; le
// déroulé de executer, sur des effets factices. Les machines, les versions
// et les valeurs sont inventées ; le relevé committé n'est lu que pour sa
// conformité.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CONSTANTES } from '../../src/geometrie/constantes.js';
import { projetBanc } from './projet.js';
import { verifierReleve } from './releve.js';
import releveCommite from './releve.json' with { type: 'json' };
import {
ATTENDUES,
CHEMIN_RELEVE,
FAMILLE_EMBARQUEE,
GARDES,
SORTIES_NAVIGATEUR,
assembler,
configurationDuBanc,
executer,
jugement,
regionConstantes,
} from './lancer.js';
// Un système d'exploitation inventé, au nom d'hôte, au dossier personnel, à
// l'utilisateur et au modèle de processeur reconnaissables : aucun ne doit
// atteindre le relevé.
const HOTE_INVENTE = 'poste-quenouille-7';
const UTILISATEUR_INVENTE = 'quenouille';
const DOSSIER_INVENTE = '/home/quenouille';
const MODELE_INVENTE = 'Processeur Quenouille 6 cœurs';
const systemeInvente = () => ({
hostname: () => HOTE_INVENTE,
homedir: () => DOSSIER_INVENTE,
userInfo: () => ({ username: UTILISATEUR_INVENTE, homedir: DOSSIER_INVENTE }),
type: () => 'Linux',
arch: () => 'x64',
cpus: () => Array.from({ length: 6 }, () => ({ model: MODELE_INVENTE })),
availableParallelism: () => 6,
});
const configuration = () => configurationDuBanc({ systeme: systemeInvente(), node: 'v26.10.0', navigateur: 'Chromium 140.0' });
// Les sorties d'un passage du banc, de valeurs inventées : une mesure par
// constante du banc et par garde, rangées dans un ordre qui n'est pas celui
// du registre. Comme celles du navigateur, les mesures de texte et la garde
// du temps par image portent leur ralentissement ; celles du parallèle n'en
// portent pas, et leur sortie le dit dans sa configuration.
const sorties = () =>
new Map([
['plafond', { plafondAPriori: [{ cle: 'grande', N: 260, T: 33, R: 4, msPlafonds: 0.07, msDiagnostic: 0.9 }] }],
[
'parallele',
{
format: 1,
configuration: {
cadre: { largeur: 1366, hauteur: 700 },
facteurEchelle: 1,
navigateur: null,
node: null,
processeursLogiques: 6,
ralentissementCpu: 1,
police: null,
},
mesures: [
{ nom: 'RALENTISSEMENT_PLEINE_CHARGE', unite: '×', valeurProposee: 2.5, critere: 't(L) / t(1)', courbe: [] },
{ nom: 'DUREE_GENERATION_PAR_DEFAUT', unite: 'ms', valeurProposee: 600, critere: 'médiane de 3 générations' },
],
parallele: [{ coeurs: 1, ralentissement: 1 }],
},
],
[
'temps_image',
{
format: 1,
mesures: [{ nom: 'TEMPS_IMAGE_LISTES', unite: 'ms', valeurProposee: null, critere: 'p95 sous 33 ms', courbe: [], verdict: 'tenu', ralentissementCpu: 4 }],
},
],
[
'temoin',
{
format: 1,
mesures: [{ nom: 'TOLERANCE_CHAINE_TEMOIN', unite: 'rapport', valeurProposee: 0.02, critere: 'quart du repli', police: 'Noto Sans', ralentissementCpu: 1 }],
},
],
[
'sieges_lisibles',
{
format: 1,
mesures: [{ nom: 'SEUIL_SIEGES_LISIBLES', unite: 'places', valeurProposee: 17, critere: 'liste dans le plan', police: 'Noto Sans', ralentissementCpu: 1 }],
},
],
['harnais', { format: 1, releves: [{ controle: 'cadre' }] }],
]);
const noms = (releve) => releve.mesures.map(({ nom }) => nom);
const DU_BANC = CONSTANTES.filter((c) => c.proprietaire === 'banc').map((c) => c.nom);
describe('lancer : les mesures attendues', () => {
test('chaque constante du banc du registre est attendue, dans l’ordre du registre, puis les gardes, par ordre de nom', () => {
assert.ok(DU_BANC.length > 0);
assert.deepEqual([...ATTENDUES], [...DU_BANC, ...GARDES]);
assert.deepEqual([...GARDES], ['DUREE_GENERATION_PAR_DEFAUT', 'TEMPS_IMAGE_LISTES']);
assert.ok(GARDES.every((nom) => !CONSTANTES.some((c) => c.nom === nom)));
});
});
describe('lancer : assembler (§ 19.10)', () => {
test('un relevé conforme : les mesures dans l’ordre du registre, puis les gardes ; plafond et parallèle repris', () => {
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
assert.deepEqual(verifierReleve(releve), []);
assert.deepEqual(noms(releve), [
'SEUIL_SIEGES_LISIBLES',
'TOLERANCE_CHAINE_TEMOIN',
'RALENTISSEMENT_PLEINE_CHARGE',
'DUREE_GENERATION_PAR_DEFAUT',
'TEMPS_IMAGE_LISTES',
]);
assert.deepEqual(Object.keys(releve), ['format', 'configuration', 'mesures', 'plafondAPriori', 'parallele']);
assert.equal(releve.plafondAPriori[0].cle, 'grande');
assert.deepEqual(releve.parallele, [{ coeurs: 1, ralentissement: 1 }]);
assert.equal(releve.mesures[0].police, 'Noto Sans');
});
test('l’ordre des mesures ne dépend ni de l’ordre des sorties ni de celui des attendues', () => {
const inverse = new Map([...sorties()].reverse());
const attendues = [...ATTENDUES].reverse();
const releve = assembler({ configuration: configuration(), sorties: inverse, attendues });
assert.deepEqual(noms(releve), [...DU_BANC, ...GARDES]);
});
test('l’ordre des mesures est celui du registre reçu : un registre renversé les renverse, les gardes restant après', () => {
const renverse = [...CONSTANTES].reverse();
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES, constantes: renverse });
assert.deepEqual(noms(releve), [...[...DU_BANC].reverse(), ...GARDES]);
});
test('chaque mesure porte le ralentissement sous lequel elle s’est prise : le sien, sinon celui de la configuration de sa sortie', () => {
const recues = sorties();
const releve = assembler({ configuration: configuration(), sorties: recues, attendues: ATTENDUES });
assert.deepEqual(
releve.mesures.map(({ nom, ralentissementCpu }) => [nom, ralentissementCpu]),
[
['SEUIL_SIEGES_LISIBLES', 1],
['TOLERANCE_CHAINE_TEMOIN', 1],
['RALENTISSEMENT_PLEINE_CHARGE', 1],
['DUREE_GENERATION_PAR_DEFAUT', 1],
['TEMPS_IMAGE_LISTES', 4],
],
);
assert.equal(releve.configuration.ralentissementCpu, 4);
assert.ok(!Object.hasOwn(recues.get('parallele').mesures[0], 'ralentissementCpu'), 'les sorties reçues restent intactes');
const sansConfiguration = sorties();
delete sansConfiguration.get('parallele').configuration;
const autre = assembler({ configuration: configuration(), sorties: sansConfiguration, attendues: ATTENDUES });
assert.ok(!Object.hasOwn(autre.mesures[2], 'ralentissementCpu'));
});
test('une sortie manquante se refuse en nommant chaque mesure qu’elle portait', () => {
const manque = sorties();
manque.delete('parallele');
assert.throws(
() => assembler({ configuration: configuration(), sorties: manque, attendues: ATTENDUES }),
/mesure attendue absente : RALENTISSEMENT_PLEINE_CHARGE, DUREE_GENERATION_PAR_DEFAUT/,
);
const vide = new Map();
assert.throws(() => assembler({ configuration: configuration(), sorties: vide, attendues: ATTENDUES }), /absente : SEUIL_SIEGES_LISIBLES/);
});
test('une mesure en double, une mesure inattendue, un champ de sommet deux fois porté se refusent', () => {
const double = sorties();
double.set('autre', { mesures: [{ nom: 'SEUIL_SIEGES_LISIBLES', unite: 'places', valeurProposee: 3, critere: 'x' }] });
assert.throws(() => assembler({ configuration: configuration(), sorties: double, attendues: ATTENDUES }), /en double : SEUIL_SIEGES_LISIBLES/);
const inattendue = sorties();
inattendue.set('autre', { mesures: [{ nom: 'TEMPS_INVENTE', unite: 'ms', valeurProposee: 1, critere: 'x' }] });
assert.throws(() => assembler({ configuration: configuration(), sorties: inattendue, attendues: ATTENDUES }), /inattendue : TEMPS_INVENTE/);
const deuxParalleles = sorties();
deuxParalleles.set('autre', { parallele: [] });
assert.throws(() => assembler({ configuration: configuration(), sorties: deuxParalleles, attendues: ATTENDUES }), /parallele : porté par deux sorties/);
});
test('un relevé que verifierReleve refuse ne s’assemble pas : la faute est nommée', () => {
const fautive = sorties();
fautive.get('temoin').mesures[0].critere = 'relevé sous /home/quenouille/banc';
assert.throws(
() => assembler({ configuration: configuration(), sorties: fautive, attendues: ATTENDUES }),
/mesures\[1\]\.critere : chemin de compte/,
);
});
test('des sorties qui ne sont pas une table se refusent', () => {
assert.throws(() => assembler({ configuration: configuration(), sorties: {}, attendues: ATTENDUES }), TypeError);
});
});
describe('lancer : la configuration ne nomme pas la machine (contrainte 27)', () => {
test('ni le nom d’hôte, ni l’utilisateur, ni le dossier personnel, ni le modèle du processeur injectés n’y paraissent', () => {
const texte = JSON.stringify(configuration());
for (const nom of [HOTE_INVENTE, UTILISATEUR_INVENTE, DOSSIER_INVENTE, MODELE_INVENTE]) assert.ok(!texte.includes(nom), nom);
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
for (const nom of [HOTE_INVENTE, MODELE_INVENTE]) assert.ok(!JSON.stringify(releve).includes(nom), nom);
});
test('la liste fermée du contrat : cadre du projet banc, facteur, versions, processeurs logiques, ralenti ×4, police embarquée', () => {
assert.deepEqual(configuration(), {
cadre: projetBanc().provide.cadreBanc,
facteurEchelle: 1,
navigateur: 'Chromium 140.0',
node: 'v26.10.0',
processeursLogiques: 6,
ralentissementCpu: 4,
police: FAMILLE_EMBARQUEE,
});
assert.equal(FAMILLE_EMBARQUEE, 'Noto Sans');
});
});
describe('lancer : le jugement qui rend la sortie non nulle', () => {
const releve = () => assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
test('un relevé tenu sous la police embarquée : aucun motif', () => {
assert.deepEqual(jugement(releve()), { bloquants: [], nonTenus: [] });
});
test('une mesure de texte sous une autre police, ou sans police nommée : motif bloquant', () => {
const r = releve();
r.mesures[0].police = 'DejaVu Sans';
delete r.mesures[1].police;
assert.deepEqual(jugement(r).bloquants, [
'SEUIL_SIEGES_LISIBLES mesurée sous la police « DejaVu Sans », et non sous Noto Sans',
'TOLERANCE_CHAINE_TEMOIN mesurée sous la police « inconnue », et non sous Noto Sans',
]);
});
test('un verdict non tenu : motif non bloquant, qui nomme la mesure et son critère', () => {
const r = releve();
r.mesures[4].verdict = 'nonTenu';
assert.deepEqual(jugement(r), { bloquants: [], nonTenus: ['TEMPS_IMAGE_LISTES : verdict non tenu — p95 sous 33 ms'] });
});
});
describe('lancer : la région des constantes (§ 19.8)', () => {
test('autant de lignes que le registre, sous un en-tête et son séparateur', () => {
const lignes = regionConstantes().split('\n');
assert.equal(lignes.length, CONSTANTES.length + 2);
CONSTANTES.forEach((c, i) => assert.ok(lignes[i + 2].startsWith(`| \`${c.nom}\` |`), c.nom));
});
test('elle lit le relevé reçu : la valeur proposée paraît', () => {
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
const ligne = regionConstantes({ releve }).split('\n').find((l) => l.startsWith('| `SEUIL_SIEGES_LISIBLES` |'));
assert.match(ligne, /\| 17 places \|/);
});
test('chaque constante du banc cite le ralentissement de sa mesure : à ×1, « processeur non ralenti », jamais ×4', () => {
const releve = assembler({ configuration: configuration(), sorties: sorties(), attendues: ATTENDUES });
const lignes = regionConstantes({ releve }).split('\n');
for (const nom of DU_BANC) {
const ligne = lignes.find((l) => l.startsWith(`| \`${nom}\` |`));
assert.match(ligne, /, processeur non ralenti, police Noto Sans \|$/, nom);
assert.doesNotMatch(ligne, /ralenti ×4/, nom);
}
});
});
describe('lancer : executer, sur des effets factices', () => {
// Un passage factice : chaque effet se note dans journal, dans l'ordre.
// fichiers tient les sorties que le navigateur aurait écrites.
function passage({ codeVitest = 0, fichiers = sorties(), plafond = sorties().get('plafond').plafondAPriori } = {}) {
const journal = [];
const ecrits = new Map();
const lignes = [];
const effets = {
mesurerPlafond: () => {
journal.push('plafond');
return plafond;
},
lancerVitest: () => {
journal.push('vitest');
return codeVitest;
},
lireNavigateur: async () => 'Chromium 140.0',
systeme: systemeInvente(),
node: 'v26.10.0',
effacer: (chemin) => journal.push(`effacer ${chemin}`),
lire: (chemin) => {
const nom = /^scripts\/banc\/sorties\/([a-z_]+)\.json$/.exec(chemin)?.[1];
if (nom === undefined || !fichiers.has(nom)) throw Object.assign(new Error(`absent : ${chemin}`), { code: 'ENOENT' });
return JSON.stringify(fichiers.get(nom));
},
ecrire: (chemin, texte) => ecrits.set(chemin, texte),
sortie: (ligne) => lignes.push(ligne),
};
return { effets, journal, ecrits, lignes };
}
test('le déroulé : sorties effacées, mesures node, banc navigateur ; le relevé écrit, le tableau et les écarts imprimés ; code 0', async () => {
const p = passage();
assert.equal(await executer(p.effets), 0);
assert.deepEqual(p.journal, [...SORTIES_NAVIGATEUR.map((nom) => `effacer scripts/banc/sorties/${nom}.json`), 'plafond', 'vitest']);
assert.deepEqual([...p.ecrits.keys()], [CHEMIN_RELEVE]);
const ecrit = p.ecrits.get(CHEMIN_RELEVE);
assert.ok(ecrit.endsWith('}\n'));
const releve = JSON.parse(ecrit);
assert.deepEqual(verifierReleve(releve), []);
assert.deepEqual(noms(releve), [...ATTENDUES]);
const texte = p.lignes.join('\n');
assert.ok(texte.includes(regionConstantes({ releve })));
assert.match(texte, /SEUIL_SIEGES_LISIBLES : registre 16 places, relevé 17 places — à adopter/);
assert.match(
texte,
/RALENTISSEMENT_PLEINE_CHARGE : registre 2 ×, relevé 2,5 × — relevé, non adopté : défaut de travail sous la machine de référence/,
);
assert.doesNotMatch(texte, /RALENTISSEMENT_PLEINE_CHARGE : .*à adopter/);
assert.match(texte, /Accord du registre avec le relevé : aucun écart/);
});
test('une épreuve du banc tombée (un contrôle du harnais) : code 1, aucun relevé écrit', async () => {
const p = passage({ codeVitest: 1 });
assert.equal(await executer(p.effets), 1);
assert.equal(p.ecrits.size, 0);
assert.match(p.lignes.join('\n'), /épreuve du banc est tombée/);
});
test('une sortie manquante : code 1, aucun relevé écrit, la mesure nommée', async () => {
const fichiers = sorties();
fichiers.delete('temoin');
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(p.ecrits.size, 0);
assert.match(p.lignes.join('\n'), /absente : TOLERANCE_CHAINE_TEMOIN/);
});
test('un verdict non tenu : le relevé s’écrit, qui le porte, et le code vaut 1', async () => {
const fichiers = sorties();
fichiers.get('temps_image').mesures[0].verdict = 'nonTenu';
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(JSON.parse(p.ecrits.get(CHEMIN_RELEVE)).mesures[4].verdict, 'nonTenu');
assert.match(p.lignes.join('\n'), /TEMPS_IMAGE_LISTES : verdict non tenu/);
});
test('une mesure de texte hors de la police embarquée : code 1, aucun relevé écrit', async () => {
const fichiers = sorties();
fichiers.get('temoin').mesures[0].police = 'DejaVu Sans';
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(p.ecrits.size, 0);
assert.match(p.lignes.join('\n'), /sous la police « DejaVu Sans »/);
});
});
describe('lancer : le relevé committé', () => {
test('il est conforme, et porte chaque mesure attendue dans l’ordre du registre', () => {
assert.deepEqual(verifierReleve(releveCommite), []);
assert.deepEqual(noms(releveCommite), [...ATTENDUES]);
for (const champ of ['navigateur', 'node', 'processeursLogiques']) {
assert.notEqual(releveCommite.configuration[champ] ?? null, null, champ);
}
assert.deepEqual(Object.keys(releveCommite.configuration), [
'cadre',
'facteurEchelle',
'navigateur',
'node',
'processeursLogiques',
'ralentissementCpu',
'police',
]);
for (const mesure of releveCommite.mesures) assert.ok(Number.isFinite(mesure.ralentissementCpu), mesure.nom);
});
});

View file

@ -8,8 +8,12 @@
// fait citer à la configuration de l'entrée le résumé de celle du relevé.
// accordRegistre refuse ensuite toute constante du banc mesurée que le relevé
// ne soutient pas. Le relevé ne nomme pas sa machine : sa configuration est
// une liste fermée de champs, et aucun texte n'y porte un chemin de compte
// ni une date. Fonctions pures, sans lecture de fichier ni horloge.
// une liste fermée de champs — cadre, facteur d'échelle, versions, nombre
// de processeurs logiques, ralentissement, police —, et aucun texte n'y
// porte un chemin de compte ni une date. Une mesure peut porter son propre
// ralentissement, celui sous lequel elle s'est prise : c'est lui que cite
// la configuration de sa constante. Fonctions pures, sans lecture de
// fichier ni horloge.
import { CONSTANTES } from '../../src/geometrie/constantes.js';
@ -19,7 +23,7 @@ import { CONSTANTES } from '../../src/geometrie/constantes.js';
* navigateur: string|null, node: string|null, processeursLogiques: number|null,
* ralentissementCpu: number|null, police: string|null}} ConfigurationReleve
* @typedef {{nom: string, unite: string, valeurProposee: number|null, critere: string,
* courbe?: object[], verdict?: 'tenu'|'nonTenu'}} MesureReleve
* courbe?: object[], verdict?: 'tenu'|'nonTenu', ralentissementCpu?: number|null}} MesureReleve
* @typedef {{format: 1, configuration: ConfigurationReleve, mesures: MesureReleve[],
* plafondAPriori?: object[], parallele?: object[]}} Releve
*/
@ -38,23 +42,41 @@ const CHAMPS_CONFIGURATION = [
];
// Les noms de champ qui désignent l'hôte, son utilisateur ou un instant,
// refusés à toute profondeur du relevé. Un nom se lit en minuscules, sans
// tiret bas ni tiret : « Hostname », « host_name » et « nom-hote » se
// valent. Il est identifiant quand il contient un mot de MOT_IDENTIFIANT
// (nomMachine, computerName), commence par un préfixe de
// PREFIXE_IDENTIFIANT (dateReleve, userName), ou vaut un nom de
// NOM_IDENTIFIANT. Ces derniers ne se lisent qu'entiers, leur début
// commençant des mots ordinaires : compteur, postérieur.
// refusés à toute profondeur du relevé. Un nom se coupe en mots — au tiret
// bas, au tiret, à l'espace et à chaque majuscule qui suit une minuscule ou
// un chiffre, ou qui ouvre un mot après un sigle (« HOSTName » : host,
// name) —, puis se lit sans accent et en minuscules : « nomHôte »,
// « nom_hote » et « NOM-HOTE » se valent. Il est identifiant quand le nom
// entier, mots recollés, contient un mot de MOT_IDENTIFIANT (hostname,
// nomMachine), quand l'un de ses mots commence par un préfixe de
// PREFIXE_IDENTIFIANT (userName, currentUser, releveDate), ou quand l'un de
// ses mots vaut un nom de NOM_IDENTIFIANT (nomDuPoste, nomCompte). Ces
// derniers ne se lisent qu'entiers, leur début commençant des mots
// ordinaires : compteur, postérieur, instantané. NOM_ADMIS lève le soupçon
// sur un nom entier qui ne désigne personne : userAgent nomme le navigateur.
const MOT_IDENTIFIANT = ['host', 'hote', 'machine', 'computer', 'utilisateur', 'horodatage', 'timestamp'];
const PREFIXE_IDENTIFIANT = ['user', 'date', 'login'];
const NOM_IDENTIFIANT = new Set(['poste', 'compte', 'instant']);
const NOM_ADMIS = new Set(['useragent']);
// Les mots d'un nom de champ, sans accent et en minuscules.
const motsDe = (cle) =>
cle
.normalize('NFD')
.replace(/\p{M}/gu, '')
.replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2')
.replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2')
.toLowerCase()
.split(/[\s_-]+/)
.filter((mot) => mot !== '');
function estChampIdentifiant(cle) {
const nom = cle.toLowerCase().replace(/[_-]/g, '');
const mots = motsDe(cle);
const nom = mots.join('');
if (NOM_ADMIS.has(nom)) return false;
return (
MOT_IDENTIFIANT.some((mot) => nom.includes(mot)) ||
PREFIXE_IDENTIFIANT.some((prefixe) => nom.startsWith(prefixe)) ||
NOM_IDENTIFIANT.has(nom)
mots.some((mot) => PREFIXE_IDENTIFIANT.some((prefixe) => mot.startsWith(prefixe)) || NOM_IDENTIFIANT.has(mot))
);
}
@ -84,6 +106,10 @@ const estEntierPositif = (valeur) => Number.isInteger(valeur) && valeur > 0;
// dépend pas de l'ordre d'écriture du fichier.
const clesTriees = (objet) => Object.keys(objet).sort();
// Un ralentissement du processeur : un facteur ≥ 1, ou null pour inconnu.
const estRalentissement = (v) => v === null || (Number.isFinite(v) && v >= 1);
const FAUTE_RALENTISSEMENT = 'nombre ≥ 1 ou null attendu';
// Chaque champ de la configuration, avec ce qu'il admet et la faute qu'il
// porte sinon. null vaut « inconnu » pour les champs qui le permettent : un
// relevé qui ne connaît pas encore son navigateur reste vérifiable.
@ -96,7 +122,7 @@ const REGLES_CONFIGURATION = {
navigateur: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'],
node: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'],
processeursLogiques: [(v) => v === null || estEntierPositif(v), 'entier positif ou null attendu'],
ralentissementCpu: [(v) => v === null || (Number.isFinite(v) && v >= 1), 'nombre ≥ 1 ou null attendu'],
ralentissementCpu: [estRalentissement, FAUTE_RALENTISSEMENT],
police: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'],
};
@ -153,6 +179,9 @@ function fautesMesure(mesure, rang, vus, constantes) {
if (Object.hasOwn(mesure, 'verdict') && mesure.verdict !== 'tenu' && mesure.verdict !== 'nonTenu') {
faute('verdict', '« tenu » ou « nonTenu » attendu');
}
if (Object.hasOwn(mesure, 'ralentissementCpu') && !estRalentissement(mesure.ralentissementCpu)) {
faute('ralentissementCpu', FAUTE_RALENTISSEMENT);
}
return fautes;
}
@ -211,9 +240,13 @@ export function verifierReleve(releve, constantes = CONSTANTES) {
return fautesIdentifiantes(releve, '', fautes);
}
// Un nombre écrit à la française : la virgule décimale, les milliers
// séparés par une espace, comme le registre les écrit (« 1 366 »).
function nombre(valeur) {
/**
* Un nombre écrit à la française : la virgule décimale, les milliers
* séparés par une espace, comme le registre les écrit (« 1 366 »).
* @param {number} valeur
* @returns {string}
*/
export function nombre(valeur) {
const [entiere, decimale] = String(valeur).split('.');
const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ' ');
return decimale === undefined ? groupee : `${groupee},${decimale}`;
@ -226,11 +259,16 @@ function nombre(valeur) {
* ordre quel que soit celui des champs. Un champ null se dit inconnu.
* @param {ConfigurationReleve} configuration
* @returns {string}
* @throws {TypeError} sur ce qui n'est pas un objet
* @throws {TypeError} sur ce qui n'est pas un objet, ou dont le cadre n'est
* pas un objet aux largeur et hauteur numériques
*/
export function resumeConfiguration(configuration) {
if (!estObjet(configuration)) throw new TypeError('configuration de relevé : objet attendu');
const { cadre, facteurEchelle, navigateur, node, processeursLogiques: p, ralentissementCpu: r, police } = configuration;
const { cadre, facteurEchelle, navigateur, node } = configuration;
const { processeursLogiques: p, ralentissementCpu: r, police } = configuration;
if (!estObjet(cadre) || !Number.isFinite(cadre.largeur) || !Number.isFinite(cadre.hauteur)) {
throw new TypeError('configuration de relevé : cadre absent ou sans largeur et hauteur numériques');
}
const processeurs =
p === null ? 'processeurs logiques inconnus' : `${nombre(p)} ${p === 1 ? 'processeur logique' : 'processeurs logiques'}`;
let ralentissement = `processeur ralenti ×${nombre(r)}`;
@ -250,20 +288,32 @@ export function resumeConfiguration(configuration) {
// La mesure que le relevé porte pour une constante, undefined sinon.
const mesureDe = (releve, nom) => (Array.isArray(releve.mesures) ? releve.mesures : []).find((m) => m?.nom === nom);
// Le résumé que cite une constante du banc : celui de la configuration du
// relevé, au ralentissement de sa mesure quand elle en porte un — une
// mesure prise sans ralentissement sous un relevé dont la machine de
// référence est ralentie se dit « non ralenti ». Sans mesure, ou sans
// ralentissement propre, celui de la configuration.
function resumeDeMesure(releve, mesure) {
if (mesure === undefined || !Object.hasOwn(mesure, 'ralentissementCpu')) return resumeConfiguration(releve.configuration);
return resumeConfiguration({ ...releve.configuration, ralentissementCpu: mesure.ralentissementCpu });
}
/**
* L'accord du registre avec un relevé : pour chaque constante du banc
* déclarée mesurée, dans l'ordre du registre, « absente » quand le relevé ne
* la porte pas ou n'en propose aucune valeur ; sinon « valeur » quand le
* registre porte une autre valeur que la proposée, puis « configuration »
* quand la configuration de l'entrée ne cite pas le résumé de celle du
* relevé. Une constante défaut de travail, ou d'un autre propriétaire, ne
* relevé, au ralentissement propre de la mesure quand elle en porte un. Une constante défaut de travail, ou d'un autre propriétaire, ne
* produit rien : le relevé ne la soutient ni ne la contredit.
* @param {ReadonlyArray<import('../../src/geometrie/constantes.js').Constante>} constantes
* @param {Releve} releve
* @returns {Array<{nom: string, faute: 'absente'|'valeur'|'configuration'}>}
*/
export function accordRegistre(constantes, releve) {
const resume = resumeConfiguration(releve.configuration);
// Une configuration que le résumé refuse lève d'emblée, qu'une constante
// la cite ou non.
resumeConfiguration(releve.configuration);
const fautes = [];
for (const { nom, valeur, configuration, proprietaire, statut } of constantes) {
if (proprietaire !== 'banc' || statut !== 'mesuree') continue;
@ -273,7 +323,7 @@ export function accordRegistre(constantes, releve) {
continue;
}
if (mesure.valeurProposee !== valeur) fautes.push({ nom, faute: 'valeur' });
if (!configuration.includes(resume)) fautes.push({ nom, faute: 'configuration' });
if (!configuration.includes(resumeDeMesure(releve, mesure))) fautes.push({ nom, faute: 'configuration' });
}
return fautes;
}
@ -315,8 +365,9 @@ function valeurProposee(constante, releve) {
* constante du registre, dans son ordre — ce qu'elle mesure, sa valeur
* (« relevée à l'exécution » sans valeur), son statut en toutes lettres, son
* propriétaire, son protocole, la valeur que le relevé propose, et la
* configuration : le résumé de celle du relevé pour une constante du banc,
* celle du registre pour les autres. Lignes jointes par un saut de ligne,
* configuration : pour une constante du banc, le résumé de celle du relevé
* au ralentissement propre de sa mesure quand elle en porte un ; celle du
* registre pour les autres. Lignes jointes par un saut de ligne,
* sans saut final.
* @param {ReadonlyArray<import('../../src/geometrie/constantes.js').Constante>} constantes
* @param {Releve} releve
@ -325,7 +376,9 @@ function valeurProposee(constante, releve) {
*/
export function tableauConstantes(constantes, releve) {
if (constantes.length === 0) throw new RangeError('tableau des constantes : registre vide');
const resume = resumeConfiguration(releve.configuration);
// Une configuration que le résumé refuse lève d'emblée, qu'une constante
// la cite ou non.
resumeConfiguration(releve.configuration);
const lignes = constantes.map((c) =>
ligne([
`\`${c.nom}\``,
@ -335,7 +388,7 @@ export function tableauConstantes(constantes, releve) {
PROPRIETAIRES[c.proprietaire],
c.protocole,
valeurProposee(c, releve),
c.proprietaire === 'banc' ? resume : c.configuration,
c.proprietaire === 'banc' ? resumeDeMesure(releve, mesureDe(releve, c.nom)) : c.configuration,
]),
);
return [ligne(ENTETE), ligne(ENTETE.map(() => '---')), ...lignes].join('\n');

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,32 @@ describe('releve : verifierReleve (§ 19.10, § 14.2)', () => {
assert.deepEqual(verifierReleve({ ...minimal, configuration: inconnue }), []);
});
test('la configuration est la liste fermée du contrat : ni le système ni le modèle du processeur ne s’y écrivent (contrainte 27)', () => {
for (const champ of ['systeme', 'processeur']) {
const r = releve();
r.configuration[champ] = 'Exemple';
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), [`configuration.${champ}`], champ);
assert.match(
fautes[0].faute,
/hors de la configuration admise : cadre, facteurEchelle, navigateur, node, processeursLogiques, ralentissementCpu, police$/,
);
}
});
test('le ralentissement propre à une mesure : un nombre ≥ 1 ou null, comme celui de la configuration', () => {
const r = releve();
r.mesures[0].ralentissementCpu = 1;
r.mesures[1].ralentissementCpu = null;
r.mesures[2].ralentissementCpu = 4;
assert.deepEqual(verifierReleve(r), []);
for (const fautif of [0.5, '4', Number.NaN]) {
const autre = releve();
autre.mesures[1].ralentissementCpu = fautif;
assert.deepEqual(verifierReleve(autre), [{ chemin: 'mesures[1].ralentissementCpu', faute: 'nombre ≥ 1 ou null attendu' }], String(fautif));
}
});
test('un relevé sans mesure se refuse : un relevé vide ne prouve rien', () => {
const fautes = verifierReleve({ ...releve(), mesures: [] });
assert.deepEqual(chemins(fautes), ['mesures']);
@ -98,22 +124,43 @@ describe('releve : verifierReleve (§ 19.10, § 14.2)', () => {
for (const { faute } of fautes) assert.match(faute, /chemin de compte/);
});
// Chaque branche du chemin de compte, et la casse qu'un système de
// fichiers insensible à la casse admet. Les chemins sont inventés.
for (const chemin of [
'/root/.cache/navigateurs/chromium/chrome',
'lancé sous /root',
'/HOME/exemple/banc',
'/users/exemple/banc',
'c:\\users\\exemple\\banc',
'D:/Documents and Settings/Exemple/banc',
'c:\\documents and settings\\exemple',
]) {
test(`un chemin de compte écrit ${chemin} se refuse`, () => {
const r = releve();
r.mesures[0].critere = chemin;
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), ['mesures[0].critere']);
assert.match(fautes[0].faute, /chemin de compte/);
});
}
test('les chemins voisins hors des comptes passent', () => {
const r = releve();
r.configuration.navigateur = '/opt/homebrew/chromium/chrome';
r.configuration.police = '/usr/share/fonts/noto/NotoSans-Regular.ttf';
r.mesures[0].critere = 'C:\\Program Files\\Chromium : liste dans la hauteur du plan';
r.mesures[1].critere = '/rootfs/usr/lib/chromium, /root-exemple et /homebrew';
assert.deepEqual(verifierReleve(r), []);
});
test('un champ hote se refuse, dans la configuration, au sommet et dans une mesure ; tout champ hors de la configuration admise aussi', () => {
const r = releve();
r.configuration.hote = 'poste-exemple';
r.configuration.systeme = 'linux';
r.configuration.disque = 'ssd';
r.hote = 'poste-exemple';
r.mesures[1].utilisateur = 'exemple';
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), ['configuration.systeme', 'configuration.hote', 'hote', 'mesures[1].utilisateur']);
assert.deepEqual(chemins(fautes), ['configuration.disque', 'configuration.hote', 'hote', 'mesures[1].utilisateur']);
assert.match(fautes[0].faute, /hors de la configuration admise/);
assert.match(fautes[1].faute, /ne nomme pas sa machine/);
assert.match(fautes[2].faute, /ne nomme pas sa machine/);
@ -154,6 +201,17 @@ describe('releve : verifierReleve (§ 19.10, § 14.2)', () => {
'horodatage',
'timestamp',
'instant',
'nomHôte',
'HÔTE',
'machineName',
'utilisateurCourant',
'currentUser',
'HOSTName',
'PCUser',
'releveDate',
'nomCompte',
'nomDuPoste',
'instant_prise',
]) {
test(`un champ ${cle} se refuse dans une mesure, ouverte par ailleurs`, () => {
const r = releve();
@ -166,7 +224,10 @@ describe('releve : verifierReleve (§ 19.10, § 14.2)', () => {
test('les champs voisins qui ne nomment ni hôte, ni utilisateur, ni instant passent', () => {
const r = releve();
Object.assign(r.mesures[2].courbe[0], { compteur: 3, posterieur: true, update: 1, mandat: 'aucun' });
Object.assign(r.mesures[2].courbe[0], { compteur: 3, posterieur: true, update: 1, mandat: 'aucun', instantane: 1 });
// userAgent nomme le navigateur, pas une personne : « user » n'y est
// pas un mot seul.
r.mesures[2].courbe[1].userAgent = 'Mozilla/5.0 (X11; Linux x86_64) Chromium/140.0';
r.plafondAPriori[0].postes = 2;
assert.deepEqual(verifierReleve(r), []);
});
@ -299,6 +360,18 @@ describe('releve : accordRegistre (§ 19.10, § 14.1)', () => {
]);
});
test('la configuration citée est celle de la mesure : son ralentissement propre l’emporte sur celui du relevé', () => {
const r = releve();
r.mesures[0].ralentissementCpu = 1;
const aUn = resumeConfiguration({ ...configuration(), ralentissementCpu: 1 });
const aQuatre = resumeConfiguration(configuration());
const citant = (resume) => avec('SEUIL_SIEGES_LISIBLES', { statut: 'mesuree', valeur: 18, configuration: `banc de mesure, ${resume}` });
assert.deepEqual(accordRegistre(citant(aUn), r), []);
assert.deepEqual(accordRegistre(citant(aQuatre), r), [{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'configuration' }]);
const tolerance = avec('TOLERANCE_CHAINE_TEMOIN', { statut: 'mesuree', valeur: 0.02, configuration: aQuatre });
assert.deepEqual(accordRegistre(tolerance, r), []);
});
test('ni une constante défaut de travail, ni une constante mesurée hors du banc ne produisent de faute ; les fautes suivent le registre', () => {
const r = releve();
assert.deepEqual(accordRegistre(avec('SEUIL_SIEGES_LISIBLES', { valeur: 12 }), r), []);
@ -338,6 +411,14 @@ describe('releve : resumeConfiguration', () => {
assert.equal(resumeConfiguration({ police, ...reste, cadre }), resumeConfiguration(configuration()));
assert.throws(() => resumeConfiguration(null), TypeError);
});
test('une configuration sans cadre, ou au cadre mal formé, se refuse en nommant le cadre', () => {
const { cadre, ...sansCadre } = configuration();
for (const autre of [sansCadre, { ...configuration(), cadre: null }, { ...configuration(), cadre: { largeur: 1366 } }]) {
assert.throws(() => resumeConfiguration(autre), { name: 'TypeError', message: /cadre/ });
assert.throws(() => tableauConstantes(registre(), { ...releve(), configuration: autre }), { name: 'TypeError', message: /cadre/ });
}
});
});
describe('releve : tableauConstantes (§ 19.8, § 19.10)', () => {
@ -397,6 +478,18 @@ describe('releve : tableauConstantes (§ 19.8, § 19.10)', () => {
});
});
test('une mesure qui porte son ralentissement le fait citer à sa ligne : à ×1, « processeur non ralenti » sous une configuration à ×4', () => {
const r = releve();
r.mesures[0].ralentissementCpu = 1;
const lignes = tableauConstantes(liste, r).split('\n').slice(2).map(cellules);
const configurationDe = (nom) => lignes.find((l) => l[0] === `\`${nom}\``)[7];
assert.equal(configurationDe('SEUIL_SIEGES_LISIBLES'), resumeConfiguration({ ...configuration(), ralentissementCpu: 1 }));
assert.match(configurationDe('SEUIL_SIEGES_LISIBLES'), /, processeur non ralenti, /);
assert.doesNotMatch(configurationDe('SEUIL_SIEGES_LISIBLES'), /×4/);
assert.equal(configurationDe('TOLERANCE_CHAINE_TEMOIN'), resumeConfiguration(configuration()));
assert.equal(configurationDe('RALENTISSEMENT_PLEINE_CHARGE'), resumeConfiguration(configuration()));
});
test('une barre verticale ou un saut de ligne dans un texte ne cassent pas le tableau ; un registre vide se refuse', () => {
const registre = avec('K_LISTES', { mesure: 'échelle | zoom\nde la vue' });
const rang = registre.findIndex((c) => c.nom === 'K_LISTES');

View file

@ -13,7 +13,8 @@
// définition des réglages, la table des colonnes du CSV, le registre des
// commandes et leurs libellés figés, la table des raccourcis, les
// démonstrations livrées passées au diagnostic, les styles relevés au
// manifeste des captures, le registre des constantes. Aucune valeur de ces
// manifeste des captures, le registre des constantes, avec le relevé du
// banc quand il existe (scripts/banc/releve.json). Aucune valeur de ces
// tableaux ne s'écrit ici : elle se lit, ou se compose par la traduction.
// Seul s'écrit ici ce qui appartient au document : les en-têtes de colonnes,
// le nom d'un contexte, d'une touche, d'un statut ou de l'état d'une
@ -62,6 +63,7 @@ import { diagnostiquer } from '../src/moteur/diagnostic.js';
import { analyser, configurationDepuisCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
import { CHEMIN_MANIFESTE, ecartDeVersion } from './documentation/manifeste.js';
import { tableauConstantes } from './banc/releve.js';
/** @typedef {{nom: string, debut: number, fin: number, contenu: string}} Region
* debut et fin bornent le contenu dans le texte : texte.slice(debut, fin). */
@ -570,9 +572,13 @@ export function tableauRegistre(constantes = CONSTANTES) {
// --- Les producteurs --------------------------------------------------------------
/** Le relevé du banc de mesure, relatif à la racine (§ 19.10). */
export const CHEMIN_RELEVE = 'scripts/banc/releve.json';
/**
* @typedef {{manifeste: () => object|null}} Contexte
* manifeste : le manifeste des captures lu, null quand il manque
* @typedef {{manifeste: () => object|null, releve: () => object|null}} Contexte
* manifeste : le manifeste des captures lu, null quand il manque ;
* releve : le relevé du banc lu, null quand il manque
*/
const producteur = (nom, produire) => Object.freeze({ nom, produire });
@ -589,7 +595,10 @@ export const PRODUCTEURS = Object.freeze([
producteur('raccourcis', () => tableauRaccourcis()),
producteur('demonstrations', () => tableauDemonstrations()),
producteur('contrastes', (contexte) => tableauContrastes(contexte.manifeste())),
producteur('constantes', () => tableauRegistre()),
producteur('constantes', (contexte) => {
const releve = contexte.releve();
return releve === null ? tableauRegistre() : tableauConstantes(CONSTANTES, releve);
}),
]);
/**
@ -616,31 +625,35 @@ const estAbsent = (erreur) => erreur?.code === 'ENOENT';
// Le lecteur par défaut d'une racine : le texte UTF-8 d'un chemin relatif.
const lecteurDe = (racine) => (chemin) => readFileSync(join(racine, chemin), 'utf8');
// Le contexte d'un arbre : le manifeste, lu au premier besoin, une fois ;
// null quand il manque. Un manifeste qui n'est pas du JSON lève.
function contexteDe(lire) {
// Le lecteur d'un fichier JSON de l'arbre, lu au premier besoin, une fois :
// l'objet lu, ou null quand le fichier manque. Un texte qui n'est pas du
// JSON lève en nommant le chemin.
function lecteurJson(lire, chemin) {
let lu;
return {
manifeste() {
if (lu !== undefined) return lu;
let texte;
try {
texte = lire(CHEMIN_MANIFESTE);
} catch (erreur) {
if (!estAbsent(erreur)) throw erreur;
lu = null;
return lu;
}
try {
lu = JSON.parse(texte);
} catch {
throw new Error(`${CHEMIN_MANIFESTE} : JSON illisible`);
}
return () => {
if (lu !== undefined) return lu;
let texte;
try {
texte = lire(chemin);
} catch (erreur) {
if (!estAbsent(erreur)) throw erreur;
lu = null;
return lu;
},
}
try {
lu = JSON.parse(texte);
} catch {
throw new Error(`${chemin} : JSON illisible`);
}
return lu;
};
}
// Le contexte d'un arbre : le manifeste des captures et le relevé du banc.
function contexteDe(lire) {
return { manifeste: lecteurJson(lire, CHEMIN_MANIFESTE), releve: lecteurJson(lire, CHEMIN_RELEVE) };
}
// Une faute de région, au chemin de son porteur.
const auChemin = (chemin) => ({ region, faute }) => ({
chemin,

View file

@ -26,7 +26,17 @@ import { TABLES_INTERFACE } from '../src/interface/libelles.js';
import { diagnostiquer } from '../src/moteur/diagnostic.js';
import { analyser, configurationDepuisCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
import { PORTEURS, PRODUCTEURS, RACINE, lireRegions, verifierArbre } from '../scripts/documentation.js';
import { CONSTANTES } from '../src/geometrie/constantes.js';
import { tableauConstantes } from '../scripts/banc/releve.js';
import {
PORTEURS,
PRODUCTEURS,
RACINE,
engendrerArbre,
lireRegions,
tableauRegistre,
verifierArbre,
} from '../scripts/documentation.js';
import {
A_VENIR,
CAPTURES,
@ -262,6 +272,65 @@ describe('documentation : les régions engendrées des documents livrés (§ 19.
});
});
// --- La région des constantes et le relevé du banc (§ 19.8, § 19.10) ----------------
describe('documentation : la région « constantes » lit le relevé du banc quand il existe (§ 19.10)', () => {
const RELEVE = 'scripts/banc/releve.json';
const PORTEUR = 'README.md';
const TEXTE = '# Essai\n\n<!-- engendre:debut constantes -->\n<!-- engendre:fin constantes -->\n';
// Un relevé inventé, d'une mesure du banc.
const releve = {
format: 1,
configuration: {
cadre: { largeur: 1366, hauteur: 700 },
facteurEchelle: 1,
navigateur: 'Chromium 140.0',
node: 'v26.10.0',
processeursLogiques: 8,
ralentissementCpu: 4,
police: 'Noto Sans',
},
mesures: [{ nom: 'SEUIL_SIEGES_LISIBLES', unite: 'places', valeurProposee: 17, critere: 'liste dans la hauteur du plan' }],
};
// Engendre le porteur d'essai sur un arbre en mémoire où le relevé vaut
// texteReleve, ou manque quand il vaut null ; rend le bilan et le texte
// écrit.
function engendrer(texteReleve) {
const ecrits = new Map();
const lire = (chemin) => {
if (chemin === PORTEUR) return TEXTE;
if (chemin === RELEVE && texteReleve !== null) return texteReleve;
throw Object.assign(new Error(`absent : ${chemin}`), { code: 'ENOENT' });
};
const bilan = engendrerArbre({ porteurs: [PORTEUR], lire, ecrire: (chemin, texte) => ecrits.set(chemin, texte) });
return { bilan, ecrit: ecrits.get(PORTEUR) };
}
const region = (texte) => lireRegions(texte)[0].contenu.trim();
test('sans relevé, la région est le tableau du registre seul', () => {
const { bilan, ecrit } = engendrer(null);
assert.equal(bilan.code, 0);
assert.equal(region(ecrit), tableauRegistre());
});
test('avec un relevé, la région est le tableau des constantes, la valeur proposée à côté de celle du registre', () => {
const { bilan, ecrit } = engendrer(JSON.stringify(releve));
assert.equal(bilan.code, 0);
assert.equal(region(ecrit), tableauConstantes(CONSTANTES, releve));
assert.match(region(ecrit), /\| `SEUIL_SIEGES_LISIBLES` \|.*\| 17 places \| cadre de 1 366 × 700 px/);
});
test('un relevé illisible fait échouer la production, en nommant son chemin, et rien ne s’écrit', () => {
const { bilan, ecrit } = engendrer('{ format: 1');
assert.equal(bilan.code, 1);
assert.equal(ecrit, undefined);
assert.match(bilan.fautes[0].faute, /scripts\/banc\/releve\.json : JSON illisible/);
});
});
// --- La section sur l'algorithme (§ 19.9) ---------------------------------------------
// Les nombres de un à douze, en lettres : la section écrit en lettres tout