gestion_table_tournante_libre/test/navigateur/captures.js
Mathieu Benoit 43dbfad8a5 [ADD] docs: photographed walkthrough publishes doc/captures
The screenshots must come from the tested walkthrough, not from a separate
script that can drift. The browser walkthrough runs on the simulated file
system with a driven clock and the direct executor, light theme, 1366 x
700 at scale 1, transitions off, and photographs each step once its
condition holds. make captures writes the images and the manifest; the
plain test run compares them instead. Eighteen captures are published, the
later ones named as postponed. The forged-names scan skips the manifest,
which records the demo names shown on screen.
Checked: red first; 3620 node, 681 browser and 101 long tests green from
the index alone.

--- FR ---

[ADD] docs : parcours photographié, publication de doc/captures

Les captures doivent venir du parcours éprouvé, non d'un script à part qui
dérive. Le parcours navigateur joue sur le système de fichiers simulé,
l'horloge pilotée et l'exécuteur direct, thème clair, 1366 × 700 au facteur
1, transitions coupées, et photographie chaque étape quand sa condition
tient. make captures écrit les images et le manifeste ; la série ordinaire
les compare au lieu de les écrire. Dix-huit captures sont publiées, les
suivantes nommées reportées. Le balayage des noms forgés saute le
manifeste, qui relève les noms de démonstration affichés.
Vérifié : rouge d'abord ; 3620 node, 681 navigateur et 101 longues vertes
depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 15:33:59 -04:00

