From 55765e93c299d37132e558f2187fbabb8716f072 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 18:52:00 -0400 Subject: [PATCH] [ADD] documentation: verifier checks every cited image against the disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifier read no image: an older PNG published under an unchanged manifest, or an image outside doc/captures cited by the guide, left every series green. It now refuses any image a carrier cites outside doc/captures/.png, in every Markdown or HTML form, and checks each cited capture's hash and size against its manifest entry. When every carrier is read, a manifest entry no carrier cites is a fault too; a check narrowed by --porteur does not judge the carriers it skips. Checked: 3877 node tests from the index alone; verifier at 0 fault. --- FR --- [ADD] documentation : verifier confronte chaque image citée au disque verifier ne lisait aucune image : un PNG plus ancien publié sous un manifeste inchangé, ou une image hors de doc/captures citée par le guide, laissaient toutes les séries vertes. Il refuse désormais toute image citée hors de doc/captures/.png, sous chaque forme Markdown ou HTML, et confronte l'empreinte et la taille de chaque capture citée à son entrée du manifeste. Quand tous les porteurs sont lus, une entrée que nul ne cite est une faute ; restreint par --porteur, il ne juge pas les porteurs qu'il ne lit pas. Vérifié : 3877 node depuis l'index seul ; verifier à 0 faute. Assisted-by: Claude Opus 5.5 --- scripts/documentation.js | 213 ++++++++++++++++-- test/documentation.test.js | 165 +++++++++++++- test/fixtures/documentation/README.md | 10 + .../doc/captures/01-premier-ecran.png | Bin 0 -> 549 bytes .../doc/captures/02-import-apercu.png | Bin 0 -> 197 bytes .../documentation/doc/captures/manifeste.json | 4 +- 6 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 test/fixtures/documentation/doc/captures/01-premier-ecran.png create mode 100644 test/fixtures/documentation/doc/captures/02-import-apercu.png diff --git a/scripts/documentation.js b/scripts/documentation.js index fc57c0c..6a22e30 100644 --- a/scripts/documentation.js +++ b/scripts/documentation.js @@ -30,7 +30,8 @@ // ferme une autre région, une région ouverte dans une autre, un nom en // double ou inconnu sont des fautes ; un porteur sans région aussi, et une // liste de porteurs vide : un balayage vide ne passe pas (§ 14.2). Seuls les -// fichiers de PORTEURS se lisent. +// fichiers de PORTEURS se lisent, avec le manifeste et les captures qu'ils +// citent. // // verifier mesure aussi l'écart entre la version réelle inscrite au // manifeste des captures et celle de la construction (ecartDeVersion, @@ -38,6 +39,18 @@ // bloquant sous --livraison (§ 19.7). Un manifeste absent ne prouve aucune // version, et compte pour un écart. // +// verifier contrôle enfin les images que les porteurs affichent (§ 19.1, +// § 19.7). Toute image citée — image Markdown, en ligne ou par référence, +// balise , lien vers un fichier image — a la forme +// doc/captures/.png ; toute autre cible est une faute qui la nomme. +// Chaque capture citée a son entrée au manifeste, son fichier sur le disque, +// et ce fichier a l'empreinte et les dimensions que l'entrée inscrit +// (fautesDImage) : un PNG remplacé par une version antérieure, le manifeste +// inchangé, fait échouer la vérification, donc la construction de livraison. +// Réciproquement, chaque capture inscrite au manifeste est citée par un +// porteur : un document privé de ses images ne passe pas sur un balayage +// vide. +// // Ligne de commande, depuis la racine du projet ou d'ailleurs : // node scripts/documentation.js engendrer | verifier [--livraison] // [--racine ] [--porteur ]… @@ -62,7 +75,7 @@ import { RACCOURCIS } from '../src/interface/raccourcis.js'; import { diagnostiquer } from '../src/moteur/diagnostic.js'; import { analyser, configurationDepuisCharge } from '../src/stockage/document.js'; import { VERSION } from '../src/version.genere.js'; -import { CHEMIN_MANIFESTE, ecartDeVersion } from './documentation/manifeste.js'; +import { CHEMIN_MANIFESTE, cheminImage, ecartDeVersion, fautesDImage } from './documentation/manifeste.js'; import { tableauConstantes } from './banc/releve.js'; /** @typedef {{nom: string, debut: number, fin: number, contenu: string}} Region @@ -617,6 +630,154 @@ export function producteursDe(contexte) { ); } +// --- Les images que les porteurs affichent (§ 19.1, § 19.7) ----------------------- + +// La seule forme d'une image affichée : une capture du parcours, en PNG. +const FORME_CAPTURE = /^doc\/captures\/[A-Za-z0-9._-]+\.png$/; +// L'extension d'un fichier image : un lien vers un tel fichier montre une +// image au lecteur, comme une image en ligne. +const EXTENSION_IMAGE = /\.(?:png|jpe?g|gif|svg|webp|avif|bmp|ico|tiff?)$/i; + +// La cible d'une image ou d'un lien Markdown : entre chevrons, ou sans blanc +// avec au plus un niveau de parenthèses ; puis un titre facultatif. +const CIBLE = String.raw`\s*(<[^>\n]*>|[^\s()<>]*(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)`; +// Un caractère du texte entre crochets : un caractère échappé, crochet +// compris, ou tout autre qu'un crochet ; un saut de ligne seul, que ne suit +// pas une ligne vide, car une ligne vide ferme le paragraphe et ni image ni +// lien ne la franchit. Les quatre formes s'excluent : aucun retour en +// arrière ne se multiplie. +const CARACTERE = String.raw`(?:\\[^\n]|\\(?=\n)|[^\[\]\\\n]|\n(?![ \t\r]*\n))`; +// Le texte entre crochets, avec au plus un niveau de crochets imbriqués : +// une image dans un lien, [![…](…)](…), garde ses deux cibles, et le texte +// d'une image, ![Écran [ancien]](…), peut en porter. +const CROCHETS = String.raw`\[((?:${CARACTERE}|\[${CARACTERE}*\])*)\]`; +// Une étiquette de référence : sans crochet non échappé (CommonMark). +const ETIQUETTE = String.raw`\[(${CARACTERE}*)\]`; +const IMAGE_EN_LIGNE = new RegExp(String.raw`!${CROCHETS}\(${CIBLE}`, 'g'); +const LIEN_EN_LIGNE = new RegExp(String.raw`(?\n]*>|\S+)/gm; +// Une image par référence : ![texte][étiquette], ![texte][] ou ![texte], +// pourvu que ce dernier ne s'ouvre pas sur une cible en ligne. +const IMAGE_PAR_REFERENCE = new RegExp(String.raw`!${CROCHETS}(?:${ETIQUETTE}|(?![(\[]))`, 'g'); +// Une balise HTML d'image, ou une source d'une balise . +const BALISE_IMAGE = /<(img|source)\b[^>]*>/gi; +const ATTRIBUT_SRC = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i; +const ATTRIBUT_SRCSET = /\bsrcset\s*=/i; + +// Une étiquette de référence comparable : sans casse, blancs réduits. +const etiquette = (texte) => texte.trim().replace(/\s+/g, ' ').toLowerCase(); +// Une cible sans ses chevrons. +const sansChevrons = (cible) => (cible.startsWith('<') && cible.endsWith('>') ? cible.slice(1, -1) : cible); + +/** + * Les images qu'un document Markdown affiche, dans l'ordre du texte : les + * images en ligne et par référence, les balises et , les + * liens dont la cible a une extension d'image. Chaque citation est + * { cible, sorte, indice } : cible est le chemin cité, null pour une balise + * sans src lisible ou qui porte un srcset ; sorte est 'image', 'reference', + * 'lien', 'balise' ou 'srcset' ; indice est la position dans le texte. Un + * texte de code n'est pas mis à part : un exemple d'image se cite aussi. + * + * @param {string} texte + * @returns {Array<{cible: string|null, sorte: string, indice: number}>} + */ +export function imagesCitees(texte) { + if (typeof texte !== 'string') throw new TypeError('les images se relèvent dans une chaîne'); + const citees = []; + for (const trouve of texte.matchAll(IMAGE_EN_LIGNE)) citees.push({ cible: sansChevrons(trouve[2]), sorte: 'image', indice: trouve.index }); + for (const trouve of texte.matchAll(LIEN_EN_LIGNE)) { + const cible = sansChevrons(trouve[2]); + if (EXTENSION_IMAGE.test(cible.replace(/[?#].*$/, ''))) citees.push({ cible, sorte: 'lien', indice: trouve.index }); + } + const definitions = new Map(); + for (const trouve of texte.matchAll(DEFINITION)) { + const cle = etiquette(trouve[1]); + if (!definitions.has(cle)) definitions.set(cle, { cible: sansChevrons(trouve[2]), indice: trouve.index, utilisee: false }); + } + for (const trouve of texte.matchAll(IMAGE_PAR_REFERENCE)) { + const definition = definitions.get(etiquette(trouve[2] || trouve[1])); + if (definition === undefined) continue; + definition.utilisee = true; + citees.push({ cible: definition.cible, sorte: 'reference', indice: trouve.index }); + } + // Une définition vers un fichier image sert un lien par référence, ou + // une image : elle est citée une fois, à sa place, si aucune image ne l'a + // déjà prise. + for (const { cible, indice, utilisee } of definitions.values()) { + if (!utilisee && EXTENSION_IMAGE.test(cible.replace(/[?#].*$/, ''))) citees.push({ cible, sorte: 'lien', indice }); + } + for (const trouve of texte.matchAll(BALISE_IMAGE)) { + const balise = trouve[0]; + if (ATTRIBUT_SRCSET.test(balise)) citees.push({ cible: null, sorte: 'srcset', indice: trouve.index }); + const src = ATTRIBUT_SRC.exec(balise); + if (src !== null) citees.push({ cible: src[1] ?? src[2] ?? src[3], sorte: 'balise', indice: trouve.index }); + else if (trouve[1].toLowerCase() === 'img') citees.push({ cible: null, sorte: 'balise', indice: trouve.index }); + } + return citees.sort((a, b) => a.indice - b.indice); +} + +/** Vrai pour une cible citée qu'un document a le droit d'afficher. */ +export const estCapture = (cible) => typeof cible === 'string' && FORME_CAPTURE.test(cible); + +/** + * Les fautes des images d'un document : chaque citation dont la cible n'a + * pas la forme doc/captures/.png, nommée avec sa ligne ; une balise + * sans src lisible ; un srcset, dont les cibles échappent au contrôle. + * + * @param {string} chemin le document, relatif à la racine + * @param {string} texte + * @returns {Faute[]} + */ +export function fautesImagesCitees(chemin, texte) { + const fautes = []; + for (const { cible, sorte, indice } of imagesCitees(texte)) { + const ligne = `ligne ${ligneDe(texte, indice)} : `; + if (sorte === 'srcset') fautes.push({ chemin, faute: `${ligne}image en srcset : seules les captures doc/captures/.png s’affichent` }); + else if (cible === null) fautes.push({ chemin, faute: `${ligne}balise sans src lisible` }); + else if (!estCapture(cible)) fautes.push({ chemin, faute: `${ligne}image hors de doc/captures : « ${cible} »` }); + } + return fautes; +} + +// Les fautes des captures que les porteurs citent, contre le manifeste et le +// disque : chaque image une fois, dans l'ordre de sa première citation. +// Une image absente du manifeste ne se lit pas ; une image présente se lit +// et passe fautesDImage. Quand tous les porteurs de PORTEURS sont lus +// (complet), suivent, dans l'ordre du manifeste, ses captures que nul +// porteur ne cite : une capture inscrite et jamais montrée n'est pas +// examinée, et zéro capture examinée n'est pas zéro capture périmée (§ 19.7). +// Une vérification restreinte par --porteur ne juge pas les citations des +// porteurs qu'elle ne lit pas. +function fautesDesCaptures(citees, manifeste, lireOctets, complet) { + const entrees = new Map(); + for (const entree of Array.isArray(manifeste?.captures) ? manifeste.captures : []) { + const chemin = cheminImage(entree?.fichier); + if (!entrees.has(chemin)) entrees.set(chemin, entree); + } + const fautes = []; + for (const [image, porteur] of citees) { + const entree = entrees.get(image); + if (entree === undefined) { + fautes.push({ chemin: image, faute: `citée par ${porteur}, absente du manifeste` }); + continue; + } + let octets; + try { + octets = lireOctets(image); + } catch (erreur) { + if (!estAbsent(erreur)) throw erreur; + fautes.push({ chemin: image, faute: `citée par ${porteur}, absente du disque` }); + continue; + } + fautes.push(...fautesDImage(entree, octets)); + } + for (const image of complet ? entrees.keys() : []) { + if (!citees.has(image)) fautes.push({ chemin: image, faute: 'inscrite au manifeste, citée par aucun porteur' }); + } + return fautes; +} + // --- L'arbre --------------------------------------------------------------------- // Vrai pour l'erreur d'un fichier absent. @@ -624,6 +785,9 @@ const estAbsent = (erreur) => erreur?.code === 'ENOENT'; // Le lecteur par défaut d'une racine : le texte UTF-8 d'un chemin relatif. const lecteurDe = (racine) => (chemin) => readFileSync(join(racine, chemin), 'utf8'); +// Le lecteur d'octets par défaut d'une racine : le contenu d'un chemin +// relatif, en Uint8Array. +const lecteurOctetsDe = (racine) => (chemin) => readFileSync(join(racine, chemin)); // Le lecteur d'un fichier JSON de l'arbre, lu au premier besoin, une fois : // l'objet lu, ou null quand le fichier manque. Un texte qui n'est pas du @@ -674,20 +838,34 @@ function lirePorteur(lire, chemin) { } /** - * La vérification d'un arbre : les régions de chaque porteur, puis l'écart - * de version du manifeste. Seuls les porteurs et le manifeste se lisent. + * La vérification d'un arbre : les régions et les images citées de chaque + * porteur, l'écart de version du manifeste, puis chaque capture citée contre + * son entrée du manifeste et son fichier, et, quand PORTEURS est lu en + * entier, chaque entrée du manifeste contre les citations. Se lisent : les porteurs et le + * manifeste par lire, les seules captures citées par lireOctets. * * @param {{racine?: string, porteurs?: ReadonlyArray, livraison?: boolean, - * version?: string, lire?: (chemin: string) => string}} options + * version?: string, lire?: (chemin: string) => string, + * lireOctets?: (chemin: string) => Uint8Array}} options * @returns {{fautes: Faute[], ecart: null|{manifeste: string|null, construction: string}, - * regions: number, code: 0|1}} - * code 1 sur une faute, ou sur un écart sous livraison + * regions: number, images: number, code: 0|1}} + * images : le nombre de captures citées, chacune comptée une fois ; code 1 + * sur une faute, ou sur un écart sous livraison */ -export function verifierArbre({ racine = RACINE, porteurs = PORTEURS, livraison = false, version = VERSION.affichee, lire = lecteurDe(racine) } = {}) { +export function verifierArbre({ + racine = RACINE, + porteurs = PORTEURS, + livraison = false, + version = VERSION.affichee, + lire = lecteurDe(racine), + lireOctets = lecteurOctetsDe(racine), +} = {}) { const contexte = contexteDe(lire); const producteurs = producteursDe(contexte); const fautes = porteurs.length === 0 ? [SANS_PORTEUR] : []; let regions = 0; + // Chaque capture citée, au premier porteur qui la cite. + const citees = new Map(); for (const chemin of porteurs) { const { texte, faute } = lirePorteur(lire, chemin); if (faute !== null) { @@ -700,16 +878,19 @@ export function verifierArbre({ racine = RACINE, porteurs = PORTEURS, livraison } catch (erreur) { if (!(erreur instanceof ErreurRegions)) throw erreur; } + fautes.push(...fautesImagesCitees(chemin, texte)); + for (const { cible } of imagesCitees(texte)) if (estCapture(cible) && !citees.has(cible)) citees.set(cible, chemin); } let ecart = null; try { const manifeste = contexte.manifeste(); ecart = manifeste === null ? { manifeste: null, construction: version } : ecartDeVersion(manifeste, version); + fautes.push(...fautesDesCaptures(citees, manifeste, lireOctets, PORTEURS.every((porteur) => porteurs.includes(porteur)))); } catch (erreur) { fautes.push({ chemin: CHEMIN_MANIFESTE, faute: erreur.message }); } const code = fautes.length > 0 || (livraison && ecart !== null) ? 1 : 0; - return { fautes, ecart, regions, code }; + return { fautes, ecart, regions, images: citees.size, code }; } /** @@ -780,11 +961,12 @@ function lireArguments(argv) { * * @param {string[]} argv sans l'exécutable ni le script * @param {{racine?: string, porteurs?: ReadonlyArray, version?: string, - * lire?: (chemin: string) => string, ecrire?: (chemin: string, texte: string) => void, + * lire?: (chemin: string) => string, lireOctets?: (chemin: string) => Uint8Array, + * ecrire?: (chemin: string, texte: string) => void, * sortie?: (ligne: string) => void}} [options] * @returns {0|1|2} 2 pour des arguments illisibles, sans rien lire */ -export function executer(argv, { racine = RACINE, porteurs = PORTEURS, version, lire, ecrire, sortie = (ligne) => process.stdout.write(`${ligne}\n`) } = {}) { +export function executer(argv, { racine = RACINE, porteurs = PORTEURS, version, lire, lireOctets, ecrire, sortie = (ligne) => process.stdout.write(`${ligne}\n`) } = {}) { const lus = lireArguments(argv); if (lus === null) { sortie(USAGE); @@ -803,14 +985,19 @@ export function executer(argv, { racine = RACINE, porteurs = PORTEURS, version, sortie(`engendrer : ${options.porteurs.length} porteur(s), ${bilan.ecrits.length} réécrit(s), ${bilan.fautes.length} faute(s)`); return bilan.code; } - const bilan = verifierArbre({ ...options, livraison: lus.livraison, ...(version === undefined ? {} : { version }) }); + const bilan = verifierArbre({ + ...options, + livraison: lus.livraison, + ...(version === undefined ? {} : { version }), + ...(lireOctets === undefined ? {} : { lireOctets }), + }); ecrireFautes(bilan.fautes); if (bilan.ecart !== null) { const portee = lus.livraison ? 'bloquant' : 'non bloquant'; const manifeste = bilan.ecart.manifeste ?? 'aucune (manifeste absent ou sans capture)'; sortie(`écart de version (${portee}) : manifeste ${manifeste}, construction ${bilan.ecart.construction}`); } - sortie(`verifier : ${options.porteurs.length} porteur(s), ${bilan.regions} région(s), ${bilan.fautes.length} faute(s)`); + sortie(`verifier : ${options.porteurs.length} porteur(s), ${bilan.regions} région(s), ${bilan.images} image(s), ${bilan.fautes.length} faute(s)`); return bilan.code; } diff --git a/test/documentation.test.js b/test/documentation.test.js index 9abbb0d..5aadc72 100644 --- a/test/documentation.test.js +++ b/test/documentation.test.js @@ -4,7 +4,10 @@ // Épreuves des documents livrés, sur les vrais fichiers (§ 16.1, § 19.7 à // § 19.9) : GUIDE-USAGE.md suit les étapes du parcours et apparie ses images // au manifeste et au disque ; les régions engendrées de README.md et du guide -// sont telles qu'engendrer les écrirait ; la section sur l'algorithme cite +// sont telles qu'engendrer les écrirait ; README.md et le guide n'affichent +// que des captures doc/captures/.png, et chaque PNG publié a +// l'empreinte et les dimensions que le manifeste inscrit, au niveau de +// l'épreuve comme de verifier ; la section sur l'algorithme cite // des nombres que l'arithmétique et le diagnostic des démonstrations livrées // recalculent, et ne cite ni code, ni identifiant exporté, ni module ; // GUIDE-WINDOWS.md, texte seul, cite le gabarit du nom de l'exécutable et la @@ -33,6 +36,9 @@ import { PRODUCTEURS, RACINE, engendrerArbre, + executer, + fautesImagesCitees, + imagesCitees, lireRegions, tableauRegistre, verifierArbre, @@ -46,6 +52,7 @@ import { REPORTEES, cheminImage, cheminsHorsRacine, + fautesDImage, imagesReferencees, verifierAppariement, verifierOrdre, @@ -57,6 +64,7 @@ const GUIDE_WINDOWS = 'GUIDE-WINDOWS.md'; const README = 'README.md'; const lire = (chemin) => readFileSync(join(RACINE, chemin), 'utf8'); +const lireOctets = (chemin) => readFileSync(join(RACINE, chemin)); const lireManifeste = () => JSON.parse(lire(CHEMIN_MANIFESTE)); // Les images PNG du dossier des captures, en chemins relatifs à la racine. @@ -223,6 +231,161 @@ describe('documentation : le guide d’usage suit les étapes et apparie ses ima }); }); +// --- Les images que la documentation affiche (§ 19.1, § 19.7) ----------------------- + +// Un lecteur de l'arbre réel où un document est remplacé par un texte donné. +const lireAvec = (remplace, texte) => (chemin) => (chemin === remplace ? texte : lire(chemin)); + +describe('documentation : les documents n’affichent que les captures, telles que le manifeste les inscrit (§ 19.1, § 19.7)', () => { + test('le guide d’usage et README.md ne citent aucune image hors de doc/captures', () => { + for (const document of [GUIDE_USAGE, README]) assert.deepEqual(fautesImagesCitees(document, lire(document)), [], document); + // Le relevé n'est pas vide : il voit chaque image que l'appariement voit. + const cibles = imagesCitees(lire(GUIDE_USAGE)).map((citee) => citee.cible); + assert.deepEqual([...new Set(cibles)].sort(), [...imagesReferencees(lire(GUIDE_USAGE))].sort()); + assert.equal(new Set(cibles).size, CAPTURES.length); + }); + + test('rouge : une image hors de doc/captures, ou qui n’y est pas un PNG, se relève en mémoire, chacune nommée', () => { + const texte = lire(GUIDE_USAGE); + const altere = `${texte}\n![Un ancien écran](images/ancienne.png)\n\n![Un autre](doc/captures/x.jpg)\n`; + const fautes = fautesImagesCitees(GUIDE_USAGE, altere); + assert.deepEqual(fautes.map((f) => f.chemin), [GUIDE_USAGE, GUIDE_USAGE]); + assert.match(fautes[0].faute, /image hors de doc\/captures : « images\/ancienne\.png »/); + assert.match(fautes[1].faute, /image hors de doc\/captures : « doc\/captures\/x\.jpg »/); + // La construction refuse aussi : verifier lit les mêmes fautes. + const bilan = verifierArbre({ lire: lireAvec(GUIDE_USAGE, altere) }); + assert.deepEqual(bilan.fautes, fautes); + assert.equal(bilan.code, 1); + }); + + test('rouge : les autres formes d’image — balise, référence, lien vers une image — se relèvent dans README.md', () => { + const texte = lire(README); + const avec = (ajout) => fautesImagesCitees(README, `${texte}\n${ajout}\n`).map((f) => f.faute.replace(/^ligne \d+ : /, '')); + assert.deepEqual(avec('ancien'), ['image hors de doc/captures : « images/ancienne.png »']); + assert.deepEqual(avec(""), [ + 'image en srcset : seules les captures doc/captures/.png s’affichent', + ]); + assert.deepEqual(avec('sans source'), ['balise sans src lisible']); + assert.deepEqual(avec('![Un écran][ancien]\n\n[ancien]: images/ancienne.png'), ['image hors de doc/captures : « images/ancienne.png »']); + // Une image par référence se relève aussi quand sa cible n'a pas + // d'extension d'image : c'est la syntaxe qui l'affiche. + assert.deepEqual(avec('![Un écran][Distante]\n\n[distante]: '), [ + 'image hors de doc/captures : « https://images.exemple.invalid/ecran »', + ]); + assert.deepEqual(avec('Voir [l’ancien écran](images/ancienne.png).'), ['image hors de doc/captures : « images/ancienne.png »']); + // Un texte entre crochets coupé à la ligne, ou qui porte des crochets, + // imbriqués ou échappés, n'arrête pas le relevé : CommonMark affiche + // ces images et ces liens. + // Une cible sans extension d'image ne se relève que comme image : aucun + // lien ne la rattrape. + const distante = 'https://images.exemple.invalid/ecran'; + const formes = [ + ['![Un ancien\nécran](images/ancienne.png)', 'images/ancienne.png'], + ['![Écran [ancien]](images/ancienne.png)', 'images/ancienne.png'], + [`![Écran \\[ancien](${distante})`, distante], + [`![Écran \\]](${distante})`, distante], + ['Voir [l’ancien\nécran](images/ancienne.png).', 'images/ancienne.png'], + [`![Un\nécran][Distante]\n\n[distante]: <${distante}>`, distante], + [`![Un écran][Image\ndistante]\n\n[image distante]: <${distante}>`, distante], + ]; + assert.deepEqual( + formes.map(([forme]) => [forme, avec(forme)]), + formes.map(([forme, cible]) => [forme, [`image hors de doc/captures : « ${cible} »`]]), + ); + // Une ligne vide ferme le paragraphe : ni image ni lien ne la franchit. + assert.deepEqual(avec('![Un ancien\n\nécran](images/ancienne.png)'), []); + assert.deepEqual(avec('![Un écran](doc/captures/01-premier-ecran.png "titre")'), []); + // Un lien vers un document qui n'est pas une image n'en est pas une. + assert.deepEqual(avec('Voir [le guide](GUIDE-USAGE.md).'), []); + }); + + test('chaque image du manifeste a sur le disque l’empreinte et les dimensions qu’il inscrit', () => { + const { captures } = lireManifeste(); + assert.equal(captures.length, CAPTURES.length - A_VENIR.length); + for (const capture of captures) assert.deepEqual(fautesDImage(capture, lireOctets(cheminImage(capture.fichier))), [], capture.fichier); + }); + + test('rouge : les octets de 04-grille-participants passés pour 05-tables-etat donnent une faute qui nomme 05', () => { + const entree = lireManifeste().captures.find((c) => c.fichier === '05-tables-etat'); + const fautes = fautesDImage(entree, lireOctets(cheminImage('04-grille-participants'))); + assert.deepEqual(fautes.map((f) => f.chemin), [cheminImage('05-tables-etat')]); + assert.match(fautes[0].faute, /^image remplacée/); + }); + + test('verifier, sur l’arbre réel : chaque image citée par un porteur est contrôlée, aucune faute', () => { + const bilan = verifierArbre(); + assert.equal(bilan.images, CAPTURES.length - A_VENIR.length); + assert.deepEqual(bilan.fautes, []); + const lignes = []; + assert.equal(executer(['verifier'], { sortie: (ligne) => lignes.push(ligne) }), 0); + assert.match(lignes.at(-1), new RegExp(`, ${bilan.images} image\\(s\\), 0 faute\\(s\\)$`)); + }); + + test('rouge : une capture du manifeste que nul porteur ne cite est une faute de verifier ; zéro citée n’est pas zéro périmée', () => { + const fichiers = lireManifeste().captures.map((capture) => cheminImage(capture.fichier)); + const nonCitee = (chemin) => ({ chemin, faute: 'inscrite au manifeste, citée par aucun porteur' }); + // Le guide privé de ses images : seuls ses liens vers une capture restent. + const sansImages = lire(GUIDE_USAGE) + .split('\n') + .filter((ligne) => !/^!\[[^\]]*\]\(doc\/captures\//.test(ligne)) + .join('\n'); + const restantes = new Set(imagesCitees(sansImages).map((citee) => citee.cible)); + assert.ok(restantes.size > 0 && restantes.size < fichiers.length, `${restantes.size}`); + // Puis privé aussi de ces liens : aucune capture citée. + const sansCitation = sansImages.replace(/\]\(doc\/captures\/[^)]*\)/g, '](#)'); + assert.deepEqual(imagesCitees(sansCitation), []); + for (const livraison of [false, true]) { + const partiel = verifierArbre({ livraison, lire: lireAvec(GUIDE_USAGE, sansImages) }); + assert.deepEqual(partiel.fautes, fichiers.filter((chemin) => !restantes.has(chemin)).map(nonCitee)); + assert.deepEqual([partiel.images, partiel.code], [restantes.size, 1]); + const vide = verifierArbre({ livraison, lire: lireAvec(GUIDE_USAGE, sansCitation) }); + assert.deepEqual(vide.fautes, fichiers.map(nonCitee)); + assert.deepEqual([vide.images, vide.code], [0, 1]); + } + // Restreinte par --porteur, la vérification ne juge pas les citations des + // porteurs qu'elle ne lit pas : le seul README, sur l'arbre réel, passe. + const restreinte = verifierArbre({ porteurs: ['README.md'] }); + assert.deepEqual([restreinte.fautes, restreinte.code], [[], 0]); + assert.equal(executer(['verifier', '--porteur', 'README.md'], { sortie: () => {} }), 0); + }); + + test('rouge : un PNG publié remplacé par un autre, le manifeste inchangé, fait échouer verifier et la livraison', () => { + const remplacee = cheminImage('05-tables-etat'); + const ancienne = (chemin) => lireOctets(chemin === remplacee ? cheminImage('04-grille-participants') : chemin); + for (const livraison of [false, true]) { + const bilan = verifierArbre({ livraison, lireOctets: ancienne }); + assert.deepEqual(bilan.fautes.map((f) => f.chemin), [remplacee]); + assert.match(bilan.fautes[0].faute, /^image remplacée/); + assert.equal(bilan.code, 1); + } + const lignes = []; + assert.equal(executer(['verifier', '--livraison'], { lireOctets: ancienne, sortie: (ligne) => lignes.push(ligne) }), 1); + assert.ok(lignes.some((ligne) => ligne.startsWith(`${remplacee} : image remplacée`)), lignes.join('\n')); + }); + + test('rouge : une image citée absente du disque, ou absente du manifeste, est une faute de verifier', () => { + const absente = cheminImage('05-tables-etat'); + const sansFichier = (chemin) => { + if (chemin === absente) throw Object.assign(new Error(`absent : ${chemin}`), { code: 'ENOENT' }); + return lireOctets(chemin); + }; + const surDisque = verifierArbre({ lireOctets: sansFichier }); + assert.deepEqual(surDisque.fautes, [{ chemin: absente, faute: `citée par ${GUIDE_USAGE}, absente du disque` }]); + // Un nom inventé, ni au manifeste ni sur le disque : le manifeste suffit à + // le refuser, aucun octet ne se lit. + const inventee = cheminImage('05z-table-inventee'); + const lus = []; + const horsManifeste = verifierArbre({ + lire: lireAvec(GUIDE_USAGE, `${lire(GUIDE_USAGE)}\n![Une table inventée](${inventee})\n`), + lireOctets: (chemin) => (lus.push(chemin), lireOctets(chemin)), + }); + assert.deepEqual(horsManifeste.fautes, [{ chemin: inventee, faute: `citée par ${GUIDE_USAGE}, absente du manifeste` }]); + assert.equal(horsManifeste.code, 1); + assert.ok(!lus.includes(inventee)); + assert.equal(lus.length, CAPTURES.length - A_VENIR.length); + }); +}); + // --- Les régions engendrées --------------------------------------------------------- describe('documentation : les régions engendrées des documents livrés (§ 19.8)', () => { diff --git a/test/fixtures/documentation/README.md b/test/fixtures/documentation/README.md index 7e96ee6..76dc5bf 100644 --- a/test/fixtures/documentation/README.md +++ b/test/fixtures/documentation/README.md @@ -19,3 +19,13 @@ dans une copie temporaire. Le texte qui suit la région ne bouge pas quand elle s'engendre. + +## Captures + +Chaque capture du manifeste est citée ici : la vérification refuse une +capture inscrite que nul porteur ne montre. Les deux images sont des aplats +inventés, aux dimensions du cadre. + +![Le premier écran](doc/captures/01-premier-ecran.png) + +![L’aperçu d’un import](doc/captures/02-import-apercu.png) diff --git a/test/fixtures/documentation/doc/captures/01-premier-ecran.png b/test/fixtures/documentation/doc/captures/01-premier-ecran.png new file mode 100644 index 0000000000000000000000000000000000000000..fb4058102160b20153eba860375c34e40931002f GIT binary patch literal 549 zcmeAS@N?(olHy`uVBq!ia0y~yU=3qnVA{h76kyP?x_Om>f$@!}i(^Q|t+(eGc^MdZ tm=u0bzb12Ek@2N$Vd*FsRv}PZFZ;Fm_L}NS;TBN*d%F6$taD0e0s!eb?F9e; literal 0 HcmV?d00001 diff --git a/test/fixtures/documentation/doc/captures/02-import-apercu.png b/test/fixtures/documentation/doc/captures/02-import-apercu.png new file mode 100644 index 0000000000000000000000000000000000000000..ca5397986e23b140f9ac2ab0d016949bd5bcb8b0 GIT binary patch literal 197 zcmeAS@N?(olHy`uVBq!ia0y~yU=3qnVA{h76kyP?x_K2y^?15ChE&{odytVA$UC&) kyMHd9`2rx5?%=_3hHLQ*jGNRhh=T0)boFyt=akR{0KYL7P5=M^ literal 0 HcmV?d00001 diff --git a/test/fixtures/documentation/doc/captures/manifeste.json b/test/fixtures/documentation/doc/captures/manifeste.json index f430861..5386908 100644 --- a/test/fixtures/documentation/doc/captures/manifeste.json +++ b/test/fixtures/documentation/doc/captures/manifeste.json @@ -32,7 +32,7 @@ }, "etat": null, "pdf": null, - "image": { "sha256": "0000000000000000000000000000000000000000000000000000000000000001", "largeur": 1366, "hauteur": 700 }, + "image": { "sha256": "7fc987234e652157eb447403d62532084fc7f405b35fd3dc190dbd83bf28d89e", "largeur": 1366, "hauteur": 700 }, "empreinte": "0000000000000000000000000000000000000000000000000000000000000002" }, { @@ -59,7 +59,7 @@ }, "etat": null, "pdf": null, - "image": { "sha256": "0000000000000000000000000000000000000000000000000000000000000003", "largeur": 1366, "hauteur": 700 }, + "image": { "sha256": "0841a7a16c56d362c183c7d760e6546f7bbb9577a978052c540bfce098f9f405", "largeur": 1366, "hauteur": 700 }, "empreinte": "0000000000000000000000000000000000000000000000000000000000000004" } ],