[ADD] bench: versioned record format and its agreement with constants

Measured thresholds must be adopted by a developer, never written by the
bench into the code. The record holds the bench configuration and its
measures; its reader refuses an empty record, a measure without its
constant, and any field or value that looks like a host, an account or a
date. A guard in the constants test makes every registry value that claims
a bench origin agree with the record.
Checked: red first, eight mutations caught; 3392 node tests green from the
index alone.

--- FR ---

[ADD] banc : format du relevé versionné, accord avec les constantes

Un seuil mesuré s'adopte par un geste du développeur ; le banc ne l'écrit
jamais dans le code. Le relevé porte la configuration du banc et ses
mesures ; sa lecture refuse un relevé vide, une mesure sans sa constante,
et tout champ ou valeur qui ressemble à un hôte, un compte ou une date. Une
garde de l'épreuve des constantes accorde au relevé chaque valeur du
registre qui se dit tirée du banc.
Vérifié : rouge d'abord, huit mutations attrapées ; 3392 node verts depuis
l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-09 13:03:20 -04:00
parent 4e25e73a5d
commit 307ee526fc
4 changed files with 775 additions and 1 deletions

342
scripts/banc/releve.js Normal file
View file

@ -0,0 +1,342 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le relevé du banc de mesure (§ 19.10, § 14.1) et son accord avec le
// registre des constantes. Le banc écrit un relevé, scripts/banc/releve.json,
// jamais le registre : adopter une valeur est un geste du développeur, qui
// l'écrit dans src/geometrie/constantes.js, passe le statut à « mesuree » et
// 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.
import { CONSTANTES } from '../../src/geometrie/constantes.js';
/**
* @typedef {{chemin: string, faute: string}} Faute
* @typedef {{cadre: {largeur: number, hauteur: number}, facteurEchelle: number,
* 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
* @typedef {{format: 1, configuration: ConfigurationReleve, mesures: MesureReleve[],
* plafondAPriori?: object[], parallele?: object[]}} Releve
*/
// Les champs d'un relevé, et ceux de sa configuration : rien d'autre ne s'y
// écrit, un champ de plus pouvant nommer la machine.
const CHAMPS_RELEVE = ['format', 'configuration', 'mesures', 'plafondAPriori', 'parallele'];
const CHAMPS_CONFIGURATION = [
'cadre',
'facteurEchelle',
'navigateur',
'node',
'processeursLogiques',
'ralentissementCpu',
'police',
];
// 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.
const MOT_IDENTIFIANT = ['host', 'hote', 'machine', 'computer', 'utilisateur', 'horodatage', 'timestamp'];
const PREFIXE_IDENTIFIANT = ['user', 'date', 'login'];
const NOM_IDENTIFIANT = new Set(['poste', 'compte', 'instant']);
function estChampIdentifiant(cle) {
const nom = cle.toLowerCase().replace(/[_-]/g, '');
return (
MOT_IDENTIFIANT.some((mot) => nom.includes(mot)) ||
PREFIXE_IDENTIFIANT.some((prefixe) => nom.startsWith(prefixe)) ||
NOM_IDENTIFIANT.has(nom)
);
}
// Un chemin de compte : le dossier personnel sous Linux (/home/x, et
// /var/home/x qui le contient), sous macOS (/Users/x), celui de root, et le
// profil sous Windows (C:\Users\x, C:\Documents and Settings\x), quel que
// soit le séparateur. /opt/homebrew ne s'y lit pas : « home » y est suivi
// d'autre chose qu'un séparateur.
const CHEMIN_DE_COMPTE = /\/(?:home|Users)\/[^/\s]+|\/root(?:\/|$)|[A-Za-z]:[\\/](?:Users|Documents and Settings)[\\/][^\\/\s]+/i;
// Une date calendaire dans un texte, seule ou au début d'un horodatage :
// AAAA-MM-JJ, AAAA.MM.JJ ou AAAA/MM/JJ, d'un même séparateur, le mois de 01
// à 12 et le jour de 01 à 31 ; ou JJ/MM/AAAA et JJ.MM.AAAA, jour et mois
// sur un ou deux chiffres, dans un ordre ou dans l'autre (31/12 ou 12/31).
// Aucun chiffre ne la précède ni ne la suit : un nombre plus long ne s'y lit pas.
const DATE =
/(?<!\d)(?:\d{4}([-./])(?:0[1-9]|1[0-2])\1(?:0[1-9]|[12]\d|3[01])|(?:0?[1-9]|[12]\d|3[01])([/.])(?:0?[1-9]|[12]\d|3[01])\2\d{4})(?!\d)/;
const MESSAGE_IDENTIFIANT = 'un relevé ne nomme pas sa machine : ni hôte, ni utilisateur, ni date';
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
const estTexte = (valeur) => typeof valeur === 'string' && valeur.length > 0;
const estPositif = (valeur) => Number.isFinite(valeur) && valeur > 0;
const estEntierPositif = (valeur) => Number.isInteger(valeur) && valeur > 0;
// Les clés d'un objet, triées par unités de code : l'ordre des fautes ne
// dépend pas de l'ordre d'écriture du fichier.
const clesTriees = (objet) => Object.keys(objet).sort();
// 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.
const REGLES_CONFIGURATION = {
cadre: [
(v) => estObjet(v) && clesTriees(v).join(',') === 'hauteur,largeur' && estEntierPositif(v.largeur) && estEntierPositif(v.hauteur),
'largeur et hauteur en px, entiers positifs, et rien d’autre',
],
facteurEchelle: [estPositif, 'nombre positif attendu'],
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'],
police: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'],
};
// Fautes de la configuration : un champ hors de la liste (les champs
// identifiants sont laissés au balayage), puis chaque champ de la liste,
// dans son ordre.
function fautesConfiguration(configuration) {
if (!estObjet(configuration)) return [{ chemin: 'configuration', faute: 'configuration absente : objet attendu' }];
const fautes = [];
for (const cle of clesTriees(configuration)) {
if (!CHAMPS_CONFIGURATION.includes(cle) && !estChampIdentifiant(cle)) {
fautes.push({
chemin: `configuration.${cle}`,
faute: `champ hors de la configuration admise : ${CHAMPS_CONFIGURATION.join(', ')}`,
});
}
}
for (const champ of CHAMPS_CONFIGURATION) {
const chemin = `configuration.${champ}`;
if (!Object.hasOwn(configuration, champ)) {
fautes.push({ chemin, faute: 'champ absent' });
continue;
}
const [admet, faute] = REGLES_CONFIGURATION[champ];
if (!admet(configuration[champ])) fautes.push({ chemin, faute });
}
return fautes;
}
// Fautes d'une mesure, dans l'ordre de ses champs. Une mesure qui nomme une
// constante du registre appartient au banc et en porte l'unité ; un autre
// nom est une garde du banc (le temps par image), qui porte la sienne.
function fautesMesure(mesure, rang, vus, constantes) {
const base = `mesures[${rang}]`;
if (!estObjet(mesure)) return [{ chemin: base, faute: 'objet attendu' }];
const fautes = [];
const faute = (champ, texte) => fautes.push({ chemin: `${base}.${champ}`, faute: texte });
const entree = estTexte(mesure.nom) ? constantes.find((c) => c.nom === mesure.nom) : undefined;
if (!estTexte(mesure.nom)) faute('nom', 'nom non vide attendu');
else if (vus.has(mesure.nom)) faute('nom', 'mesure déjà relevée plus haut');
else if (entree !== undefined && entree.proprietaire !== 'banc') {
faute('nom', `constante de propriétaire ${entree.proprietaire} : le banc ne la relève pas`);
}
if (estTexte(mesure.nom)) vus.add(mesure.nom);
if (!estTexte(mesure.unite)) faute('unite', 'unité non vide attendue');
else if (entree !== undefined && entree.unite !== mesure.unite) {
faute('unite', `unité « ${mesure.unite} », le registre dit « ${entree.unite} »`);
}
if (!(mesure.valeurProposee === null || Number.isFinite(mesure.valeurProposee))) {
faute('valeurProposee', 'nombre fini ou null attendu');
}
if (!estTexte(mesure.critere)) faute('critere', 'critère non vide attendu');
if (Object.hasOwn(mesure, 'courbe') && !Array.isArray(mesure.courbe)) faute('courbe', 'liste attendue');
if (Object.hasOwn(mesure, 'verdict') && mesure.verdict !== 'tenu' && mesure.verdict !== 'nonTenu') {
faute('verdict', '« tenu » ou « nonTenu » attendu');
}
return fautes;
}
// Balayage du relevé entier, clés triées à chaque niveau : un champ
// identifiant, et tout texte qui porte un chemin de compte ou une date.
function fautesIdentifiantes(valeur, chemin, fautes) {
if (typeof valeur === 'string') {
if (CHEMIN_DE_COMPTE.test(valeur)) fautes.push({ chemin, faute: 'chemin de compte : un relevé ne nomme pas sa machine' });
if (DATE.test(valeur)) fautes.push({ chemin, faute: 'date : un relevé ne porte pas l’instant de sa prise' });
} else if (Array.isArray(valeur)) {
valeur.forEach((element, rang) => fautesIdentifiantes(element, `${chemin}[${rang}]`, fautes));
} else if (estObjet(valeur)) {
for (const cle of clesTriees(valeur)) {
const sous = chemin === '' ? cle : `${chemin}.${cle}`;
if (estChampIdentifiant(cle)) fautes.push({ chemin: sous, faute: MESSAGE_IDENTIFIANT });
fautesIdentifiantes(valeur[cle], sous, fautes);
}
}
return fautes;
}
/**
* Les fautes d'un relevé, [] quand il est conforme. Dans l'ordre : un champ
* hors du format ; le format, 1 ; la configuration ; les mesures, une liste
* non vide — un relevé vide ne prouve rien (§ 14.2) —, chacune nommée une
* seule fois, et à l'unité du registre quand elle nomme l'une de ses
* constantes, qui doit alors appartenir au banc ; plafondAPriori et
* parallele, des listes quand ils sont présents ; puis, à toute profondeur,
* chaque champ identifiant et chaque texte qui porte un chemin de compte ou
* une date.
* @param {Releve} releve
* @param {ReadonlyArray<import('../../src/geometrie/constantes.js').Constante>} [constantes]
* @returns {Faute[]}
*/
export function verifierReleve(releve, constantes = CONSTANTES) {
if (!estObjet(releve)) return [{ chemin: '', faute: 'relevé : objet attendu' }];
const fautes = [];
for (const cle of clesTriees(releve)) {
if (!CHAMPS_RELEVE.includes(cle) && !estChampIdentifiant(cle)) {
fautes.push({ chemin: cle, faute: 'champ hors du format du relevé' });
}
}
if (releve.format !== 1) fautes.push({ chemin: 'format', faute: `format ${String(releve.format)} : 1 attendu` });
fautes.push(...fautesConfiguration(releve.configuration));
if (!Array.isArray(releve.mesures)) {
fautes.push({ chemin: 'mesures', faute: 'liste attendue' });
} else if (releve.mesures.length === 0) {
fautes.push({ chemin: 'mesures', faute: 'aucune mesure : un relevé vide ne prouve rien' });
} else {
const vus = new Set();
releve.mesures.forEach((mesure, rang) => fautes.push(...fautesMesure(mesure, rang, vus, constantes)));
}
for (const champ of ['plafondAPriori', 'parallele']) {
if (Object.hasOwn(releve, champ) && !Array.isArray(releve[champ])) fautes.push({ chemin: champ, faute: 'liste attendue' });
}
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) {
const [entiere, decimale] = String(valeur).split('.');
const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ' ');
return decimale === undefined ? groupee : `${groupee},${decimale}`;
}
/**
* Le résumé d'une configuration de relevé, celui que la configuration d'une
* constante adoptée cite : cadre, facteur d'échelle, navigateur, Node,
* processeurs logiques, ralentissement du processeur et police, dans cet
* 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
*/
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 processeurs =
p === null ? 'processeurs logiques inconnus' : `${nombre(p)} ${p === 1 ? 'processeur logique' : 'processeurs logiques'}`;
let ralentissement = `processeur ralenti ×${nombre(r)}`;
if (r === null) ralentissement = 'ralentissement inconnu';
else if (r === 1) ralentissement = 'processeur non ralenti';
return [
`cadre de ${nombre(cadre.largeur)} × ${nombre(cadre.hauteur)} px`,
`facteur ${nombre(facteurEchelle)}`,
navigateur ?? 'navigateur inconnu',
`Node ${node ?? 'inconnu'}`,
processeurs,
ralentissement,
`police ${police ?? 'inconnue'}`,
].join(', ');
}
// 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);
/**
* 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
* 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);
const fautes = [];
for (const { nom, valeur, configuration, proprietaire, statut } of constantes) {
if (proprietaire !== 'banc' || statut !== 'mesuree') continue;
const mesure = mesureDe(releve, nom);
if (mesure === undefined || mesure.valeurProposee === null) {
fautes.push({ nom, faute: 'absente' });
continue;
}
if (mesure.valeurProposee !== valeur) fautes.push({ nom, faute: 'valeur' });
if (!configuration.includes(resume)) fautes.push({ nom, faute: 'configuration' });
}
return fautes;
}
// Les libellés du statut et du propriétaire, tels que le tableau les écrit.
const STATUTS = { mesuree: 'mesurée', defautDeTravail: 'défaut de travail' };
const PROPRIETAIRES = { banc: 'banc', node: 'node', manuel: 'manuel', execution: 'exécution' };
const ENTETE = [
'Constante',
'Ce qu’elle mesure',
'Valeur',
'Statut',
'Propriétaire',
'Protocole',
'Valeur proposée par le banc',
'Configuration du relevé',
];
// Une cellule de tableau Markdown : une barre verticale s'échappe, un saut
// de ligne devient une espace.
const cellule = (texte) => String(texte).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
const ligne = (cellules) => `| ${cellules.map(cellule).join(' | ')} |`;
const avecUnite = (valeur, unite) => `${nombre(valeur)} ${unite}`;
// La valeur que le relevé propose pour une constante : avec son unité ;
// « non relevée » pour une constante du banc qu'il ne mesure pas ou dont il
// ne propose rien ; « — » hors du banc, qui ne la relève pas.
function valeurProposee(constante, releve) {
if (constante.proprietaire !== 'banc') return '—';
const mesure = mesureDe(releve, constante.nom);
return mesure === undefined || mesure.valeurProposee === null ? 'non relevée' : avecUnite(mesure.valeurProposee, constante.unite);
}
/**
* Le tableau des constantes mesurées (§ 19.10, § 19.8), contenu de la région
* engendrée « constantes » : un en-tête, son séparateur, puis une ligne par
* 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,
* sans saut final.
* @param {ReadonlyArray<import('../../src/geometrie/constantes.js').Constante>} constantes
* @param {Releve} releve
* @returns {string}
* @throws {RangeError} sur un registre vide : un tableau sans ligne ne prouve rien
*/
export function tableauConstantes(constantes, releve) {
if (constantes.length === 0) throw new RangeError('tableau des constantes : registre vide');
const resume = resumeConfiguration(releve.configuration);
const lignes = constantes.map((c) =>
ligne([
`\`${c.nom}\``,
c.mesure,
c.valeur === null ? 'relevée à l’exécution' : avecUnite(c.valeur, c.unite),
STATUTS[c.statut],
PROPRIETAIRES[c.proprietaire],
c.protocole,
valeurProposee(c, releve),
c.proprietaire === 'banc' ? resume : c.configuration,
]),
);
return [ligne(ENTETE), ligne(ENTETE.map(() => '---')), ...lignes].join('\n');
}

