[ADD] documentation: verifier checks every cited image against the disk

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/<name>.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/<nom>.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
This commit is contained in:
Mathieu Benoit 2026-10-10 18:52:00 -04:00
parent aaa2c1a3a7
commit 55765e93c2
6 changed files with 376 additions and 16 deletions

View file

@ -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 <img>, lien vers un fichier image — a la forme
// doc/captures/<nom>.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 <dossier>] [--porteur <chemin>]…
@ -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`(?<!!)${CROCHETS}\(${CIBLE}`, 'g');
// Une définition de référence, en tête de ligne : [étiquette]: cible.
const DEFINITION = /^ {0,3}\[([^\]\n]+)\]:[ \t]*(<[^>\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 <picture>.
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 <img> et <source>, 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/<nom>.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/<nom>.png s’affichent` });
else if (cible === null) fautes.push({ chemin, faute: `${ligne}balise <img> 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<string>, 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<string>, 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;
}

View file

@ -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/<nom>.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('<img alt="ancien" src="images/ancienne.png">'), ['image hors de doc/captures : « images/ancienne.png »']);
assert.deepEqual(avec("<IMG src='doc/captures/01-premier-ecran.png' srcset=\"images/grande.png 2x\">"), [
'image en srcset : seules les captures doc/captures/<nom>.png s’affichent',
]);
assert.deepEqual(avec('<img alt="sans source">'), ['balise <img> 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]: <https://images.exemple.invalid/ecran>'), [
'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)', () => {

View file

@ -19,3 +19,13 @@ dans une copie temporaire.
<!-- engendre:fin contrastes -->
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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

View file

@ -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"
}
],