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\n\n\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(''), ['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(''), ['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 = [
+ ['', 'images/ancienne.png'],
+ ['![Écran [ancien]](images/ancienne.png)', 'images/ancienne.png'],
+ [``, 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(''), []);
+ assert.deepEqual(avec(''), []);
+ // 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\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.
+
+
+
+
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 0000000..fb40581
Binary files /dev/null and b/test/fixtures/documentation/doc/captures/01-premier-ecran.png differ
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 0000000..ca53979
Binary files /dev/null and b/test/fixtures/documentation/doc/captures/02-import-apercu.png differ
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"
}
],