diff --git a/scripts/documentation/manifeste.js b/scripts/documentation/manifeste.js new file mode 100644 index 0000000..e27d7c7 --- /dev/null +++ b/scripts/documentation/manifeste.js @@ -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/.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/.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/.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; +} diff --git a/scripts/documentation/manifeste.test.js b/scripts/documentation/manifeste.test.js new file mode 100644 index 0000000..8c2d58c --- /dev/null +++ b/scripts/documentation/manifeste.test.js @@ -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 »' }, + ]); + }); +}); diff --git a/test/fixtures/captures/deux_par_trois.png b/test/fixtures/captures/deux_par_trois.png new file mode 100644 index 0000000..3013b80 Binary files /dev/null and b/test/fixtures/captures/deux_par_trois.png differ diff --git a/test/fixtures/captures/doc/captures/01-premier-ecran.png b/test/fixtures/captures/doc/captures/01-premier-ecran.png new file mode 100644 index 0000000..08ddd4b Binary files /dev/null and b/test/fixtures/captures/doc/captures/01-premier-ecran.png differ diff --git a/test/fixtures/captures/doc/captures/02-import-apercu.png b/test/fixtures/captures/doc/captures/02-import-apercu.png new file mode 100644 index 0000000..f78f1d9 Binary files /dev/null and b/test/fixtures/captures/doc/captures/02-import-apercu.png differ diff --git a/test/fixtures/captures/guide.md b/test/fixtures/captures/guide.md new file mode 100644 index 0000000..f8f6bd0 --- /dev/null +++ b/test/fixtures/captures/guide.md @@ -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) diff --git a/test/fixtures/captures/manifeste.json b/test/fixtures/captures/manifeste.json new file mode 100644 index 0000000..6fc0551 --- /dev/null +++ b/test/fixtures/captures/manifeste.json @@ -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" }] +}