13
scripts/banc/releve.json Normal file
View file

@ -0,0 +1,13 @@
{
"format": 1,
"configuration": {
"cadre": { "largeur": 1366, "hauteur": 700 },
"facteurEchelle": 1,
"navigateur": null,
"node": null,
"processeursLogiques": null,
"ralentissementCpu": 4,
"police": "Noto Sans"
},
"mesures": []
}

407
scripts/banc/releve.test.js Normal file
View file

@ -0,0 +1,407 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du relevé du banc (§ 19.10, § 14.1, § 19.8) : sa vérification,
// qui refuse un relevé vide, une machine nommée et une unité qui contredit
// le registre ; son accord avec le registre, qui ne juge que les constantes
// du banc déclarées mesurées ; le résumé de sa configuration ; le tableau
// des constantes qu'en tire la documentation. Les relevés d'épreuve sont
// inventés ; 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 { accordRegistre, resumeConfiguration, tableauConstantes, verifierReleve } from './releve.js';
import releveCommite from './releve.json' with { type: 'json' };
// Une configuration de relevé complète, de valeurs inventées.
const configuration = () => ({
cadre: { largeur: 1366, hauteur: 700 },
facteurEchelle: 1,
navigateur: 'Chromium 140.0',
node: 'v26.10.0',
processeursLogiques: 8,
ralentissementCpu: 4,
police: 'Noto Sans',
});
// Un relevé conforme : deux constantes du banc et une garde hors du
// registre, qui porte sa courbe et son verdict.
const releve = () => ({
format: 1,
configuration: configuration(),
mesures: [
{ nom: 'SEUIL_SIEGES_LISIBLES', unite: 'places', valeurProposee: 18, critere: 'liste dans la hauteur du plan' },
{ nom: 'TOLERANCE_CHAINE_TEMOIN', unite: 'rapport', valeurProposee: 0.02, critere: "quart de l'écart au repli" },
{
nom: 'TEMPS_IMAGE',
unite: 'ms',
valeurProposee: null,
critere: 'p95 sous 33 ms à tout k',
courbe: [{ k: 0.2, p95Ms: 12.5 }, { k: 0.4, p95Ms: 11 }],
verdict: 'tenu',
},
],
plafondAPriori: [{ cle: 'grande', N: 260, T: 33, R: 4, msPlafonds: 0.07, msDiagnostic: 0.9 }],
parallele: [],
});
// Le registre réel en copie modifiable, ramené à l'état d'avant toute
// adoption : chaque constante en défaut de travail hors de l'exécution, et
// les valeurs que les épreuves lisent épinglées. Une adoption dans le
// registre ne change donc rien ici ; son accord avec le relevé committé est
// la garde de src/geometrie/constantes.test.js. avec(nom, champs) rend la
// copie où l'entrée nommée prend ces champs.
const ADOPTIONS_ANNULEES = { SEUIL_SIEGES_LISIBLES: 16, TOLERANCE_CHAINE_TEMOIN: 0.01, RALENTISSEMENT_PLEINE_CHARGE: 2 };
const registre = () =>
CONSTANTES.map((entree) => ({
...entree,
statut: entree.proprietaire === 'execution' ? 'mesuree' : 'defautDeTravail',
...(Object.hasOwn(ADOPTIONS_ANNULEES, entree.nom) ? { valeur: ADOPTIONS_ANNULEES[entree.nom] } : {}),
}));
const avec = (nom, champs, liste = registre()) => liste.map((e) => (e.nom === nom ? { ...e, ...champs } : e));
const chemins = (fautes) => fautes.map(({ chemin }) => chemin);
describe('releve : verifierReleve (§ 19.10, § 14.2)', () => {
test('un relevé conforme ne porte aucune faute, une garde hors du registre comprise', () => {
assert.deepEqual(verifierReleve(releve()), []);
const minimal = { format: 1, configuration: configuration(), mesures: [releve().mesures[0]] };
assert.deepEqual(verifierReleve(minimal), []);
const inconnue = { ...configuration(), navigateur: null, node: null, processeursLogiques: null, ralentissementCpu: null, police: null };
assert.deepEqual(verifierReleve({ ...minimal, configuration: inconnue }), []);
});
test('un relevé sans mesure se refuse : un relevé vide ne prouve rien', () => {
const fautes = verifierReleve({ ...releve(), mesures: [] });
assert.deepEqual(chemins(fautes), ['mesures']);
assert.match(fautes[0].faute, /aucune mesure/);
for (const mesures of [undefined, null, {}, 'SEUIL_SIEGES_LISIBLES']) {
const autre = verifierReleve({ ...releve(), mesures });
assert.deepEqual(chemins(autre), ['mesures'], String(mesures));
assert.match(autre[0].faute, /liste attendue/);
}
});
test('une configuration qui porte un chemin de compte se refuse, sous Linux, macOS et Windows, à toute profondeur', () => {
const r = releve();
r.configuration.navigateur = '/home/exemple/.cache/navigateurs/chromium/chrome';
r.configuration.police = 'C:\\Users\\Exemple\\AppData\\Local\\Fonts\\NotoSans-Regular.ttf';
r.mesures[2].courbe[1].source = '/Users/exemple/banc/sortie.json';
r.plafondAPriori[0].cle = 'grande sous /var/home/exemple';
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), [
'configuration.navigateur',
'configuration.police',
'mesures[2].courbe[1].source',
'plafondAPriori[0].cle',
]);
for (const { faute } of fautes) assert.match(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';
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.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.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/);
assert.match(fautes[3].faute, /ne nomme pas sa machine/);
const sommet = verifierReleve({ ...releve(), source: 'banc' });
assert.deepEqual(chemins(sommet), ['source']);
assert.match(sommet[0].faute, /hors du format du relevé/);
});
test('une date se refuse : un relevé ne porte pas l’instant de sa prise', () => {
const r = releve();
r.mesures[0].critere = 'relevé le 1999-12-31';
r.configuration.node = 'v26.10.0 (1999-12-31)';
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), ['configuration.node', 'mesures[0].critere']);
for (const { faute } of fautes) assert.match(faute, /date/);
});
// Les formes d'un champ identifiant : la casse, le tiret bas et le tiret
// ne changent rien ; l'hôte et son utilisateur se lisent au début ou au
// milieu du nom, une date au début. Les valeurs sont inventées.
for (const cle of [
'Hostname',
'host_name',
'HOTE',
'nom-hote',
'machine',
'nomMachine',
'poste',
'computerName',
'USER',
'user_name',
'Utilisateur',
'nomUtilisateur',
'login',
'compte',
'dateReleve',
'horodatage',
'timestamp',
'instant',
]) {
test(`un champ ${cle} se refuse dans une mesure, ouverte par ailleurs`, () => {
const r = releve();
r.mesures[0][cle] = cle === 'horodatage' || cle === 'timestamp' ? 915148800000 : 'poste-exemple';
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), [`mesures[0].${cle}`]);
assert.match(fautes[0].faute, /ne nomme pas sa machine/);
});
}
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' });
r.plafondAPriori[0].postes = 2;
assert.deepEqual(verifierReleve(r), []);
});
// Les formes d'une date dans un texte : AAAA-MM-JJ, AAAA.MM.JJ et
// AAAA/MM/JJ, puis JJ/MM/AAAA et JJ.MM.AAAA, jour et mois sur un ou deux
// chiffres. Les dates sont inventées.
for (const date of ['1999.12.31', '1999/12/31', '31/12/1999', '31.12.1999', '12/31/1999', '1/2/1999']) {
test(`une date écrite ${date} se refuse`, () => {
const r = releve();
r.mesures[0].critere = `relevé le ${date}`;
const fautes = verifierReleve(r);
assert.deepEqual(chemins(fautes), ['mesures[0].critere']);
assert.match(fautes[0].faute, /date/);
});
}
test('les versions, les rapports et le cadre ne se lisent pas comme des dates', () => {
const r = releve();
r.configuration.navigateur = 'Chromium 140.0.7339.80';
r.configuration.node = 'v26.10.0';
r.mesures[0].critere = 'liste dans la hauteur du plan, 1/2 de 1366 × 700, 33/16 ms';
r.mesures[1].critere = 'écart 2024-13-40 hors calendrier, 1999-12.31 aux séparateurs mêlés';
assert.deepEqual(verifierReleve(r), []);
});
test("une unité qui diffère du registre se refuse, et nomme l'une et l'autre", () => {
const r = releve();
r.mesures[0].unite = 'px';
r.mesures[1].unite = 'ms';
const fautes = verifierReleve(r);
assert.deepEqual(fautes, [
{ chemin: 'mesures[0].unite', faute: 'unité « px », le registre dit « places »' },
{ chemin: 'mesures[1].unite', faute: 'unité « ms », le registre dit « rapport »' },
]);
// Le registre se reçoit : la même unité contredit un autre registre.
const autre = avec('SEUIL_SIEGES_LISIBLES', { unite: 'sièges' });
assert.deepEqual(chemins(verifierReleve(releve(), autre)), ['mesures[0].unite']);
});
test('une constante que le banc ne relève pas, une mesure répétée et des champs de mesure mal formés se refusent', () => {
const r = releve();
r.mesures.push({ nom: 'K_LISTES', unite: 'px/cm', valeurProposee: 0.25, critere: 'temps par image' });
r.mesures.push({ ...r.mesures[0] });
r.mesures.push({ nom: '', unite: '', valeurProposee: Number.NaN, critere: '', courbe: {}, verdict: 'oui' });
r.mesures.push(null);
const fautes = verifierReleve(r);
assert.deepEqual(fautes, [
{ chemin: 'mesures[3].nom', faute: 'constante de propriétaire manuel : le banc ne la relève pas' },
{ chemin: 'mesures[4].nom', faute: 'mesure déjà relevée plus haut' },
{ chemin: 'mesures[5].nom', faute: 'nom non vide attendu' },
{ chemin: 'mesures[5].unite', faute: 'unité non vide attendue' },
{ chemin: 'mesures[5].valeurProposee', faute: 'nombre fini ou null attendu' },
{ chemin: 'mesures[5].critere', faute: 'critère non vide attendu' },
{ chemin: 'mesures[5].courbe', faute: 'liste attendue' },
{ chemin: 'mesures[5].verdict', faute: '« tenu » ou « nonTenu » attendu' },
{ chemin: 'mesures[6]', faute: 'objet attendu' },
]);
});
test('le format, la configuration et ses champs se vérifient un à un', () => {
assert.deepEqual(chemins(verifierReleve({ ...releve(), format: 2 })), ['format']);
assert.deepEqual(chemins(verifierReleve({ ...releve(), configuration: undefined })), ['configuration']);
const r = releve();
r.configuration.cadre = { largeur: 1366.5, hauteur: 700 };
r.configuration.facteurEchelle = 0;
r.configuration.navigateur = '';
r.configuration.processeursLogiques = 2.5;
r.configuration.ralentissementCpu = 0.5;
delete r.configuration.police;
r.plafondAPriori = {};
r.parallele = 'aucun';
assert.deepEqual(chemins(verifierReleve(r)), [
'configuration.cadre',
'configuration.facteurEchelle',
'configuration.navigateur',
'configuration.processeursLogiques',
'configuration.ralentissementCpu',
'configuration.police',
'plafondAPriori',
'parallele',
]);
const cadre = releve();
cadre.configuration.cadre = { largeur: 1366, hauteur: 700, x: 0 };
assert.deepEqual(chemins(verifierReleve(cadre)), ['configuration.cadre']);
assert.deepEqual(chemins(verifierReleve(null)), ['']);
});
test('le relevé committé porte la configuration du banc, ne nomme pas la machine, et ne se refuse que faute de mesure', () => {
const attendues = Array.isArray(releveCommite.mesures) && releveCommite.mesures.length === 0 ? ['mesures'] : [];
assert.deepEqual(chemins(verifierReleve(releveCommite)), attendues);
assert.deepEqual(releveCommite.configuration.cadre, { largeur: 1366, hauteur: 700 });
assert.equal(releveCommite.configuration.facteurEchelle, 1);
});
});
describe('releve : accordRegistre (§ 19.10, § 14.1)', () => {
test('un registre sans constante du banc mesurée s’accorde avec tout relevé', () => {
assert.ok(registre().some((c) => c.proprietaire === 'banc'));
assert.deepEqual(accordRegistre(registre(), releve()), []);
assert.deepEqual(accordRegistre(registre(), { ...releve(), mesures: [] }), []);
});
test('une constante du banc mesurée dont le relevé propose une autre valeur : valeur', () => {
const cite = `banc de mesure, ${resumeConfiguration(configuration())}`;
const registre = avec('SEUIL_SIEGES_LISIBLES', { statut: 'mesuree', valeur: 16, configuration: cite });
assert.deepEqual(accordRegistre(registre, releve()), [{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'valeur' }]);
const adoptee = avec('SEUIL_SIEGES_LISIBLES', { statut: 'mesuree', valeur: 18, configuration: cite });
assert.deepEqual(accordRegistre(adoptee, releve()), []);
});
test('une constante du banc mesurée que le relevé ne porte pas, ou dont il ne propose rien : absente', () => {
const cite = resumeConfiguration(configuration());
const registre = avec('RALENTISSEMENT_PLEINE_CHARGE', { statut: 'mesuree', configuration: cite });
assert.deepEqual(accordRegistre(registre, releve()), [{ nom: 'RALENTISSEMENT_PLEINE_CHARGE', faute: 'absente' }]);
const r = releve();
r.mesures[0].valeurProposee = null;
const seuil = avec('SEUIL_SIEGES_LISIBLES', { statut: 'mesuree', valeur: 18, configuration: cite });
assert.deepEqual(accordRegistre(seuil, r), [{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'absente' }]);
assert.deepEqual(accordRegistre(seuil, { ...releve(), mesures: [] }), [{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'absente' }]);
});
test('une constante du banc mesurée dont la configuration ne cite pas celle du relevé : configuration', () => {
const registre = avec('TOLERANCE_CHAINE_TEMOIN', { statut: 'mesuree', valeur: 0.02 });
assert.deepEqual(accordRegistre(registre, releve()), [{ nom: 'TOLERANCE_CHAINE_TEMOIN', faute: 'configuration' }]);
const deux = avec('TOLERANCE_CHAINE_TEMOIN', { statut: 'mesuree', valeur: 0.03 });
assert.deepEqual(accordRegistre(deux, releve()), [
{ nom: 'TOLERANCE_CHAINE_TEMOIN', faute: 'valeur' },
{ nom: 'TOLERANCE_CHAINE_TEMOIN', faute: 'configuration' },
]);
});
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), []);
assert.deepEqual(accordRegistre(avec('K_LISTES', { statut: 'mesuree', valeur: 0.25 }), r), []);
let registre = avec('RALENTISSEMENT_PLEINE_CHARGE', { statut: 'mesuree' });
registre = avec('SEUIL_SIEGES_LISIBLES', { statut: 'mesuree' }, registre);
assert.deepEqual(accordRegistre(registre, r), [
{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'valeur' },
{ nom: 'SEUIL_SIEGES_LISIBLES', faute: 'configuration' },
{ nom: 'RALENTISSEMENT_PLEINE_CHARGE', faute: 'absente' },
]);
});
});
describe('releve : resumeConfiguration', () => {
test('le résumé que le registre cite : cadre, facteur, navigateur, Node, processeurs, ralentissement, police', () => {
assert.equal(
resumeConfiguration(configuration()),
'cadre de 1 366 × 700 px, facteur 1, Chromium 140.0, Node v26.10.0, 8 processeurs logiques, ' +
'processeur ralenti ×4, police Noto Sans',
);
});
test('un champ inconnu se dit inconnu ; un ralentissement de 1 est l’absence de ralentissement ; les décimales prennent la virgule', () => {
const inconnue = { ...configuration(), navigateur: null, node: null, processeursLogiques: null, ralentissementCpu: null, police: null };
assert.equal(
resumeConfiguration(inconnue),
'cadre de 1 366 × 700 px, facteur 1, navigateur inconnu, Node inconnu, processeurs logiques inconnus, ' +
'ralentissement inconnu, police inconnue',
);
const autre = { ...configuration(), cadre: { largeur: 800, hauteur: 600 }, facteurEchelle: 1.25, ralentissementCpu: 1, processeursLogiques: 1 };
assert.match(resumeConfiguration(autre), /^cadre de 800 × 600 px, facteur 1,25, .*, 1 processeur logique, processeur non ralenti, /);
});
test("le résumé ne dépend pas de l'ordre des champs, et refuse ce qui n'est pas une configuration", () => {
const { police, cadre, ...reste } = configuration();
assert.equal(resumeConfiguration({ police, ...reste, cadre }), resumeConfiguration(configuration()));
assert.throws(() => resumeConfiguration(null), TypeError);
});
});
describe('releve : tableauConstantes (§ 19.8, § 19.10)', () => {
// Les cellules d'une ligne de tableau Markdown, sans les barres de bord ;
// une barre précédée d'une barre oblique inverse appartient à la cellule.
const cellules = (ligne) => ligne.slice(1, -1).split(/(?<!\\)\|/).map((c) => c.trim());
const liste = registre();
test('une ligne par constante du registre, sous un en-tête et son séparateur', () => {
const lignes = tableauConstantes(liste, releve()).split('\n');
assert.ok(liste.length > 0);
assert.equal(lignes.length, liste.length + 2);
const entete = cellules(lignes[0]);
assert.deepEqual(entete, [
'Constante',
'Ce qu’elle mesure',
'Valeur',
'Statut',
'Propriétaire',
'Protocole',
'Valeur proposée par le banc',
'Configuration du relevé',
]);
assert.ok(cellules(lignes[1]).every((c) => /^-{3,}$/.test(c)));
liste.forEach((c, i) => {
const ligne = cellules(lignes[i + 2]);
assert.equal(ligne.length, entete.length, c.nom);
assert.equal(ligne[0], `\`${c.nom}\``);
assert.equal(ligne[1], c.mesure);
assert.equal(ligne[5], c.protocole);
});
});
test('« défaut de travail » en toutes lettres, la valeur proposée à côté de celle du registre', () => {
const lignes = tableauConstantes(liste, releve()).split('\n').slice(2).map(cellules);
const ligne = (nom) => lignes.find((l) => l[0] === `\`${nom}\``);
const defauts = liste.filter((c) => c.statut === 'defautDeTravail').length;
assert.ok(defauts > 0);
assert.equal(lignes.filter((l) => l[3] === 'défaut de travail').length, defauts);
assert.deepEqual(ligne('SEUIL_SIEGES_LISIBLES').slice(2, 7).filter((_, i) => i !== 3), ['16 places', 'défaut de travail', 'banc', '18 places']);
assert.equal(ligne('TOLERANCE_CHAINE_TEMOIN')[2], '0,01 rapport');
assert.equal(ligne('TOLERANCE_CHAINE_TEMOIN')[6], '0,02 rapport');
assert.equal(ligne('RALENTISSEMENT_PLEINE_CHARGE')[6], 'non relevée');
assert.equal(ligne('K_LISTES')[6], '—');
assert.deepEqual(ligne('HAUTEUR_LIGNE').slice(2, 5), ['relevée à l’exécution', 'mesurée', 'exécution']);
});
test('chaque ligne porte la configuration : celle du relevé pour le banc, celle du registre ailleurs', () => {
const resume = resumeConfiguration(configuration());
const lignes = tableauConstantes(liste, releve()).split('\n').slice(2).map(cellules);
const banc = liste.filter((c) => c.proprietaire === 'banc').length;
assert.ok(banc > 0);
assert.equal(lignes.filter((l) => l[7] === resume).length, banc);
liste.forEach((c, i) => {
assert.equal(lignes[i][7], c.proprietaire === 'banc' ? resume : c.configuration, c.nom);
assert.ok(lignes[i][7].length > 0, c.nom);
});
});
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');
const ligne = tableauConstantes(registre, releve()).split('\n')[2 + rang];
assert.equal(cellules(ligne)[1], 'échelle \\| zoom de la vue');
assert.throws(() => tableauConstantes([], releve()), RangeError);
});
});