772 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les captures d'écran du parcours, côté page (§ 19.2 à § 19.5, § 19.7,
// § 19.11, § 14.5). photographier(m, fichier) contrôle l'écran du montage
// m, relève ce que le manifeste en garde, puis, selon le mode, compare
// l'entrée à celle du manifeste ou dépose image et entrée pour la
// publication. Les fichiers, le manifeste et la capture d'élément sont de
// node : les commandes de test/navigateur/commandes_documentation.js.
//
// Le mode vient de provide (vitest.config.js, GTT_CAPTURES) : 'verifier'
// par défaut, 'ecrire' sous make captures ; une option le remplace pour une
// épreuve du mécanisme, sur un dossier d'épreuve.
//
// Les relevés de contraste des textes et des marques vivent ici, une seule
// fois : l'épreuve transversale les lit sur chaque écran, photographier les
// range en styles du manifeste, par jeton et par rôle, dans les deux thèmes.
// Un relevé complet amène dans la fenêtre l'élément qu'il mesure ; le
// relevé de la fenêtre ne fait rien défiler et ne lit que ce que l'image
// montre. photographier rend après chaque thème, à chaque élément qui
// défile, sa position d'avant.
import { inject } from 'vitest';
import { commands } from 'vitest/browser';
import { TABLES_APPLICATION } from '../../src/application/libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from '../../src/application/traduction.js';
import { constante } from '../../src/geometrie/constantes.js';
import JETONS_CSS from '../../src/interface/jetons.css?raw';
import { TABLES_INTERFACE } from '../../src/interface/libelles.js';
import { MARQUES, TEXTES } from '../../src/interface/marques.js';
import { serialiserCharge } from '../../src/stockage/canonique.js';
import { analyser } from '../../src/stockage/document.js';
import { SUFFIXES } from '../../src/stockage/noms.js';
import { VERSION } from '../../src/version.genere.js';
import { MODE_AFFICHAGE } from '../chromium.js';
import { CHAINE_TEMOIN, longueurTemoin } from '../temoin.js';
import { feuillePosee } from './feuilles.js';
import {
attenuations,
composantes,
contraste,
couleurTexte,
couleursPeintes,
designation,
fondPeintSous,
porteursDeTexte,
sousTheme,
visible,
} from './rendu.js';
/** Le cadre d'affichage des captures, en px CSS (§ 19.4). */
export const CADRE = Object.freeze({ largeur: 1366, hauteur: 700 });
// Seuils du § 14.5 : le texte, les marques porteuses d'information.
const SEUIL_TEXTE = 4.5;
const SEUIL_MARQUE = 3;
// --- Les aides des relevés ---------------------------------------------------------
// Une couleur en texte, pour un message ; l'égalité de deux couleurs ; un
// texte aplati, réduit à soixante caractères ; un élément rendu.
export const rgb = (c) => `rgb(${c.slice(0, 3).join(', ')}${c[3] === 1 ? '' : `, ${c[3]}`})`;
export const egales = (a, b) => a.join() === b.join();
export const extrait = (texte) => {
const plat = texte.replace(/\s+/g, ' ').trim();
return plat.length > 60 ? `${plat.slice(0, 57)}…` : plat;
};
export const rendu = (element) => element.checkVisibility({ visibilityProperty: true, opacityProperty: true });
// Les conteneurs désignés qui situent un élément dans son écran.
const REPERES = '[data-liste], [data-siege], [data-table], [data-pastille], [data-panneau], [data-menu], [data-section], [data-ecran], [data-bandeau]';
// Le nom d'un élément, suivi de celui du plus proche conteneur désigné qui
// le porte : un tspan se situe par sa liste, une pastille par sa table.
export function situer(element) {
const repere = element.parentElement?.closest(REPERES) ?? null;
return repere === null ? designation(element) : `${designation(element)} dans ${designation(repere)}`;
}
// --- Les contrastes des textes et les atténuations --------------------------------
// Vrai quand la boîte de l'élément coupe la fenêtre, bords exclus.
function coupeLaFenetre(element) {
const b = element.getBoundingClientRect();
return b.right > 0 && b.bottom > 0 && b.left < innerWidth && b.top < innerHeight;
}
// Vrai quand la boîte de l'élément tient entière dans la fenêtre et dans
// chaque ancêtre qui rogne ce qui le déborde — overflow autre que visible,
// ou un <svg> —, à un demi-pixel près.
function entiereDansLaFenetre(element) {
const b = element.getBoundingClientRect();
const dedans = (r) => b.left >= r.left - 0.5 && b.top >= r.top - 0.5 && b.right <= r.right + 0.5 && b.bottom <= r.bottom + 0.5;
if (!dedans({ left: 0, top: 0, right: innerWidth, bottom: innerHeight })) return false;
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
const rogne = ancetre.localName === 'svg' || getComputedStyle(ancetre).overflow !== 'visible';
if (rogne && !dedans(ancetre.getBoundingClientRect())) return false;
}
return true;
}
// Amène un élément dans la fenêtre, s'il en sort.
function amener(element) {
const b = element.getBoundingClientRect();
if (b.left < 0 || b.top < 0 || b.right > innerWidth || b.bottom > innerHeight) {
element.scrollIntoView({ block: 'center', inline: 'nearest' });
}
}
// Le <svg> le plus extérieur qui contient un élément SVG.
function svgExterieur(element) {
let svg = element.ownerSVGElement ?? element;
while (svg.ownerSVGElement !== null) svg = svg.ownerSVGElement;
return svg;
}
// Feuille qui rend chaque élément atteignable le temps d'une lecture, comme
// celle de rendu.js : elementsFromPoint suit l'atteinte du pointeur.
const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }';
// Le point (x, y) de la fenêtre montre-t-il le dessin qui porte l'élément ?
// Dans la fenêtre, et le <svg> extérieur, ou l'un de ses descendants, parmi
// les éléments présents au point : un ancêtre qui défile rogne ce qui
// déborde de sa boîte, et le point rogné n'atteint plus le dessin.
function montreLeDessin(element, x, y) {
if (!(x >= 0 && y >= 0 && x < innerWidth && y < innerHeight)) return false;
const svg = svgExterieur(element);
const feuille = document.createElement('style');
feuille.textContent = ATTEIGNABLE;
document.head.append(feuille);
try {
return document.elementsFromPoint(x, y).some((n) => svg.contains(n));
} finally {
feuille.remove();
}
}
// Le centre de la boîte d'un élément SVG montre-t-il son dessin ?
function centreDansLeDessin(element) {
const b = element.getBoundingClientRect();
return montreLeDessin(element, b.left + b.width / 2, b.top + b.height / 2);
}
// Le plus faible contraste d'une couleur sur une liste de fonds.
const pire = (couleur, fonds) =>
fonds.map((fond) => ({ fond, ratio: contraste(couleur, fond) })).reduce((a, b) => (b.ratio < a.ratio ? b : a));
// Les champs de saisie dont la valeur ou le texte indicatif se peint.
export const CHAMPS = 'input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]), select, textarea';
/**
* Contrastes des textes rendus sous racine (§ 14.5) : chaque porteur de
* texte et chaque champ de saisie, son texte indicatif compris, sur le fond
* peint dessous ; un texte SVG par son fill sur la forme dessous. Un porteur
* que l'œil ne voit pas — couvert, rogné, de boîte vide — n'est pas mesuré.
* → { fautes, attenuations, couleurs: Map(clé → couleurs), mesures, ratios },
* ratios : { couleur, fond, ratio } de chaque texte mesuré, le fond le plus
* défavorable, dans l'ordre des mesures. fenetre vrai : seuls les éléments
* dont la boîte coupe la fenêtre se mesurent, sans rien amener — ce que
* montre une capture d'écran.
*/
export function releverTextes(racine, { fenetre = false } = {}) {
const fautes = [];
const fautesAttenuation = [];
const couleurs = new Map();
const ratios = [];
let mesures = 0;
const mesurer = (cle, nom, couleurDe, fondDe, cible) => {
let couleur;
let fonds;
try {
couleur = couleurDe();
fonds = fondDe();
} catch (erreur) {
fautes.push(`${nom} : ${erreur.message}`);
return;
}
mesures += 1;
if (fonds.length === 0) {
fautes.push(`${nom} : aucun fond opaque dessous`);
return;
}
try {
const { fond, ratio } = pire(couleur, fonds);
ratios.push({ couleur, fond, ratio });
if (ratio < SEUIL_TEXTE) fautes.push(`${nom} : ${ratio.toFixed(2)}:1, ${rgb(couleur)} sur ${rgb(fond)}`);
} catch (erreur) {
fautes.push(`${nom} : ${erreur.message}`);
}
couleurs.set(cle, `${rgb(couleur)} sur ${fonds.map(rgb).join(' + ')}`);
for (const a of attenuations(cible)) fautesAttenuation.push(`${nom} : ${designation(a)} ${a.propriete}: ${a.valeur}`);
};
for (const [rang, porteur] of porteursDeTexte(racine).entries()) {
const { element } = porteur;
if (fenetre && !coupeLaFenetre(element)) continue;
if (!rendu(element)) continue;
const b = element.getBoundingClientRect();
if (b.width === 0 || b.height === 0) continue;
if (!fenetre) amener(element);
const svg = element instanceof SVGElement;
if (!svg && visible(porteur) !== true) continue;
// Un texte SVG se lit par la géométrie du dessin, non par l'atteinte
// d'un point : son centre doit tomber dans le <svg> extérieur, qui rogne
// ce qui le déborde, et dans la fenêtre.
if (svg && !centreDansLeDessin(element)) continue;
const texte = porteur.pseudo === null ? [...element.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => n.textContent).join('') : '';
const nom = `${situer(element)}${porteur.pseudo ?? ''} « ${extrait(texte)} »`;
mesurer(`${rang}:${designation(porteur)}`, nom, () => couleurTexte(porteur), () => fondPeintSous(porteur), porteur);
}
for (const [rang, champ] of [...racine.querySelectorAll(CHAMPS)].entries()) {
if (fenetre && !coupeLaFenetre(champ)) continue;
if (!rendu(champ)) continue;
if (!fenetre) amener(champ);
if (visible(champ) !== true) continue;
const nom = designation(champ);
mesurer(`champ ${rang}:${nom}`, `${nom} (valeur)`, () => composantes(getComputedStyle(champ).color), () => fondPeintSous(champ), champ);
if ((champ.getAttribute('placeholder') ?? '') !== '') {
mesurer(`indicatif ${rang}:${nom}`, `${nom}::placeholder`, () => composantes(getComputedStyle(champ, '::placeholder').color), () => fondPeintSous(champ), champ);
}
}
return { fautes, attenuations: fautesAttenuation, couleurs, mesures, ratios };
}
// --- Les marques -------------------------------------------------------------------
/** La couleur résolue d'un jeton dans le thème courant. */
export function couleurDeJeton(jeton) {
const sonde = document.createElement('span');
sonde.style.color = `var(${jeton})`;
document.body.append(sonde);
try {
return composantes(getComputedStyle(sonde).color);
} finally {
sonde.remove();
}
}
// Des points, dans le repère propre d'une forme, où elle peint quoi
// ('fill' ou 'stroke') et que la fenêtre montre, POINTS_MAX au plus : le
// centre de sa boîte, les milieux de ses bords, puis une grille de neuf sur
// neuf, dans cet ordre ; aucun pour une forme rognée en entier.
const POINTS_MAX = 8;
function pointsPeints(forme, quoi) {
const b = forme.getBBox();
const candidats = [
[b.x + b.width / 2, b.y + b.height / 2],
[b.x + b.width / 2, b.y],
[b.x + b.width, b.y + b.height / 2],
[b.x + b.width / 2, b.y + b.height],
[b.x, b.y + b.height / 2],
];
for (let i = 0; i <= 8; i += 1) for (let j = 0; j <= 8; j += 1) candidats.push([b.x + (b.width * i) / 8, b.y + (b.height * j) / 8]);
const versEcran = forme.getScreenCTM();
const points = [];
for (const [x, y] of candidats) {
const point = new DOMPoint(x, y);
if (!(quoi === 'stroke' ? forme.isPointInStroke(point) : forme.isPointInFill(point))) continue;
const ecran = point.matrixTransform(versEcran);
if (montreLeDessin(forme, ecran.x, ecran.y)) points.push(point);
if (points.length === POINTS_MAX) break;
}
return points;
}
// Ce qui est peint sous la plupart des points où la forme peint : les
// couleurs dessous en chaque point, puis la liste la plus fréquente, la
// première à égalité. Un trait qui croise un autre trait en un point se
// juge sur ce qui l'entoure, non sur ce croisement.
function dessousLePlusFrequent(forme, quoi) {
const comptes = new Map();
const points = pointsPeints(forme, quoi);
if (points.length === 0) return null;
for (const point of points) {
const couleurs = dessousAuPoint(forme, point);
const cle = couleurs.map(rgb).join(' + ');
const vu = comptes.get(cle) ?? { couleurs, n: 0 };
vu.n += 1;
comptes.set(cle, vu);
}
return [...comptes.values()].reduce((a, b) => (b.n > a.n ? b : a)).couleurs;
}
// Les couleurs peintes sous une forme SVG au point donné de son repère : une
// sonde sans peinture, posée juste avant elle chez son parent, se mesure par
// fondPeintSous, qui lit les formes peintes avant elle à ce point, puis ce
// qui est sous le dessin.
function dessousAuPoint(forme, point) {
const ecran = point.matrixTransform(forme.getScreenCTM());
const parent = forme.parentNode;
const matrice = parent.getScreenCTM();
const local = ecran.matrixTransform(matrice.inverse());
const cote = 0.5 / Math.hypot(matrice.a, matrice.b);
const sonde = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
sonde.setAttribute('x', String(local.x - cote / 2));
sonde.setAttribute('y', String(local.y - cote / 2));
sonde.setAttribute('width', String(cote));
sonde.setAttribute('height', String(cote));
sonde.style.fill = 'none';
sonde.style.stroke = 'none';
parent.insertBefore(sonde, forme);
try {
return fondPeintSous(sonde);
} finally {
sonde.remove();
}
}
// Les bords d'un élément HTML qui se peignent, et leurs couleurs.
export function bordsPeints(style) {
return ['top', 'right', 'bottom', 'left']
.filter((cote) => style.getPropertyValue(`border-${cote}-style`) !== 'none' && parseFloat(style.getPropertyValue(`border-${cote}-width`)) > 0)
.map((cote) => composantes(style.getPropertyValue(`border-${cote}-color`)));
}
/**
* Ce qu'une marque peint de son jeton, et ce qui l'entoure : { peinture,
* voisins }. Une forme SVG : son remplissage — une trame se mesure contre
* les autres couleurs de son motif, un aplat contre ce qui est peint dessous
* en un point qu'il couvre — ou son trait, contre ce qui est dessous en un
* point du trait et contre son propre remplissage. Un élément HTML : sa
* bordure ou son fond, contre ce qui est peint sous son parent, et son fond
* propre pour une bordure. peinture null : le jeton n'est peint nulle part.
*/
function voisinage(element, jeton) {
if (element instanceof SVGGeometryElement) {
const motif = /^url\(/.test(getComputedStyle(element).fill);
const { fill, stroke } = couleursPeintes(element);
if (fill.some((c) => egales(c, jeton))) {
if (motif) return { peinture: 'trame', voisins: fill.filter((c) => !egales(c, jeton)) };
const dessous = dessousLePlusFrequent(element, 'fill');
return dessous === null ? { peinture: 'cachee', voisins: [] } : { peinture: 'remplissage', voisins: dessous };
}
if (stroke.some((c) => egales(c, jeton))) {
const dessous = dessousLePlusFrequent(element, 'stroke');
return dessous === null ? { peinture: 'cachee', voisins: [] } : { peinture: 'trait', voisins: [...dessous, ...fill] };
}
return { peinture: null, voisins: [] };
}
const style = getComputedStyle(element);
const fond = composantes(style.backgroundColor);
if (bordsPeints(style).some((c) => egales(c, jeton))) {
return { peinture: 'bordure', voisins: [...(fond[3] === 1 ? [fond] : []), ...fondPeintSous(element.parentElement)] };
}
if (egales(fond, jeton)) return { peinture: 'fond', voisins: fondPeintSous(element.parentElement) };
return { peinture: null, voisins: [] };
}
/**
* Les marques rendues sous racine (§ 14.5, § 19.5) : chacune nommée au
* registre, peinte de son jeton, à 3:1 de ce qui l'entoure, sans
* atténuation. → { vues: string[], fautes, attenuations, mesures, ratios },
* ratios : { marque, jeton, ratio } de chaque marque mesurée, contre ce qui
* l'entoure de plus défavorable, dans l'ordre des mesures. fenetre vrai :
* seules les marques que la fenêtre montre entières se lisent, sans rien
* amener : une marque rognée par le bord de la fenêtre ou d'un ancêtre qui
* rogne ne montre qu'une partie de ce qui l'entoure — une chaise coupée par
* le bord du plan ne garde parfois, de son trait, que le point où il touche
* le trait du plateau.
*/
export function releverMarques(racine, { fenetre = false } = {}) {
const vues = new Set();
const ratios = [];
const fautes = [];
const fautesAttenuation = [];
let mesures = 0;
for (const element of racine.querySelectorAll('[data-marque]')) {
if (fenetre && !entiereDansLaFenetre(element)) continue;
if (!rendu(element)) continue;
const nom = element.dataset.marque;
const entree = MARQUES.find((m) => m.nom === nom);
if (entree === undefined) {
fautes.push(`${situer(element)} : marque hors du registre MARQUES`);
continue;
}
for (const a of attenuations(element)) fautesAttenuation.push(`${situer(element)} : ${designation(a)} ${a.propriete}: ${a.valeur}`);
// Sous une fenêtre modale, la page est inerte et le test d'atteinte
// rencontre la fenêtre d'abord : une marque HTML de la page ne s'y
// mesure pas, comme ses textes, et se mesure sur les écrans sans
// fenêtre. Le dessin SVG, lu par sa géométrie, se mesure partout. Une
// marque ne compte comme vue que là où elle se mesure.
const modale = document.querySelector('dialog:modal');
if (modale !== null && !(element instanceof SVGElement) && !modale.contains(element)) continue;
vues.add(nom);
if (!fenetre) amener(element);
const jeton = couleurDeJeton(entree.jeton);
try {
const { peinture, voisins } = voisinage(element, jeton);
if (peinture === null) {
fautes.push(`${situer(element)} : ne peint pas son jeton ${entree.jeton} (${rgb(jeton)})`);
continue;
}
// Rognée par un ancêtre qui défile, la marque ne se voit pas ici.
if (peinture === 'cachee') continue;
if (voisins.length === 0) {
fautes.push(`${situer(element)} : ${peinture} sans rien d'opaque autour`);
continue;
}
mesures += 1;
const { fond, ratio } = pire(jeton, voisins);
ratios.push({ marque: nom, jeton: entree.jeton, ratio });
if (ratio < SEUIL_MARQUE) fautes.push(`${situer(element)} (${peinture}) : ${ratio.toFixed(2)}:1, ${rgb(jeton)} contre ${rgb(fond)}`);
} catch (erreur) {
fautes.push(`${situer(element)} : ${erreur.message}`);
}
}
return { vues: [...vues], fautes, attenuations: fautesAttenuation, mesures, ratios };
}
// --- La capture d'écran d'une étape ---------------------------------------------------
const THEMES = Object.freeze(['clair', 'sombre']);
// Le corps, en px, de la chaîne témoin mesurée : grand, pour que l'écart
// relatif lise la police et non l'arrondi d'un pixel.
const CORPS_TEMOIN = 100;
const SVG = 'http://www.w3.org/2000/svg';
// Les éléments dont le texte ne se peint jamais : leurs nœuds de texte ne
// sont pas relevés.
const SANS_TEXTE = new Set(['script', 'style', 'template', 'noscript']);
// Les éléments qui n'existent que pour porter un texte : rendus vides, sans
// image ni nom annoncé, ils trahissent un libellé manquant (§ 19.11).
const PORTEURS_DE_LIBELLE = 'button, label, legend, caption, th, h1, h2, h3, h4, h5, h6, p, li, dt, dd, summary, option, a';
// Ce qui donne un nom à un élément sans texte.
const NOMMANTS = ['aria-label', 'aria-labelledby', 'title'];
// Les clés de la traduction : un texte qui en égale une est une clé brute.
const CLES = new Set(creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]).cles());
// Les jetons de couleur que la feuille des jetons déclare, dans l'ordre de
// leur première déclaration ; les jetons des textes du registre (TEXTES)
// d'abord, qui nomment une couleur partagée par plusieurs jetons.
const JETONS = (() => {
const declares = [...new Set([...JETONS_CSS.matchAll(/(--couleur-[a-z0-9-]+)\s*:/g)].map(([, nom]) => nom))];
const deTexte = TEXTES.map(({ jeton }) => jeton);
return Object.freeze([...deTexte.filter((jeton) => declares.includes(jeton)), ...declares.filter((jeton) => !deTexte.includes(jeton))]);
})();
// Les captures déclarées, lues une fois de node.
let declarations = null;
async function declaration(fichier) {
declarations ??= commands.declarationsCaptures();
const { captures } = await declarations;
return captures.find((capture) => capture.fichier === fichier) ?? null;
}
// Les captures photographiées sur la racine du projet depuis le chargement
// du module : le contrôle de clôture du parcours.
const prises = new Set();
/** Les noms des captures que photographier a menées à leur terme sur la
* racine du projet, dans l'ordre. */
export function capturesPrises() {
return [...prises];
}
/** Le mode des captures que fournit la configuration : 'verifier' ou
* 'ecrire'. */
export function modeFourni() {
return inject('captures')?.mode ?? 'verifier';
}
/**
* Les textes du conteneur, en ordre de document : chaque nœud de texte hors
* des éléments qui ne se peignent jamais, ses blancs réduits à une espace,
* vides écartés (§ 19.5).
*
* @param {Element} racine
* @returns {string[]}
*/
export function texteReleve(racine) {
const textes = [];
const marcheur = document.createTreeWalker(racine, NodeFilter.SHOW_TEXT);
for (let noeud = marcheur.nextNode(); noeud !== null; noeud = marcheur.nextNode()) {
if (noeud.parentElement === null || noeud.parentElement.closest('script, style, template, noscript') !== null) continue;
if (SANS_TEXTE.has(noeud.parentElement.localName)) continue;
const texte = noeud.textContent.replace(/\s+/g, ' ').trim();
if (texte !== '') textes.push(texte);
}
return textes;
}
/**
* Les fautes de libellé du conteneur (§ 19.11) : un porteur de libellé
* rendu sans texte, sans image ni contrôle dedans, sans nom annoncé, hors
* d'une zone d'annonce ; un texte égal à une clé de la traduction ; une
* marque de la langue témoin.
*
* @param {Element} racine
* @returns {string[]}
*/
export function fautesDeLibelle(racine) {
const fautes = [];
for (const element of racine.querySelectorAll(PORTEURS_DE_LIBELLE)) {
if (!rendu(element) || element.textContent.trim() !== '') continue;
if (element.querySelector('svg, img, canvas, input, select, textarea') !== null) continue;
if (NOMMANTS.some((nom) => (element.getAttribute(nom) ?? '').trim() !== '')) continue;
if (element.closest('[aria-live], [role="status"], [role="alert"], [role="log"]') !== null) continue;
fautes.push(`élément de texte vide : ${situer(element)}`);
}
for (const texte of texteReleve(racine)) {
if (CLES.has(texte)) fautes.push(`clé brute affichée : « ${texte} »`);
if (texte.includes(MARQUE_TEMOIN.ouverture) || texte.includes(MARQUE_TEMOIN.fermeture)) {
fautes.push(`marque de la langue témoin : « ${extrait(texte)} »`);
}
}
return fautes;
}
// La largeur rendue de la chaîne témoin dans la police de l'interface, au
// corps CORPS_TEMOIN, crénage et ligatures coupés comme longueurTemoin les
// compte, sur un <text> posé dans le conteneur le temps de la lecture.
function largeurTemoin(racine) {
const svg = document.createElementNS(SVG, 'svg');
const texte = document.createElementNS(SVG, 'text');
texte.setAttribute(
'style',
`font-family: var(--police-interface); font-size: ${CORPS_TEMOIN}px; font-weight: 400; font-kerning: none; font-variant-ligatures: none`,
);
texte.textContent = CHAINE_TEMOIN;
svg.append(texte);
racine.append(svg);
try {
return texte.getComputedTextLength();
} finally {
svg.remove();
}
}
// La couleur en clé de comparaison.
const cleCouleur = (couleur) => couleur.join(',');
// Le jeton d'une couleur de texte dans le thème courant : le premier de
// JETONS qui la résout ; sa forme rgb(…) quand aucun ne la résout.
function jetonsDuTheme() {
const parCouleur = new Map();
for (const jeton of JETONS) {
const cle = cleCouleur(couleurDeJeton(jeton));
if (!parCouleur.has(cle)) parCouleur.set(cle, jeton);
}
return (couleur) => parCouleur.get(cleCouleur(couleur)) ?? rgb(couleur);
}
// Un ratio arrondi au centième, comme le tableau des contrastes l'écrit.
const centieme = (ratio) => Math.round(ratio * 100) / 100;
/**
* Les styles relevés dans le thème courant (§ 14.5, § 19.5), sur ce que la
* fenêtre montre — la région photographiée : pour chaque
* jeton et chaque rôle — texte, marque —, le plus petit ratio mesuré et le
* seuil du rôle, rangés par jeton puis rôle ; et les fautes : un ratio sous
* son seuil, une mesure impossible, une atténuation.
*
* @param {Element} racine
* @returns {{styles: Array<{jeton: string, role: string, ratio: number, seuil: number}>, fautes: string[]}}
*/
export function stylesDuTheme(racine) {
const textes = releverTextes(racine, { fenetre: true });
const marques = releverMarques(racine, { fenetre: true });
const jetonDe = jetonsDuTheme();
const parCle = new Map();
const retenir = (jeton, role, ratio, seuil) => {
const cle = `${jeton}\u{0}${role}`;
const vu = parCle.get(cle);
if (vu === undefined || ratio < vu.ratio) parCle.set(cle, { jeton, role, ratio, seuil });
};
for (const { couleur, ratio } of textes.ratios) retenir(jetonDe(couleur), 'texte', ratio, SEUIL_TEXTE);
for (const { jeton, ratio } of marques.ratios) retenir(jeton, 'marque', ratio, SEUIL_MARQUE);
const styles = [...parCle.values()]
.map((style) => ({ ...style, ratio: centieme(style.ratio) }))
.sort((a, b) => (a.jeton < b.jeton ? -1 : a.jeton > b.jeton ? 1 : a.role < b.role ? -1 : a.role > b.role ? 1 : 0));
return { styles, fautes: [...textes.fautes, ...textes.attenuations, ...marques.fautes, ...marques.attenuations] };
}
// Les positions de défilement du document et de chaque élément du
// conteneur qui a défilé ; rend la fonction qui les restitue.
function retenirDefilements(racine) {
const positions = [document.scrollingElement, racine, ...racine.querySelectorAll('*')]
.filter((element) => element !== null && (element.scrollTop !== 0 || element.scrollLeft !== 0 || element.scrollHeight > element.clientHeight))
.map((element) => [element, element.scrollTop, element.scrollLeft]);
return () => {
for (const [element, haut, gauche] of positions) {
element.scrollTop = haut;
element.scrollLeft = gauche;
}
};
}
const hex = (tampon) => [...new Uint8Array(tampon)].map((octet) => octet.toString(16).padStart(2, '0')).join('');
/**
* L'empreinte d'état (§ 8.8, § 19.5) : le SHA-256 hexadécimal de
* serialiserCharge de l'événement ouvert, lu sur le disque simulé du
* montage ; null sans événement ouvert.
*
* @param {Object} m un montage de monterApp
* @returns {Promise<string|null>}
*/
export async function empreinteEtat(m) {
const { evenement, racine } = m.seance.etat();
if (evenement === null) return null;
const texte = m.fs.contenu(racine.id, `${evenement.base}${SUFFIXES.etat}`);
if (texte === null) throw new Error(`état de ${evenement.base} absent du disque simulé`);
const { charge } = analyser(texte);
return hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialiserCharge(charge))));
}
// Un élément que l'étape affirme est-il visible ? Un élément HTML : visible
// de rendu.js, son centre atteint et non couvert. Un élément SVG se lit par
// la géométrie du dessin, comme ses textes au relevé des contrastes : rendu,
// et le centre de sa boîte dans le <svg> extérieur et dans la fenêtre — un
// dessin sous le pointeur ou hors d'atteinte reste visible. Rend true, ou la
// raison.
function conditionVisible(element) {
if (!(element instanceof SVGElement)) return visible(element);
if (!rendu(element)) return 'checkVisibility faux';
return centreDansLeDessin(element) ? true : 'le centre de sa boîte est hors du dessin ou de la fenêtre';
}
/**
* Photographie l'écran du montage m sous le nom fichier, une capture de
* CAPTURES. Dans l'ordre, chaque contrôle échoue en nommant la capture et
* la nature de la faute : capture déclarée ; feuille sans mouvement posée ;
* conditions visibles ; collecteur vide ; aucune requête hors de la boucle
* locale ; cadre relu au CADRE, facteur d'échelle 1 ; thème clair ; chaîne
* témoin dans sa tolérance ; aucun chemin hors de la racine de travail
* dans le texte relevé ; aucune faute de libellé ; styles relevés dans les
* deux thèmes, sous leurs seuils ; empreinte d'état. L'entrée se compose,
* puis 'verifier' la compare au manifeste — un écart nomme l'étape et sa
* nature —, et 'ecrire' dépose l'image, prise avant les relevés, et
* l'entrée.
*
* @param {Object} m un montage de monterApp
* @param {string} fichier le nom de la capture, sans extension
* @param {Object} [options]
* @param {string|string[]} [options.condition] les sélecteurs que l'étape
* affirme : présents dans le conteneur, visibles
* @param {number|null} [options.graine] la graine écrite dans le scénario
* @param {'verifier'|'ecrire'} [options.mode] sinon celui de la configuration
* @param {string|null} [options.racine] un dossier d'épreuve, sinon le projet
* @returns {Promise<Object>} l'entrée, sans image ni empreinte
*/
export async function photographier(m, fichier, { condition = [], graine = null, mode = modeFourni(), racine = null } = {}) {
const echec = (nature) => new Error(`capture ${fichier} : ${nature}`);
const declaree = await declaration(fichier);
if (declaree === null) throw echec('absente de CAPTURES');
if (!feuillePosee()) throw echec('la feuille qui annule transitions et animations n’est pas posée');
for (const selecteur of [condition].flat()) {
const element = m.racine.querySelector(selecteur);
if (element === null) throw echec(`sélecteur absent « ${selecteur} »`);
const vu = conditionVisible(element);
if (vu !== true) throw echec(`« ${selecteur} » invisible : ${vu}`);
}
const [erreur] = m.erreurs();
if (erreur !== undefined) throw echec(`collecteur : ${erreur.type} « ${erreur.message} »`);
const [requete] = await commands.requetesHorsBoucle();
if (requete !== undefined) throw echec(`requête hors de la boucle locale : ${requete}`);
if (innerWidth !== CADRE.largeur || innerHeight !== CADRE.hauteur) {
throw echec(`cadre ${innerWidth} × ${innerHeight}, attendu ${CADRE.largeur} × ${CADRE.hauteur}`);
}
if (devicePixelRatio !== 1) throw echec(`facteur d’échelle ${devicePixelRatio}, attendu 1`);
const theme = document.documentElement.getAttribute('data-theme');
if (theme !== 'clair') throw echec(`thème « ${theme} », l’image se prend en thème clair`);
const { regulier } = await m.polices();
const attendue = longueurTemoin(regulier, CORPS_TEMOIN);
const lue = largeurTemoin(m.racine);
const tolerance = constante('TOLERANCE_CHAINE_TEMOIN');
if (!(Math.abs(lue - attendue) / attendue <= tolerance)) {
throw echec(`chaîne témoin à ${lue} px, ${attendue} px attendus à ${tolerance} près : la police rendue n’est pas l’embarquée`);
}
const selecteur = `[data-montage="${m.racine.dataset.montage}"]`;
const image = mode === 'ecrire' ? await commands.prendreCliche({ fichier, selecteur, racine }) : null;
const texte = texteReleve(m.racine);
const racineNeutre = m.seance.etat().racine?.chemin ?? null;
if (racineNeutre === null) throw echec('aucune racine de travail : le texte relevé ne se contrôle pas');
const [chemin] = await commands.cheminsHorsRacine({ texte, racine: racineNeutre });
if (chemin !== undefined) throw echec(`chemin hors de la racine neutre : « ${chemin} »`);
const [libelle] = fautesDeLibelle(m.racine);
if (libelle !== undefined) throw echec(libelle);
// Chaque thème se relève sur la position où l'image a été prise.
const restituer = retenirDefilements(m.racine);
const styles = {};
for (const nom of THEMES) {
let releve;
try {
releve = await sousTheme(nom, () => stylesDuTheme(m.racine));
} finally {
restituer();
}
const { styles: releves, fautes } = releve;
if (fautes.length > 0) throw echec(`thème ${nom} : ${fautes.length} faute(s) de contraste ou d’atténuation :\n${fautes.join('\n')}`);
if (releves.length === 0) throw echec(`thème ${nom} : aucun style relevé`);
styles[nom] = releves;
}
const entree = {
fichier,
etape: declaree.etape,
source: 'navigateur',
versionReelle: VERSION.affichee,
graine,
cadre: { largeur: innerWidth, hauteur: innerHeight },
facteurEchelle: devicePixelRatio,
mode: MODE_AFFICHAGE,
theme,
instant: m.horloge.horodatage(),
texte,
styles,
etat: await empreinteEtat(m),
pdf: null,
};
if (mode === 'ecrire') {
await commands.deposerCapture({ fichier, entree, chemin: image.chemin, racine });
} else if (mode === 'verifier') {
const { ecarts } = await commands.comparerCapture({ fichier, entree, racine });
if (ecarts.length > 0) throw echec(`écart au manifeste :\n${ecarts.join('\n')}`);
} else {
throw echec(`mode inconnu « ${mode} »`);
}
if (racine === null) prises.add(fichier);
return entree;
}
/**
* Clôt un parcours photographié (§ 19.1, § 19.5, § 19.7). declarees : les
* captures que le parcours porte, CAPTURES hors A_VENIR par défaut. Une
* épreuve du fichier en échec, qu'elle ait photographié ou non, refuse la
* clôture en se nommant : en écriture, le dossier d'attente est jeté et
* rien n'est publié. Sans échec, l'écriture publie d'un bloc et rend le
* bilan de publierCaptures ; la vérification affirme que le manifeste porte
* exactement les déclarées.
*
* @param {Object} options
* @param {string[]} options.echecs les épreuves du fichier qui ont échoué
* @param {string[]|null} [options.declarees]
* @param {'verifier'|'ecrire'} [options.mode] sinon celui de la configuration
* @param {string|null} [options.racine] un dossier d'épreuve, sinon le projet
* @param {string|null} options.racineNeutre la racine de travail du montage
* @returns {Promise<{declarees: string[], bilan: Object|null}>}
*/
export async function cloreCaptures({ echecs, declarees = null, mode = modeFourni(), racine = null, racineNeutre }) {
let liste = declarees;
if (liste === null) {
const { captures, aVenir } = await commands.declarationsCaptures();
liste = captures.map(({ fichier }) => fichier).filter((fichier) => !aVenir.includes(fichier));
}
if (liste.length === 0) throw new Error('captures : aucune capture déclarée — zéro capture n’est jamais un succès');
if (echecs.length > 0) {
if (mode === 'ecrire') await commands.viderAttente({ racine });
const natures = echecs.map((nom) => `« ${nom} »`).join(', ');
throw new Error(`captures non ${mode === 'ecrire' ? 'publiées' : 'vérifiées'} : ${echecs.length} épreuve(s) du parcours en échec : ${natures}`);
}
const memes = (a, b) => [...a].sort().join('\n') === [...b].sort().join('\n');
if (mode === 'ecrire') {
const bilan = await commands.publierCaptures({ declarees: liste, racine, racineNeutre });
if (!memes([...bilan.ecrites, ...bilan.gardees], liste)) throw new Error(`captures : publiées ${[...bilan.ecrites, ...bilan.gardees].join(', ')}, déclarées ${liste.join(', ')}`);
return { declarees: liste, bilan };
}
if (mode !== 'verifier') throw new Error(`captures : mode inconnu « ${mode} »`);
const manifeste = await commands.lireManifeste({ racine });
const portees = (manifeste?.captures ?? []).map(({ fichier }) => fichier);
if (portees.join('\n') !== liste.join('\n')) throw new Error(`captures : le manifeste porte ${portees.join(', ') || '(rien)'}, déclarées ${liste.join(', ')}`);
return { declarees: liste, bilan: null };
}