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
772 lines
36 KiB
JavaScript
772 lines
36 KiB
JavaScript
// © 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 };
|
||
}
|