From 8371e8f1ecd1221a7800e597f08725c63c22a8cf Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 13:06:01 -0400 Subject: [PATCH] [ADD] docs: generated regions in README and guides, checked on build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tables in the documentation must come from the code, not be copied by hand. scripts/documentation.js fills marked regions of README.md and the guides from the single sources: settings, shortcuts, CSV columns, constants and measured contrasts. A check mode reports any region that differs, and a missing manifest blocks a delivery build. Editing a carrier document reruns the documentation tests. Checked: red first; 3454 node, 655 browser and 76 long tests green from the index alone. --- FR --- [ADD] docs : régions engendrées du README et des guides, vérifiées Les tableaux de la documentation doivent venir du code, non d'une copie à la main. scripts/documentation.js remplit les régions marquées de README.md et des guides à partir des sources uniques : réglages, raccourcis, colonnes CSV, constantes et contrastes relevés. Un mode de vérification signale toute région qui diffère, et un manifeste absent bloque une construction de livraison. Modifier un document porteur relance les épreuves de la documentation. Vérifié : rouge d'abord ; 3454 node, 655 navigateur et 76 longues vertes depuis l'index seul. Assisted-by: Claude Opus 5.5 --- scripts/documentation.js | 804 ++++++++++++++++++ scripts/documentation.test.js | 618 ++++++++++++++ test/fixtures/documentation/HORS-PORTEURS.md | 6 + test/fixtures/documentation/README.md | 21 + .../documentation/doc/captures/manifeste.json | 67 ++ vitest.config.js | 4 + 6 files changed, 1520 insertions(+) create mode 100644 scripts/documentation.js create mode 100644 scripts/documentation.test.js create mode 100644 test/fixtures/documentation/HORS-PORTEURS.md create mode 100644 test/fixtures/documentation/README.md create mode 100644 test/fixtures/documentation/doc/captures/manifeste.json diff --git a/scripts/documentation.js b/scripts/documentation.js new file mode 100644 index 0000000..74fa76c --- /dev/null +++ b/scripts/documentation.js @@ -0,0 +1,804 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les régions engendrées de la documentation (§ 19.8) et le contrôle de +// version des captures (§ 19.7). Un document porteur délimite chaque région +// par deux marqueurs nommés, seuls à se lire comme tels : +// +// +// +// +// Le nom, en minuscules, chiffres et traits d'union, désigne un producteur +// de PRODUCTEURS, qui rend le texte de la région depuis sa source unique : la +// définition des réglages, la table des colonnes du CSV, le registre des +// commandes et leurs libellés figés, la table des raccourcis, les +// démonstrations livrées passées au diagnostic, les styles relevés au +// manifeste des captures, le registre des constantes. Aucune valeur de ces +// tableaux ne s'écrit ici : elle se lit, ou se compose par la traduction. +// Seul s'écrit ici ce qui appartient au document : les en-têtes de colonnes, +// le nom d'un contexte, d'une touche, d'un statut ou de l'état d'une +// bascule. +// +// engendrer remplace le contenu de chaque région — ce qui sépare la fin du +// marqueur de début du début du marqueur de fin — par deux sauts de ligne, +// le texte du producteur, deux sauts de ligne ; aucun octet hors de ces +// bornes ne change. verifier compare chaque région à ce qu'engendrer y +// écrirait et nomme celle qui diffère : une correction faite à la main dans +// une région fait échouer la vérification au lieu de se perdre au passage +// suivant (§ 14.3). Un marqueur illisible, sans fin, sans début ou qui +// 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. +// +// verifier mesure aussi l'écart entre la version réelle inscrite au +// manifeste des captures et celle de la construction (ecartDeVersion, +// documentation/manifeste.js) : affiché et non bloquant au cycle court, +// bloquant sous --livraison (§ 19.7). Un manifeste absent ne prouve aucune +// version, et compte pour un écart. +// +// Ligne de commande, depuis la racine du projet ou d'ailleurs : +// node scripts/documentation.js engendrer | verifier [--livraison] +// [--racine ] [--porteur ]… +// --racine remplace la racine du projet ; chaque --porteur, relatif à la +// racine, remplace la liste PORTEURS. Code de sortie : 0 sans faute, 1 sur +// une faute ou, sous --livraison, un écart de version, 2 sur des arguments +// illisibles. +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { COMMANDES } from '../src/application/commandes.js'; +import { creerFormat } from '../src/application/format.js'; +import { TABLES_APPLICATION } from '../src/application/libelles.js'; +import { creerTraduction } from '../src/application/traduction.js'; +import { ENTETES_DES_CHAMPS } from '../src/csv/colonnes.js'; +import { CATALOGUE } from '../src/demo/catalogue.js'; +import { LIVREES } from '../src/demo/livrees.js'; +import { CONSTANTES } from '../src/geometrie/constantes.js'; +import { REGLAGES_GENERATION } from '../src/interface/generation/reglages.js'; +import { TABLES_INTERFACE } from '../src/interface/libelles.js'; +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'; + +/** @typedef {{nom: string, debut: number, fin: number, contenu: string}} Region + * debut et fin bornent le contenu dans le texte : texte.slice(debut, fin). */ +/** @typedef {{region: string|null, faute: string}} FauteRegion */ +/** @typedef {{chemin: string, faute: string}} Faute chemin relatif à la racine */ + +/** La racine du projet. */ +export const RACINE = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Les fichiers porteurs de régions, relatifs à la racine : la liste fermée + * des documents que verifier et engendrer lisent. Chacun porte au moins une + * région. + * @type {ReadonlyArray} + */ +export const PORTEURS = Object.freeze([]); + +/** Une faute de structure des régions : la liste de ses fautes, par région. */ +export class ErreurRegions extends Error { + /** @param {FauteRegion[]} fautes */ + constructor(fautes) { + super(fautes.map(({ region, faute }) => (region === null ? faute : `région « ${region} » : ${faute}`)).join(' ; ')); + this.name = 'ErreurRegions'; + this.fautes = fautes; + } +} + +// --- Les marqueurs --------------------------------------------------------------- + +// Un commentaire HTML, fermé ou courant jusqu'à la fin du texte. +const COMMENTAIRE = /|$)/g; +// Le corps d'un commentaire qui se veut un marqueur. +const SE_VEUT_MARQUEUR = /^\s*engendre\b/; +// Le corps d'un marqueur lisible : sa sorte et son nom. +const MARQUEUR = /^\s*engendre:(debut|fin)\s+([a-z0-9]+(?:-[a-z0-9]+)*)\s*$/; + +// Un extrait d'un marqueur pour un message, sur une ligne. +const extrait = (texte) => JSON.stringify(texte.length > 60 ? `${texte.slice(0, 60)}…` : texte); + +/** + * Les régions d'un texte, dans leur ordre. Un commentaire dont le corps + * s'ouvre sur « engendre » est un marqueur : lisible, ou une faute ; tout + * autre commentaire est du texte. + * + * @param {string} texte + * @returns {Region[]} + * @throws {ErreurRegions} chaque faute de structure : marqueur illisible, + * début dans une région ouverte, fin sans début, fin qui ferme une autre + * région, région en double, marqueur sans fin + */ +export function lireRegions(texte) { + if (typeof texte !== 'string') throw new TypeError('les régions se lisent dans une chaîne'); + const regions = []; + const fautes = []; + const vues = new Set(); + let ouverte = null; + for (const trouve of texte.matchAll(COMMENTAIRE)) { + const [entier, corps, fermeture] = trouve; + if (!SE_VEUT_MARQUEUR.test(corps)) continue; + const lu = fermeture === '-->' ? MARQUEUR.exec(corps) : null; + if (lu === null) { + fautes.push({ region: null, faute: `marqueur illisible : ${extrait(entier)}` }); + continue; + } + const [, sorte, nom] = lu; + if (sorte === 'debut') { + if (ouverte !== null) fautes.push({ region: nom, faute: `ouverte dans la région « ${ouverte.nom} »` }); + else ouverte = { nom, debut: trouve.index + entier.length }; + } else if (ouverte === null) { + fautes.push({ region: nom, faute: 'fin sans début' }); + } else if (ouverte.nom !== nom) { + fautes.push({ region: nom, faute: `sa fin ferme la région « ${ouverte.nom} »` }); + } else { + if (vues.has(nom)) fautes.push({ region: nom, faute: 'région en double' }); + vues.add(nom); + regions.push({ nom, debut: ouverte.debut, fin: trouve.index, contenu: texte.slice(ouverte.debut, trouve.index) }); + ouverte = null; + } + } + if (ouverte !== null) fautes.push({ region: ouverte.nom, faute: 'marqueur sans fin' }); + if (fautes.length > 0) throw new ErreurRegions(fautes); + return regions; +} + +/** + * Le contenu d'une région, tel qu'engendrer l'écrit : deux sauts de ligne, + * le texte produit sans ses sauts de ligne finaux, deux sauts de ligne. + * + * @param {string} produit + * @returns {string} + */ +export function contenuDeRegion(produit) { + if (typeof produit !== 'string') throw new TypeError('un producteur rend une chaîne'); + return `\n\n${produit.replace(/\n+$/, '')}\n\n`; +} + +// Les noms des régions qu'aucun producteur ne porte, en fautes. +function nomsInconnus(regions, producteurs) { + return regions + .filter(({ nom }) => !producteurs.has(nom)) + .map(({ nom }) => ({ region: nom, faute: 'nom inconnu : aucun producteur ne le porte' })); +} + +/** + * Le texte dont chaque région porte ce que son producteur rend ; le reste, + * marqueurs compris, inchangé à l'unité près. Un texte sans région se rend + * tel quel. + * + * @param {string} texte + * @param {Map string>} producteurs par nom de région + * @returns {string} + * @throws {ErreurRegions} une faute de structure, ou un nom inconnu, avant + * tout appel d'un producteur + */ +export function remplacerRegions(texte, producteurs) { + const regions = lireRegions(texte); + const inconnus = nomsInconnus(regions, producteurs); + if (inconnus.length > 0) throw new ErreurRegions(inconnus); + let rendu = ''; + let curseur = 0; + for (const { nom, debut, fin } of regions) { + rendu += texte.slice(curseur, debut) + contenuDeRegion(producteurs.get(nom)()); + curseur = fin; + } + return rendu + texte.slice(curseur); +} + +// La première ligne où deux contenus diffèrent : son rang dans le contenu, +// et la ligne de chacun, null au-delà de sa fin. +function premiereDifference(lu, attendu) { + const a = lu.split('\n'); + const b = attendu.split('\n'); + let rang = 0; + while (rang < a.length && rang < b.length && a[rang] === b[rang]) rang += 1; + return { rang, lue: a[rang] ?? null, attendue: b[rang] ?? null }; +} + +// Le numéro, à partir de 1, de la ligne du texte où tombe l'indice. +const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length; + +/** + * Les fautes des régions d'un texte : de structure, de nom, de producteur, + * ou de contenu — une région qui diffère de ce qu'engendrer y écrirait, + * nommée avec la première ligne qui s'écarte. Un texte sans région est une + * faute. + * + * @param {string} texte + * @param {Map string>} producteurs + * @returns {FauteRegion[]} vide quand toutes les régions sont conformes + */ +export function verifierRegions(texte, producteurs) { + let regions; + try { + regions = lireRegions(texte); + } catch (erreur) { + if (erreur instanceof ErreurRegions) return erreur.fautes; + throw erreur; + } + if (regions.length === 0) return [{ region: null, faute: 'aucune région engendrée : un porteur en a au moins une' }]; + const fautes = nomsInconnus(regions, producteurs); + for (const { nom, debut, contenu } of regions) { + if (!producteurs.has(nom)) continue; + let attendu; + try { + attendu = contenuDeRegion(producteurs.get(nom)()); + } catch (erreur) { + fautes.push({ region: nom, faute: `le producteur échoue : ${erreur.message}` }); + continue; + } + if (contenu === attendu) continue; + const { rang, lue, attendue } = premiereDifference(contenu, attendu); + const ligne = ligneDe(texte, debut) + rang; + const cite = (texteLigne) => (texteLigne === null ? 'rien' : `« ${texteLigne} »`); + fautes.push({ + region: nom, + faute: + `diffère de ce que le producteur engendre, ligne ${ligne} : ${cite(lue)} au lieu de ${cite(attendue)} ; ` + + 'une correction à la main se perd au prochain engendrer', + }); + } + return fautes; +} + +// --- Les tableaux --------------------------------------------------------------- + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const { nombre } = creerFormat(t); +const VIDE = t('format.vide'); +// Les clés de l'application : un libellé d'historique y vit. +const CLES_APPLICATION = new Set(creerTraduction(TABLES_APPLICATION).cles()); + +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +// Un identifiant, en code. +const code = (texte) => `\`${texte}\``; + +// Le nombre de décimales qu'un nombre porte dans son écriture la plus +// courte, celle de String : 0,3 en porte une, 200 000 aucune. +function decimalesDe(valeur) { + const ecrit = String(valeur); + if (/e/i.test(ecrit)) throw new RangeError(`documentation : ${ecrit} ne s'écrit pas en chiffres`); + return (ecrit.split('.')[1] ?? '').length; +} + +// Une cellule : un saut de ligne, et les blancs qui l'entourent, deviennent +// une espace ; une barre s'échappe, qui fermerait la cellule. +const cellule = (texte) => String(texte).replace(/\s*(?:\r\n|\r|\n)\s*/g, ' ').replace(/\|/g, '\\|'); + +/** + * Un tableau Markdown : la ligne d'en-tête, le séparateur, une ligne par + * entrée, sans saut de ligne final. + * + * @param {string[]} entetes + * @param {Array>} lignes + * @returns {string} + */ +export function tableau(entetes, lignes) { + const ligne = (cellules) => `| ${cellules.map(cellule).join(' | ')} |`; + return [ligne(entetes), `|${entetes.map(() => '---').join('|')}|`, ...lignes.map(ligne)].join('\n'); +} + +/** + * Le tableau des réglages (§ 19.8) : chaque entrée de la définition, dans + * son ordre — son libellé, qui porte l'unité d'un champ, l'écran qui la + * montre, sa valeur par défaut. + * + * @param {ReadonlyArray} [definition] + * @returns {string} + */ +export function tableauReglages(definition = REGLAGES_GENERATION) { + const defaut = ({ genre, defaut: valeur, cle }) => { + if (genre === 'champ') return nombre(valeur); + if (genre === 'bascule') return valeur ? 'cochée' : 'décochée'; + throw new RangeError(`reglages : genre inconnu ${JSON.stringify(genre)} pour ${cle}`); + }; + return tableau( + ['Réglage', 'Écran', 'Valeur par défaut'], + definition.map((entree) => [t(entree.etiquette), t(`coquille.section.${entree.section}`), defaut(entree)]), + ); +} + +/** + * Le tableau des colonnes du CSV (§ 10.1, § 19.8) : chaque champ, dans + * l'ordre de l'export — son en-tête, puis ses synonymes. + * + * @param {ReadonlyArray]>} [entetes] + * @returns {string} + */ +export function tableauColonnesCsv(entetes = ENTETES_DES_CHAMPS) { + return tableau( + ['Colonne', 'Synonymes reconnus'], + entetes.map(([, [entete, ...synonymes]]) => [code(entete), synonymes.length === 0 ? VIDE : synonymes.map(code).join(', ')]), + ); +} + +/** + * Les détails d'exemple du libellé d'historique de chaque commande du + * registre : les noms sont inventés. Le libellé lui-même se compose par la + * traduction, de la clé ENTREE_ de la commande. + */ +export const EXEMPLES_COMMANDES = Object.freeze({ + ajouterParticipant: { personne: 'Iris Ombrelle' }, + modifierParticipant: { personne: 'Ondine Lacasse', champs: ['courriel'], devenu: null }, + exclureParticipant: { personne: 'Théo Pervenche' }, + reintegrerParticipant: { personne: 'Théo Pervenche' }, + supprimerParticipant: { personne: 'Ondine Lacasse' }, + importerParticipants: { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }, + changerEtat: { etat: 'bloque' }, + debloquer: { etat: 'retenu' }, + enregistrerGeneration: { n: 5, premier: 3, dernier: 7 }, + retenirProposition: { id: 3 }, + effacerPropositions: { n: 4, gardee: 2 }, + creerTable: { numero: 3 }, + supprimerTable: { numero: 2, reservations: 2, titres: 1 }, + deplacerTable: { numero: 3 }, + changerPlacesTable: { numero: 5, sieges: 10 }, + realignerTable: { numero: 5, sieges: 8 }, + changerDefaut: { sieges: 10, realignees: 4 }, + changerTours: { tours: 3, retirees: 2 }, + modifierReglage: { cle: 'separerAppartenances', valeur: false }, + changerCibleRedondance: { cible: 1200 }, + reserver: { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }, + retirerReservation: { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }, + deplacerReservation: { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }, + poserTitre: { libelle: 'accueil', numero: 3, siege: 1 }, + retirerTitre: { libelle: 'accueil', numero: 3, siege: 1 }, + designer: { personne: 'Iris Ombrelle', numero: 3, siege: 1, libelle: 'accueil' }, + convertirEnTous: { personne: 'Iris Ombrelle', numero: 3 }, + deplacerDansRetenu: { personne: 'Iris Ombrelle', de: 4, vers: 7, siege: null, tour: 2 }, + echangerDansRetenu: { a: 'Iris Ombrelle', deA: 4, b: 'Théo Pervenche', deB: null, tour: 2 }, + retirerDuTour: { personne: 'Iris Ombrelle', numero: 4, tour: 2 }, +}); + +// La clé du libellé d'historique d'une commande : ENTREE_ puis son nom, un +// souligné devant chaque capitale, en capitales. +const cleDHistorique = (nom) => `ENTREE_${nom.replace(/[A-Z]/g, (lettre) => `_${lettre}`).toUpperCase()}`; + +/** + * Le tableau des commandes (§ 8.2, § 19.8) : chaque commande du registre, + * dans son ordre, et un exemple de son libellé d'historique. + * + * @param {Map} [commandes] + * @param {Record} [exemples] + * @returns {string} + * @throws {RangeError} une commande sans exemple ou sans libellé + * d'historique, un exemple sans commande, chacun nommé + */ +export function tableauCommandes(commandes = COMMANDES, exemples = EXEMPLES_COMMANDES) { + const noms = [...commandes.keys()]; + const sansCommande = Object.keys(exemples).filter((nom) => !commandes.has(nom)).sort(comparerTextes); + if (sansCommande.length > 0) throw new RangeError(`commandes : exemple sans commande au registre : ${sansCommande.join(', ')}`); + return tableau( + ['Commande', "Libellé d'historique (exemple)"], + noms.map((nom) => { + if (!Object.hasOwn(exemples, nom)) throw new RangeError(`commandes : « ${nom} » n'a pas d'exemple (EXEMPLES_COMMANDES)`); + const cle = cleDHistorique(nom); + if (!CLES_APPLICATION.has(cle)) throw new RangeError(`commandes : « ${nom} » n'a pas de libellé d'historique ${cle}`); + return [code(nom), t(cle, exemples[nom])]; + }), + ); +} + +// Le nom de chaque contexte de la table des raccourcis, tel que le +// tableau le montre. +const NOMS_CONTEXTES = new Map([ + ['plan', 'Plan'], + ['grille', 'Grille des participants'], + ['champ', 'Champ de saisie'], + ['commande', 'Bouton de commande'], + ['geste', 'Geste en cours'], + ['fenetre', 'Panneau ou infobulle'], +]); + +// Le nom de chaque touche, par sa valeur de KeyboardEvent.key. +const NOMS_TOUCHES = new Map([ + ['ArrowRight', '\u{2192}'], + ['ArrowDown', '\u{2193}'], + ['ArrowLeft', '\u{2190}'], + ['ArrowUp', '\u{2191}'], + ['Enter', 'Entrée'], + [' ', 'Espace'], + ['Escape', 'Échap'], + ['Tab', 'Tab'], + ['F2', 'F2'], +]); + +// Le nom d'une touche, « Maj+ » gardé devant celui de sa touche. +function nomDeTouche(touche) { + if (touche.startsWith('Maj+')) return `Maj+${nomDeTouche(touche.slice('Maj+'.length))}`; + const nom = NOMS_TOUCHES.get(touche); + if (nom === undefined) throw new RangeError(`raccourcis : touche sans nom ${JSON.stringify(touche)}`); + return nom; +} + +/** + * Le tableau des raccourcis (§ 7.3, § 19.8) : une ligne par contexte et par + * effet, dans l'ordre de la table — le contexte, ses touches, le libellé de + * l'effet. + * + * @param {ReadonlyArray<{touche: string, contexte: string, effet: string, cle: string}>} [raccourcis] + * @returns {string} + * @throws {RangeError} une touche ou un contexte sans nom + */ +export function tableauRaccourcis(raccourcis = RACCOURCIS) { + const effets = new Map(); + for (const { touche, contexte, effet, cle } of raccourcis) { + const groupe = `${contexte}.${effet}`; + if (!effets.has(groupe)) effets.set(groupe, { contexte, cle, touches: [] }); + effets.get(groupe).touches.push(touche); + } + return tableau( + ['Où', 'Touches', 'Effet'], + [...effets.values()].map(({ contexte, cle, touches }) => { + const nom = NOMS_CONTEXTES.get(contexte); + if (nom === undefined) throw new RangeError(`raccourcis : contexte sans nom ${JSON.stringify(contexte)}`); + return [nom, touches.map(nomDeTouche).join(' ou '), t(cle)]; + }), + ); +} + +/** + * Le tableau des démonstrations (§ 15, § 19.8) : chaque fichier livré, dans + * l'ordre de LIVREES, lu puis passé au diagnostic — son nom au catalogue, + * son effectif, ses tables, ses tours, l'étendue de son plafond a priori. + * + * @param {ReadonlyArray<{cle: string, texte: string}>} [livrees] + * @returns {string} + */ +export function tableauDemonstrations(livrees = LIVREES) { + return tableau( + ['Démonstration', 'Effectif', 'Tables', 'Tours', 'Plafond a priori, minimum', 'Plafond a priori, maximum'], + livrees.map(({ cle, texte }) => { + const entree = CATALOGUE.find((candidate) => candidate.cle === cle); + if (entree === undefined) throw new RangeError(`demonstrations : « ${cle} » n'est pas au catalogue`); + const configuration = configurationDepuisCharge(analyser(texte).charge); + const { plafondAPriori } = diagnostiquer(configuration); + return [ + entree.nom, + nombre(configuration.participants.length), + nombre(configuration.tables.length), + nombre(configuration.tours), + nombre(plafondAPriori.min), + nombre(plafondAPriori.max), + ]; + }), + ); +} + +const THEMES = Object.freeze(['clair', 'sombre']); + +// Vrai pour un style relevé lisible : jeton et rôle nommés, ratio et seuil +// finis. +const styleLisible = (style) => + style !== null && + typeof style === 'object' && + typeof style.jeton === 'string' && + style.jeton !== '' && + typeof style.role === 'string' && + style.role !== '' && + Number.isFinite(style.ratio) && + Number.isFinite(style.seuil); + +/** + * Le tableau des ratios de contraste (§ 14.5, § 19.8), engendré des styles + * relevés au pilotage : une ligne par jeton et par rôle, rangée par jeton + * puis par rôle, unité UTF-16 par unité ; pour chaque thème, le plus petit + * ratio relevé et la première capture, dans l'ordre du manifeste, où il + * l'est — rien quand le thème ne relève pas ce jeton dans ce rôle. + * + * Un style relevé est { jeton, role, ratio, seuil, … } sous + * captures[].styles.clair et .sombre ; ses autres champs ne se lisent pas. + * + * @param {object|null} manifeste + * @returns {string} + * @throws {Error} un manifeste absent, un style illisible, deux seuils pour + * un même jeton dans un même rôle, aucun style relevé + */ +export function tableauContrastes(manifeste) { + if (manifeste === null || typeof manifeste !== 'object' || !Array.isArray(manifeste.captures)) { + throw new Error(`contrastes : manifeste absent ou sans liste de captures (${CHEMIN_MANIFESTE})`); + } + const groupes = new Map(); + for (const capture of manifeste.captures) { + for (const theme of THEMES) { + const styles = capture?.styles?.[theme]; + if (!Array.isArray(styles)) throw new Error(`contrastes : ${capture?.fichier} : styles.${theme} n'est pas une liste`); + for (const style of styles) { + if (!styleLisible(style)) { + throw new Error(`contrastes : ${capture.fichier}, thème ${theme} : un style sans jeton, rôle, ratio ou seuil`); + } + const { jeton, role, ratio, seuil } = style; + const cle = JSON.stringify([jeton, role]); + if (!groupes.has(cle)) groupes.set(cle, { jeton, role, seuil, clair: null, sombre: null }); + const groupe = groupes.get(cle); + if (groupe.seuil !== seuil) { + throw new Error(`contrastes : « ${jeton} », rôle ${role} : deux seuils, ${groupe.seuil} et ${seuil} (${capture.fichier})`); + } + if (groupe[theme] === null || ratio < groupe[theme].ratio) groupe[theme] = { ratio, capture: capture.fichier }; + } + } + } + if (groupes.size === 0) throw new Error('contrastes : aucun style relevé dans le manifeste'); + const ranges = [...groupes.values()].sort((a, b) => comparerTextes(a.jeton, b.jeton) || comparerTextes(a.role, b.role)); + const releve = (mesure) => (mesure === null ? [VIDE, VIDE] : [nombre(mesure.ratio, { decimales: 2 }), mesure.capture]); + return tableau( + ['Jeton', 'Rôle', 'Seuil', 'Thème clair', 'Capture', 'Thème sombre', 'Capture'], + ranges.map(({ jeton, role, seuil, clair, sombre }) => [ + code(jeton), + role, + nombre(seuil, { decimales: decimalesDe(seuil) }), + ...releve(clair), + ...releve(sombre), + ]), + ); +} + +const STATUTS = new Map([ + ['mesuree', 'mesurée'], + ['defautDeTravail', 'défaut de travail'], +]); + +/** + * Le tableau du registre des constantes (§ 14.1, § 19.8), lu seul : chaque + * constante, dans l'ordre du registre — son nom, sa valeur, son unité, son + * propriétaire, son statut en toutes lettres, son protocole. Une valeur + * relevée à l'exécution (null) s'écrit vide. + * + * @param {ReadonlyArray} [constantes] + * @returns {string} + * @throws {RangeError} un statut inconnu + */ +export function tableauRegistre(constantes = CONSTANTES) { + return tableau( + ['Constante', 'Valeur', 'Unité', 'Propriétaire', 'Statut', 'Protocole'], + constantes.map(({ nom, valeur, unite, proprietaire, statut, protocole }) => { + const enLettres = STATUTS.get(statut); + if (enLettres === undefined) throw new RangeError(`constantes : statut inconnu ${JSON.stringify(statut)} pour ${nom}`); + const ecrite = valeur === null ? VIDE : nombre(valeur, { decimales: decimalesDe(valeur) }); + return [code(nom), ecrite, unite, proprietaire, enLettres, protocole]; + }), + ); +} + +// --- Les producteurs -------------------------------------------------------------- + +/** + * @typedef {{manifeste: () => object|null}} Contexte + * manifeste : le manifeste des captures lu, null quand il manque + */ + +const producteur = (nom, produire) => Object.freeze({ nom, produire }); + +/** + * Les producteurs, par nom de région, dans l'ordre du § 19.8 : chacun + * (contexte) → le texte de sa région. + * @type {ReadonlyArray string}>>} + */ +export const PRODUCTEURS = Object.freeze([ + producteur('reglages', () => tableauReglages()), + producteur('colonnes-csv', () => tableauColonnesCsv()), + producteur('commandes', () => tableauCommandes()), + producteur('raccourcis', () => tableauRaccourcis()), + producteur('demonstrations', () => tableauDemonstrations()), + producteur('contrastes', (contexte) => tableauContrastes(contexte.manifeste())), + producteur('constantes', () => tableauRegistre()), +]); + +/** + * Les producteurs liés à un contexte, par nom, dans l'ordre de PRODUCTEURS : + * chacun produit une fois, puis rend le même texte. + * + * @param {Contexte} contexte + * @returns {Map string>} + */ +export function producteursDe(contexte) { + return new Map( + PRODUCTEURS.map(({ nom, produire }) => { + let texte = null; + return [nom, () => (texte ??= produire(contexte))]; + }), + ); +} + +// --- L'arbre --------------------------------------------------------------------- + +// Vrai pour l'erreur d'un fichier absent. +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 contexte d'un arbre : le manifeste, lu au premier besoin, une fois ; +// null quand il manque. Un manifeste qui n'est pas du JSON lève. +function contexteDe(lire) { + let lu; + return { + manifeste() { + if (lu !== undefined) return lu; + let texte; + try { + texte = lire(CHEMIN_MANIFESTE); + } catch (erreur) { + if (!estAbsent(erreur)) throw erreur; + lu = null; + return lu; + } + try { + lu = JSON.parse(texte); + } catch { + throw new Error(`${CHEMIN_MANIFESTE} : JSON illisible`); + } + return lu; + }, + }; +} + +// Une faute de région, au chemin de son porteur. +const auChemin = (chemin) => ({ region, faute }) => ({ + chemin, + faute: region === null ? faute : `région « ${region} » : ${faute}`, +}); + +// La faute d'une liste de porteurs vide. +const SANS_PORTEUR = Object.freeze({ chemin: '.', faute: 'aucun fichier porteur : un balayage vide ne passe pas' }); + +// Le texte d'un porteur, ou la faute qui dit qu'il manque. +function lirePorteur(lire, chemin) { + try { + return { texte: lire(chemin), faute: null }; + } catch (erreur) { + if (!estAbsent(erreur)) throw erreur; + return { texte: null, faute: { chemin, faute: 'porteur absent' } }; + } +} + +/** + * 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. + * + * @param {{racine?: string, porteurs?: ReadonlyArray, livraison?: boolean, + * version?: string, lire?: (chemin: string) => string}} 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 + */ +export function verifierArbre({ racine = RACINE, porteurs = PORTEURS, livraison = false, version = VERSION.affichee, lire = lecteurDe(racine) } = {}) { + const contexte = contexteDe(lire); + const producteurs = producteursDe(contexte); + const fautes = porteurs.length === 0 ? [SANS_PORTEUR] : []; + let regions = 0; + for (const chemin of porteurs) { + const { texte, faute } = lirePorteur(lire, chemin); + if (faute !== null) { + fautes.push(faute); + continue; + } + fautes.push(...verifierRegions(texte, producteurs).map(auChemin(chemin))); + try { + regions += lireRegions(texte).length; + } catch (erreur) { + if (!(erreur instanceof ErreurRegions)) throw erreur; + } + } + let ecart = null; + try { + const manifeste = contexte.manifeste(); + ecart = manifeste === null ? { manifeste: null, construction: version } : ecartDeVersion(manifeste, version); + } 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 }; +} + +/** + * L'engendrement d'un arbre : chaque porteur reçoit ses régions produites ; + * seuls ceux qui changent s'écrivent, et rien ne s'écrit quand un porteur + * est en faute. + * + * @param {{racine?: string, porteurs?: ReadonlyArray, lire?: (chemin: string) => string, + * ecrire?: (chemin: string, texte: string) => void}} options + * @returns {{fautes: Faute[], ecrits: string[], code: 0|1}} + */ +export function engendrerArbre({ + racine = RACINE, + porteurs = PORTEURS, + lire = lecteurDe(racine), + ecrire = (chemin, texte) => writeFileSync(join(racine, chemin), texte), +} = {}) { + const producteurs = producteursDe(contexteDe(lire)); + const fautes = porteurs.length === 0 ? [SANS_PORTEUR] : []; + const aEcrire = []; + for (const chemin of porteurs) { + const { texte, faute } = lirePorteur(lire, chemin); + if (faute !== null) { + fautes.push(faute); + continue; + } + try { + if (lireRegions(texte).length === 0) { + fautes.push({ chemin, faute: 'aucune région engendrée : un porteur en a au moins une' }); + continue; + } + const rendu = remplacerRegions(texte, producteurs); + if (rendu !== texte) aEcrire.push([chemin, rendu]); + } catch (erreur) { + if (erreur instanceof ErreurRegions) fautes.push(...erreur.fautes.map(auChemin(chemin))); + else fautes.push({ chemin, faute: `le producteur échoue : ${erreur.message}` }); + } + } + if (fautes.length > 0) return { fautes, ecrits: [], code: 1 }; + for (const [chemin, rendu] of aEcrire) ecrire(chemin, rendu); + return { fautes, ecrits: aEcrire.map(([chemin]) => chemin), code: 0 }; +} + +const USAGE = + 'usage : node scripts/documentation.js engendrer | verifier [--livraison] [--racine ] [--porteur ]…'; + +// Les arguments lus : { mode, livraison, racine, porteurs }, ou null quand +// ils ne se lisent pas. +function lireArguments(argv) { + const [mode, ...reste] = argv; + if (mode !== 'engendrer' && mode !== 'verifier') return null; + const lus = { mode, livraison: false, racine: null, porteurs: [] }; + for (let rang = 0; rang < reste.length; rang += 1) { + const option = reste[rang]; + if (option === '--livraison' && mode === 'verifier') lus.livraison = true; + else if ((option === '--racine' || option === '--porteur') && rang + 1 < reste.length) { + rang += 1; + if (option === '--racine') lus.racine = resolve(reste[rang]); + else lus.porteurs.push(reste[rang]); + } else return null; + } + return lus; +} + +/** + * La ligne de commande : lit ses arguments, lance le mode, écrit son bilan + * ligne à ligne sur la sortie. + * + * @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, + * 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`) } = {}) { + const lus = lireArguments(argv); + if (lus === null) { + sortie(USAGE); + return 2; + } + const options = { + racine: lus.racine ?? racine, + porteurs: lus.porteurs.length > 0 ? lus.porteurs : porteurs, + ...(lire === undefined ? {} : { lire }), + }; + const ecrireFautes = (fautes) => fautes.forEach(({ chemin, faute }) => sortie(`${chemin} : ${faute}`)); + if (lus.mode === 'engendrer') { + const bilan = engendrerArbre({ ...options, ...(ecrire === undefined ? {} : { ecrire }) }); + ecrireFautes(bilan.fautes); + bilan.ecrits.forEach((chemin) => sortie(`engendré : ${chemin}`)); + 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 }) }); + 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)`); + return bilan.code; +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = executer(process.argv.slice(2)); +} diff --git a/scripts/documentation.test.js b/scripts/documentation.test.js new file mode 100644 index 0000000..8f5bb27 --- /dev/null +++ b/scripts/documentation.test.js @@ -0,0 +1,618 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des régions engendrées de la documentation (§ 19.8, § 19.7) : la +// lecture des marqueurs et ses refus, le remplacement qui ne touche rien hors +// des régions, la vérification qui nomme la région corrigée à la main, les +// sept producteurs lus sur leur source unique, l'arbre des fichiers porteurs +// et l'écart de version, non bloquant au cycle court et bloquant à la +// livraison. L'arbre de test/fixtures/documentation/ porte un document aux +// régions conformes et un manifeste inventé, sans version réelle : chaque +// épreuve qui en a besoin l'écrit dans une copie temporaire. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from '../test/lanceur.js'; +import { versionVoisine } from '../test/version_voisine.js'; +import { creerFormat } from '../src/application/format.js'; +import { COMMANDES } from '../src/application/commandes.js'; +import { TABLES_APPLICATION, libelle } from '../src/application/libelles.js'; +import { creerTraduction } from '../src/application/traduction.js'; +import { ENTETES_DES_CHAMPS } from '../src/csv/colonnes.js'; +import { LIVREES } from '../src/demo/livrees.js'; +import { CATALOGUE } from '../src/demo/catalogue.js'; +import { CONSTANTES } from '../src/geometrie/constantes.js'; +import { REGLAGES_GENERATION } from '../src/interface/generation/reglages.js'; +import { TABLES_INTERFACE } from '../src/interface/libelles.js'; +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 { + ErreurRegions, + EXEMPLES_COMMANDES, + PORTEURS, + PRODUCTEURS, + engendrerArbre, + executer, + lireRegions, + producteursDe, + remplacerRegions, + tableau, + tableauColonnesCsv, + tableauCommandes, + tableauContrastes, + tableauDemonstrations, + tableauRaccourcis, + tableauReglages, + tableauRegistre, + verifierArbre, + verifierRegions, +} from './documentation.js'; + +const FIXTURE = fileURLToPath(new URL('../test/fixtures/documentation/', import.meta.url)); +const SCRIPT = fileURLToPath(new URL('./documentation.js', import.meta.url)); +const MANIFESTE = 'doc/captures/manifeste.json'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const { nombre } = creerFormat(t); + +// Producteurs inventés, comptés : chaque appel s'inscrit dans appels. +function producteursComptes(textes) { + const appels = []; + const producteurs = new Map( + Object.entries(textes).map(([nom, texte]) => [ + nom, + () => { + appels.push(nom); + return texte; + }, + ]), + ); + return { producteurs, appels }; +} + +const debut = (nom) => ``; +const fin = (nom) => ``; +const region = (nom, contenu) => `${debut(nom)}${contenu}${fin(nom)}`; + +// Les lignes d'un tableau Markdown, sans en-tête ni séparateur : chacune la +// liste de ses cellules, une barre précédée d'une barre oblique inverse +// restant dans sa cellule. +function lignesDuTableau(texte) { + return texte + .split('\n') + .slice(2) + .map((ligne) => + ligne + .slice(1, -1) + .split(/(? cellule.trim()), + ); +} + +// Le texte privé du contenu de ses régions : ce qui reste hors des marqueurs, +// marqueurs compris. +function horsRegions(texte) { + let reste = ''; + let curseur = 0; + for (const { debut: d, fin: f } of lireRegions(texte)) { + reste += texte.slice(curseur, d); + curseur = f; + } + return reste + texte.slice(curseur); +} + +// Une copie temporaire de l'arbre de fixture, dont chaque capture porte la +// version donnée (aucune version n'est écrite en clair, § 18.5, point 6) ; +// rendue avec la fonction qui l'efface. +function copieDeLArbre(version = VERSION.affichee) { + const racine = mkdtempSync(join(tmpdir(), 'gtt-documentation-')); + cpSync(FIXTURE, racine, { recursive: true }); + const chemin = join(racine, MANIFESTE); + const manifeste = JSON.parse(readFileSync(chemin, 'utf8')); + for (const capture of manifeste.captures) capture.versionReelle = version; + writeFileSync(chemin, `${JSON.stringify(manifeste, null, 2)}\n`); + return { racine, effacer: () => rmSync(racine, { recursive: true, force: true }) }; +} + +function manifesteFixture() { + return JSON.parse(readFileSync(join(FIXTURE, MANIFESTE), 'utf8')); +} + +// Une sortie qui retient ses lignes. +function sortieRetenue() { + const lignes = []; + return { lignes, ecrire: (ligne) => lignes.push(ligne) }; +} + +describe('documentation : lire les régions (§ 19.8)', () => { + test('deux régions, dans leur ordre, chacune son nom, ses bornes et son contenu', () => { + const texte = `Avant\n${region('reglages', '\nA\n')}\nEntre\n${region('raccourcis', '')}\nAprès\n`; + const regions = lireRegions(texte); + assert.deepEqual( + regions.map(({ nom, contenu }) => ({ nom, contenu })), + [ + { nom: 'reglages', contenu: '\nA\n' }, + { nom: 'raccourcis', contenu: '' }, + ], + ); + for (const { debut: d, fin: f, contenu } of regions) assert.equal(texte.slice(d, f), contenu); + assert.deepEqual(lireRegions('Aucune région ici.\n\n'), []); + }); + + test('un marqueur sans fin : échec qui nomme la région', () => { + assert.throws( + () => lireRegions(`${debut('reglages')}\ncontenu\n`), + (erreur) => { + assert.ok(erreur instanceof ErreurRegions); + assert.deepEqual(erreur.fautes.map((f) => f.region), ['reglages']); + assert.match(erreur.fautes[0].faute, /sans fin/); + return true; + }, + ); + }); + + test('une fin sans début, une région ouverte dans une autre, une fin qui en ferme une autre : échecs nommés', () => { + const cas = [ + [`texte\n${fin('commandes')}\n`, 'commandes', /sans début/], + [`${debut('reglages')}\n${region('commandes', '')}\n${fin('reglages')}`, 'commandes', /ouverte dans la région « reglages »/], + [`${debut('reglages')}\n${fin('commandes')}\n`, 'commandes', /ferme la région « reglages »/], + ]; + for (const [texte, nom, motif] of cas) { + assert.throws( + () => lireRegions(texte), + (erreur) => { + assert.ok(erreur instanceof ErreurRegions, texte); + const faute = erreur.fautes.find((f) => f.region === nom); + assert.ok(faute !== undefined, `${texte} : ${JSON.stringify(erreur.fautes)}`); + assert.match(faute.faute, motif); + return true; + }, + ); + } + }); + + test('une région en double : échec qui la nomme', () => { + assert.throws( + () => lireRegions(`${region('raccourcis', '\n')}\n${region('raccourcis', '\n')}\n`), + (erreur) => { + assert.ok(erreur instanceof ErreurRegions); + assert.deepEqual(erreur.fautes.map((f) => [f.region, /en double/.test(f.faute)]), [['raccourcis', true]]); + return true; + }, + ); + }); + + test('un marqueur illisible — sans nom, nom en capitales, mot inconnu, commentaire jamais fermé — est un échec, jamais un texte ordinaire', () => { + for (const marqueur of [ + '', + '', + '', + ' diff --git a/test/fixtures/documentation/README.md b/test/fixtures/documentation/README.md new file mode 100644 index 0000000..7e96ee6 --- /dev/null +++ b/test/fixtures/documentation/README.md @@ -0,0 +1,21 @@ +# Arbre d'épreuve des régions engendrées + +Un petit arbre inventé que lisent les épreuves de `scripts/documentation.js`. +Son seul fichier porteur est ce document ; sa région se nourrit du manifeste +de `doc/captures/`, qui ne porte aucune version réelle : l'épreuve l'y écrit +dans une copie temporaire. + +## Contrastes + + + +| Jeton | Rôle | Seuil | Thème clair | Capture | Thème sombre | Capture | +|---|---|---|---|---|---|---| +| `encre` | marque | 3 | — | — | 5,50 | 02-import-apercu | +| `encre` | texte | 4,5 | 9,81 | 02-import-apercu | 11,20 | 01-premier-ecran | +| `encre-douce` | texte | 4,5 | 5,20 | 01-premier-ecran | 4,62 | 02-import-apercu | +| `focus` | marque | 3 | 3,40 | 01-premier-ecran | 3,90 | 02-import-apercu | + + + +Le texte qui suit la région ne bouge pas quand elle s'engendre. diff --git a/test/fixtures/documentation/doc/captures/manifeste.json b/test/fixtures/documentation/doc/captures/manifeste.json new file mode 100644 index 0000000..f430861 --- /dev/null +++ b/test/fixtures/documentation/doc/captures/manifeste.json @@ -0,0 +1,67 @@ +{ + "format": 1, + "pilote": { + "navigateur": "chromium", + "versionNavigateur": "inventee", + "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"], + "styles": { + "clair": [ + { "jeton": "encre", "role": "texte", "ratio": 12.6, "seuil": 4.5 }, + { "jeton": "encre-douce", "role": "texte", "ratio": 5.2, "seuil": 4.5 }, + { "jeton": "focus", "role": "marque", "ratio": 3.4, "seuil": 3 } + ], + "sombre": [ + { "jeton": "encre", "role": "texte", "ratio": 11.2, "seuil": 4.5 }, + { "jeton": "encre-douce", "role": "texte", "ratio": 4.9, "seuil": 4.5 } + ] + }, + "etat": null, + "pdf": null, + "image": { "sha256": "0000000000000000000000000000000000000000000000000000000000000001", "largeur": 1366, "hauteur": 700 }, + "empreinte": "0000000000000000000000000000000000000000000000000000000000000002" + }, + { + "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:30:00-04:00", + "texte": ["Importer des participants", "Club des Merles"], + "styles": { + "clair": [ + { "jeton": "encre", "role": "texte", "ratio": 9.81, "seuil": 4.5 }, + { "jeton": "focus", "role": "marque", "ratio": 3.4, "seuil": 3 } + ], + "sombre": [ + { "jeton": "encre-douce", "role": "texte", "ratio": 4.62, "seuil": 4.5 }, + { "jeton": "focus", "role": "marque", "ratio": 3.9, "seuil": 3 }, + { "jeton": "encre", "role": "marque", "ratio": 5.5, "seuil": 3 } + ] + }, + "etat": null, + "pdf": null, + "image": { "sha256": "0000000000000000000000000000000000000000000000000000000000000003", "largeur": 1366, "hauteur": 700 }, + "empreinte": "0000000000000000000000000000000000000000000000000000000000000004" + } + ], + "reportees": [] +} diff --git a/vitest.config.js b/vitest.config.js index 21011d3..bd2a5aa 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -139,6 +139,10 @@ export default defineConfig({ testsToRun: () => [...RELANCES_D_UN_COMPOSANT] }, { pattern: /(^|\/)src\/interface\/jetons\.css$/, testsToRun: () => [...RELANCES_DES_JETONS] }, + // Les documents porteurs de régions engendrées et le manifeste des + // captures, que scripts/documentation.js lit par le disque (§ 19.8). + { pattern: /(^|\/)(README\.md|GUIDE-[^/]*\.md|doc\/captures\/manifeste\.json)$/, + testsToRun: () => 'scripts/documentation.test.js' }, ], projects: [ { test: {