View file

@ -3,9 +3,14 @@
// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les
// dix-huit entrées du contrat de données, chacune complète et de statut
// épinglé, et la vérification qui nomme l'entrée et le champ d'une faute.
// épinglé, la vérification qui nomme l'entrée et le champ d'une faute, et
// l'accord du registre avec le relevé committé du banc : le banc écrit un
// relevé, jamais le registre, et une constante du banc déclarée mesurée
// porte la valeur que le relevé propose, sur la configuration qu'il décrit.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { accordRegistre } from '../../scripts/banc/releve.js';
import releveCommite from '../../scripts/banc/releve.json' with { type: 'json' };
import { CONSTANTES, constante, verifierRegistre } from './constantes.js';
// Le contrat de données : nom, valeur de départ, unité, propriétaire,
@ -128,3 +133,10 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
assert.deepEqual(verifierRegistre([{ ...CONSTANTES[6], protocole: '' }]), [{ nom: 'ZONE_SILENCE_MM', champ: 'protocole' }]);
});
});
describe('constantes : accord avec le relevé du banc (§ 19.10, § 14.1)', () => {
test('chaque constante du banc déclarée mesurée porte la valeur du relevé committé et cite sa configuration', () => {
assert.ok(CONSTANTES.some((c) => c.proprietaire === 'banc'), 'le registre ne porte aucune constante du banc');
assert.deepEqual(accordRegistre(CONSTANTES, releveCommite), []);
});
});