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: {