`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
687 lines
36 KiB
JavaScript
687 lines
36 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Épreuves des documents livrés, sur les vrais fichiers (§ 16.1, § 19.7 à
|
||
// § 19.9) : GUIDE-USAGE.md suit les étapes du parcours et apparie ses images
|
||
// au manifeste et au disque ; les régions engendrées de README.md et du guide
|
||
// sont telles qu'engendrer les écrirait ; la section sur l'algorithme cite
|
||
// des nombres que l'arithmétique et le diagnostic des démonstrations livrées
|
||
// recalculent, et ne cite ni code, ni identifiant exporté, ni module ;
|
||
// GUIDE-WINDOWS.md, texte seul, cite le gabarit du nom de l'exécutable et la
|
||
// règle du dossier de travail avec ses deux issues.
|
||
//
|
||
// Chaque contrôle est une fonction qui rend ses fautes : appliquée au vrai
|
||
// fichier, elle n'en rend aucune ; appliquée à une copie altérée en mémoire
|
||
// (une image retirée, un nombre changé, un nom de fonction collé), elle en
|
||
// rend une, ce qui montre qu'elle ne passe pas à vide (§ 14.2).
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync, readFileSync } from 'node:fs';
|
||
import { join, relative } from 'node:path';
|
||
import { describe, test } from './lanceur.js';
|
||
import { TABLES_APPLICATION } from '../src/application/libelles.js';
|
||
import { creerTraduction } from '../src/application/traduction.js';
|
||
import { CATALOGUE } from '../src/demo/catalogue.js';
|
||
import { LIVREES } from '../src/demo/livrees.js';
|
||
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 { 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,
|
||
CHEMIN_MANIFESTE,
|
||
DOSSIER_CAPTURES,
|
||
ETAPES,
|
||
REPORTEES,
|
||
cheminImage,
|
||
cheminsHorsRacine,
|
||
imagesReferencees,
|
||
verifierAppariement,
|
||
verifierOrdre,
|
||
} from '../scripts/documentation/manifeste.js';
|
||
import { deriver } from '../scripts/version.js';
|
||
|
||
const GUIDE_USAGE = 'GUIDE-USAGE.md';
|
||
const GUIDE_WINDOWS = 'GUIDE-WINDOWS.md';
|
||
const README = 'README.md';
|
||
|
||
const lire = (chemin) => readFileSync(join(RACINE, chemin), 'utf8');
|
||
const lireManifeste = () => JSON.parse(lire(CHEMIN_MANIFESTE));
|
||
|
||
// Les images PNG du dossier des captures, en chemins relatifs à la racine.
|
||
const imagesSurDisque = () =>
|
||
readdirSync(join(RACINE, DOSSIER_CAPTURES))
|
||
.filter((nom) => nom.endsWith('.png'))
|
||
.map((nom) => `${DOSSIER_CAPTURES}/${nom}`);
|
||
|
||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||
|
||
// --- Le découpage d'un document Markdown ----------------------------------------
|
||
|
||
// Les sections de niveau 2 d'un texte : { titre, debut, texte }, texte allant
|
||
// du titre exclu au titre suivant de même niveau ; ce qui précède le premier
|
||
// titre n'en est pas une.
|
||
function sections(texte) {
|
||
const titres = [...texte.matchAll(/^## (.+)$/gm)];
|
||
return titres.map((trouve, rang) => {
|
||
const debut = trouve.index + trouve[0].length;
|
||
const fin = rang + 1 < titres.length ? titres[rang + 1].index : texte.length;
|
||
return { titre: trouve[1], debut, texte: texte.slice(debut, fin) };
|
||
});
|
||
}
|
||
|
||
// Le titre de la section d'une étape : « Étape <id> — <intitulé> ».
|
||
const TITRE_ETAPE = /^Étape (\S+) \u{2014} \S/u;
|
||
// Le titre de la section sur l'algorithme (§ 19.9).
|
||
const TITRE_ALGORITHME = 'Comment le logiciel cherche';
|
||
|
||
// Un texte dont chaque suite de blancs, saut de ligne compris, devient une
|
||
// espace : une phrase cherchée se trouve quel que soit le retour à la ligne
|
||
// du paragraphe qui la porte.
|
||
const enLigne = (texte) => texte.replace(/\s+/g, ' ');
|
||
|
||
// Le texte où la première occurrence d'une phrase est remplacée, quel que
|
||
// soit le retour à la ligne entre ses mots ; échoue si la phrase est absente.
|
||
function remplacerPhrase(texte, phrase, remplacement) {
|
||
const motif = phrase.split(/\s+/).map((mot) => mot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
|
||
const remplace = texte.replace(new RegExp(motif, 'u'), remplacement);
|
||
assert.notEqual(remplace, texte, `phrase introuvable : ${phrase}`);
|
||
return remplace;
|
||
}
|
||
|
||
// L'espèce d'une faute : ce qui précède son premier « : ».
|
||
const especes = (fautes) => fautes.map((faute) => faute.split(' : ')[0]);
|
||
|
||
// La section dont le titre est exactement celui donné, ou null.
|
||
const sectionNommee = (texte, titre) => sections(texte).find((s) => s.titre === titre) ?? null;
|
||
|
||
// --- Les étapes et les images du guide d'usage ------------------------------------
|
||
|
||
/**
|
||
* Les fautes du guide d'usage contre les étapes et le manifeste : une section
|
||
* par étape d'ETAPES, dans leur ordre, et chaque image citée pour la première
|
||
* fois dans la section de l'étape que le manifeste lui déclare.
|
||
*/
|
||
function fautesEtapes(texte, manifeste) {
|
||
const fautes = [];
|
||
const etapes = sections(texte)
|
||
.map((section) => ({ ...section, etape: TITRE_ETAPE.exec(section.titre)?.[1] ?? null }))
|
||
.filter((section) => section.etape !== null);
|
||
const lues = etapes.map((s) => s.etape);
|
||
if (JSON.stringify(lues) !== JSON.stringify([...ETAPES])) {
|
||
fautes.push(`sections d'étape ${JSON.stringify(lues)}, attendues ${JSON.stringify(ETAPES)}`);
|
||
}
|
||
const etapeDe = new Map(manifeste.captures.map((c) => [cheminImage(c.fichier), c.etape]));
|
||
const premiere = new Map();
|
||
for (const section of etapes) {
|
||
for (const image of imagesReferencees(section.texte)) {
|
||
const vue = texte.indexOf(image);
|
||
if (!premiere.has(image) && vue >= section.debut && vue < section.debut + section.texte.length) {
|
||
premiere.set(image, section.etape);
|
||
}
|
||
}
|
||
}
|
||
for (const image of imagesReferencees(texte)) {
|
||
const attendue = etapeDe.get(image);
|
||
const lue = premiere.get(image) ?? null;
|
||
if (attendue !== undefined && lue !== attendue) {
|
||
fautes.push(`${image} : citée d'abord hors de la section de l'étape ${attendue} (${lue ?? 'hors des étapes'})`);
|
||
}
|
||
}
|
||
return fautes;
|
||
}
|
||
|
||
// Un entier écrit en chiffres comme les documents l'écrivent : des groupes de
|
||
// trois séparés par une espace fine insécable.
|
||
const enChiffres = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '\u{202F}');
|
||
|
||
// La graine et le compte d'arrêt que le parcours écrit avant de générer les
|
||
// propositions qu'il photographie, lus dans son texte : le guide les donne
|
||
// pour que ses écrans se reproduisent (§ 16.1).
|
||
function reglagesDuParcours() {
|
||
const texte = lire('src/interface/parcours.navigateur.test.js');
|
||
const graine = /^const GRAINE = (\d+);$/m.exec(texte)?.[1];
|
||
const arret = /^const ARRET = (\d+);$/m.exec(texte)?.[1];
|
||
assert.ok(graine !== undefined && arret !== undefined, 'la graine et le compte d’arrêt du parcours');
|
||
return { graine, arret: BigInt(arret) };
|
||
}
|
||
|
||
// Les références du guide, telles que verifierAppariement les attend : le
|
||
// guide d'usage seul porte des images (GUIDE-WINDOWS.md est texte seul).
|
||
const referencesDe = (texteGuide) => [{ document: GUIDE_USAGE, images: imagesReferencees(texteGuide) }];
|
||
|
||
describe('documentation : le guide d’usage suit les étapes et apparie ses images (§ 16.1, § 19.3, § 19.7)', () => {
|
||
test('appariement : chaque image du guide est au manifeste et sur le disque, et réciproquement', () => {
|
||
const fautes = verifierAppariement({ manifeste: lireManifeste(), references: referencesDe(lire(GUIDE_USAGE)), images: imagesSurDisque() });
|
||
assert.deepEqual(fautes, []);
|
||
});
|
||
|
||
test('ordre : le manifeste range ses captures et ses reportées selon les étapes, chaque étape couverte', () => {
|
||
assert.deepEqual(verifierOrdre(lireManifeste()), []);
|
||
});
|
||
|
||
test('une section par étape, dans l’ordre du parcours ; chaque image citée d’abord sous son étape', () => {
|
||
assert.deepEqual(fautesEtapes(lire(GUIDE_USAGE), lireManifeste()), []);
|
||
});
|
||
|
||
test('l’introduction et l’étape 9 donnent la graine et le compte d’arrêt du parcours qui photographie', () => {
|
||
const { graine, arret } = reglagesDuParcours();
|
||
const texte = lire(GUIDE_USAGE);
|
||
const introduction = enLigne(texte.slice(0, texte.indexOf('\n## ')));
|
||
const etape9 = enLigne(sections(texte).find((s) => /^Étape 9 \u{2014}/u.test(s.titre))?.texte ?? '');
|
||
for (const [nom, morceau] of [['introduction', introduction], ['étape 9', etape9]]) {
|
||
// Chaque nombre est borné à droite : un nombre que le guide prolonge
|
||
// ne passe pas pour celui du parcours.
|
||
const suivi = (prefixe) => morceau.split(prefixe).slice(1).some((reste) => !/^[\d\u{202F}]/u.test(reste));
|
||
assert.ok(suivi(`la graine ${graine}`), `${nom} : la graine ${graine}`);
|
||
assert.ok(suivi(enLigne(`un compte d\u{2019}arrêt de ${enChiffres(arret)}`)), `${nom} : le compte d’arrêt ${arret}`);
|
||
}
|
||
});
|
||
|
||
test('les captures reportées ou à venir ne sont pas citées : sans image, elles seraient orphelines', () => {
|
||
const citees = new Set(imagesReferencees(lire(GUIDE_USAGE)));
|
||
const absentes = [...REPORTEES.map((r) => r.fichier), ...A_VENIR].map(cheminImage);
|
||
assert.ok(absentes.length > 0);
|
||
assert.deepEqual(absentes.filter((chemin) => citees.has(chemin)), []);
|
||
// Chaque capture déclarée qui n'est pas à venir est publiée, donc citée.
|
||
const publiees = CAPTURES.filter((c) => !A_VENIR.includes(c.fichier)).map((c) => cheminImage(c.fichier));
|
||
assert.deepEqual(publiees.filter((chemin) => !citees.has(chemin)), []);
|
||
});
|
||
|
||
test('rouge : une image retirée du guide devient orpheline, au chemin de cette image', () => {
|
||
const texte = lire(GUIDE_USAGE);
|
||
const retiree = cheminImage('05-tables-etat');
|
||
assert.ok(texte.includes(retiree));
|
||
const sans = texte.split('\n').filter((ligne) => !ligne.includes(retiree)).join('\n');
|
||
const fautes = verifierAppariement({ manifeste: lireManifeste(), references: referencesDe(sans), images: imagesSurDisque() });
|
||
assert.deepEqual(fautes.map((f) => f.chemin), [retiree]);
|
||
assert.match(fautes[0].faute, /^orpheline : .*absente de la documentation$/);
|
||
});
|
||
|
||
test('rouge : une section d’étape déplacée ou une image citée sous une autre étape se relèvent', () => {
|
||
const texte = lire(GUIDE_USAGE);
|
||
const manifeste = lireManifeste();
|
||
const renommee = texte.replace('## Étape 1b \u{2014}', '## Étape 1c \u{2014}');
|
||
assert.notEqual(renommee, texte);
|
||
const [etapes, image] = fautesEtapes(renommee, manifeste);
|
||
assert.match(etapes, /^sections d'étape \["1","1c","2",/);
|
||
assert.equal(image, `${cheminImage('01b-passer-en-ecriture')} : citée d'abord hors de la section de l'étape 1b (1c)`);
|
||
const deplacee = cheminImage('10-diagnostic');
|
||
const avancee = texte.replace('## Étape 1b \u{2014}', `\n\n## Étape 1b \u{2014}`);
|
||
assert.deepEqual(fautesEtapes(avancee, manifeste), [`${deplacee} : citée d'abord hors de la section de l'étape 8 (1)`]);
|
||
});
|
||
});
|
||
|
||
// --- Les régions engendrées ---------------------------------------------------------
|
||
|
||
describe('documentation : les régions engendrées des documents livrés (§ 19.8)', () => {
|
||
test('PORTEURS nomme README.md et le guide d’usage ; chaque porteur porte les régions qui lui reviennent', () => {
|
||
assert.deepEqual([...PORTEURS], [README, GUIDE_USAGE]);
|
||
assert.deepEqual(lireRegions(lire(README)).map((r) => r.nom), ['contrastes', 'constantes']);
|
||
assert.deepEqual(
|
||
lireRegions(lire(GUIDE_USAGE)).map((r) => r.nom),
|
||
['reglages', 'colonnes-csv', 'commandes', 'raccourcis', 'demonstrations'],
|
||
);
|
||
// Chaque producteur sert une région, et une seule.
|
||
const servies = PORTEURS.flatMap((chemin) => lireRegions(lire(chemin)).map((r) => r.nom)).sort();
|
||
assert.deepEqual(servies, PRODUCTEURS.map(({ nom }) => nom).sort());
|
||
});
|
||
|
||
test('verifier, sur l’arbre réel et la liste par défaut : aucune faute, toutes les régions lues', () => {
|
||
const bilan = verifierArbre();
|
||
assert.deepEqual(bilan.fautes, []);
|
||
assert.equal(bilan.regions, PRODUCTEURS.length);
|
||
assert.equal(bilan.code, 0);
|
||
});
|
||
|
||
test('aucun document de la racine hors de PORTEURS ne porte de région : rien n’échappe à la vérification', () => {
|
||
const documents = readdirSync(RACINE).filter((nom) => nom.endsWith('.md'));
|
||
assert.ok(documents.includes(GUIDE_WINDOWS));
|
||
const horsPorteurs = documents.filter((nom) => !PORTEURS.includes(nom));
|
||
assert.deepEqual(horsPorteurs.filter((nom) => lireRegions(lire(nom)).length > 0), []);
|
||
});
|
||
|
||
test('la construction de livraison vérifie les régions et la version des captures avant de construire', () => {
|
||
const { scripts } = JSON.parse(lire('package.json'));
|
||
const etapes = scripts['build:livraison'].split('&&').map((etape) => etape.trim());
|
||
const verifier = etapes.indexOf('node scripts/documentation.js verifier --livraison');
|
||
const construire = etapes.findIndex((etape) => etape.startsWith('vite build'));
|
||
assert.ok(verifier >= 0, scripts['build:livraison']);
|
||
assert.ok(construire > verifier, scripts['build:livraison']);
|
||
});
|
||
|
||
test('rouge : une cellule corrigée à la main dans une région du guide fait échouer verifier', () => {
|
||
const texte = lire(GUIDE_USAGE);
|
||
const region = lireRegions(texte).find((r) => r.nom === 'reglages');
|
||
const ligne = region.contenu.split('\n').find((l) => l.startsWith('| ') && !l.startsWith('| Réglage'));
|
||
const corrige = texte.slice(0, region.debut) + region.contenu.replace(ligne, `${ligne} corrigé |`) + texte.slice(region.fin);
|
||
const bilan = verifierArbre({ porteurs: [GUIDE_USAGE], lire: (chemin) => (chemin === GUIDE_USAGE ? corrige : lire(chemin)) });
|
||
assert.deepEqual(bilan.fautes.map((f) => f.chemin), [GUIDE_USAGE]);
|
||
assert.match(bilan.fautes[0].faute, /région « reglages » : diffère de ce que le producteur engendre/);
|
||
});
|
||
});
|
||
|
||
// --- 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
|
||
// nombre qu'elle ne fait pas recalculer.
|
||
const EN_LETTRES = ['zéro', 'un', 'deux', 'trois', 'quatre', 'cinq', 'six', 'sept', 'huit', 'neuf', 'dix', 'onze', 'douze'];
|
||
function enLettres(n) {
|
||
if (!Number.isInteger(n) || n < 0 || n >= EN_LETTRES.length) throw new RangeError(`pas de lettres pour ${n}`);
|
||
return EN_LETTRES[n];
|
||
}
|
||
|
||
const EXPOSANTS = new Map([
|
||
['\u{2070}', 0n], ['\u{B9}', 1n], ['\u{B2}', 2n], ['\u{B3}', 3n], ['\u{2074}', 4n],
|
||
['\u{2075}', 5n], ['\u{2076}', 6n], ['\u{2077}', 7n], ['\u{2078}', 8n], ['\u{2079}', 9n],
|
||
]);
|
||
// Un nombre écrit en chiffres : des groupes de trois après une espace fine
|
||
// insécable, et un exposant facultatif en chiffres supérieurs.
|
||
const NOMBRE = /\d+(?:\u{202F}\d{3})*([\u{2070}\u{B9}\u{B2}\u{B3}\u{2074}-\u{2079}]*)/gu;
|
||
|
||
// Une puissance de dix écrite comme la section l'écrit : 10 et l'exposant en
|
||
// chiffres supérieurs.
|
||
const CHIFFRES_SUPERIEURS = [...EXPOSANTS.keys()];
|
||
const puissanceDeDix = (exposant) => `10${[...String(exposant)].map((chiffre) => CHIFFRES_SUPERIEURS[Number(chiffre)]).join('')}`;
|
||
|
||
// La valeur d'un nombre écrit, en BigInt.
|
||
function valeurEcrite(ecrit, exposant) {
|
||
const base = BigInt(ecrit.slice(0, ecrit.length - exposant.length).replaceAll('\u{202F}', ''));
|
||
if (exposant === '') return base;
|
||
let puissance = 0n;
|
||
for (const chiffre of exposant) puissance = puissance * 10n + EXPOSANTS.get(chiffre);
|
||
return base ** puissance;
|
||
}
|
||
|
||
// Le texte lisible d'une section : les cibles des liens et des images
|
||
// retirées, qui portent des chemins et non des nombres cités.
|
||
const sansCibles = (texte) => texte.replace(/\]\([^)]*\)/g, ']');
|
||
|
||
// Les valeurs des nombres écrits en chiffres, sans doublon, en ordre croissant.
|
||
function nombresCites(texte) {
|
||
const valeurs = new Set();
|
||
for (const [ecrit, exposant] of sansCibles(texte).matchAll(NOMBRE)) valeurs.add(valeurEcrite(ecrit, exposant));
|
||
return [...valeurs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).map(String);
|
||
}
|
||
|
||
const factorielle = (n) => (n <= 1n ? 1n : n * factorielle(n - 1n));
|
||
|
||
// La configuration d'une démonstration livrée, lue de son fichier.
|
||
function configurationLivree(cle) {
|
||
const livree = LIVREES.find((l) => l.cle === cle);
|
||
assert.ok(livree, `démonstration livrée ${cle}`);
|
||
return configurationDepuisCharge(analyser(livree.texte).charge);
|
||
}
|
||
|
||
/**
|
||
* Les nombres de la section recalculés sur les fichiers livrés, et les
|
||
* phrases qui les affirment : chaque nombre cité en chiffres y est attaché à
|
||
* la grandeur qu'il mesure et aux nombres en lettres dont il découle, de sorte
|
||
* qu'un nombre déplacé vers une autre grandeur manque sa phrase même quand
|
||
* l'ensemble des nombres cités ne change pas.
|
||
* - les façons d'asseoir la petite démonstration pour un tour, tables
|
||
* numérotées : n! divisé par le produit des factorielles des capacités ;
|
||
* - la plus petite puissance de dix que ce nombre dépasse sur tous les tours ;
|
||
* - le plafond a priori de la petite, tours × (capacité − 1) ;
|
||
* - le plafond d'un mobile de la grande, sur les tables de la plus grande capacité ;
|
||
* - le plafond des animateurs ancrés aux tables de la plus petite capacité.
|
||
*/
|
||
function attendusAlgorithme() {
|
||
const petite = configurationLivree('petite');
|
||
const n = petite.participants.length;
|
||
const capacites = petite.tables.map((table) => table.capacite);
|
||
assert.equal(capacites.reduce((a, b) => a + b, 0), n, 'la petite démonstration remplit exactement ses tables');
|
||
assert.equal(new Set(capacites).size, 1, 'la petite démonstration a des tables égales');
|
||
const capacite = capacites[0];
|
||
const facons = capacites.reduce((reste, c) => reste / factorielle(BigInt(c)), factorielle(BigInt(n)));
|
||
const surTousLesTours = facons ** BigInt(petite.tours);
|
||
const puissance = BigInt(String(surTousLesTours).length - 1);
|
||
const plafondPetite = diagnostiquer(petite).plafondAPriori;
|
||
assert.equal(plafondPetite.min, plafondPetite.max);
|
||
assert.equal(plafondPetite.max, petite.tours * (capacite - 1));
|
||
|
||
const grande = configurationLivree('grande');
|
||
const parCapacite = new Map();
|
||
for (const table of grande.tables) parCapacite.set(table.capacite, [...(parCapacite.get(table.capacite) ?? []), table.id]);
|
||
const grandeCapacite = Math.max(...parCapacite.keys());
|
||
const petiteCapacite = Math.min(...parCapacite.keys());
|
||
const diagnosticGrande = diagnostiquer(grande);
|
||
assert.equal(diagnosticGrande.plafondAPriori.max, grande.tours * (grandeCapacite - 1));
|
||
const tablesAnimees = new Set(parCapacite.get(petiteCapacite));
|
||
const animateurs = grande.reservations
|
||
.filter((r) => r.portee === 'tous' && tablesAnimees.has(r.table))
|
||
.map((r) => r.participant);
|
||
assert.equal(animateurs.length, tablesAnimees.size, 'un animateur par table de la plus petite capacité');
|
||
const rangDe = new Map(grande.participants.map((p, rang) => [p.id, rang]));
|
||
const plafondsAnimateurs = new Set(animateurs.map((id) => diagnosticGrande.plafondAPriori.parPersonne[rangDe.get(id)]));
|
||
assert.equal(plafondsAnimateurs.size, 1);
|
||
const plafondAnimateurs = [...plafondsAnimateurs][0];
|
||
assert.equal(plafondAnimateurs, grande.tours * (petiteCapacite - 1));
|
||
assert.equal(plafondAnimateurs, diagnosticGrande.plafondAPriori.min);
|
||
|
||
const nombres = [facons, 10n ** puissance, BigInt(plafondPetite.max), BigInt(diagnosticGrande.plafondAPriori.max), BigInt(plafondAnimateurs)];
|
||
const phrases = [
|
||
`les ${enLettres(n)} personnes de la petite démonstration et ses ${enLettres(capacites.length)} tables numérotées de ${enLettres(capacite)} places`,
|
||
`Pour un seul tour, il y a ${enChiffres(facons)} façons de les répartir`,
|
||
`Sur ${enLettres(petite.tours)} tours, le nombre dépasse ${puissanceDeDix(puissance)}`,
|
||
`chacun a ${enLettres(capacite - 1)} voisins par tour : en ${enLettres(petite.tours)} tours, il rencontre au plus ${enChiffres(plafondPetite.max)} personnes, sur ${enLettres(n - 1)} personnes`,
|
||
`À une table de ${enLettres(grandeCapacite)}, chacun a ${enLettres(grandeCapacite - 1)} voisins : en ${enLettres(grande.tours)} tours, ${enChiffres(diagnosticGrande.plafondAPriori.max)} rencontres au plus`,
|
||
`les ${enLettres(animateurs.length)} animateurs des tables de ${enLettres(petiteCapacite)} restent à leur table toute la soirée, et leur plafond vaut ${enChiffres(plafondAnimateurs)}`,
|
||
];
|
||
return {
|
||
nombres: [...new Set(nombres)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).map(String),
|
||
phrases,
|
||
depasse: surTousLesTours > 10n ** puissance,
|
||
};
|
||
}
|
||
|
||
// Les identifiants qu'exporte un module de src/, lus dans son texte.
|
||
function exportsDe(texte) {
|
||
const noms = new Set();
|
||
for (const [, nom] of texte.matchAll(/export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) noms.add(nom);
|
||
for (const [, liste] of texte.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||
for (const partie of liste.split(',')) {
|
||
const nom = partie.trim().split(/\s+as\s+/).at(-1);
|
||
if (/^[A-Za-z_$][\w$]*$/.test(nom) && nom !== 'default') noms.add(nom);
|
||
}
|
||
}
|
||
return noms;
|
||
}
|
||
|
||
// Les modules de src/, hors épreuves, en chemins relatifs à la racine.
|
||
function modulesSource(dossier = join(RACINE, 'src')) {
|
||
return readdirSync(dossier, { withFileTypes: true }).flatMap((entree) => {
|
||
const chemin = join(dossier, entree.name);
|
||
if (entree.isDirectory()) return modulesSource(chemin);
|
||
return entree.name.endsWith('.js') && !entree.name.endsWith('.test.js') ? [relative(RACINE, chemin)] : [];
|
||
});
|
||
}
|
||
|
||
// Vrai pour un identifiant que sa forme désigne comme du code : une capitale
|
||
// après une minuscule, un souligné, un chiffre, ou tout en capitales. Un
|
||
// identifiant d'un seul mot minuscule (un verbe, un nom) se lit aussi comme
|
||
// un mot de la langue : il n'est relevé qu'appelé, suivi d'une parenthèse.
|
||
const formeDeCode = (nom) => /[a-z][A-Z]/.test(nom) || /[_\d$]/.test(nom) || /^[A-Z]{2,}$/.test(nom);
|
||
|
||
// Les familles d'algorithmes et les marques de complexité que la section ne
|
||
// nomme pas (§ 19.9), cherchées sans égard à la casse.
|
||
const VOCABULAIRE_SAVANT = [
|
||
/heuristique/i, /recuit/i, /tabou/i, /glouton/i, /génétique/i, /recherche locale/i, /local search/i,
|
||
/hill.?climbing/i, /annealing/i, /\bO\(/, /complexité/i, /NP-/,
|
||
];
|
||
|
||
/**
|
||
* Les fautes de la section sur l'algorithme : absente ; pas neuf points ;
|
||
* un nombre écrit en chiffres que l'arithmétique ne recalcule pas, ou un
|
||
* nombre recalculé qu'elle ne cite pas ; une phrase qui affirme un nombre
|
||
* absente ; un
|
||
* bloc de code, un passage en code, un nom de fichier .js ou un chemin de
|
||
* src/ ; un identifiant exporté de src/ ; un mot savant ; ni le diagnostic
|
||
* ni les propositions en image.
|
||
*/
|
||
function fautesAlgorithme(texteGuide, { attendus, exportes }) {
|
||
const section = sectionNommee(texteGuide, TITRE_ALGORITHME);
|
||
if (section === null) return [`section « ${TITRE_ALGORITHME} » absente`];
|
||
const texte = section.texte;
|
||
const fautes = [];
|
||
const points = [...texte.matchAll(/^### /gm)].length;
|
||
if (points !== 9) fautes.push(`${points} points, le § 19.9 en compte neuf`);
|
||
const cites = nombresCites(texte);
|
||
if (JSON.stringify(cites) !== JSON.stringify(attendus.nombres)) {
|
||
fautes.push(`nombres cités ${cites.join(', ')}, recalculés ${attendus.nombres.join(', ')} ; tout autre nombre s'écrit en lettres`);
|
||
}
|
||
for (const phrase of attendus.phrases) if (!enLigne(texte).includes(enLigne(phrase))) fautes.push(`phrase absente : « ${phrase} »`);
|
||
if (/^(?: {4}|\t)\S/m.test(texte.replace(/^[-*] .*$|^\d+\. .*$/gm, ''))) fautes.push('bloc de code indenté');
|
||
if (/```|~~~/.test(texte)) fautes.push('bloc de code');
|
||
if (/`/.test(texte)) fautes.push('passage en code');
|
||
if (/\.js\b/.test(texte)) fautes.push('nom de fichier .js');
|
||
if (/\bsrc\//.test(texte)) fautes.push('chemin de src/');
|
||
const lisible = sansCibles(texte);
|
||
const releves = new Set();
|
||
for (const [mot] of lisible.matchAll(/[\p{L}\p{N}_$]+/gu)) if (exportes.has(mot) && formeDeCode(mot)) releves.add(mot);
|
||
for (const [, mot] of lisible.matchAll(/([\p{L}\p{N}_$]+)\(/gu)) if (exportes.has(mot)) releves.add(mot);
|
||
for (const mot of [...releves].sort()) fautes.push(`identifiant exporté de src/ : ${mot}`);
|
||
for (const motif of VOCABULAIRE_SAVANT) if (motif.test(lisible)) fautes.push(`vocabulaire savant : ${motif}`);
|
||
const images = imagesReferencees(texte);
|
||
for (const fichier of ['10-diagnostic', '11-propositions']) {
|
||
if (!images.includes(cheminImage(fichier))) fautes.push(`la section ne s'appuie pas sur ${fichier}`);
|
||
}
|
||
return fautes;
|
||
}
|
||
|
||
describe('documentation : la section sur l’algorithme (§ 19.9)', () => {
|
||
const attendus = attendusAlgorithme();
|
||
const exportes = new Set(modulesSource().flatMap((chemin) => [...exportsDe(lire(chemin))]));
|
||
|
||
test('les nombres recalculés sur les démonstrations livrées : 369\u{202F}600, 10\u{B2}\u{B2}, 8, 28 et 24, aucun autre', () => {
|
||
assert.ok(attendus.depasse);
|
||
assert.deepEqual(attendus.nombres, ['8', '24', '28', '369600', String(10n ** 22n)]);
|
||
assert.ok(CATALOGUE.some((entree) => entree.cle === 'petite') && CATALOGUE.some((entree) => entree.cle === 'grande'));
|
||
});
|
||
|
||
test('la relève des identifiants exportés lit les modules de src/ : elle n’est pas vide', () => {
|
||
assert.ok(exportes.size > 100, `${exportes.size} identifiants`);
|
||
for (const nom of ['diagnostiquer', 'configurationDepuisCharge', 'CATALOGUE']) assert.ok(exportes.has(nom), nom);
|
||
});
|
||
|
||
test('la section du guide : neuf points, les nombres recalculés, ni code, ni identifiant, ni module, ni mot savant', () => {
|
||
assert.deepEqual(fautesAlgorithme(lire(GUIDE_USAGE), { attendus, exportes }), []);
|
||
});
|
||
|
||
test('rouge : un nombre changé, un nombre de plus en chiffres, une phrase en lettres changée', () => {
|
||
const texte = lire(GUIDE_USAGE);
|
||
const change = texte.replace('369\u{202F}600', '369\u{202F}601');
|
||
assert.notEqual(change, texte);
|
||
assert.match(fautesAlgorithme(change, { attendus, exportes }).join('\n'), /nombres cités 8, 24, 28, 369601,/);
|
||
const section = sectionNommee(texte, TITRE_ALGORITHME);
|
||
const finSection = section.debut + section.texte.length;
|
||
const enPlus = `${texte.slice(0, finSection)}Trois tours suffisent à 12 personnes.\n\n${texte.slice(finSection)}`;
|
||
assert.match(fautesAlgorithme(enPlus, { attendus, exportes }).join('\n'), /nombres cités 8, 12, 24,/);
|
||
const lettres = texte.replace('sept voisins', 'six voisins');
|
||
assert.notEqual(lettres, texte);
|
||
assert.match(fautesAlgorithme(lettres, { attendus, exportes }).join('\n'), /phrase absente : « À une table de huit, chacun a sept voisins/);
|
||
});
|
||
|
||
test('rouge : un nombre déplacé vers une autre grandeur, ou un nombre de tours changé à côté d’un nombre cité', () => {
|
||
const texte = lire(GUIDE_USAGE);
|
||
const avec = (change) => especes(fautesAlgorithme(change, { attendus, exportes }));
|
||
// 8 et 24 échangés : l'ensemble des nombres cités ne change pas.
|
||
const echange = remplacerPhrase(remplacerPhrase(texte, 'au plus 8 personnes', 'au plus 24 personnes'), 'leur plafond vaut 24', 'leur plafond vaut 8');
|
||
assert.deepEqual(avec(echange), ['phrase absente', 'phrase absente']);
|
||
assert.deepEqual(avec(remplacerPhrase(texte, 'Sur quatre tours, le nombre dépasse', 'Sur trois tours, le nombre dépasse')), ['phrase absente']);
|
||
assert.deepEqual(avec(remplacerPhrase(texte, 'en quatre tours, 28 rencontres', 'en trois tours, 28 rencontres')), ['phrase absente']);
|
||
});
|
||
|
||
test('rouge : un nom de fonction collé, appelé ou en code, un .js, un bloc de code, un mot savant se relèvent', () => {
|
||
const texte = lire(GUIDE_USAGE);
|
||
const section = sectionNommee(texte, TITRE_ALGORITHME);
|
||
const finSection = section.debut + section.texte.length;
|
||
const avec = (ajout) => fautesAlgorithme(`${texte.slice(0, finSection)}${ajout}\n\n${texte.slice(finSection)}`, { attendus, exportes });
|
||
assert.deepEqual(avec('Le logiciel appelle configurationDepuisCharge sur le fichier.'), ['identifiant exporté de src/ : configurationDepuisCharge']);
|
||
assert.deepEqual(avec('Le moteur lance diagnostiquer(configuration) avant tout.'), ['identifiant exporté de src/ : diagnostiquer']);
|
||
assert.deepEqual(avec('Le diagnostic vit dans le fichier diagnostic.js.'), ['nom de fichier .js']);
|
||
assert.deepEqual(avec('```\nfaire tant que mieux\n```'), ['bloc de code', 'passage en code']);
|
||
assert.deepEqual(avec('C’est une heuristique.'), ['vocabulaire savant : /heuristique/i']);
|
||
// Un mot de la langue qu'un module exporte aussi ne se relève pas en prose.
|
||
assert.deepEqual(avec('Le logiciel sait diagnostiquer une configuration.'), []);
|
||
});
|
||
});
|
||
|
||
// --- GUIDE-WINDOWS.md ------------------------------------------------------------------
|
||
|
||
// Le gabarit du nom de l'exécutable et son préfixe, tirés de la dérivation de
|
||
// la version (§ 18.2) : le nom de la construction courante, sa version
|
||
// remplacée par les quatre champs nommés.
|
||
function gabaritDuNom() {
|
||
const { nomFichier } = deriver(VERSION.affichee);
|
||
const champs = VERSION.affichee.replaceAll('.', '_');
|
||
assert.ok(nomFichier.includes(champs));
|
||
return {
|
||
gabarit: nomFichier.replace(champs, '<AAAA>_<MM>_<JJ>_<NN>'),
|
||
prefixe: nomFichier.slice(0, nomFichier.indexOf(champs)),
|
||
};
|
||
}
|
||
|
||
const NOM_PRODUIT = JSON.parse(lire('package.json')).productName;
|
||
|
||
// Les éléments de la première liste numérotée d'un texte, blancs normalisés :
|
||
// un élément s'ouvre sur « <chiffres>. » en début de ligne et se poursuit sur
|
||
// les lignes indentées qui le suivent ; la première ligne qui n'est ni l'un
|
||
// ni l'autre, blanche comprise, ferme la liste.
|
||
function listeNumerotee(texte) {
|
||
const elements = [];
|
||
for (const ligne of texte.split('\n')) {
|
||
if (/^\d+\. /.test(ligne)) elements.push(ligne.replace(/^\d+\. /, ''));
|
||
else if (elements.length > 0 && /^\s+\S/.test(ligne)) elements[elements.length - 1] += ` ${ligne}`;
|
||
else if (elements.length > 0) break;
|
||
}
|
||
return elements.map(enLigne);
|
||
}
|
||
|
||
/**
|
||
* Les fautes de GUIDE-WINDOWS.md (§ 16.1, § 8.6) : le gabarit et la phrase du
|
||
* préfixe absents ; un nom daté cité ; une image ou une région ; un chemin
|
||
* absolu ; la règle du dossier de travail sans sa liste de deux issues, dans
|
||
* l'ordre du § 8.6 — d'abord le dossier data à côté de l'exécutable, puis le
|
||
* dossier du produit dans les Documents —, ni la commande qui l'ouvre ; ni
|
||
* « À propos » ni son bloc à copier.
|
||
*/
|
||
function fautesWindows(brut) {
|
||
const { gabarit, prefixe } = gabaritDuNom();
|
||
const texte = enLigne(brut);
|
||
const fautes = [];
|
||
if (!texte.includes(`\`${gabarit}\``)) fautes.push('gabarit du nom absent');
|
||
if (!texte.includes(`le fichier dont le nom commence par \`${prefixe}\``)) fautes.push('phrase du préfixe absente');
|
||
if (new RegExp(`${prefixe}\\d`).test(texte)) fautes.push('nom daté cité');
|
||
if (/!\[|doc\/captures\/|<img/i.test(texte)) fautes.push('image');
|
||
if (lireRegions(brut).length > 0) fautes.push('région engendrée');
|
||
if (cheminsHorsRacine(brut, 'C:\\Racine\\Inventee').length > 0) fautes.push('chemin absolu');
|
||
const dossier = sectionNommee(brut, 'Le dossier de travail');
|
||
if (dossier === null) fautes.push('section « Le dossier de travail » absente');
|
||
else {
|
||
const issues = listeNumerotee(dossier.texte);
|
||
const [portable = '', documents = ''] = issues;
|
||
if (issues.length !== 2) fautes.push(`liste des issues : ${issues.length} élément(s), le § 8.6 en compte deux`);
|
||
if (!portable.includes('`data`') || !portable.includes('à côté de l\u{2019}exécutable')) {
|
||
fautes.push('issue portable absente : le dossier data à côté de l’exécutable');
|
||
}
|
||
if (!documents.includes(`\`Documents\\${NOM_PRODUIT}\``)) fautes.push('issue Documents absente');
|
||
if (!enLigne(dossier.texte).includes(`« ${t('accueil.dossier.explorateur')} »`)) fautes.push('commande qui ouvre le dossier absente');
|
||
}
|
||
const version = sectionNommee(brut, 'Où lire la version');
|
||
const texteVersion = version === null ? '' : enLigne(version.texte);
|
||
if (!texteVersion.includes(`« ${t('coquille.apropos.titre')} »`) || !texteVersion.includes(`« ${t('coquille.apropos.copier')} »`)) {
|
||
fautes.push('« À propos » et son bloc à copier absents');
|
||
}
|
||
return fautes;
|
||
}
|
||
|
||
describe('documentation : GUIDE-WINDOWS.md, texte seul (§ 16.1, § 8.6, § 18.2)', () => {
|
||
test('le gabarit, le préfixe, les deux issues du dossier de travail, la commande, la version', () => {
|
||
assert.deepEqual(fautesWindows(lire(GUIDE_WINDOWS)), []);
|
||
});
|
||
|
||
test('les sections que § 16.1 demande, dans son ordre', () => {
|
||
const titres = sections(lire(GUIDE_WINDOWS)).map((s) => s.titre);
|
||
assert.deepEqual(titres, [
|
||
'Obtenir l’exécutable',
|
||
'Le lancer',
|
||
'Le dossier de travail',
|
||
'Sauvegarder',
|
||
'Si l’antivirus bloque',
|
||
'Où lire la version',
|
||
'Signaler un problème',
|
||
]);
|
||
});
|
||
|
||
test('rouge : un nom daté, une issue retirée, une image ajoutée se relèvent', () => {
|
||
const texte = lire(GUIDE_WINDOWS);
|
||
const { prefixe } = gabaritDuNom();
|
||
assert.deepEqual(fautesWindows(`${texte}\nLancer ${prefixe}2026_01_02_03.exe.\n`), ['nom daté cité']);
|
||
assert.deepEqual(fautesWindows(texte.replaceAll('`data`', '`donnees`')), ['issue portable absente : le dossier data à côté de l’exécutable']);
|
||
assert.deepEqual(fautesWindows(`${texte}\n\n`), ['image']);
|
||
});
|
||
|
||
test('rouge : l’issue portable retirée de la liste, la seconde renumérotée, se relève', () => {
|
||
const lignes = lire(GUIDE_WINDOWS).split('\n');
|
||
const premier = lignes.findIndex((ligne) => /^1\. /.test(ligne));
|
||
const second = lignes.findIndex((ligne) => /^2\. /.test(ligne));
|
||
assert.ok(premier >= 0 && second > premier);
|
||
const sans = [...lignes.slice(0, premier), lignes[second].replace(/^2\. /, '1. '), ...lignes.slice(second + 1)].join('\n');
|
||
assert.deepEqual(fautesWindows(sans), [
|
||
'liste des issues : 1 élément(s), le § 8.6 en compte deux',
|
||
'issue portable absente : le dossier data à côté de l’exécutable',
|
||
'issue Documents absente',
|
||
]);
|
||
});
|
||
});
|