gestion_table_tournante_libre/scripts/banc/releve.js
Mathieu Benoit 307ee526fc [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
2026-10-09 13:03:20 -04:00

342 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// 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');
}