[ADD] docs: screenshot manifest with order, path and image checks

Screenshots and guides must stay in step with the software they show. The
manifest names each screenshot, its stage and its fingerprint; the checks
refuse a missing or out-of-order image, a guide reference to an unknown
capture, a wrong PNG size, and any absolute path outside the neutral root.
Captures planned for a later iteration are named as postponed and admitted.
Checked: 50 tests red first; 3169 node tests green from the index alone.

--- FR ---

[ADD] docs : manifeste des captures, contrôles d'ordre, chemins, images

Les captures et les guides doivent suivre le logiciel qu'ils montrent. Le
manifeste nomme chaque capture, son étape et son empreinte ; les contrôles
refusent une image absente ou hors d'ordre, un guide qui cite une capture
inconnue, une taille de PNG fausse, et tout chemin absolu hors de la racine
neutre. Les captures prévues pour une itération ultérieure sont nommées
reportées, et admises.
Vérifié : 50 épreuves rouges d'abord ; 3169 node verts depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-09 04:52:07 -04:00
parent 9d4f203ead
commit aabb05e038
7 changed files with 1178 additions and 0 deletions

View file

@ -0,0 +1,581 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le manifeste des captures et ses contrôles du niveau node (§ 19.3, § 19.5,
// § 19.7). Pur : aucune lecture de fichier, aucune horloge. L'appelant lit le
// manifeste, la documentation et le dossier des images, puis passe leurs
// contenus ; chaque contrôle rend la liste de ses fautes, vide quand tout
// tient, et refuse de passer sur un balayage vide.
//
// Un nom de capture (« fichier ») n'a pas d'extension : l'image est
// doc/captures/<fichier>.png. L'ordre déclaré des captures et des reportées
// est celui de leurs noms, comparés unité de code par unité de code : le
// préfixe NN, puis « a », « b », range chaque entrée à sa place dans le guide.
//
// Une faute est { chemin, faute } : le chemin du fichier en cause, relatif à
// la racine du projet (l'image d'une capture, le manifeste, un document), et
// la nature de la faute en toutes lettres.
import { createHash } from 'node:crypto';
import { VERSION } from '../../src/version.genere.js';
/** @typedef {{chemin: string, faute: string}} Faute */
export const DOSSIER_CAPTURES = 'doc/captures';
export const CHEMIN_MANIFESTE = `${DOSSIER_CAPTURES}/manifeste.json`;
/** Chemin de l'image d'une capture, relatif à la racine du projet. */
export function cheminImage(fichier) {
return `${DOSSIER_CAPTURES}/${fichier}.png`;
}
// Les treize étapes du parcours de l'opérateur (§ 2.1), dans leur ordre : la
// seule source de l'ordre des captures (§ 19.3).
export const ETAPES = Object.freeze(['1', '1b', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']);
const capture = (fichier, etape, configuration, source = 'navigateur') => Object.freeze({ fichier, etape, configuration, source });
// La table du § 19.3 en données, avec 02b, 07a et 07b. « configuration » nomme
// ce que la capture exige : le répertoire neuf, le fichier d'import
// d'exemples/, l'identifiant de démonstration du catalogue, ou l'événement que
// le parcours construit puis retient.
export const CAPTURES = Object.freeze([
capture('01-premier-ecran', '1', 'repertoire-neuf'),
capture('01b-passer-en-ecriture', '1b', 'repertoire-neuf'),
capture('02-import-apercu', '2', 'participants_cas_limites.csv'),
capture('02b-import-cp1252', '2', 'participants_cas_limites_cp1252.csv'),
capture('03-import-rapport', '2', 'participants_cas_limites.csv'),
capture('04-grille-participants', '3', 'evenement-construit'),
capture('05-tables-etat', '4', 'evenement-construit'),
capture('06-defaut-consequence', '4', 'evenement-construit'),
capture('07-plan-tables', '4', 'evenement-construit'),
capture('07a-cibles-sieges', '4', 'evenement-construit'),
capture('07a-cibles-tables', '4', 'evenement-construit'),
capture('07b-nombre-de-tours', '5', 'evenement-construit'),
capture('08-animateurs-designes', '6', 'evenement-construit'),
capture('09-refus-sur-place', '7', 'evenement-construit'),
capture('10-diagnostic', '8', 'grande'),
capture('11-propositions', '9', 'evenement-construit'),
capture('19-ajustement', '11', 'evenement-retenu'),
capture('20-mode-lecture', '11', 'evenement-retenu'),
capture('22-plan-brouillon', '12', 'evenement-retenu'),
capture('23-plan-retenu', '12', 'evenement-retenu'),
capture('24-badges-controle', '12', 'evenement-retenu', 'pdf'),
capture('25-badges-planche', '12', 'evenement-retenu', 'pdf'),
]);
const reportee = (fichier, etape) => Object.freeze({ fichier, etape, raison: 'iteration5' });
// Les captures des écrans que l'itération 5 livre (Q8 a) : déclarées à leur
// étape, sans image ; le contrôle d'ordre les compte comme présentes.
export const REPORTEES = Object.freeze([
reportee('12-qualite-profil', '10'),
reportee('13-qualite-collisions', '10'),
reportee('14-qualite-paires', '10'),
reportee('15-pas-de-matrice', '10'),
reportee('16-minimum-atteint', '10'),
reportee('17-comparaison-manque', '10'),
reportee('18-comparaison-escalier', '10'),
reportee('21-historique', '11'),
]);
// Captures déclarées dans CAPTURES et pas encore photographiées : la
// publication et le contrôle d'ordre les admettent absentes du manifeste.
export const A_VENIR = Object.freeze(['22-plan-brouillon', '23-plan-retenu', '24-badges-controle', '25-badges-planche']);
// --- Forme canonique et empreintes ---------------------------------------------
const comparer = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
function estObjet(valeur) {
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
}
// JSON d'une valeur, les clés de chaque objet triées par unités de code, sans
// espace. Lève TypeError sur ce que JSON ne porte pas fidèlement : undefined,
// fonction, symbole, grand entier, nombre non fini.
function json(valeur) {
if (valeur === null || typeof valeur === 'string' || typeof valeur === 'boolean') return JSON.stringify(valeur);
if (typeof valeur === 'number') {
if (!Number.isFinite(valeur)) throw new TypeError(`nombre non fini dans le manifeste : ${valeur}`);
return JSON.stringify(valeur);
}
if (Array.isArray(valeur)) return `[${valeur.map(json).join(',')}]`;
if (estObjet(valeur)) {
const cles = Object.keys(valeur).sort(comparer);
return `{${cles.map((cle) => `${JSON.stringify(cle)}:${json(valeur[cle])}`).join(',')}}`;
}
throw new TypeError(`valeur que JSON ne porte pas dans le manifeste : ${typeof valeur}`);
}
const sha256 = (donnees) => createHash('sha256').update(donnees).digest('hex');
/**
* Le manifeste en JSON canonique : clés triées à toute profondeur, chaque
* élément d'un tableau de premier niveau sur sa ligne (une capture, une
* reportée), le reste sur la ligne de sa clé, une fin de ligne finale. Deux
* manifestes égaux rendent les mêmes octets, quel que soit l'ordre de leurs
* clés ; l'ordre des tableaux est conservé.
* @param {object} manifeste
* @returns {string}
*/
export function canoniser(manifeste) {
if (!estObjet(manifeste)) throw new TypeError('le manifeste est un objet');
const champs = Object.keys(manifeste).sort(comparer).map((cle) => {
const valeur = manifeste[cle];
const tete = `${JSON.stringify(cle)}:`;
if (Array.isArray(valeur) && valeur.length > 0) return `${tete}[\n${valeur.map(json).join(',\n')}\n]`;
return tete + json(valeur);
});
return `{${champs.join(',')}}\n`;
}
/**
* Empreinte d'étape (§ 19.5, amendé) : SHA-256 hexadécimal du JSON canonique
* de l'entrée, sans image, empreinte, versionReelle ni versionNavigateur, à
* plat ou sous pilote. La version réelle et celle du navigateur changent
* d'une livraison et d'un poste à l'autre sans que l'état montré change :
* les garder réécrirait chaque image à chaque incrément.
* @param {object} entree une entrée de manifeste.captures
* @returns {string}
*/
export function empreinteEtape(entree) {
if (!estObjet(entree)) throw new TypeError('une entrée de manifeste est un objet');
const { image: _image, empreinte: _empreinte, versionReelle: _version, versionNavigateur: _navigateur, ...reste } = entree;
if (estObjet(reste.pilote)) {
const { versionNavigateur: _versionPilote, ...pilote } = reste.pilote;
reste.pilote = pilote;
}
return sha256(json(reste));
}
// --- Appariement documentation, manifeste, disque --------------------------------
const MOTIF_REFERENCE = /doc\/captures\/[A-Za-z0-9._-]+\.png/g;
/**
* Les images de doc/captures qu'un document référence, sous la forme
* doc/captures/<nom>.png quel que soit le préfixe relatif qui les précède,
* chacune une fois, dans l'ordre de leur première apparition.
* @param {string} texte
* @returns {string[]}
*/
export function imagesReferencees(texte) {
if (typeof texte !== 'string') throw new TypeError('le texte d’un document est une chaîne');
return [...new Set(texte.match(MOTIF_REFERENCE) ?? [])];
}
function exigerTableau(valeur, nom) {
if (!Array.isArray(valeur)) throw new TypeError(`${nom} est un tableau`);
}
function capturesDe(manifeste) {
if (!estObjet(manifeste)) throw new TypeError('le manifeste est un objet');
const captures = manifeste.captures ?? [];
exigerTableau(captures, 'manifeste.captures');
return captures;
}
const enListe = (parties) => (parties.length === 1 ? parties[0] : `${parties.slice(0, -1).join(', ')} et ${parties.at(-1)}`);
/**
* L'appariement du § 19.7 : toute image référencée par la documentation a
* son entrée au manifeste et son fichier, toute entrée et tout fichier sont
* référencés. Une image absente d'un côté au moins est une orpheline : une
* faute par image, qui dit où elle est et d'où elle manque. Un manifeste sans
* capture, une documentation vide, un document sans image, un dossier sans
* image sont chacun une faute.
* @param {{manifeste: object, references: Array<{document: string, images: string[]}>, images: string[]}} entrees
* references : par document, les chemins doc/captures/<nom>.png qu'il
* référence ; images : les chemins des PNG présents sur le disque
* @returns {Faute[]} les balayages vides, puis les images par chemin
*/
export function verifierAppariement({ manifeste, references, images }) {
const captures = capturesDe(manifeste);
exigerTableau(references, 'references');
exigerTableau(images, 'images');
const fautes = [];
if (captures.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucune capture au manifeste' });
if (references.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucun document ne référence d’image' });
if (images.length === 0) fautes.push({ chemin: DOSSIER_CAPTURES, faute: 'aucune image sur le disque' });
const documentsDe = new Map();
for (const { document, images: referencees } of references) {
exigerTableau(referencees, `les images de ${document}`);
if (referencees.length === 0) fautes.push({ chemin: document, faute: 'aucune image référencée' });
for (const image of referencees) {
const documents = documentsDe.get(image) ?? [];
if (!documents.includes(document)) documents.push(document);
documentsDe.set(image, documents);
}
}
const auManifeste = new Set();
const doubles = new Set();
for (const entree of captures) {
const chemin = cheminImage(entree.fichier);
if (auManifeste.has(chemin)) doubles.add(chemin);
auManifeste.add(chemin);
}
const surDisque = new Set(images);
const tous = [...new Set([...documentsDe.keys(), ...auManifeste, ...surDisque])].sort(comparer);
for (const chemin of tous) {
if (doubles.has(chemin)) fautes.push({ chemin, faute: 'deux fois au manifeste' });
const presences = [];
const absences = [];
if (documentsDe.has(chemin)) presences.push(`référencée par ${documentsDe.get(chemin).join(', ')}`);
else absences.push('de la documentation');
if (auManifeste.has(chemin)) presences.push('au manifeste');
else absences.push('du manifeste');
if (surDisque.has(chemin)) presences.push('sur le disque');
else absences.push('du disque');
if (absences.length > 0) fautes.push({ chemin, faute: `orpheline : ${presences.join(', ')} ; absente ${enListe(absences)}` });
}
return fautes;
}
// --- Ordre contre les étapes ------------------------------------------------------
/**
* Le contrôle d'ordre du § 19.3 : les captures et les reportées du
* manifeste, plus chaque capture d'aVenir absente du manifeste à son étape
* déclarée dans CAPTURES, rangées par nom. Faute : une étape hors d'ETAPES ;
* un nom déclaré deux fois ; une entrée dont l'étape précède celle de
* l'entrée connue qui la devance ; une étape d'ETAPES sans aucune entrée ; un
* manifeste sans aucune entrée.
* @param {object} manifeste
* @param {{aVenir?: readonly string[]}} [options] A_VENIR par défaut
* @returns {Faute[]} les fautes d'entrée dans l'ordre des noms, puis les étapes manquantes
*/
export function verifierOrdre(manifeste, { aVenir = A_VENIR } = {}) {
const captures = capturesDe(manifeste);
const reportees = manifeste.reportees ?? [];
exigerTableau(reportees, 'manifeste.reportees');
const presentes = new Set(captures.map((c) => c.fichier));
const admises = CAPTURES.filter((c) => aVenir.includes(c.fichier) && !presentes.has(c.fichier));
const entrees = [...captures, ...reportees, ...admises]
.map(({ fichier, etape }) => ({ fichier, etape }))
.sort((a, b) => comparer(a.fichier, b.fichier));
const fautes = [];
if (entrees.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucune capture ni reportée' });
const vues = new Set();
const couvertes = new Set();
let precedente = null;
for (const entree of entrees) {
const chemin = cheminImage(entree.fichier);
if (vues.has(entree.fichier)) fautes.push({ chemin, faute: 'déclarée deux fois' });
vues.add(entree.fichier);
const rang = ETAPES.indexOf(entree.etape);
if (rang < 0) {
fautes.push({ chemin, faute: `étape inconnue « ${entree.etape} »` });
continue;
}
couvertes.add(entree.etape);
if (precedente !== null && rang < precedente.rang) {
fautes.push({
chemin,
faute: `étape ${entree.etape} après ${precedente.fichier}, de l’étape ${precedente.etape} : l’ordre contredit les étapes`,
});
}
precedente = { ...entree, rang };
}
for (const etape of ETAPES) {
if (!couvertes.has(etape)) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: `étape ${etape} : aucune capture ni reportée` });
}
return fautes;
}
// --- Version ----------------------------------------------------------------------
/**
* Écart entre la version réelle inscrite au manifeste et celle de la
* construction (§ 19.7) : null quand chaque capture porte la version
* attendue ; sinon { manifeste, construction }, où manifeste nomme les
* versions distinctes des captures, dans leur ordre, séparées par « , »
* (« absente » pour une capture qui n'en porte pas), et vaut null pour un
* manifeste sans capture, qui n'établit aucune version.
* @param {object} manifeste
* @param {string} [version] VERSION.affichee par défaut
* @returns {null | {manifeste: string|null, construction: string}}
*/
export function ecartDeVersion(manifeste, version = VERSION.affichee) {
if (typeof version !== 'string') throw new TypeError('la version de la construction est une chaîne');
const captures = capturesDe(manifeste);
if (captures.length === 0) return { manifeste: null, construction: version };
const versions = [...new Set(captures.map((c) => (typeof c.versionReelle === 'string' ? c.versionReelle : 'absente')))];
if (versions.length === 1 && versions[0] === version) return null;
return { manifeste: versions.join(', '), construction: version };
}
// --- Image --------------------------------------------------------------------------
const SIGNATURE_PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const LIMITE_PNG = 2 ** 31 - 1;
/**
* Largeur et hauteur d'un PNG, lues dans son morceau IHDR, qui suit la
* signature (octets 16 à 23, gros-boutiste). Lève sur une signature fausse,
* un en-tête tronqué, un premier morceau qui n'est pas un IHDR de 13 octets,
* une dimension nulle ou au-delà de 2³¹ − 1.
* @param {Uint8Array} octets
* @returns {{largeur: number, hauteur: number}}
*/
export function lireEntetePng(octets) {
if (!(octets instanceof Uint8Array)) throw new TypeError('les octets d’une image sont un Uint8Array');
if (octets.length < SIGNATURE_PNG.length || SIGNATURE_PNG.some((octet, rang) => octets[rang] !== octet)) {
throw new Error('signature PNG absente');
}
if (octets.length < 24) throw new Error(`en-tête PNG tronqué : ${octets.length} octets`);
const vue = new DataView(octets.buffer, octets.byteOffset, octets.byteLength);
const type = String.fromCharCode(...octets.subarray(12, 16));
if (vue.getUint32(8) !== 13 || type !== 'IHDR') throw new Error('premier morceau PNG autre qu’un IHDR de 13 octets');
const largeur = vue.getUint32(16);
const hauteur = vue.getUint32(20);
if (largeur === 0 || largeur > LIMITE_PNG) throw new Error(`largeur PNG hors domaine : ${largeur}`);
if (hauteur === 0 || hauteur > LIMITE_PNG) throw new Error(`hauteur PNG hors domaine : ${hauteur}`);
return { largeur, hauteur };
}
/**
* Les fautes du fichier image d'une entrée : son SHA-256 diffère de
* image.sha256 (une image remplacée, par une version antérieure ou par une
* autre) ; il ne se lit pas comme un PNG ; ses dimensions diffèrent du cadre
* multiplié par le facteur d'échelle (arrondi à l'entier), ou de celles
* qu'inscrit image. Le contrôle porte sur les octets du fichier, jamais sur
* des pixels (§ 19.4).
* @param {object} entree une entrée de manifeste.captures
* @param {Uint8Array} octets le contenu du fichier
* @returns {Faute[]}
*/
export function fautesDImage(entree, octets) {
if (!estObjet(entree)) throw new TypeError('une entrée de manifeste est un objet');
const chemin = cheminImage(entree.fichier);
const image = estObjet(entree.image) ? entree.image : {};
const fautes = [];
if (sha256(octets) !== image.sha256) fautes.push({ chemin, faute: 'image remplacée : son empreinte diffère de celle du manifeste' });
let lue;
try {
lue = lireEntetePng(octets);
} catch (erreur) {
fautes.push({ chemin, faute: `illisible : ${erreur.message}` });
return fautes;
}
const facteur = entree.facteurEchelle;
const largeur = Math.round(entree.cadre?.largeur * facteur);
const hauteur = Math.round(entree.cadre?.hauteur * facteur);
if (lue.largeur !== largeur || lue.hauteur !== hauteur) {
fautes.push({ chemin, faute: `dimensions ${lue.largeur} × ${lue.hauteur}, attendues ${largeur} × ${hauteur} (cadre × facteur)` });
}
if (lue.largeur !== image.largeur || lue.hauteur !== image.hauteur) {
fautes.push({ chemin, faute: `dimensions ${lue.largeur} × ${lue.hauteur}, le manifeste inscrit ${image.largeur} × ${image.hauteur}` });
}
return fautes;
}
// --- Chemins hors de la racine neutre --------------------------------------------------
// Un chemin absolu commence en début de texte ou après l'un de ces
// caractères. Le deux-points en est (« poste:/home/… ») : le « // » d'une
// adresse web reste écarté, car une barre suivie d'une barre n'ouvre pas un
// chemin POSIX et un chemin réseau ne suit pas un deux-points. Un chiffre,
// une lettre, un point ou une barre avant « / » font une fraction ou un
// chemin relatif.
const AVANT_CHEMIN = new Set([...' \t\n\r\f\v([{<"\'«“‘=,;:>', '\u{a0}', '\u{202f}']);
// Le schéma d'une adresse de fichier, comparé sans casse.
const SCHEMA_FICHIER = 'file://';
// Un chemin s'arrête à un blanc ou à l'un de ces caractères…
const FIN_CHEMIN = new Set([...'"\'<>|«»“”‘', '\u{a0}', '\u{202f}']);
// … et perd la ponctuation qui le suit dans la phrase.
const PONCTUATION_FINALE = new Set([...'.,;:!?)]}»’”']);
const estBlanc = (caractere) => /\s/u.test(caractere);
const estLettre = (caractere) => /^[A-Za-z]$/.test(caractere);
// La forme d'un chemin absolu qui commence à la position rang : 'windows'
// (lecteur, chemin réseau sous l'une ou l'autre barre), 'posix' (racine,
// dossier personnel abrégé), 'fichier' (adresse file://, quoi qui la
// précède), ou null.
function formeEn(texte, rang) {
if (texte.slice(rang, rang + SCHEMA_FICHIER.length).toLowerCase() === SCHEMA_FICHIER) return 'fichier';
const avant = rang === 0 ? null : texte[rang - 1];
const borne = avant === null || AVANT_CHEMIN.has(avant) || estBlanc(avant);
const c = texte[rang];
const suivant = texte[rang + 1];
if (estLettre(c) && suivant === ':' && (texte[rang + 2] === '\\' || texte[rang + 2] === '/')) {
return borne ? 'windows' : null;
}
if (!borne) return null;
const troisieme = texte[rang + 2];
const ouvreReseau = (barre) => c === barre && suivant === barre && troisieme !== undefined && troisieme !== barre && !estBlanc(troisieme);
if (ouvreReseau('\\') || (avant !== ':' && ouvreReseau('/'))) return 'windows';
if (c === '/' && suivant !== undefined && suivant !== '/' && !estBlanc(suivant)) return 'posix';
if (c === '~' && /^~[A-Za-z0-9._-]*\//.test(texte.slice(rang, rang + 64))) return 'posix';
return null;
}
function finDuJeton(texte, debut) {
let fin = debut;
while (fin < texte.length && !estBlanc(texte[fin]) && !FIN_CHEMIN.has(texte[fin])) fin += 1;
while (fin > debut && PONCTUATION_FINALE.has(texte[fin - 1])) fin -= 1;
return fin;
}
// Le chemin que porte une adresse file:// et sa forme : « file:///C:/… »
// donne un lecteur, « file:///… » un chemin POSIX, « file://hôte/… » un
// chemin réseau. Les séquences %XX sont décodées ; une séquence invalide
// laisse le chemin tel qu'écrit.
function cheminDeFichier(adresse) {
const reste = adresse.slice(SCHEMA_FICHIER.length - 2);
let chemin = reste.startsWith('///') ? reste.slice(2) : reste;
try {
chemin = decodeURIComponent(chemin);
} catch {
// le chemin reste encodé, et se compare tel quel
}
if (/^\/[A-Za-z]:[\\/]/.test(chemin)) return { forme: 'windows', chemin: chemin.slice(1) };
if (chemin.startsWith('//')) return { forme: 'windows', chemin };
return { forme: 'posix', chemin };
}
const normaliserWindows = (chemin) => chemin.replaceAll('/', '\\').toLowerCase();
const estSeparateur = (caractere) => caractere === '/' || caractere === '\\';
function formeDeRacine(racine) {
if (typeof racine !== 'string' || racine.length === 0) throw new TypeError('la racine neutre est un chemin absolu non vide');
if (/^[A-Za-z]:[\\/]/.test(racine) || racine.startsWith('\\\\')) return 'windows';
if (racine.startsWith('/')) return 'posix';
throw new TypeError(`la racine neutre n’est pas un chemin absolu : « ${racine} »`);
}
// La racine sans ses séparateurs finaux, sauf celui d'une racine de lecteur
// ou de système (« C:\ », « / »).
function sansSeparateurFinal(racine) {
let fin = racine.length;
while (fin > 1 && estSeparateur(racine[fin - 1]) && !/^[A-Za-z]:$/.test(racine.slice(0, fin - 1))) fin -= 1;
return racine.slice(0, fin);
}
/**
* Les chemins absolus d'un texte qui ne sont ni la racine neutre ni sous
* elle (§ 19.4) : chemins de lecteur et réseau de Windows, chemins POSIX,
* dossier personnel abrégé « ~/ », adresses file://. Une adresse se juge
* sur le chemin décodé qu'elle porte et se relève telle qu'écrite. Sous
* Windows, la comparaison ignore la casse et le sens des barres. La racine
* se reconnaît entière même quand elle porte une espace ; un autre chemin
* s'arrête au premier blanc, ce qui suffit à relever la part qui nomme un
* compte. Chaque chemin relevé une fois, dans l'ordre du texte.
* @param {string} texte
* @param {string} racine la racine neutre, absolue
* @returns {string[]}
*/
export function cheminsHorsRacine(texte, racine) {
if (typeof texte !== 'string') throw new TypeError('le texte relevé est une chaîne');
const formeRacine = formeDeRacine(racine);
const base = sansSeparateurFinal(racine);
const commeRacine = formeRacine === 'windows' ? normaliserWindows : (chemin) => chemin;
const baseComparee = commeRacine(base);
const sousLaRacine = (chemin) => {
if (commeRacine(chemin.slice(0, base.length)) !== baseComparee) return false;
const reste = chemin.slice(base.length);
return reste === '' || estSeparateur(reste[0]) || estSeparateur(base.at(-1));
};
const releves = [];
let rang = 0;
while (rang < texte.length) {
const forme = formeEn(texte, rang);
if (forme === null) {
rang += 1;
continue;
}
let fin;
let dessous;
if (forme === 'fichier') {
fin = finDuJeton(texte, rang);
const porte = cheminDeFichier(texte.slice(rang, fin));
dessous = porte.forme === formeRacine && sousLaRacine(porte.chemin);
} else {
// La racine se reconnaît avant la coupe au blanc, qu'elle peut porter.
const aLaRacine = forme === formeRacine && commeRacine(texte.slice(rang, rang + base.length)) === baseComparee;
fin = finDuJeton(texte, aLaRacine ? rang + base.length : rang);
dessous = aLaRacine && sousLaRacine(texte.slice(rang, fin));
}
if (!dessous) {
const chemin = texte.slice(rang, fin);
if (!releves.includes(chemin)) releves.push(chemin);
}
rang = Math.max(fin, rang + 1);
}
return releves;
}
// --- Un relevé ne nomme pas sa machine (contrainte 27) ---------------------------------
const CLES_MANIFESTE = ['captures', 'format', 'pilote', 'reportees'];
const CLES_PILOTE = ['cadre', 'facteurEchelle', 'mode', 'navigateur', 'versionNavigateur'];
const CLES_CADRE = ['hauteur', 'largeur'];
const CLES_CAPTURE = [
'cadre', 'empreinte', 'etape', 'etat', 'facteurEchelle', 'fichier', 'graine', 'image',
'instant', 'mode', 'pdf', 'source', 'styles', 'texte', 'theme', 'versionReelle',
];
const CLES_IMAGE = ['hauteur', 'largeur', 'sha256'];
const CLES_REPORTEE = ['etape', 'fichier', 'raison'];
function clesHorsContrat(objet, permises, prefixe) {
if (!estObjet(objet)) return [];
return Object.keys(objet)
.filter((cle) => !permises.includes(cle))
.sort(comparer)
.map((cle) => `clé hors du contrat : « ${prefixe}${cle} »`);
}
function chainesDe(valeur) {
if (typeof valeur === 'string') return [valeur];
if (Array.isArray(valeur)) return valeur.flatMap(chainesDe);
if (estObjet(valeur)) return Object.keys(valeur).sort(comparer).flatMap((cle) => [cle, ...chainesDe(valeur[cle])]);
return [];
}
function cheminsDe(valeur, racine) {
const releves = chainesDe(valeur).flatMap((chaine) => cheminsHorsRacine(chaine, racine));
return [...new Set(releves)].map((chemin) => `chemin hors de la racine neutre : « ${chemin} »`);
}
/**
* La garde de la contrainte 27 : un manifeste ne nomme ni son hôte, ni un
* compte, ni un utilisateur, ni une date de la machine. Les clés du
* manifeste, du pilote, de chaque capture, de son cadre et de son image, et
* de chaque reportée forment des listes fermées, celles du contrat : un
* champ « hote » ou « date » est une clé hors du contrat. Toute chaîne, clés
* comprises, est passée à cheminsHorsRacine.
* @param {object} manifeste
* @param {string} racine la racine neutre des captures
* @returns {Faute[]} le manifeste et son pilote, puis chaque capture et chaque reportée, dans leur ordre
*/
export function fautesDeMachine(manifeste, racine) {
const captures = capturesDe(manifeste);
const reportees = manifeste.reportees ?? [];
exigerTableau(reportees, 'manifeste.reportees');
const fautes = [];
const ajouter = (chemin, natures) => {
for (const faute of natures) fautes.push({ chemin, faute });
};
const { captures: _captures, reportees: _reportees, ...tete } = manifeste;
ajouter(CHEMIN_MANIFESTE, [
...clesHorsContrat(manifeste, CLES_MANIFESTE, ''),
...clesHorsContrat(manifeste.pilote, CLES_PILOTE, 'pilote.'),
...clesHorsContrat(manifeste.pilote?.cadre, CLES_CADRE, 'pilote.cadre.'),
...cheminsDe(tete, racine),
]);
for (const entree of captures) {
ajouter(cheminImage(entree?.fichier), [
...clesHorsContrat(entree, CLES_CAPTURE, ''),
...clesHorsContrat(entree?.cadre, CLES_CADRE, 'cadre.'),
...clesHorsContrat(entree?.image, CLES_IMAGE, 'image.'),
...cheminsDe(entree, racine),
]);
}
for (const entree of reportees) {
ajouter(cheminImage(entree?.fichier), [...clesHorsContrat(entree, CLES_REPORTEE, ''), ...cheminsDe(entree, racine)]);
}
return fautes;
}

View file

@ -0,0 +1,539 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du manifeste des captures et de ses contrôles sous node (§ 19.3,
// § 19.5, § 19.7) : la liste déclarée des captures, l'empreinte d'étape, la
// forme canonique, l'appariement documentation, manifeste et disque, l'ordre
// contre les étapes du § 2.1, l'en-tête PNG, l'écart de version, les chemins
// hors de la racine neutre et la garde qui refuse un manifeste nommant sa
// machine. Les données viennent de test/fixtures/captures/ : deux images
// minimales de 1 366 × 700, une de 2 × 3, un guide et un manifeste inventés.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
import { VERSION } from '../../src/version.genere.js';
import {
A_VENIR,
CAPTURES,
CHEMIN_MANIFESTE,
ETAPES,
REPORTEES,
canoniser,
cheminImage,
cheminsHorsRacine,
ecartDeVersion,
empreinteEtape,
fautesDImage,
fautesDeMachine,
imagesReferencees,
lireEntetePng,
verifierAppariement,
verifierOrdre,
} from './manifeste.js';
const FIXTURES = fileURLToPath(new URL('../../test/fixtures/captures/', import.meta.url));
const lire = (chemin) => readFileSync(FIXTURES + chemin);
// Racine neutre inventée : elle n'existe nulle part ailleurs dans le dépôt.
const RACINE = 'C:\\Racine\\Tablees';
const IMAGE_01 = 'doc/captures/01-premier-ecran.png';
const IMAGE_02 = 'doc/captures/02-import-apercu.png';
// Le manifeste de fixture, lu à neuf à chaque appel. Il ne porte pas la
// version réelle, qu'aucune donnée n'écrit en clair (§ 18.5, point 6) :
// chaque capture reçoit ici celle de la construction.
function manifesteFixture() {
const manifeste = JSON.parse(lire('manifeste.json').toString('utf8'));
for (const capture of manifeste.captures) capture.versionReelle = VERSION.affichee;
return manifeste;
}
function captureFixture(rang = 0) {
return manifesteFixture().captures[rang];
}
function referencesFixture() {
return [{ document: 'guide.md', images: imagesReferencees(lire('guide.md').toString('utf8')) }];
}
// Un manifeste dont seules les entrées comptent pour l'ordre : chaque capture
// déclarée, sauf celles qu'on écarte, et chaque reportée.
function manifesteDeclare({ sans = [], reportees = REPORTEES } = {}) {
return {
format: 1,
captures: CAPTURES.filter((c) => !sans.includes(c.fichier)).map((c) => ({ fichier: c.fichier, etape: c.etape })),
reportees: reportees.map((r) => ({ ...r })),
};
}
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
const fautesDe = (fautes, motif) => fautes.filter((f) => motif.test(f.faute));
describe('manifeste : la liste déclarée (§ 19.3, Q8 a)', () => {
test('les treize étapes du § 2.1, dans leur ordre', () => {
assert.deepEqual(ETAPES, ['1', '1b', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']);
assert.ok(Object.isFrozen(ETAPES));
});
test('CAPTURES égale la table du contrat : vingt-deux entrées, leur étape et leur source', () => {
const table = CAPTURES.map((c) => [c.fichier, c.etape, c.source]);
assert.deepEqual(table, [
['01-premier-ecran', '1', 'navigateur'],
['01b-passer-en-ecriture', '1b', 'navigateur'],
['02-import-apercu', '2', 'navigateur'],
['02b-import-cp1252', '2', 'navigateur'],
['03-import-rapport', '2', 'navigateur'],
['04-grille-participants', '3', 'navigateur'],
['05-tables-etat', '4', 'navigateur'],
['06-defaut-consequence', '4', 'navigateur'],
['07-plan-tables', '4', 'navigateur'],
['07a-cibles-sieges', '4', 'navigateur'],
['07a-cibles-tables', '4', 'navigateur'],
['07b-nombre-de-tours', '5', 'navigateur'],
['08-animateurs-designes', '6', 'navigateur'],
['09-refus-sur-place', '7', 'navigateur'],
['10-diagnostic', '8', 'navigateur'],
['11-propositions', '9', 'navigateur'],
['19-ajustement', '11', 'navigateur'],
['20-mode-lecture', '11', 'navigateur'],
['22-plan-brouillon', '12', 'navigateur'],
['23-plan-retenu', '12', 'navigateur'],
['24-badges-controle', '12', 'pdf'],
['25-badges-planche', '12', 'pdf'],
]);
for (const capture of CAPTURES) {
assert.deepEqual(Object.keys(capture).sort(), ['configuration', 'etape', 'fichier', 'source'], capture.fichier);
assert.equal(typeof capture.configuration, 'string', capture.fichier);
assert.ok(capture.configuration.length > 0, capture.fichier);
assert.ok(Object.isFrozen(capture), capture.fichier);
}
assert.ok(Object.isFrozen(CAPTURES));
});
test('la configuration nomme le fichier d’import ou la démonstration que la capture exige', () => {
const configuration = new Map(CAPTURES.map((c) => [c.fichier, c.configuration]));
assert.equal(configuration.get('02-import-apercu'), 'participants_cas_limites.csv');
assert.equal(configuration.get('02b-import-cp1252'), 'participants_cas_limites_cp1252.csv');
assert.equal(configuration.get('03-import-rapport'), 'participants_cas_limites.csv');
assert.equal(configuration.get('10-diagnostic'), 'grande');
});
test('REPORTEES : 12 à 18 à l’étape 10, 21 à l’étape 11, raison « iteration5 », rien d’autre', () => {
assert.deepEqual(
REPORTEES.map((r) => [r.fichier, r.etape, r.raison]),
[
['12-qualite-profil', '10', 'iteration5'],
['13-qualite-collisions', '10', 'iteration5'],
['14-qualite-paires', '10', 'iteration5'],
['15-pas-de-matrice', '10', 'iteration5'],
['16-minimum-atteint', '10', 'iteration5'],
['17-comparaison-manque', '10', 'iteration5'],
['18-comparaison-escalier', '10', 'iteration5'],
['21-historique', '11', 'iteration5'],
],
);
const rangs = REPORTEES.map((r) => Number(r.fichier.slice(0, 2)));
for (const rang of rangs) assert.ok((rang >= 12 && rang <= 18) || rang === 21, String(rang));
assert.ok(Object.isFrozen(REPORTEES));
for (const reportee of REPORTEES) assert.ok(Object.isFrozen(reportee), reportee.fichier);
});
test('A_VENIR nomme les quatre captures de l’étape 12, toutes déclarées', () => {
assert.deepEqual(A_VENIR, ['22-plan-brouillon', '23-plan-retenu', '24-badges-controle', '25-badges-planche']);
const declarees = new Set(CAPTURES.map((c) => c.fichier));
for (const fichier of A_VENIR) assert.ok(declarees.has(fichier), fichier);
assert.ok(Object.isFrozen(A_VENIR));
});
test('CAPTURES ∪ REPORTEES : des noms uniques, et l’ordre des noms suit celui des étapes', () => {
const toutes = [...CAPTURES, ...REPORTEES];
assert.equal(new Set(toutes.map((e) => e.fichier)).size, toutes.length);
const parNom = [...toutes].sort((a, b) => (a.fichier < b.fichier ? -1 : a.fichier > b.fichier ? 1 : 0));
const rangs = parNom.map((e) => ETAPES.indexOf(e.etape));
assert.ok(rangs.every((rang) => rang >= 0));
for (let i = 1; i < rangs.length; i += 1) assert.ok(rangs[i - 1] <= rangs[i], `${parNom[i - 1].fichier} › ${parNom[i].fichier}`);
const capturesParNom = [...CAPTURES].sort((a, b) => (a.fichier < b.fichier ? -1 : 1)).map((c) => c.fichier);
assert.deepEqual(CAPTURES.map((c) => c.fichier), capturesParNom);
});
test('cheminImage et le chemin du manifeste sont sous doc/captures', () => {
assert.equal(cheminImage('01-premier-ecran'), IMAGE_01);
assert.equal(CHEMIN_MANIFESTE, 'doc/captures/manifeste.json');
});
});
describe('manifeste : l’empreinte d’étape (§ 19.5, amendé)', () => {
test('SHA-256 en hexadécimal, égale à celle que porte le manifeste de fixture', () => {
for (const capture of manifesteFixture().captures) {
assert.match(empreinteEtape(capture), /^[0-9a-f]{64}$/);
assert.equal(empreinteEtape(capture), capture.empreinte, capture.fichier);
}
});
test('invariante à la version réelle, à la version du navigateur, à l’image, à l’empreinte et à l’ordre des clés', () => {
const capture = captureFixture();
const reference = empreinteEtape(capture);
assert.equal(empreinteEtape({ ...capture, versionReelle: versionVoisine(VERSION.affichee) }), reference);
const { versionReelle: _version, ...sansVersion } = capture;
assert.equal(empreinteEtape(sansVersion), reference);
assert.equal(empreinteEtape({ ...capture, versionNavigateur: '141.0.1.2' }), reference);
assert.equal(empreinteEtape({ ...capture, pilote: { navigateur: 'chromium', versionNavigateur: '141.0.1.2' } }),
empreinteEtape({ ...capture, pilote: { navigateur: 'chromium', versionNavigateur: '139.0.9.9' } }));
assert.equal(empreinteEtape({ ...capture, image: { sha256: '0'.repeat(64), largeur: 1, hauteur: 1 } }), reference);
assert.equal(empreinteEtape({ ...capture, empreinte: 'f'.repeat(64) }), reference);
const inverse = Object.fromEntries(Object.entries(capture).reverse());
assert.equal(empreinteEtape(inverse), reference);
});
test('variable au texte, au thème, à l’état, aux styles, à la graine et au cadre', () => {
const capture = captureFixture();
const reference = empreinteEtape(capture);
const variantes = [
{ ...capture, texte: [...capture.texte, 'Théo Pervenche'] },
{ ...capture, texte: capture.texte.slice().reverse() },
{ ...capture, theme: 'sombre' },
{ ...capture, etat: sha256('un autre état inventé') },
{ ...capture, styles: { ...capture.styles, clair: [{ couleur: '#000000' }] } },
{ ...capture, graine: 7 },
{ ...capture, cadre: { largeur: 1366, hauteur: 701 } },
];
for (const variante of variantes) assert.notEqual(empreinteEtape(variante), reference);
});
test('refuse ce qui n’est pas un objet, et une valeur que JSON ne porte pas', () => {
assert.throws(() => empreinteEtape(null), TypeError);
assert.throws(() => empreinteEtape([]), TypeError);
assert.throws(() => empreinteEtape({ ...captureFixture(), graine: undefined }), TypeError);
assert.throws(() => empreinteEtape({ ...captureFixture(), graine: Number.NaN }), TypeError);
});
});
describe('manifeste : la forme canonique', () => {
test('clés triées à toute profondeur, une capture et une reportée par ligne, fin de ligne finale', () => {
const manifeste = manifesteFixture();
const texte = canoniser(manifeste);
assert.ok(texte.endsWith(']}\n'), texte.slice(-10));
const lignes = texte.split('\n');
assert.equal(lignes[0], '{"captures":[');
assert.ok(lignes[1].startsWith('{"cadre":{"hauteur":700,"largeur":1366},"empreinte":'), lignes[1]);
assert.ok(lignes[1].endsWith('},'), lignes[1]);
assert.ok(lignes[2].startsWith('{"cadre":'), lignes[2]);
assert.ok(lignes[2].endsWith('}'), lignes[2]);
assert.ok(lignes[3].startsWith('],"format":1,"pilote":{"cadre":{"hauteur":700,"largeur":1366},"facteurEchelle":1,'), lignes[3]);
assert.ok(lignes[3].endsWith(',"reportees":['), lignes[3]);
assert.equal(lignes[4], '{"etape":"10","fichier":"12-qualite-profil","raison":"iteration5"}');
assert.equal(lignes[5], ']}');
assert.equal(lignes[6], '');
assert.deepEqual(JSON.parse(texte), manifeste);
});
test('l’ordre des clés reçu ne change rien ; celui des tableaux, si', () => {
const manifeste = manifesteFixture();
const inverse = Object.fromEntries(Object.entries(manifeste).reverse());
inverse.captures = manifeste.captures.map((c) => Object.fromEntries(Object.entries(c).reverse()));
assert.equal(canoniser(inverse), canoniser(manifeste));
assert.notEqual(canoniser({ ...manifeste, captures: manifeste.captures.slice().reverse() }), canoniser(manifeste));
});
test('un tableau vide reste sur sa ligne', () => {
assert.equal(canoniser({ format: 1, captures: [], reportees: [] }), '{"captures":[],"format":1,"reportees":[]}\n');
});
test('refuse une valeur que JSON ne porte pas', () => {
assert.throws(() => canoniser({ format: 1, captures: [{ fichier: undefined }] }), TypeError);
assert.throws(() => canoniser({ format: Number.POSITIVE_INFINITY }), TypeError);
assert.throws(() => canoniser([]), TypeError);
});
});
describe('manifeste : l’appariement documentation, manifeste, disque (§ 19.7)', () => {
const images = [IMAGE_01, IMAGE_02];
test('imagesReferencees relève chaque image de doc/captures, une fois, dans l’ordre du texte', () => {
assert.deepEqual(imagesReferencees(lire('guide.md').toString('utf8')), [IMAGE_01, IMAGE_02]);
assert.deepEqual(imagesReferencees('![a](../doc/captures/03-import-rapport.png) et doc/captures/x.jpg'), ['doc/captures/03-import-rapport.png']);
assert.deepEqual(imagesReferencees('aucune image'), []);
});
test('la fixture appariée ne porte aucune faute', () => {
assert.deepEqual(verifierAppariement({ manifeste: manifesteFixture(), references: referencesFixture(), images }), []);
});
test('balayage vide refusé : manifeste sans capture, document sans référence, aucune documentation, aucun fichier', () => {
const vide = { ...manifesteFixture(), captures: [] };
const sansCapture = verifierAppariement({ manifeste: vide, references: referencesFixture(), images });
assert.equal(fautesDe(sansCapture, /aucune capture/).length, 1, JSON.stringify(sansCapture));
assert.equal(fautesDe(sansCapture, /aucune capture/)[0].chemin, CHEMIN_MANIFESTE);
const documentNu = verifierAppariement({
manifeste: manifesteFixture(),
references: [...referencesFixture(), { document: 'GUIDE-WINDOWS.md', images: [] }],
images,
});
assert.deepEqual(documentNu, [{ chemin: 'GUIDE-WINDOWS.md', faute: 'aucune image référencée' }]);
const sansDocument = verifierAppariement({ manifeste: manifesteFixture(), references: [], images });
assert.equal(fautesDe(sansDocument, /aucun document/).length, 1, JSON.stringify(sansDocument));
const sansFichier = verifierAppariement({ manifeste: manifesteFixture(), references: referencesFixture(), images: [] });
assert.equal(fautesDe(sansFichier, /aucune image sur le disque/).length, 1, JSON.stringify(sansFichier));
});
test('orpheline côté document, côté manifeste, côté disque : trois fautes, chacune nommée', () => {
const manifeste = manifesteFixture();
const seule = (fichier) => ({ ...manifeste.captures[0], fichier });
manifeste.captures.push(seule('04-grille-participants'));
const references = referencesFixture();
references[0].images.push('doc/captures/03-import-rapport.png');
const fautes = verifierAppariement({ manifeste, references, images: [...images, 'doc/captures/05-tables-etat.png'] });
assert.deepEqual(fautes, [
{ chemin: 'doc/captures/03-import-rapport.png', faute: 'orpheline : référencée par guide.md ; absente du manifeste et du disque' },
{ chemin: 'doc/captures/04-grille-participants.png', faute: 'orpheline : au manifeste ; absente de la documentation et du disque' },
{ chemin: 'doc/captures/05-tables-etat.png', faute: 'orpheline : sur le disque ; absente de la documentation et du manifeste' },
]);
});
test('une image présente de deux côtés sur trois est une orpheline du troisième', () => {
const fautes = verifierAppariement({ manifeste: manifesteFixture(), references: referencesFixture(), images: [IMAGE_01] });
assert.deepEqual(fautes, [{ chemin: IMAGE_02, faute: 'orpheline : référencée par guide.md, au manifeste ; absente du disque' }]);
});
test('une capture deux fois au manifeste est une faute', () => {
const manifeste = manifesteFixture();
manifeste.captures.push({ ...manifeste.captures[1] });
const fautes = verifierAppariement({ manifeste, references: referencesFixture(), images });
assert.deepEqual(fautes, [{ chemin: IMAGE_02, faute: 'deux fois au manifeste' }]);
});
});
describe('manifeste : l’ordre contre les étapes du § 2.1 (§ 19.3)', () => {
test('CAPTURES ∪ REPORTEES réel : aucune faute', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare()), []);
});
test('les captures d’A_VENIR, absentes du manifeste, sont admises', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: A_VENIR })), []);
});
test('A_VENIR vidé, l’étape 12 sans capture est une faute', () => {
const fautes = verifierOrdre(manifesteDeclare({ sans: A_VENIR }), { aVenir: [] });
assert.deepEqual(fautes, [{ chemin: CHEMIN_MANIFESTE, faute: 'étape 12 : aucune capture ni reportée' }]);
});
test('une étape sans capture ni reportée est une faute qui la nomme', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ sans: ['07b-nombre-de-tours'] })), [
{ chemin: CHEMIN_MANIFESTE, faute: 'étape 5 : aucune capture ni reportée' },
]);
});
test('les reportées couvrent l’étape 10 ; sans elles, elle manque', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ reportees: [] })), [
{ chemin: CHEMIN_MANIFESTE, faute: 'étape 10 : aucune capture ni reportée' },
]);
});
test('ordre contredit : 02 rattachée à l’étape 4 avant 03 rattachée à la 2', () => {
const manifeste = manifesteDeclare();
manifeste.captures.find((c) => c.fichier === '02-import-apercu').etape = '4';
assert.deepEqual(verifierOrdre(manifeste), [
{ chemin: 'doc/captures/02b-import-cp1252.png', faute: 'étape 2 après 02-import-apercu, de l’étape 4 : l’ordre contredit les étapes' },
]);
});
test('l’ordre se lit sur les noms, pas sur la place dans le tableau', () => {
const manifeste = manifesteDeclare();
manifeste.captures.reverse();
assert.deepEqual(verifierOrdre(manifeste), []);
});
test('une étape inconnue, un nom déclaré deux fois : des fautes', () => {
const inconnue = manifesteDeclare();
inconnue.captures.find((c) => c.fichier === '05-tables-etat').etape = '4b';
assert.deepEqual(verifierOrdre(inconnue), [{ chemin: 'doc/captures/05-tables-etat.png', faute: 'étape inconnue « 4b »' }]);
const double = manifesteDeclare();
double.reportees.push({ fichier: '19-ajustement', etape: '11', raison: 'iteration5' });
assert.deepEqual(verifierOrdre(double), [{ chemin: 'doc/captures/19-ajustement.png', faute: 'déclarée deux fois' }]);
});
test('balayage vide refusé : aucune entrée est une faute, et chaque étape manque', () => {
const fautes = verifierOrdre({ format: 1, captures: [], reportees: [] }, { aVenir: [] });
assert.equal(fautes[0].faute, 'aucune capture ni reportée');
assert.equal(fautes.length, 1 + ETAPES.length);
});
});
describe('manifeste : l’image et son en-tête', () => {
test('lireEntetePng lit 2 × 3 sur le PNG minimal', () => {
assert.deepEqual(lireEntetePng(lire('deux_par_trois.png')), { largeur: 2, hauteur: 3 });
assert.deepEqual(lireEntetePng(new Uint8Array(lire('doc/captures/01-premier-ecran.png'))), { largeur: 1366, hauteur: 700 });
});
test('lireEntetePng refuse une signature fausse, un en-tête tronqué, un premier morceau qui n’est pas IHDR', () => {
const octets = new Uint8Array(lire('deux_par_trois.png'));
const signature = octets.slice();
signature[1] = 0x51;
assert.throws(() => lireEntetePng(signature), /signature PNG/);
assert.throws(() => lireEntetePng(octets.slice(0, 20)), /tronqué/);
const autre = octets.slice();
autre[12] = 0x69;
assert.throws(() => lireEntetePng(autre), /IHDR/);
const nulle = octets.slice();
nulle.fill(0, 16, 20);
assert.throws(() => lireEntetePng(nulle), /largeur/);
assert.throws(() => lireEntetePng('PNG'), TypeError);
});
test('l’image de la fixture, octets et dimensions conformes : aucune faute', () => {
const capture = captureFixture();
assert.deepEqual(fautesDImage(capture, lire('doc/captures/01-premier-ecran.png')), []);
});
test('image remplacée par les octets d’une autre : faute', () => {
const fautes = fautesDImage(captureFixture(0), lire('doc/captures/02-import-apercu.png'));
assert.deepEqual(fautes, [{ chemin: IMAGE_01, faute: 'image remplacée : son empreinte diffère de celle du manifeste' }]);
});
test('dimensions ≠ cadre × facteur : faute', () => {
const capture = captureFixture();
const double = { ...capture, facteurEchelle: 2 };
assert.deepEqual(fautesDImage(double, lire('doc/captures/01-premier-ecran.png')), [
{ chemin: IMAGE_01, faute: 'dimensions 1366 × 700, attendues 2732 × 1400 (cadre × facteur)' },
]);
const autreCadre = { ...capture, cadre: { largeur: 1280, hauteur: 700 } };
assert.deepEqual(fautesDImage(autreCadre, lire('doc/captures/01-premier-ecran.png')), [
{ chemin: IMAGE_01, faute: 'dimensions 1366 × 700, attendues 1280 × 700 (cadre × facteur)' },
]);
});
test('dimensions du fichier ≠ celles que le manifeste inscrit : faute', () => {
const capture = captureFixture();
const inscrite = { ...capture, image: { ...capture.image, hauteur: 699 } };
assert.deepEqual(fautesDImage(inscrite, lire('doc/captures/01-premier-ecran.png')), [
{ chemin: IMAGE_01, faute: 'dimensions 1366 × 700, le manifeste inscrit 1366 × 699' },
]);
});
test('un fichier qui n’est pas un PNG : faute, pas d’exception', () => {
const capture = captureFixture();
const octets = new TextEncoder().encode('ceci n’est pas une image');
const fautes = fautesDImage({ ...capture, image: { ...capture.image, sha256: sha256(octets) } }, octets);
assert.equal(fautes.length, 1, JSON.stringify(fautes));
assert.match(fautes[0].faute, /^illisible : /);
});
});
describe('manifeste : les chemins hors de la racine neutre (§ 19.4)', () => {
test('relève C:\\Users\\…, /home/…, /tmp/… ; admet la racine elle-même et ce qui est dessous', () => {
const texte = [
`Dossier de travail : ${RACINE}`,
`Événement : ${RACINE}\\Bal des Grisailles.gtt.json`,
'Copie : C:\\Users\\Exemple\\Bureau\\x',
'Ailleurs : /home/exemple/x',
'Temporaire (/tmp/x).',
].join('\n');
assert.deepEqual(cheminsHorsRacine(texte, RACINE), ['C:\\Users\\Exemple\\Bureau\\x', '/home/exemple/x', '/tmp/x']);
});
test('la racine se reconnaît sous l’autre séparateur et l’autre casse de Windows ; un nom qui la prolonge ne l’est pas', () => {
assert.deepEqual(cheminsHorsRacine('c:/racine/tablees/a et C:\\RACINE\\TABLEES\\b', RACINE), []);
assert.deepEqual(cheminsHorsRacine(`${RACINE}X\\a`, RACINE), [`${RACINE}X\\a`]);
assert.deepEqual(cheminsHorsRacine('D:\\Racine\\Tablees\\a', RACINE), ['D:\\Racine\\Tablees\\a']);
});
test('une racine qui porte une espace se reconnaît entière', () => {
const racine = 'C:\\Mes Tablées';
assert.deepEqual(cheminsHorsRacine(`ouvert depuis ${racine}\\Soirée des Merles.gtt.json`, racine), []);
});
test('chemin réseau, dossier personnel abrégé, racine POSIX', () => {
assert.deepEqual(cheminsHorsRacine('\\\\serveur\\partage\\x et ~/x', RACINE), ['\\\\serveur\\partage\\x', '~/x']);
assert.deepEqual(cheminsHorsRacine('/srv/neutre/a /srv/neutre /srv/neutreX/b', '/srv/neutre'), ['/srv/neutreX/b']);
});
test('ni une adresse web, ni une fraction, ni un chemin relatif ne sont des chemins absolus', () => {
assert.deepEqual(cheminsHorsRacine('http://www.example.org/a 3/4 tables, doc/captures/x.png, et/ou', RACINE), []);
assert.deepEqual(cheminsHorsRacine('Lien:https://exemple.invalid/a à 12:30', RACINE), []);
});
test('un chemin POSIX qui suit un deux-points est relevé', () => {
assert.deepEqual(cheminsHorsRacine('Chemin:/home/exemple/x et poste-exemple:/var/y', RACINE), ['/home/exemple/x', '/var/y']);
});
test('un chemin réseau aux barres obliques est relevé ; sous une racine réseau, il est admis', () => {
assert.deepEqual(cheminsHorsRacine('//serveur/partage/x et (//serveur/autre)', RACINE), ['//serveur/partage/x', '//serveur/autre']);
assert.deepEqual(cheminsHorsRacine('//serveur/partage/a', '\\\\serveur\\partage'), []);
assert.deepEqual(cheminsHorsRacine('//serveur/partageX/a', '\\\\serveur\\partage'), ['//serveur/partageX/a']);
});
test('une adresse file:// est relevée par le chemin qu’elle porte, encodage compris', () => {
const texte = 'file:///home/exemple/x, FILE:///C:/Users/Exemple/x et file://serveur/partage/x';
assert.deepEqual(cheminsHorsRacine(texte, RACINE), ['file:///home/exemple/x', 'FILE:///C:/Users/Exemple/x', 'file://serveur/partage/x']);
assert.deepEqual(cheminsHorsRacine('file:///c:/racine/tablees/a.gtt.json', RACINE), []);
assert.deepEqual(cheminsHorsRacine('file:///C:/Mes%20Tabl%C3%A9es/a.gtt.json', 'C:\\Mes Tablées'), []);
assert.deepEqual(cheminsHorsRacine('file:///srv/neutre/a file:///srv/neutreX/b', '/srv/neutre'), ['file:///srv/neutreX/b']);
});
test('chaque chemin une fois, dans l’ordre du texte ; une racine absente ou relative est refusée', () => {
assert.deepEqual(cheminsHorsRacine('/tmp/x puis /tmp/x puis /var/y', RACINE), ['/tmp/x', '/var/y']);
assert.throws(() => cheminsHorsRacine('x', ''), TypeError);
assert.throws(() => cheminsHorsRacine('x', 'relatif\\dossier'), TypeError);
assert.throws(() => cheminsHorsRacine(42, RACINE), TypeError);
});
});
describe('manifeste : l’écart de version (§ 19.7)', () => {
test('null à version égale, par défaut celle de la construction', () => {
assert.equal(ecartDeVersion(manifesteFixture(), VERSION.affichee), null);
assert.equal(ecartDeVersion(manifesteFixture()), null);
});
test('l’écart nommé sinon : la version du manifeste et celle de la construction', () => {
const voisine = versionVoisine(VERSION.affichee);
assert.deepEqual(ecartDeVersion(manifesteFixture(), voisine), { manifeste: VERSION.affichee, construction: voisine });
});
test('deux versions au manifeste : chacune nommée, une fois, dans l’ordre des captures', () => {
const voisine = versionVoisine(VERSION.affichee);
const manifeste = manifesteFixture();
manifeste.captures[1].versionReelle = voisine;
manifeste.captures.push({ ...manifeste.captures[0] });
assert.deepEqual(ecartDeVersion(manifeste, VERSION.affichee), { manifeste: `${VERSION.affichee}, ${voisine}`, construction: VERSION.affichee });
});
test('un manifeste sans capture n’établit aucune version : écart', () => {
assert.deepEqual(ecartDeVersion({ format: 1, captures: [] }, VERSION.affichee), { manifeste: null, construction: VERSION.affichee });
});
});
describe('manifeste : un relevé ne nomme pas sa machine (contrainte 27)', () => {
test('le manifeste de fixture passe', () => {
assert.deepEqual(fautesDeMachine(manifesteFixture(), RACINE), []);
});
test('un champ d’hôte au pilote, une clé inconnue d’une capture ou du manifeste : refusés', () => {
const manifeste = manifesteFixture();
manifeste.pilote.hote = 'poste-des-merles';
manifeste.captures[1].utilisateur = 'ondine';
manifeste.date = '2030-01-01';
assert.deepEqual(fautesDeMachine(manifeste, RACINE), [
{ chemin: CHEMIN_MANIFESTE, faute: 'clé hors du contrat : « date »' },
{ chemin: CHEMIN_MANIFESTE, faute: 'clé hors du contrat : « pilote.hote »' },
{ chemin: IMAGE_02, faute: 'clé hors du contrat : « utilisateur »' },
]);
});
test('un chemin de compte dans le texte relevé ou dans le pilote : refusé', () => {
const manifeste = manifesteFixture();
manifeste.captures[0].texte.push('/home/exemple/x');
manifeste.pilote.navigateur = 'C:\\Users\\Exemple\\chromium';
assert.deepEqual(fautesDeMachine(manifeste, RACINE), [
{ chemin: CHEMIN_MANIFESTE, faute: 'chemin hors de la racine neutre : « C:\\Users\\Exemple\\chromium »' },
{ chemin: IMAGE_01, faute: 'chemin hors de la racine neutre : « /home/exemple/x »' },
]);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

11
test/fixtures/captures/guide.md vendored Normal file
View file

@ -0,0 +1,11 @@
# Guide inventé de la Soirée du Club des Merles
Le premier écran offre la création d'un événement.
![Le premier écran](doc/captures/01-premier-ecran.png)
Iris Ombrelle importe la liste des invités, et l'aperçu nomme l'encodage.
![L'aperçu de l'import](doc/captures/02-import-apercu.png)
Le premier écran se revoit au retour : ![rappel](./doc/captures/01-premier-ecran.png)

47
test/fixtures/captures/manifeste.json vendored Normal file
View file

@ -0,0 +1,47 @@
{
"format": 1,
"pilote": {
"navigateur": "chromium",
"versionNavigateur": "140.0.7339.16",
"mode": "chromium-headless-shell",
"facteurEchelle": 1,
"cadre": { "largeur": 1366, "hauteur": 700 }
},
"captures": [
{
"fichier": "01-premier-ecran",
"etape": "1",
"source": "navigateur",
"graine": null,
"cadre": { "largeur": 1366, "hauteur": 700 },
"facteurEchelle": 1,
"mode": "chromium-headless-shell",
"theme": "clair",
"instant": "2030-03-14T19:30:00-04:00",
"texte": ["Gestion table tournante Libre", "Créer un événement", "Dossier de travail", "C:\\Racine\\Tablees"],
"styles": { "clair": [{ "role": "texte", "ratio": 12.6, "seuil": 4.5 }], "sombre": [{ "role": "texte", "ratio": 11.2, "seuil": 4.5 }] },
"etat": null,
"pdf": null,
"image": { "sha256": "27c4b2aa8562382048c2d25b16d00544cc39abb2606c9a637826db75480eb901", "largeur": 1366, "hauteur": 700 },
"empreinte": "332e986cf44506d6d29585d312e6af59a490857c5150bda25e4d732b98321ede"
},
{
"fichier": "02-import-apercu",
"etape": "2",
"source": "navigateur",
"graine": null,
"cadre": { "largeur": 1366, "hauteur": 700 },
"facteurEchelle": 1,
"mode": "chromium-headless-shell",
"theme": "clair",
"instant": "2030-03-14T19:31:00-04:00",
"texte": ["Aperçu de l’import", "Encodage : UTF-8", "Séparateur : point-virgule", "Iris Ombrelle", "Club des Merles", "Théo Pervenche", "Grisaille"],
"styles": { "clair": [], "sombre": [] },
"etat": "8f9c8272621ab35209365aca60702e2f123297e7875b2700d4859d3a70c98d0b",
"pdf": null,
"image": { "sha256": "84e2c14ed43e0c88461391fd60699be418deeacd9404a44899c5373c4171a564", "largeur": 1366, "hauteur": 700 },
"empreinte": "82d4af1b90aa0dc136dec6e407969cf40702b3ad97a83e3bfbf19a9f20d4fbde"
}
],
"reportees": [{ "fichier": "12-qualite-profil", "etape": "10", "raison": "iteration5" }]
}