[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
This commit is contained in:
Mathieu Benoit 2026-10-09 15:33:59 -04:00
parent ec3bca4717
commit 43dbfad8a5
30 changed files with 2253 additions and 366 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ ios/
.vitest/
coverage/
scripts/banc/sorties/
doc/captures/.attente/

View file

@ -76,6 +76,13 @@ mutation: ## Audit par mutation du moteur, avant une livraison (§ 14.13)
banc: ## Relève les constantes du banc de mesure (§ 19.10), avant une livraison
$(AVEC_NODE) npm run banc
# GTT_CAPTURES, exportée pour la seule recette, se pose de la même façon sous
# Windows : le projet navigateur publie doc/captures au lieu de le comparer.
.PHONY: captures
captures: export GTT_CAPTURES := ecrire
captures: ## Photographie le parcours et publie doc/captures (§ 19.5)
$(AVEC_NODE) npx vitest run --project navigateur src/interface/parcours.navigateur.test.js
.PHONY: build
build: ## Construit la page dans www/
$(AVEC_NODE) npm run build

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because one or more lines are too long

View file

@ -51,10 +51,18 @@ const CSV_DE_DEMONSTRATION = new Set([
join('exemples', 'participants_demo_grande.csv'),
]);
// Vrai pour les listes du générateur et pour un fichier que
// scripts/engendrer_livrables.js en engendre.
// Le manifeste des captures, que make captures écrit : il relève les textes
// visibles du parcours photographié, qui joue les démonstrations livrées.
const MANIFESTE_DES_CAPTURES = join('doc', 'captures', 'manifeste.json');
// Vrai pour les listes du générateur, pour un fichier que
// scripts/engendrer_livrables.js en engendre, et pour le manifeste des
// captures.
const porteLesNomsParConstruction = (chemin) =>
chemin === LISTES || chemin.startsWith(DEMONSTRATIONS_LIVREES) || CSV_DE_DEMONSTRATION.has(chemin);
chemin === LISTES ||
chemin.startsWith(DEMONSTRATIONS_LIVREES) ||
CSV_DE_DEMONSTRATION.has(chemin) ||
chemin === MANIFESTE_DES_CAPTURES;
// Répertoires hors balayage, à toute profondeur : .git, les dépendances, les
// caches des outils, et les sorties de construction, qui portent une copie
@ -215,6 +223,7 @@ describe('noms forgés : balayage du projet (§ 15.6)', () => {
[join('src', 'demo', 'livrees', 'grande.gtt.json'), patronyme],
[join('exemples', 'participants_demo_petite.csv'), patronyme],
[join('exemples', 'participants_demo_grande.csv'), patronyme],
[join('doc', 'captures', 'manifeste.json'), patronyme],
];
// Un fichier sous chaque répertoire exclu, à la racine et plus profond ;
// chacun porte un nom forgé.

View file

@ -11,8 +11,10 @@
// laissé, affirme sa condition avant la suivante, et finit sur un collecteur
// d'erreurs vide.
//
// Le parcours suit les captures du § 19.3 qui relèvent de l'itération — 01,
// 01b, 02 à 09, 11, 19, 20 —, puis l'ordre de lecture du § 2.1 sur
// Le parcours est le parcours photographié (§ 19.1, § 19.3) : il suit les
// captures du § 19.3 que l'itération photographie — 01, 01b, 02, 02b, 03 à
// 07, 07b, 08 à 10, les deux 07a sur la grande démonstration que 10 a
// chargée, 11, 19, 20 —, puis l'ordre de lecture du § 2.1 sur
// l'événement retenu, les fenêtres — aucune pendant un geste non
// destructeur, une qui nomme ce qui part pour chaque geste qui détruit —,
// enfin chaque famille de geste modifiant en lecture puis sur un plan
@ -21,24 +23,39 @@
// que celle de l'application. Aucun délai fixe : chaque étape attend un
// état du document (§ 19.3).
//
// Une épreuve qui porte une capture finit, ses conditions affirmées, par
// photographier (test/navigateur/captures.js) ; une épreuve qui continue
// au-delà de l'écran photographié se coupe en deux. Rien de la machine
// n'entre dans une région photographiée : la provenance « documentation »
// tient lieu de version (§ 18.6), l'exécuteur direct annonce quatre cœurs,
// l'horloge est pilotée, la feuille qui annule transitions et animations
// est posée (test/navigateur/feuilles.js). La dernière épreuve du fichier
// publie les captures d'un bloc sous make captures (§ 19.5), et les
// compare au manifeste sinon (§ 19.7).
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { cdp, commands } from 'vitest/browser';
import { flushSync } from 'svelte';
import { bilanRemplacement, cleNormalisee } from '../application/csv.js';
import { creerExecuteurDirect } from '../application/generation.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { nomAffiche } from '../application/personnes.js';
import { etatPlaces } from '../application/places.js';
import { creerTraduction } from '../application/traduction.js';
import { versionVisible } from '../application/version.js';
import { apercevoir } from '../csv/apercu.js';
import { decoder } from '../csv/encodage.js';
import { decouper } from '../csv/lecture.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { regimeCibles } from '../geometrie/designation.js';
import { echelleGraphique } from '../geometrie/echelle.js';
import { diametre } from '../geometrie/tables.js';
import { diametre, geometrieTable } from '../geometrie/tables.js';
import { vueDepuisTransform } from '../geometrie/vue.js';
import { CRITERES, classer } from '../moteur/classement.js';
import { indexerPlan, nombrePlaces, normaliser } from '../moteur/configuration.js';
import { diagnostiquer } from '../moteur/diagnostic.js';
import { mesurer } from '../moteur/indicateurs.js';
import { plafondsAPriori } from '../moteur/plafond.js';
import { rechercher } from '../moteur/recherche.js';
@ -48,8 +65,11 @@ import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import urlCp1252 from '../../exemples/participants_cas_limites_cp1252.csv?url';
import { CADRE, capturesPrises, cloreCaptures, modeFourni, photographier } from '../../test/navigateur/captures.js';
import { NOM_CAS_LIMITES, attendre, centre, octetsCasLimites } from '../../test/navigateur/ecrans.js';
import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { poserFeuille } from '../../test/navigateur/feuilles.js';
import { monterApp } from '../../test/navigateur/montage.js';
import { visible } from '../../test/navigateur/rendu.js';
import { ENCODAGES, SEPARATEURS } from './import/modele.js';
@ -64,6 +84,12 @@ const NOM_SOIREE = 'Soirée d\u{2019}essai';
const GRAINE = 4242;
const ARRET = 300;
// Le CSV des cas limites enregistré en windows-1252 (§ 10.3, capture 02b).
const NOM_CP1252 = 'participants_cas_limites_cp1252.csv';
// Ce qui tient lieu de version dans une construction de documentation.
const MARQUE_PROVENANCE = versionVisible('documentation', VERSION.affichee, t);
// La personne que l'ordre de lecture ajoute : un nom inventé.
const AJOUTEE = { nom: 'Ombrelle', prenom: 'Iris' };
@ -100,15 +126,24 @@ const DUREE = 120_000;
// Le montage du parcours, que chaque épreuve reprend où la précédente l'a
// laissé ; les relevés des fenêtres.
let m = null;
let retirerFeuille = null;
const gestesSurveilles = new Set();
const fenetresVues = [];
// Les épreuves du fichier qui ont échoué, test ou crochet : la dernière
// épreuve refuse de publier tant qu'une seule y figure.
const epreuvesEnEchec = [];
// Ce que les étapes se transmettent : la personne désignée, la table et la
// proposition retenue, la charge d'avant l'ajustement de 19.
const memoire = {};
// En écriture, le dossier d'attente part vide : le dépôt d'un passage
// interrompu ne se publie jamais avec celui-ci.
beforeAll(async () => {
await fixerCadre(1366, 700);
m = await monterApp();
await fixerCadre(CADRE.largeur, CADRE.hauteur);
retirerFeuille = poserFeuille();
await commands.requetesHorsBoucle();
if (modeFourni() === 'ecrire') await commands.viderAttente();
m = await monterApp({ provenance: 'documentation', executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) });
});
// La capture d'un échec prend pour nom de fichier le titre de son groupe et
@ -118,7 +153,10 @@ beforeAll(async () => {
// cette limite.
const TITRE_MAX_OCTETS = 200;
beforeEach(({ task }) => {
beforeEach(({ task, onTestFailed }) => {
onTestFailed(() => {
epreuvesEnEchec.push(task.name);
});
const titre = `${task.suite?.name ?? ''} ${task.name}`;
expect(new TextEncoder().encode(titre).length, titre).toBeLessThanOrEqual(TITRE_MAX_OCTETS);
});
@ -129,6 +167,7 @@ afterEach(() => {
afterAll(async () => {
m?.demonter();
retirerFeuille?.();
window.scrollTo(0, 0);
});
@ -140,7 +179,8 @@ const etat = () => m.seance.etat();
const charge = () => etat().charge;
const revision = () => etat().evenement.revision;
const base = () => etat().evenement.base;
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const plat = (texte) => texte.replace(/\s+/g, ' ').trim();
const texteDe = (element) => plat(element.textContent);
// Le texte de l'état et du journal de l'événement ouvert, et sa révision.
function fichiers() {
@ -249,6 +289,54 @@ async function glisserVers(depuis, vers) {
await images(3);
}
// Ferme l'aperçu d'import ouvert, puis ouvre celui du fichier donné ; rend
// l'aperçu, son nom de fichier affiché.
async function ouvrirApercu(nom, octets) {
if (dans('[data-apercu]') !== null) {
await cliquer('[data-apercu] [data-commande="fermerApercu"]');
await attendre('l’aperçu fermé', () => dans('[data-apercu]') === null);
}
m.fs.choisirFichierAImporter = async () => ({ nom, octets });
await cliquer('[data-section="import"] [data-commande="choisirFichier"]');
return attendre(`l’aperçu de ${nom}`, () => dans('[data-apercu] [data-fichier]')?.textContent.trim() === nom && dans('[data-apercu]'));
}
// Les crans de « Zoom + » au-delà du premier zoom où chaque table de la
// grande démonstration passe au régime des sièges (capture 07a). À ce
// premier zoom, une chaise mesure environ 18 px : le trait de la chaise 3,
// d'épaisseur fixe à l'écran, longe celui du plateau qu'elle touche sur une
// bande où tombent la plupart des points que le relevé des marques
// échantillonne (captures.js), et la marque s'y mesure contre un trait de sa
// propre couleur. Deux crans plus haut, la bande ne retient plus qu'une
// minorité de ces points.
const CRANS_AU_DESSUS = 2;
// Le zoom lu sur le groupe de la vue du plan.
const zoomLu = () => vueDepuisTransform(dans('[data-vue]').getAttribute('transform')).k;
// Le régime des cibles de chaque table montrée, au zoom lu.
const regimes = () => etatMontre().tables.map((table) => regimeCibles(zoomLu(), geometrieTable(table, table.capacite)));
// Clique la commande de zoom jusqu'à ce que chaque table soit au régime
// voulu, en au plus crans clics.
async function zoomerJusqua(regime, commande, crans = 40) {
for (let cran = 0; regimes().some((r) => r !== regime); cran += 1) {
expect(cran, `crans de ${commande} jusqu’au régime ${regime}`).toBeLessThan(crans);
await cliquer(`[data-commande="${commande}"]`);
await images(2);
}
}
// Ramène en haut chaque ancêtre qui défile d'un élément.
function enHaut(element) {
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
if (ancetre.scrollHeight > ancetre.clientHeight) ancetre.scrollTop = 0;
}
}
// L'élément du document qu'atteint le point (x, y) de la fenêtre.
const atteintAuPoint = ({ x, y }) => document.elementFromPoint(x, y);
// Ouvre une section de l'événement par sa navigation.
async function ouvrirSection(nom) {
await cliquer(`[data-commande="section"][data-cible="${nom}"]`);
@ -421,7 +509,7 @@ async function refusPres(porteur, code, remede, geste) {
// --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------
describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE }, () => {
test('01 le répertoire vide : le catalogue offert, la version, le chemin ; « Soirée d’essai » créée s’ouvre en lecture', async () => {
test('01 le répertoire vide : le catalogue offert, la marque de provenance à la place de la version, le chemin', async () => {
await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-dossier-travail]'));
expect(tous('[data-evenement]')).toHaveLength(0);
const offertes = tous('[data-demonstration]');
@ -431,12 +519,17 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(texteDe(entree)).toBe(CATALOGUE[rang].nom);
expect(visible(entree)).toBe(true);
});
expect(dans('[data-version]').textContent).toContain(VERSION.affichee);
expect(dans('[data-bandeau]').textContent).toContain(VERSION.affichee);
for (const porteur of ['[data-version]', '[data-bandeau]']) {
expect(dans(porteur).textContent, porteur).toContain(MARQUE_PROVENANCE);
expect(dans(porteur).textContent, porteur).not.toContain(VERSION.affichee);
}
const chemin = dans('[data-dossier-travail]').textContent;
expect(chemin.length).toBeGreaterThan(0);
expect(chemin).toBe(etat().racine.chemin);
await photographier(m, '01-premier-ecran', { condition: ['[data-dossier-travail]', '[data-version]', '[data-demonstration]'] });
});
test('01 « Soirée d’essai » créée s’ouvre en lecture', async () => {
await taper(dans('[data-ecran="accueil"] input[data-champ="nom"]'), NOM_SOIREE);
await cliquer('[data-ecran="accueil"] [data-commande="creer"]');
await attendre('l’événement ouvert', () => dans('[data-ecran="evenement"]') && etat().evenement !== null);
@ -445,7 +538,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(charge().participants).toHaveLength(0);
});
test('01b une modification refusée avant « Modifier », à côté de son geste ; acceptée après ; le cadre visible', async () => {
test('01b une modification refusée en lecture, à côté de son geste, avec « Modifier » pour remède ; le cadre de lecture', async () => {
await ouvrirSection('tables');
const avant = fichiers();
expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture');
@ -454,7 +547,12 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(document.querySelectorAll(FENETRE)).toHaveLength(0);
await rienEcrit(avant, 'créer une table en lecture');
expect(charge().tables).toHaveLength(0);
await photographier(m, '01b-passer-en-ecriture', {
condition: ['[data-place-fixe] [data-commande="modifier"]', '[data-refus="MODE_LECTURE"]', '[data-mode-libelle="cadre"]'],
});
});
test('01b « Modifier » : l’écriture et son cadre ; la même modification passe', async () => {
await cliquer('[data-place-fixe] [data-commande="modifier"]');
await attendre('l’écriture', () => etat().mode === 'ecriture' && dans('[data-place-fixe] [data-commande="lecture"]'));
const cadre = dans('[data-mode]');
@ -486,9 +584,32 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(apercu.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1);
expect(apercu.querySelector('[data-commande="importer"]')).not.toBeNull();
await rienEcrit(avant, 'l’aperçu');
await photographier(m, '02-import-apercu', { condition: ['[data-apercu] [data-encodage]', '[data-apercu] [data-separateur]'] });
});
test('02b l’aperçu d’un CSV en windows-1252 : l’encodage nommé, chaque caractère accentué intact', async () => {
const octets = new Uint8Array(await (await fetch(urlCp1252)).arrayBuffer());
const avant = fichiers();
const apercu = await ouvrirApercu(NOM_CP1252, octets);
const { texte, encodage } = decoder(octets);
expect(encodage).toBe('windows-1252');
expect(texteDe(apercu.querySelector('[data-encodage]'))).toBe(t(ENCODAGES.get(encodage)));
// Les cellules hors ASCII du fichier, décodées par l'analyseur : celles
// que l'aperçu montre y sont entières ; aucun caractère de remplacement,
// aucune séquence UTF-8 lue en windows-1252.
const accentuees = decouper(texte, apercevoir(texte).separateur)
.enregistrements.flat()
.map((cellule) => cellule.trim())
.filter((cellule) => /[^\u{0}-\u{7F}]/u.test(cellule));
const montrees = accentuees.filter((cellule) => apercu.textContent.includes(cellule));
expect(montrees.length, accentuees.join(' | ')).toBeGreaterThan(0);
expect(apercu.textContent).not.toMatch(/\u{FFFD}|\u{C3}[\u{80}-\u{BF}]/u);
await rienEcrit(avant, 'l’aperçu en windows-1252');
await photographier(m, '02b-import-cp1252', { condition: ['[data-apercu] [data-encodage]', '[data-apercu] [data-fichier]'] });
});
test('03 le rapport : une entrée, les lignes valides comptées, le groupe fondu et la ligne refusée', async () => {
await ouvrirApercu(NOM_CAS_LIMITES, await octetsCasLimites());
const r = revision();
await cliquer('[data-apercu] [data-commande="importer"]');
const rapport = await attendre('le rapport', () => dans('[data-rapport]'));
@ -499,6 +620,7 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(rapport.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1);
expect(rapport.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1);
memoire.importes = ajoutes;
await photographier(m, '03-import-rapport', { condition: ['[data-rapport] [data-compte="ajoutes"]'] });
});
test('04 la grille : le compte égal à l’import, une personne exclue ; trier, filtrer ; modifier, exclure, réintégrer sans fenêtre', async () => {
@ -571,6 +693,14 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
await uneEntree(r, 'réintégrer');
});
expect(charge().participants.find((p) => p.id === cible.id).exclu).toBe(false);
// La capture montre le haut de la grille, son tri et son filtre : le
// focus de la dernière cellule saisie part, la grille revient en haut.
document.activeElement?.blur();
enHaut(section().querySelector('[role="grid"]'));
await images(2);
await photographier(m, '04-grille-participants', {
condition: ['[data-section="participants"] [data-champ="filtre"]', '[data-section="participants"] [data-commande="trier"][data-cible="nom"]'],
});
});
test('05 deux tables à 8, l’une au défaut, l’autre surchargée à 8 : leurs états diffèrent à valeur égale', async () => {
@ -595,17 +725,25 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(visible(etatDe(premiere.id))).toBe(true);
expect(visible(etatDe(seconde.id))).toBe(true);
expect(texteDe(etatDe(premiere.id))).not.toBe(texteDe(etatDe(seconde.id)));
await photographier(m, '05-tables-etat', {
condition: [`[data-section="tables"] [data-table="${premiere.id}"] [data-etat-table]`, `[data-section="tables"] [data-table="${seconde.id}"] [data-etat-table]`],
});
});
test('06 l’énoncé d’un changement de défaut, son total égal au moteur ; étape 5, le nombre de tours', async () => {
test('06 l’énoncé d’un changement de défaut, chiffré avant toute application : rien d’écrit', async () => {
const avant = fichiers();
await saisir(dans('[data-section="tables"] input[data-champ="siegesParDefaut"]'), '10');
const enonce = await attendre('l’énoncé', () => dans('[data-enonce="defaut"]'));
expect(visible(enonce)).toBe(true);
await rienEcrit(avant, 'l’énoncé');
const phrase = [...enonce.querySelectorAll('[data-phrase]')].map(texteDe).join(' ');
memoire.phrase = [...enonce.querySelectorAll('[data-phrase]')].map(texteDe).join(' ');
expect(memoire.phrase).toMatch(/(\d+)\s*\u{2192}\s*(\d+)/u);
await photographier(m, '06-defaut-consequence', { condition: ['[data-enonce="defaut"]', '[data-enonce="defaut"] [data-commande="appliquer"]'] });
});
test('06 appliquer le défaut : le total annoncé égal au moteur ; étape 5, le nombre de tours', async () => {
const { phrase } = memoire;
const total = phrase.match(/(\d+)\s*\u{2192}\s*(\d+)/u);
expect(total, phrase).not.toBeNull();
await sansFenetre('appliquerDefaut', async () => {
const r = revision();
await cliquer('[data-enonce="defaut"] [data-commande="appliquer"]');
@ -645,6 +783,54 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
const mention = [...dans('[data-surface]').querySelectorAll('p')].find((p) => texteDe(p) === t('plan.echelle.mention'));
expect(mention).toBeDefined();
expect(visible(mention)).toBe(true);
await photographier(m, '07-plan-tables', { condition: ['[data-surface] [data-marque="echelle"]', '[data-plan]'] });
});
// Étape 5 : retirer un tour qui porte une réservation « tour désigné »
// l'annonce avant d'écrire ; la réservation posée pour l'annonce part
// ensuite par « Défaire ».
test('07b retirer un tour : l’annonce nomme la réservation détruite, avant toute écriture', async () => {
await ouvrirPlan();
const selecteurTour = dans('[data-champ="tour"]');
selecteurTour.value = '3';
selecteurTour.dispatchEvent(new Event('change', { bubbles: true }));
await attendre('le tour 3 montré', () => m.ui.etat().tour === 3);
await images(2);
await regimeSieges();
let r = revision();
const reservee = await choisir('[data-reserve]');
const libre = chaiseVue((c) => c.occupant === null && c.reservation === null && c.titre === null);
await cliquerPointeur(chaise(libre.table, libre.siege));
await appuyer(actionPlace('reserver'), 'réserver au tour 3');
await uneEntree(r, 'réserver au tour 3');
const avantAnnonce = charge();
expect(avantAnnonce.reservations).toContainEqual(expect.objectContaining({ participant: reservee, portee: 'tour', tour: 3 }));
selecteurTour.value = '1';
selecteurTour.dispatchEvent(new Event('change', { bubbles: true }));
await attendre('le tour 1 montré', () => m.ui.etat().tour === 1);
await ouvrirSection('tables');
const avant = fichiers();
await saisir(dans('[data-section="tables"] input[data-champ="tours"]'), '2');
const fenetre = await attendre('l’annonce', () => dans('dialog[open]'));
const numero = avantAnnonce.tables.find(({ id }) => id === libre.table).numero;
const nom = nomAffiche(avantAnnonce.participants.find(({ id }) => id === reservee));
expect(texteDe(fenetre.querySelector('h2'))).toBe(plat(t('tables.tours.confirmation', { avant: 3, apres: 2 })));
expect([...fenetre.querySelectorAll('li')].map(texteDe)).toContain(
plat(t('tables.tours.reservation', { nom, numero, tour: 3 })),
);
await rienEcrit(avant, 'l’annonce');
await photographier(m, '07b-nombre-de-tours', { condition: ['dialog[open] h2', 'dialog[open] [data-commande="renoncer"]'] });
await appuyer(fenetre.querySelector('[data-commande="renoncer"]'), 'renoncer');
await attendre('l’annonce fermée', () => dans('dialog[open]') === null);
await rienEcrit(avant, 'renoncer');
r = revision();
await cliquer('[data-historique] [data-commande="defaire"]');
await uneEntree(r, 'défaire la réservation');
expect(charge().reservations).toEqual([]);
expect(charge().evenement.tours).toBe(3);
await ouvrirPlan();
});
test('08 deux animateurs — cadenas fermé et anneau, hachuré et cadenas ouvert —, leurs lignes, les deux listes de travail', async () => {
@ -708,6 +894,9 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(titres.map((b) => b.dataset.titre.split(':')[0])).toEqual([String(t2)]);
expect(texteDe(titres[0])).toContain(titre);
expect(tous('[data-liste-travail="pressentis"] [data-participant]').length).toBeGreaterThan(0);
await photographier(m, '08-animateurs-designes', {
condition: [`[data-siege="${designee[0].dataset.siege}"]`, `[data-siege="${titree[0].dataset.siege}"]`],
});
});
test('09 un refus sur la place, sa cause et son remède, sans fenêtre ni écriture', async () => {
@ -732,10 +921,71 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(visible(message)).toBe(true);
expect(vues).toEqual([]);
await rienEcrit(avant, 'le refus sur la place');
await photographier(m, '09-refus-sur-place', { condition: [`[data-siege="${t1}:${reservee.siege}"]`] });
await oublierChoix();
});
test('11 générer, graine écrite, compte réduit : trois propositions du moteur, dans l’ordre de sa clé ; diversité ; retenir', async () => {
// La grande démonstration, chargée depuis le premier écran : l'échelle est
// le propos (§ 19.3), et le diagnostic se lit avant toute génération.
test('10 la grande démonstration, chargée depuis le premier écran : 99 retours imposés, plafond 24, plancher d’écart, égaux au moteur', async () => {
memoire.base = base();
await cliquer('[data-bandeau] [data-commande="liste"]');
await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-demonstration="grande"]'));
await cliquer('[data-demonstration="grande"]');
await attendre('la grande démonstration ouverte', () => dans('[data-ecran="evenement"]') && etat().evenement !== null && etat().evenement.base !== memoire.base);
await ouvrirSection('diagnostic');
const diagnostic = diagnostiquer(configurationDepuisCharge(charge()));
expect(diagnostic.retoursImposes).toBe(99);
expect(diagnostic.plafondAPriori.repartition[0]).toEqual({ valeur: 24, ancres: 4, mobiles: 0 });
expect(diagnostic.ecartItineraire?.plancher).toBe(1);
const phrases = tous('[data-section="diagnostic"] [data-phrase]').map(texteDe);
for (const attendue of [
t('diagnostic.plafond', diagnostic.plafondAPriori.repartition[0]),
t('diagnostic.imposes', { retoursImposes: diagnostic.retoursImposes, ancragesImposantRetours: diagnostic.ancragesImposantRetours }),
t('diagnostic.ecartItineraire', diagnostic.ecartItineraire),
]) {
expect(phrases).toContain(plat(attendue));
}
await photographier(m, '10-diagnostic', { condition: ['[data-section="diagnostic"] [data-phrase]'] });
});
// Le seuil de CIBLE_MIN_PX (§ 7.3, § 19.10), au-dessus puis au-dessous,
// sur la grande démonstration : au zoom ajusté de la soirée construite, ses
// tables restent au régime des sièges jusqu'au zoom minimal, la moitié du
// zoom ajusté. Le point du centre d'une chaise atteint la chaise au
// régime des sièges, la table au régime de la table.
test('07a les sièges cibles, sur la grande démonstration : zoomée jusqu’au régime des sièges, le centre d’une chaise atteint la chaise', async () => {
await ouvrirPlan();
await zoomerJusqua('sieges', 'zoomer');
for (let cran = 0; cran < CRANS_AU_DESSUS; cran += 1) await cliquer('[data-commande="zoomer"]');
await images(2);
const vue = chaiseVue(() => true);
const disque = chaise(vue.table, vue.siege).querySelector('circle');
const point = await centre(disque);
expect(atteintAuPoint(point)?.closest('[data-siege]'), 'l’élément au centre de la chaise').toBe(chaise(vue.table, vue.siege));
memoire.centreChaise = { table: vue.table, x: Number(disque.getAttribute('cx')), y: Number(disque.getAttribute('cy')) };
await photographier(m, '07a-cibles-sieges', { condition: [`[data-siege="${vue.table}:${vue.siege}"]`] });
});
test('07a la table cible, sur la grande démonstration : au zoom ajusté, régime de la table, le même point atteint la table, aucune chaise', async () => {
await cliquer('[data-commande="ajuster"]');
await images(2);
expect(regimes().every((regime) => regime === 'table'), regimes().join(' ')).toBe(true);
expect(tous('[data-plan] [data-siege]')).toHaveLength(0);
const { table, x, y } = memoire.centreChaise;
const point = new DOMPoint(x, y).matrixTransform(dans('[data-vue]').getScreenCTM());
const atteint = atteintAuPoint(point);
expect(atteint?.closest('[data-siege]') ?? null).toBeNull();
expect(atteint?.closest('[data-table]')?.dataset.table, 'la table au point de sa chaise').toBe(String(table));
await photographier(m, '07a-cibles-tables', { condition: [`[data-plan] [data-table="${table}"]`] });
});
test('11 rouvrir la soirée ; générer, graine écrite, compte réduit : trois propositions du moteur, dans l’ordre de sa clé ; diversité ; retenir', async () => {
await cliquer('[data-bandeau] [data-commande="liste"]');
await cliquer(`[data-evenement="${memoire.base}"] [data-commande="ouvrir"]`);
await attendre('la soirée rouverte', () => dans('[data-ecran="evenement"]') && etat().evenement?.base === memoire.base);
await cliquer('[data-place-fixe] [data-commande="modifier"]');
await attendre('l’écriture', () => etat().mode === 'ecriture');
await ouvrirSection('generation');
await taper(dans('[data-section="generation"] input[data-champ="graine"]'), String(GRAINE));
let r = revision();
@ -783,18 +1033,30 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
expect(charge().retenu.proposition).toBe(ordre[0]);
expect(charge().evenement.etat).toBe('retenu');
await attendre('la ligne du retenu', () => section.querySelector(`tr[data-retenu="${ordre[0]}"]`));
await photographier(m, '11-propositions', { condition: ['[data-section="propositions"] [data-ordre]'], graine: GRAINE });
});
test('19 un ajustement : le glissement dit lequel des deux gestes est en cours, une entrée, le retenu changé', async () => {
test('19 un ajustement en cours : le glissement dit lequel des deux gestes, celui du placement retenu', async () => {
await ouvrirPlan();
memoire.avantAjustement = serialiserCharge(charge());
const [t1, t2] = charge().tables.map(({ id }) => id);
const depuis = chaiseOccupee(t1);
const vers = chaiseLibre(t2);
const r = revision();
// La chaise libre de t2 la plus à gauche : le libellé du fantôme, à
// droite du pointeur, reste dans le cadre du plan.
const libres = chaisesMontrees().filter((c) => c.table === t2 && c.occupant === null && c.reservation === null && c.titre === null);
expect(libres.length).toBeGreaterThan(0);
const gauche = (c) => chaise(t2, c.siege).getBoundingClientRect().left;
const vers = libres.reduce((a, b) => (gauche(b) < gauche(a) ? b : a));
memoire.ajustement = { depuis, vers, revision: revision() };
await presserEtBouger(await centre(chaise(t1, depuis.siege)), await centre(chaise(t2, vers.siege)));
const etatDonnees = await attendre('le libellé de l’état des données', () => dans('[data-fantome] [data-partie="etat"]'));
expect(texteDe(etatDonnees)).toBe(t('plan.fantome.retenu'));
await photographier(m, '19-ajustement', { condition: ['[data-fantome] [data-partie="etat"]'], graine: GRAINE });
});
test('19 le dépôt : une entrée qui nomme la personne, le retenu changé', async () => {
const [, t2] = charge().tables.map(({ id }) => id);
const { depuis, vers, revision: r } = memoire.ajustement;
const point = await centre(chaise(t2, vers.siege));
await pointer('relacher', point.x, point.y);
const libelle = await uneEntree(r, 'ajuster');
@ -804,12 +1066,12 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
});
// Glisser une personne, une table, tirer une poignée, poser une réservation.
test('20 la lecture : quatre gestes du plan, empreinte inchangée ; « Défaire » refusé avec « Modifier », puis rend 19', async () => {
test('20 la lecture : quatre gestes du plan, empreinte inchangée ; le cadre, son libellé et le bandeau', async () => {
await cliquer('[data-place-fixe] [data-commande="lecture"]');
await attendre('la lecture', () => etat().mode === 'lecture');
expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture');
expect(visible(dans('[data-mode-libelle="cadre"]'))).toBe(true);
expect(dans('[data-bandeau]').textContent).toContain(VERSION.affichee);
expect(dans('[data-bandeau]').textContent).toContain(MARQUE_PROVENANCE);
const empreinte = serialiserCharge(charge());
const avant = fichiers();
const [t1, t2] = charge().tables.map(({ id }) => id);
@ -831,7 +1093,12 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
await rienEcrit(avant, 'les quatre gestes en lecture');
expect(dans(`[data-table="${t2}"]`).getAttribute('transform')).toBeNull();
for (const t of etatMontre().tables) expect(tous(`[data-siege^="${t.id}:"]`), `table ${t.numero}`).toHaveLength(t.capacite);
await photographier(m, '20-mode-lecture', { condition: ['[data-mode-libelle="cadre"]', '[data-mode-libelle="bandeau"]'], graine: GRAINE });
});
test('20 « Défaire » en lecture : refusé avec « Modifier » ; en écriture, rend l’état d’avant 19', async () => {
expect(etat().mode).toBe('lecture');
const avant = fichiers();
// « Défaire » en lecture : refusé à côté de lui, « Modifier » pour remède.
const defaire = dans('[data-historique] [data-commande="defaire"]');
await appuyer(defaire, 'défaire en lecture');
@ -1266,3 +1533,19 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', {
await rienEcrit(avant, 'bloqué, regarder');
});
});
// --- Les captures d'écran du parcours (§ 19.5, § 19.7) ----------------------------------------
// La dernière épreuve du fichier. Une épreuve du fichier en échec, même
// après sa photographie ou sans capture, la fait échouer en la nommant, et
// rien n'est publié (§ 19.1). En écriture, elle publie d'un bloc : la
// publication refuse quand une étape déclarée n'a rien déposé, et jette le
// dépôt dans tous les cas. En vérification, chaque étape déclarée a été
// comparée à son entrée du manifeste, qui n'en porte aucune autre. Zéro
// capture n'est jamais un succès (§ 19.7).
describe('les captures d’écran du parcours (§ 19.5, § 19.7)', () => {
test('chaque capture déclarée photographiée : publiées d’un bloc en écriture, conformes au manifeste en vérification', async () => {
const { declarees } = await cloreCaptures({ echecs: epreuvesEnEchec, racineNeutre: m?.seance.etat().racine?.chemin ?? null });
expect([...capturesPrises()].sort()).toEqual([...declarees].sort());
});
});

View file

@ -5,7 +5,9 @@
// § 19.11, § 12.7, § 7.6, § 13.1, § 2, § 19.2, § 8.4, § 18.6) : langue
// témoin, erreurs par leur libellé, contrastes et marques dans les deux
// thèmes, aucune atténuation, jamais la seule couleur, teinte réservée, aucun
// emoji, balisage littéral, bandeau, formulaires, transitions annulées.
// emoji, balisage littéral, bandeau, formulaires, transitions annulées ;
// puis le mécanisme des captures d'écran (§ 19.4, § 19.5, § 19.7, § 19.11),
// chaque contrôle montré en train d'échouer sur un dossier d'épreuve.
//
// Un parcours unique, en langue témoin, au cadre de 1 366 × 700, atteint
// chaque écran de test/navigateur/ecrans.js et y fait ses relevés, une fois
@ -20,6 +22,8 @@
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { commands } from 'vitest/browser';
import { creerExecuteurDirect } from '../application/generation.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { nomAffiche } from '../application/personnes.js';
import { etatPlaces } from '../application/places.js';
@ -44,17 +48,26 @@ import {
ouvrirEvenement,
} from '../../test/navigateur/ecrans.js';
import { fixerCadre, pointer } from '../../test/navigateur/entrees.js';
import { monterApp } from '../../test/navigateur/montage.js';
import {
attenuations,
composantes,
contraste,
couleurTexte,
couleursPeintes,
designation,
fondPeintSous,
porteursDeTexte,
visible,
} from '../../test/navigateur/rendu.js';
CADRE,
CHAMPS,
bordsPeints,
couleurDeJeton,
egales,
extrait,
releverMarques,
releverTextes,
rendu,
rgb,
cloreCaptures,
photographier,
situer,
stylesDuTheme,
texteReleve,
} from '../../test/navigateur/captures.js';
import { poserFeuille } from '../../test/navigateur/feuilles.js';
import { composantes, couleursPeintes, designation, sousTheme, visible } from '../../test/navigateur/rendu.js';
import { provenanceVisible } from './apropos.js';
import { TABLES_INTERFACE } from './libelles.js';
import { CONFLITS, MARQUES } from './marques.js';
@ -64,10 +77,6 @@ const { ouverture: OUVERTURE, fermeture: FERMETURE } = MARQUE_TEMOIN;
const PROVENANCE = 'test';
const THEMES = ['clair', 'sombre'];
// Seuils du § 14.5.
const SEUIL_TEXTE = 4.5;
const SEUIL_MARQUE = 3;
// Attributs annoncés : lus par l'accessibilité, ils sont du texte visible
// au sens du § 14.6 (contrainte 7).
const ATTRIBUTS_ANNONCES = ['aria-label', 'aria-roledescription', 'aria-description', 'aria-valuetext', 'title', 'placeholder', 'alt'];
@ -85,25 +94,6 @@ const JETON_DE_TRAME = new Map(CONFLITS.map(({ marque, jeton }) => [marque, jeto
const PICTOGRAPHIQUE = /\p{Extended_Pictographic}/u;
const LETTRE = /\p{L}/u;
// Une couleur en texte, pour un message.
const rgb = (c) => `rgb(${c.slice(0, 3).join(', ')}${c[3] === 1 ? '' : `, ${c[3]}`})`;
const egales = (a, b) => a.join() === b.join();
const extrait = (texte) => {
const plat = texte.replace(/\s+/g, ' ').trim();
return plat.length > 60 ? `${plat.slice(0, 57)}…` : plat;
};
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.
function situer(element) {
const repere = element.parentElement?.closest(REPERES) ?? null;
return repere === null ? designation(element) : `${designation(element)} dans ${designation(repere)}`;
}
// --- Les données connues de l'épreuve ------------------------------------------
/**
@ -257,297 +247,6 @@ export function fautesEmoji(racine, { titre = null } = {}) {
return { fautes, lus: textes.length };
}
// --- Les contrastes des textes et les atténuations --------------------------------
// 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.
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 }
*/
export function releverTextes(racine) {
const fautes = [];
const fautesAttenuation = [];
const couleurs = new Map();
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);
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 (!rendu(element)) continue;
const b = element.getBoundingClientRect();
if (b.width === 0 || b.height === 0) continue;
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 (!rendu(champ)) continue;
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 };
}
// --- 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.
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 }
*/
export function releverMarques(racine) {
const vues = new Set();
const fautes = [];
const fautesAttenuation = [];
let mesures = 0;
for (const element of racine.querySelectorAll('[data-marque]')) {
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);
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);
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 };
}
// --- Les listes de noms et les tables -----------------------------------------------
// Côté de la grille de points qui couvre l'intersection de deux boîtes.
@ -1135,13 +834,11 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
return finie;
};
expect(await attendTransition()).toBe(true);
const feuille = document.createElement('style');
feuille.textContent = '*,*::before,*::after{transition:none!important;animation:none!important}';
document.head.append(feuille);
const retirer = poserFeuille();
try {
expect(await attendTransition()).toBe(false);
} finally {
feuille.remove();
retirer();
}
});
});
@ -1360,9 +1057,7 @@ describe('balisage littéral (§ 13.1)', () => {
describe('transitions annulées (§ 19.2)', () => {
test('sous une feuille qui annule transitions et animations, chaque écran s’atteint', async () => {
await fixerCadre(1366, 700);
const feuille = document.createElement('style');
feuille.textContent = '*,*::before,*::after{transition:none!important;animation:none!important}';
document.head.append(feuille);
const retirer = poserFeuille();
const fautes = [];
let atteintes = 0;
try {
@ -1378,10 +1073,326 @@ describe('transitions annulées (§ 19.2)', () => {
}
}
} finally {
feuille.remove();
retirer();
}
expect(atteintes, 'écrans atteints').toBeGreaterThan(0);
expect(atteintes + fautes.length).toBe(ECRANS.length);
aucuneFaute(fautes, 'transitions annulées');
}, 900_000);
});
// --- Les captures d'écran : le mécanisme ------------------------------------------------------
describe('les captures d’écran : chaque contrôle échoue en nommant la capture (§ 19.4, § 19.5, § 19.7, § 19.11)', () => {
const ACCUEIL = '[data-ecran="accueil"]';
const ADRESSE_INVENTEE = 'http://192.0.2.1/';
let retirer = null;
beforeAll(async () => {
await fixerCadre(CADRE.largeur, CADRE.hauteur);
retirer = poserFeuille();
await commands.requetesHorsBoucle();
});
afterAll(() => {
retirer?.();
});
// Un montage du premier écran, sous la provenance des captures, sur un
// dossier d'épreuve neuf ; tout deux défaits à la fin.
async function avecMontage(faire) {
const racine = await commands.dossierEpreuve();
const m = await monterApp({ provenance: 'documentation', executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) });
try {
await attendre('le premier écran', () => m.racine.querySelector(`${ACCUEIL} [data-dossier-travail]`));
const racineNeutre = m.seance.etat().racine.chemin;
return await faire(m, { racine, racineNeutre, ecrire: { mode: 'ecrire', racine }, verifier: { mode: 'verifier', racine } });
} finally {
m.demonter();
await commands.effacerDossierEpreuve(racine);
}
}
// Pose un élément dans le premier écran, le temps d'une capture.
function poser(m, html) {
const gabarit = document.createElement('template');
gabarit.innerHTML = html;
const element = gabarit.content.firstElementChild;
m.racine.querySelector(ACCUEIL).append(element);
return element;
}
// Une requête vers une adresse inventée hors de la boucle locale : l'erreur
// qu'elle rend.
async function requeteInventee() {
try {
await fetch(ADRESSE_INVENTEE, { mode: 'no-cors', signal: AbortSignal.timeout(10_000) });
return null;
} catch (erreur) {
return erreur;
}
}
// Les échecs hors boucle que node relève, attendus image après image : ils
// lui arrivent par le protocole de débogage, au plus quelques images
// après le rejet que la page a reçu, et après la requête elle-même.
async function echecsReleves() {
const echecs = [];
for (let image = 0; image < 60 && echecs.length === 0; image += 1) {
echecs.push(...(await commands.echecsHorsBoucle()));
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
return echecs;
}
// Le mandataire mort de test/chromium.js refuse la connexion : la requête
// n'est jamais partie de la machine, et Chromium le dit par sa cause.
test('réseau : une requête vers une adresse littérale inventée échoue en connexion au mandataire mort, la page de la boucle locale se charge, le relevé la nomme', async () => {
expect((await fetch('/collecteur.js')).ok).toBe(true);
expect(await commands.requetesHorsBoucle()).toEqual([]);
await commands.echecsHorsBoucle();
const erreur = await requeteInventee();
expect(erreur?.name, String(erreur)).toBe('TypeError');
expect(await echecsReleves()).toEqual([{ adresse: ADRESSE_INVENTEE, cause: 'net::ERR_PROXY_CONNECTION_FAILED' }]);
expect(await commands.requetesHorsBoucle()).toEqual([ADRESSE_INVENTEE]);
});
test('un sélecteur manquant, une erreur au collecteur, une requête hors boucle : chacun échoue en nommant la capture', async () => {
await avecMontage(async (m, { ecrire }) => {
await expect(photographier(m, '01-premier-ecran', { ...ecrire, condition: '[data-absent]' })).rejects.toThrow(
'capture 01-premier-ecran : sélecteur absent « [data-absent] »',
);
await requeteInventee();
expect(await echecsReleves()).toHaveLength(1);
await expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow(
`capture 01-premier-ecran : requête hors de la boucle locale : ${ADRESSE_INVENTEE}`,
);
console.error('erreur provoquée par l’épreuve');
await expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow(
'capture 01-premier-ecran : collecteur : console.error « erreur provoquée par l’épreuve »',
);
});
});
test('un chemin hors de la racine neutre, un élément de texte vide, une clé brute, une marque de la langue témoin : chacun échoue', async () => {
await avecMontage(async (m, { ecrire }) => {
const cas = [
['<p>/home/exemple/notes.txt</p>', 'chemin hors de la racine neutre : « /home/exemple/notes.txt »'],
['<p>Rangé sous C:\\Users\\Autre\\soiree.gtt.json</p>', 'chemin hors de la racine neutre : « C:\\Users\\Autre\\soiree.gtt.json »'],
['<button type="button"></button>', 'élément de texte vide : button dans '],
['<p>plan.echelle.mention</p>', 'clé brute affichée : « plan.echelle.mention »'],
[`<p>${OUVERTURE}Libellé${FERMETURE}</p>`, `marque de la langue témoin : « ${OUVERTURE}Libellé${FERMETURE} »`],
];
for (const [html, nature] of cas) {
const element = poser(m, html);
try {
await expect(photographier(m, '01-premier-ecran', ecrire), html).rejects.toThrow(`capture 01-premier-ecran : ${nature}`);
} finally {
element.remove();
}
}
});
});
test('publication : zéro capture échoue ; une étape en échec ne publie rien — attente jetée, manifeste et images inchangés', async () => {
await avecMontage(async (m, { racine, racineNeutre, ecrire }) => {
const declarees = ['01-premier-ecran', '01b-passer-en-ecriture'];
await expect(commands.publierCaptures({ declarees: [], racine, racineNeutre })).rejects.toThrow('aucune capture déclarée');
await expect(commands.publierCaptures({ declarees, racine, racineNeutre })).rejects.toThrow('aucune capture déposée');
for (const fichier of declarees) await photographier(m, fichier, ecrire);
const bilan = await commands.publierCaptures({ declarees, racine, racineNeutre });
expect(bilan).toEqual({ ecrites: declarees, gardees: [], retirees: [], manifeste: true });
const publie = await commands.empreintesDossier({ racine });
expect(publie.map(([chemin]) => chemin)).toEqual([
'doc/captures/01-premier-ecran.png',
'doc/captures/01b-passer-en-ecriture.png',
'doc/captures/manifeste.json',
]);
poser(m, '<p>Iris Ombrelle</p>');
await photographier(m, '01-premier-ecran', ecrire);
await expect(photographier(m, '01b-passer-en-ecriture', { ...ecrire, condition: '[data-absent]' })).rejects.toThrow();
expect((await commands.empreintesDossier({ racine })).map(([chemin]) => chemin)).toContain('doc/captures/.attente/01-premier-ecran.json');
await expect(commands.publierCaptures({ declarees, racine, racineNeutre })).rejects.toThrow(
'étapes sans capture déposée, rien n’est publié : 01b-passer-en-ecriture',
);
expect(await commands.empreintesDossier({ racine })).toEqual(publie);
});
});
test('publication : une empreinte d’étape inchangée laisse les octets de l’image ; changée, l’image se réécrit', async () => {
await avecMontage(async (m, { racine, racineNeutre, ecrire }) => {
const declarees = ['01-premier-ecran'];
const image = async () => new Map(await commands.empreintesDossier({ racine })).get('doc/captures/01-premier-ecran.png');
await photographier(m, '01-premier-ecran', ecrire);
await commands.publierCaptures({ declarees, racine, racineNeutre });
const premiere = await image();
expect(premiere).toMatch(/^[0-9a-f]{64}$/);
// Une pastille sans texte ni marque, dans un coin vide : les pixels
// changent, l'entrée non.
poser(m, '<div style="position: fixed; right: 0; bottom: 0; width: 6px; height: 6px; background: rgb(200, 30, 30)"></div>');
await photographier(m, '01-premier-ecran', ecrire);
const deposee = new Map(await commands.empreintesDossier({ racine })).get('doc/captures/.attente/01-premier-ecran.png');
expect(deposee).not.toBe(premiere);
expect(await commands.publierCaptures({ declarees, racine, racineNeutre })).toEqual({
ecrites: [],
gardees: declarees,
retirees: [],
manifeste: false,
});
expect(await image()).toBe(premiere);
// Un texte de plus : l'entrée change, l'image se réécrit.
poser(m, '<p>Théo Pervenche</p>');
await photographier(m, '01-premier-ecran', ecrire);
expect(await commands.publierCaptures({ declarees, racine, racineNeutre })).toEqual({
ecrites: declarees,
gardees: [],
retirees: [],
manifeste: true,
});
expect(await image()).not.toBe(premiere);
});
});
test('vérification : la même étape passe ; un libellé modifié est un écart qui nomme l’étape et le texte', async () => {
await avecMontage(async (m, { racine, racineNeutre, ecrire, verifier }) => {
await photographier(m, '01-premier-ecran', ecrire);
await commands.publierCaptures({ declarees: ['01-premier-ecran'], racine, racineNeutre });
await photographier(m, '01-premier-ecran', verifier);
const titre = m.racine.querySelector(`${ACCUEIL} h2`);
const avant = titre.textContent;
titre.textContent = `${avant} modifié`;
await expect(photographier(m, '01-premier-ecran', verifier)).rejects.toThrow(
`01-premier-ecran (étape 1) : écart de texte, rang ${texteReleve(m.racine).indexOf(`${avant} modifié`)} : « ${avant} modifié » au lieu de « ${avant} »`,
);
});
});
test('cadre, facteur d’échelle, thème, chaîne témoin, feuille, contraste : chacun échoue en nommant la capture', async () => {
await avecMontage(async (m, { ecrire }) => {
const refus = (nature) => expect(photographier(m, '01-premier-ecran', ecrire)).rejects.toThrow(nature);
await fixerCadre(CADRE.largeur, CADRE.hauteur - 1);
try {
await refus(`capture 01-premier-ecran : cadre ${CADRE.largeur} × ${CADRE.hauteur - 1}, attendu ${CADRE.largeur} × ${CADRE.hauteur}`);
} finally {
await fixerCadre(CADRE.largeur, CADRE.hauteur);
}
// Le facteur que la page lit, remplacé le temps d'une capture.
const facteur = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio');
Object.defineProperty(window, 'devicePixelRatio', { value: 2, configurable: true });
try {
await refus('capture 01-premier-ecran : facteur d’échelle 2, attendu 1');
} finally {
if (facteur === undefined) delete window.devicePixelRatio;
else Object.defineProperty(window, 'devicePixelRatio', facteur);
}
expect(devicePixelRatio).toBe(1);
const documentRacine = document.documentElement;
const theme = documentRacine.getAttribute('data-theme');
documentRacine.setAttribute('data-theme', 'sombre');
try {
await refus('capture 01-premier-ecran : thème « sombre », l’image se prend en thème clair');
} finally {
documentRacine.setAttribute('data-theme', theme);
}
m.racine.style.setProperty('--police-interface', 'serif');
try {
await refus(/^capture 01-premier-ecran : chaîne témoin à [\d.]+ px/);
} finally {
m.racine.style.removeProperty('--police-interface');
}
retirer();
try {
await refus('capture 01-premier-ecran : la feuille qui annule transitions et animations n’est pas posée');
} finally {
retirer = poserFeuille();
}
const pale = poser(m, '<p style="position: fixed; left: 600px; top: 300px; z-index: 2147483647; margin: 0; color: rgb(210, 210, 210); background: rgb(255, 255, 255)">Gris pâle</p>');
try {
await refus(/^capture 01-premier-ecran : thème clair : \d+ faute\(s\) de contraste[^]*« Gris pâle »/);
} finally {
pale.remove();
}
await photographier(m, '01-premier-ecran', ecrire);
});
});
test('relevé des styles : un porteur à demi hors de la fenêtre ne fait rien défiler et n’entre pas dans les styles', async () => {
await avecMontage(async (m) => {
const releve = async () => {
const styles = {};
for (const nom of THEMES) styles[nom] = (await sousTheme(nom, () => stylesDuTheme(m.racine))).styles;
return styles;
};
const defilements = () => [document.scrollingElement, ...m.racine.querySelectorAll('*')].map((e) => `${e.scrollTop},${e.scrollLeft}`).join(';');
const sans = await releve();
// Le porteur se pose six pixels au-dessus du bord bas de la fenêtre,
// son centre au-dessous ; une cale le descend, une queue fait défiler.
const bloc = poser(m, '<div><div data-cale></div><p style="margin: 0; line-height: 40px; color: rgb(20, 60, 20)">Texte à demi caché</p><div style="height: 1500px"></div></div>');
const cale = bloc.querySelector('[data-cale]');
const porteur = bloc.querySelector('p');
cale.style.height = `${innerHeight - 6 - porteur.getBoundingClientRect().top}px`;
try {
expect(Math.round(porteur.getBoundingClientRect().top)).toBe(innerHeight - 6);
const avant = defilements();
const avec = await releve();
expect(defilements()).toBe(avant);
expect(avec).toEqual(sans);
} finally {
bloc.remove();
}
});
});
test('clôture : une épreuve du parcours en échec ne publie rien, jette l’attente et se nomme ; sans échec, la clôture publie', async () => {
await avecMontage(async (m, { racine, racineNeutre, ecrire }) => {
const declarees = ['01-premier-ecran'];
const clore = (echecs) => cloreCaptures({ echecs, declarees, mode: 'ecrire', racine, racineNeutre });
await photographier(m, '01-premier-ecran', ecrire);
await expect(clore(['20 « Défaire » en lecture'])).rejects.toThrow('captures non publiées : 1 épreuve(s) du parcours en échec : « 20 « Défaire » en lecture »');
expect(await commands.empreintesDossier({ racine })).toEqual([]);
await photographier(m, '01-premier-ecran', ecrire);
expect(await clore([])).toEqual({ declarees, bilan: { ecrites: declarees, gardees: [], retirees: [], manifeste: true } });
});
});
test('publication : un dépôt qui nomme un chemin de la machine est refusé par la garde de la contrainte 27, le chemin nommé', async () => {
await avecMontage(async (m, { racine, racineNeutre, ecrire }) => {
const entree = await photographier(m, '01-premier-ecran', ecrire);
const forgee = { ...entree, texte: [...entree.texte, '/home/exemple/x'] };
await commands.deposerCapture({ fichier: '01-premier-ecran', entree: forgee, chemin: `${racine}/doc/captures/.attente/01-premier-ecran.png`, racine });
await expect(commands.publierCaptures({ declarees: ['01-premier-ecran'], racine, racineNeutre })).rejects.toThrow(
'doc/captures/01-premier-ecran.png : chemin hors de la racine neutre : « /home/exemple/x »',
);
expect(await commands.empreintesDossier({ racine })).toEqual([]);
});
});
test('publication : une image qui n’est plus déclarée part ; une image remplacée, à empreinte d’étape égale, se réécrit', async () => {
await avecMontage(async (m, { racine, racineNeutre, ecrire }) => {
const declarees = ['01-premier-ecran'];
const empreintes = async () => new Map(await commands.empreintesDossier({ racine }));
await photographier(m, '01-premier-ecran', ecrire);
await commands.copierDansEpreuve({ racine, depuis: 'doc/captures/.attente/01-premier-ecran.png', vers: 'doc/captures/09-refus-sur-place.png' });
expect((await empreintes()).has('doc/captures/09-refus-sur-place.png')).toBe(true);
const bilan = await commands.publierCaptures({ declarees, racine, racineNeutre });
expect(bilan.retirees).toEqual(['09-refus-sur-place']);
expect([...(await empreintes()).keys()]).toEqual(['doc/captures/01-premier-ecran.png', 'doc/captures/manifeste.json']);
const publiee = (await empreintes()).get('doc/captures/01-premier-ecran.png');
// Une autre image prend la place de la publiée : une pastille change
// les pixels, l'entrée non.
const pastille = poser(m, '<div style="position: fixed; right: 0; bottom: 0; width: 6px; height: 6px; background: rgb(200, 30, 30)"></div>');
await photographier(m, '01-premier-ecran', ecrire);
pastille.remove();
await commands.copierDansEpreuve({ racine, depuis: 'doc/captures/.attente/01-premier-ecran.png', vers: 'doc/captures/01-premier-ecran.png' });
const remplacee = (await empreintes()).get('doc/captures/01-premier-ecran.png');
expect(remplacee).not.toBe(publiee);
await photographier(m, '01-premier-ecran', ecrire);
expect(await commands.publierCaptures({ declarees, racine, racineNeutre })).toMatchObject({ ecrites: declarees, gardees: [], retirees: [] });
expect((await empreintes()).get('doc/captures/01-premier-ecran.png')).not.toBe(remplacee);
});
});
});

View file

@ -21,6 +21,20 @@
// alors cette cause. C.UTF-8 vient avec la glibc 2.35 et suivantes, et avec
// les distributions qui le livrent avant ; l'environnement reçu reste tel
// quel.
//
// Le projet navigateur reçoit optionsNavigateur : les mêmes options, plus
// trois arguments de lancement et un contexte au facteur d'échelle 1
// (§ 19.2, § 19.4). Le résolveur de Chromium ne résout que localhost ; un
// mandataire mort, 127.0.0.1:9, reçoit toute requête vers une adresse
// littérale, que le résolveur ne voit pas. Chromium contourne d'office le
// mandataire pour la boucle locale — localhost, 127.0.0.0/8, [::1] — : la
// page des épreuves se charge, et une requête qui sortirait de la machine
// échoue en connexion, observable, au lieu de partir. La règle de
// contournement <-loopback> retirerait ce contournement ; elle n'est pas
// posée. Le facteur d'échelle se fixe au lancement, que ni Playwright ni
// une commande du standard ne fixent pour la fenêtre de la machine.
// suivreRequetes relève, page par page, les requêtes hors de la boucle
// locale que la page émet, refusées ou non, et la cause de chaque échec.
// Un nom de paramètres régionaux dont le jeu de caractères est UTF-8 :
// « fr_CA.UTF-8 », « C.utf8 », « de_DE.UTF-8@euro ».
@ -44,3 +58,92 @@ export function optionsChromium(env) {
}
return Object.keys(lancement).length > 0 ? { launchOptions: lancement } : {};
}
/** Les arguments de lancement du projet navigateur, dans cet ordre. */
export const ARGUMENTS_NAVIGATEUR = Object.freeze([
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost',
'--proxy-server=127.0.0.1:9',
'--force-device-scale-factor=1',
]);
/** Le mode d'affichage du navigateur des captures, nommé au manifeste
* (§ 19.4) : une valeur de la configuration, jamais lue de la machine. */
export const MODE_AFFICHAGE = 'chromium-headless-shell';
/**
* Les options du fournisseur Playwright du projet navigateur : celles de
* optionsChromium(env), ARGUMENTS_NAVIGATEUR ajoutés à la fin des arguments
* de lancement, et un contexte de navigation au facteur d'échelle 1. env
* n'est pas modifié.
*
* @param {Record<string, string|undefined>} env
*/
export function optionsNavigateur(env) {
const { launchOptions = {}, ...autres } = optionsChromium(env);
return {
...autres,
launchOptions: { ...launchOptions, args: [...(launchOptions.args ?? []), ...ARGUMENTS_NAVIGATEUR] },
contextOptions: { deviceScaleFactor: 1 },
};
}
// Les schémas d'une adresse qui ne passe pas par le réseau.
const SANS_RESEAU = new Set(['data:', 'blob:', 'about:']);
// Un nom d'hôte de la boucle locale, tel que l'URL le rend : localhost,
// une adresse IPv4 de 127.0.0.0/8, ou [::1].
const BOUCLE = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/;
/**
* Vrai quand une requête vers cette adresse sortirait de la machine : toute
* adresse dont l'hôte n'est pas de la boucle locale, hors des schémas sans
* réseau (data:, blob:, about:). Une adresse illisible compte comme sortante.
*
* @param {string} adresse
* @returns {boolean}
*/
export function horsBoucle(adresse) {
let url;
try {
url = new URL(adresse);
} catch {
return true;
}
if (SANS_RESEAU.has(url.protocol)) return false;
return !BOUCLE.test(url.hostname);
}
// Les requêtes hors boucle relevées par page, et leurs échecs, en attente
// d'être lus.
const RELEVES = new WeakMap();
/**
* Suit les requêtes de la page Playwright : la première fois, deux
* écouteurs se posent sur la page, « request » et « requestfailed », qui
* relèvent chaque adresse hors de la boucle locale, cadres compris, et
* chaque échec d'une telle requête avec sa cause — l'errorText de
* Chromium, net::ERR_PROXY_CONNECTION_FAILED quand le mandataire mort l'a
* reçue. Rend { relever(), releverEchecs() } : les adresses, puis les
* échecs { adresse, cause }, relevés depuis la lecture précédente, dans
* l'ordre, chacun vidé par sa lecture. Deux suivis de la même page
* partagent le même relevé.
*
* @param {{on: (evenement: string, ecouteur: Function) => unknown}} page
* @returns {{relever: () => string[], releverEchecs: () => Array<{adresse: string, cause: string|null}>}}
*/
export function suivreRequetes(page) {
if (!RELEVES.has(page)) {
const releve = { requetes: [], echecs: [] };
RELEVES.set(page, releve);
page.on('request', (requete) => {
const adresse = requete.url();
if (horsBoucle(adresse)) releve.requetes.push(adresse);
});
page.on('requestfailed', (requete) => {
const adresse = requete.url();
if (horsBoucle(adresse)) releve.echecs.push({ adresse, cause: requete.failure()?.errorText ?? null });
});
}
const { requetes, echecs } = RELEVES.get(page);
return { relever: () => requetes.splice(0, requetes.length), releverEchecs: () => echecs.splice(0, echecs.length) };
}

View file

@ -3,12 +3,21 @@
// Le Chromium des épreuves du navigateur : celui de Playwright par défaut,
// celui que GTT_CHROMIUM désigne sinon ; sous des paramètres régionaux qui ne
// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. vitest.config.js
// applique la règle.
// sont pas UTF-8, il reçoit l'environnement sous LANG=C.UTF-8. Le projet
// navigateur le lance sans réseau hors de la boucle locale, au facteur
// d'échelle 1 (§ 19.2, § 19.4), et lui donne les commandes et le mode des
// captures d'écran (§ 19.5). vitest.config.js applique la règle ; le Makefile
// lance la publication.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import configuration from '../vitest.config.js';
import { optionsChromium } from './chromium.js';
import { ARGUMENTS_NAVIGATEUR, horsBoucle, optionsChromium, optionsNavigateur, suivreRequetes } from './chromium.js';
import { describe, test } from './lanceur.js';
import * as commandesDocumentation from './navigateur/commandes_documentation.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
// Un environnement d'épreuve, sous des paramètres régionaux UTF-8.
const UTF8 = { PATH: '/usr/bin', LANG: 'fr_CA.UTF-8' };
@ -25,9 +34,9 @@ describe('chromium : l’exécutable des épreuves du navigateur', () => {
});
});
test('vitest.config.js passe au projet navigateur les options de cet environnement', () => {
test('vitest.config.js passe au projet navigateur les options de cet environnement, réseau et échelle compris', () => {
const navigateur = configuration.test.projects.find(({ test: { name } }) => name === 'navigateur');
assert.deepEqual(navigateur.test.browser.provider.options, optionsChromium(process.env));
assert.deepEqual(navigateur.test.browser.provider.options, optionsNavigateur(process.env));
});
});
@ -71,3 +80,132 @@ describe('chromium : les paramètres régionaux du navigateur des épreuves', ()
});
});
});
describe('chromium : aucun réseau hors de la boucle locale, le facteur d’échelle 1 (§ 19.2, § 19.4)', () => {
test('les arguments du navigateur : le résolveur ne résout que la boucle locale, un mandataire mort reçoit le reste, le facteur d’échelle est fixé au lancement', () => {
assert.deepEqual(ARGUMENTS_NAVIGATEUR, [
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost',
'--proxy-server=127.0.0.1:9',
'--force-device-scale-factor=1',
]);
// La boucle locale contourne le mandataire d'office ; <-loopback> la
// ferait passer par lui, et la page des épreuves ne se chargerait plus.
assert.equal(ARGUMENTS_NAVIGATEUR.some((argument) => argument.includes('<-loopback>')), false);
assert.equal(Object.isFrozen(ARGUMENTS_NAVIGATEUR), true);
});
test('optionsNavigateur : les options de optionsChromium, les arguments ajoutés, un contexte au facteur 1 ; l’environnement reçu reste tel quel', () => {
assert.deepEqual(optionsNavigateur({ LANG: 'fr_CA.UTF-8' }), {
launchOptions: { args: [...ARGUMENTS_NAVIGATEUR] },
contextOptions: { deviceScaleFactor: 1 },
});
const env = { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' };
assert.deepEqual(optionsNavigateur(env), {
launchOptions: {
executablePath: '/opt/essai/chromium',
env: { GTT_CHROMIUM: '/opt/essai/chromium', LANG: 'C.UTF-8' },
args: [...ARGUMENTS_NAVIGATEUR],
},
contextOptions: { deviceScaleFactor: 1 },
});
assert.deepEqual(env, { LC_ALL: 'C', GTT_CHROMIUM: '/opt/essai/chromium' });
});
test('horsBoucle : une adresse hors de la boucle locale est relevée ; la boucle locale et les adresses sans réseau ne le sont pas', () => {
const dehors = [
'http://192.0.2.1/',
'https://exemple.invalid/police.woff2',
'ws://198.51.100.7:8080/',
'http://localhost.exemple.invalid/',
'http://127.0.0.1.exemple.invalid/',
'http://[2001:db8::1]/',
'ftp://exemple.invalid/x',
'gopher://exemple.invalid/',
];
const dedans = [
'http://localhost:63315/__vitest_test__/',
'ws://localhost:63315/__vitest_browser_api__',
'http://LOCALHOST/',
'http://127.0.0.1:5173/collecteur.js',
'http://127.8.9.10/',
'http://[::1]:63315/',
'data:text/plain,x',
'blob:http://localhost:63315/0f3c',
'about:blank',
];
for (const adresse of dehors) assert.equal(horsBoucle(adresse), true, adresse);
for (const adresse of dedans) assert.equal(horsBoucle(adresse), false, adresse);
assert.equal(horsBoucle('pas une adresse'), true);
});
test('suivreRequetes : chaque requête hors boucle de la page, relevée une fois et dans l’ordre ; un second suivi de la même page reprend le premier', () => {
const ecouteurs = [];
const page = { on: (evenement, fn) => ecouteurs.push([evenement, fn]) };
const requete = (adresse) => ({ url: () => adresse });
const suivi = suivreRequetes(page);
assert.deepEqual(ecouteurs.map(([evenement]) => evenement), ['request', 'requestfailed']);
const [[, ecouter]] = ecouteurs;
ecouter(requete('http://localhost:63315/a.js'));
ecouter(requete('http://192.0.2.1/'));
ecouter(requete('https://exemple.invalid/b'));
assert.deepEqual(suivreRequetes(page).relever(), ['http://192.0.2.1/', 'https://exemple.invalid/b']);
assert.equal(ecouteurs.length, 2);
assert.deepEqual(suivi.relever(), []);
ecouter(requete('http://192.0.2.1/c'));
assert.deepEqual(suivi.relever(), ['http://192.0.2.1/c']);
assert.deepEqual(suivreRequetes({ on: () => {} }).relever(), []);
});
test('suivreRequetes : chaque échec d’une requête hors boucle, avec sa cause, relevé une fois ; ceux de la boucle locale ne le sont pas', () => {
const ecouteurs = new Map();
const page = { on: (evenement, fn) => ecouteurs.set(evenement, fn) };
const suivi = suivreRequetes(page);
assert.deepEqual([...ecouteurs.keys()], ['request', 'requestfailed']);
const echec = (adresse, errorText) => ({ url: () => adresse, failure: () => (errorText === null ? null : { errorText }) });
ecouteurs.get('requestfailed')(echec('http://localhost:63315/a.js', 'net::ERR_ABORTED'));
ecouteurs.get('requestfailed')(echec('http://192.0.2.1/', 'net::ERR_PROXY_CONNECTION_FAILED'));
ecouteurs.get('requestfailed')(echec('http://192.0.2.1/b', null));
assert.deepEqual(suivi.releverEchecs(), [
{ adresse: 'http://192.0.2.1/', cause: 'net::ERR_PROXY_CONNECTION_FAILED' },
{ adresse: 'http://192.0.2.1/b', cause: null },
]);
assert.deepEqual(suivi.releverEchecs(), []);
assert.deepEqual(suivi.relever(), []);
});
});
describe('le projet navigateur : les commandes et le mode des captures d’écran (§ 19.5)', () => {
const navigateur = configuration.test.projects.find(({ test: { name } }) => name === 'navigateur');
test('modeCaptures : verifier par défaut, ecrire sous GTT_CAPTURES=ecrire ; une autre valeur lève en la nommant', () => {
assert.equal(commandesDocumentation.modeCaptures({}), 'verifier');
assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: '' }), 'verifier');
assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'verifier' }), 'verifier');
assert.equal(commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'ecrire' }), 'ecrire');
assert.throws(() => commandesDocumentation.modeCaptures({ GTT_CAPTURES: 'ecrir' }), /GTT_CAPTURES.*« ecrir »/);
});
test('vitest.config.js fournit le mode à la page par provide, et déclare chaque commande de commandes_documentation.js', () => {
assert.deepEqual(navigateur.test.provide, { captures: { mode: commandesDocumentation.modeCaptures(process.env) } });
const declarees = navigateur.test.browser.commands;
assert.deepEqual(Object.keys(declarees).sort(), [...commandesDocumentation.COMMANDES].sort());
for (const nom of commandesDocumentation.COMMANDES) {
assert.equal(typeof commandesDocumentation[nom], 'function', nom);
assert.equal(declarees[nom], commandesDocumentation[nom], nom);
}
assert.ok(commandesDocumentation.COMMANDES.length > 0);
});
test('make captures lance le parcours du projet navigateur sous GTT_CAPTURES=ecrire, par avec_node ; .gitignore tait le dossier d’attente', () => {
const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8');
assert.match(makefile, /^\.PHONY: captures$/m);
assert.match(makefile, /^captures: export GTT_CAPTURES := ecrire$/m);
assert.match(
makefile,
/^captures: ## Photographie le parcours et publie doc\/captures \(§ 19\.5\)\n\t\$\(AVEC_NODE\) npx vitest run --project navigateur src\/interface\/parcours\.navigateur\.test\.js$/m,
);
const ignores = readFileSync(join(RACINE, '.gitignore'), 'utf8').split('\n');
assert.ok(ignores.includes('doc/captures/.attente/'), ignores.join(' | '));
assert.equal(ignores.some((ligne) => /^doc\/captures\/?$|^doc\/?$|\*\.png/.test(ligne)), false);
});
});

772
test/navigateur/captures.js Normal file
View file

@ -0,0 +1,772 @@
// © 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 };
}

View file

@ -0,0 +1,478 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les commandes des captures d'écran, côté node (§ 19.4, § 19.5, § 19.7) :
// vitest.config.js les déclare dans browser.commands, et la page les appelle
// par commands de vitest/browser. Chacune reçoit d'abord le contexte de la
// commande — la page Playwright, le cadre de l'épreuve —, puis l'unique
// objet que la page lui passe ; elle rend du JSON. Aucun octet ne traverse
// en Uint8Array : le RPC des commandes sérialiserait un tableau typé en objet
// {"0": …}. La capture d'élément se prend ici, sur le cadre de l'épreuve, et
// s'écrit sur le disque sans repasser par la page.
//
// Les fichiers vivent sous une racine : celle du projet (racine null), ou
// un dossier d'épreuve que dossierEpreuve a créé, et que seul
// effacerDossierEpreuve retire. Toute autre racine est refusée.
//
// Le mode d'écriture. Chaque étape photographiée dépose, sous
// doc/captures/.attente/, son image <fichier>.png et son entrée complète
// <fichier>.json. publierCaptures publie d'un bloc : toutes les captures
// déclarées déposées, ni plus ni moins, chaque entrée relue et ses
// dimensions contrôlées, le manifeste assemblé et ses contrôles d'ordre et
// de machine tenus, alors seulement les images et le manifeste s'écrivent.
// Une image ne se réécrit que si l'empreinte de son étape a changé, ou si
// le fichier présent n'est plus celui que le manifeste décrit ; le
// manifeste ne se réécrit que si ses octets changent ; une image qui n'est
// plus déclarée part. Le dossier d'attente est jeté à la fin de chaque
// publication, réussie ou refusée : une publication refusée laisse le
// manifeste et les images tels qu'avant.
//
// Le mode de vérification compare l'entrée d'une étape à celle du
// manifeste (comparerCapture) et n'écrit rien.
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
A_VENIR,
CAPTURES,
CHEMIN_MANIFESTE,
DOSSIER_CAPTURES,
REPORTEES,
canoniser,
cheminImage,
cheminsHorsRacine as cheminsHors,
empreinteEtape,
fautesDImage,
fautesDeMachine,
lireEntetePng,
verifierOrdre,
} from '../../scripts/documentation/manifeste.js';
import { MODE_AFFICHAGE, suivreRequetes } from '../chromium.js';
/** Les deux modes des captures : verifier compare au manifeste, ecrire
* dépose puis publie (make captures). */
export const MODES = Object.freeze(['verifier', 'ecrire']);
/** Les commandes que vitest.config.js déclare, toutes exportées ici. */
export const COMMANDES = Object.freeze([
'requetesHorsBoucle',
'echecsHorsBoucle',
'declarationsCaptures',
'cheminsHorsRacine',
'lireManifeste',
'prendreCliche',
'deposerCapture',
'comparerCapture',
'publierCaptures',
'viderAttente',
'dossierEpreuve',
'effacerDossierEpreuve',
'empreintesDossier',
'copierDansEpreuve',
]);
/**
* Le mode des captures que GTT_CAPTURES demande : 'verifier' quand la
* variable est absente ou vide.
*
* @param {Record<string, string|undefined>} env
* @returns {'verifier'|'ecrire'}
* @throws {Error} une autre valeur, nommée
*/
export function modeCaptures(env) {
const valeur = env.GTT_CAPTURES ?? '';
if (valeur === '') return 'verifier';
if (MODES.includes(valeur)) return valeur;
throw new Error(`GTT_CAPTURES : « ${valeur} » n’est ni ${MODES.map((m) => `« ${m} »`).join(' ni ')}`);
}
// La racine du projet, deux dossiers au-dessus de ce module.
const PROJET = fileURLToPath(new URL('../..', import.meta.url));
// Le dossier d'attente, relatif à une racine.
const ATTENTE = `${DOSSIER_CAPTURES}/.attente`;
// Les dossiers d'épreuve créés par dossierEpreuve et pas encore effacés.
const DOSSIERS_EPREUVE = new Set();
// Un nom de capture : lettres, chiffres, tirets, sans point ni barre.
const NOM_CAPTURE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
// La racine absolue que désigne racine : le projet pour null, un dossier
// d'épreuve vivant, sinon un refus.
function racineDe(racine) {
if (racine === null || racine === undefined) return PROJET;
if (DOSSIERS_EPREUVE.has(racine)) return racine;
throw new Error(`captures : racine refusée « ${racine} » — ni le projet, ni un dossier d’épreuve vivant`);
}
function exigerNom(fichier) {
if (typeof fichier !== 'string' || !NOM_CAPTURE.test(fichier)) {
throw new Error(`captures : nom de capture refusé ${JSON.stringify(fichier)}`);
}
}
// Le contenu d'un fichier, null quand il n'existe pas.
async function lireSiPresent(chemin, encodage) {
try {
return await readFile(chemin, encodage);
} catch (erreur) {
if (erreur.code === 'ENOENT') return null;
throw erreur;
}
}
// Écrit un fichier par un fichier voisin renommé : un lecteur ne voit jamais
// un fichier à moitié écrit.
async function ecrireAtomique(chemin, contenu) {
const provisoire = `${chemin}.ecriture`;
await writeFile(provisoire, contenu);
await rename(provisoire, chemin);
}
// Le manifeste d'une racine absolue, analysé ; null quand il n'existe pas.
async function manifesteDe(base) {
const texte = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8');
if (texte === null) return null;
try {
return JSON.parse(texte);
} catch (erreur) {
throw new Error(`${CHEMIN_MANIFESTE} illisible : ${erreur.message}`);
}
}
/** Les requêtes hors de la boucle locale que la page a émises depuis
* l'appel précédent (test/chromium.js) ; le premier appel pose le suivi. */
export async function requetesHorsBoucle(context) {
return suivreRequetes(context.page).relever();
}
/** Les échecs des requêtes hors de la boucle locale, { adresse, cause },
* depuis l'appel précédent : la cause dit qui a refusé la connexion. */
export async function echecsHorsBoucle(context) {
return suivreRequetes(context.page).releverEchecs();
}
/** Les captures déclarées (CAPTURES) et celles qui sont à venir (A_VENIR) :
* la page ne charge pas scripts/documentation/manifeste.js, qui est de node. */
export async function declarationsCaptures() {
return { captures: CAPTURES.map((capture) => ({ ...capture })), aVenir: [...A_VENIR] };
}
/** Les chemins hors de la racine neutre que porte le texte relevé :
* cheminsHorsRacine de scripts/documentation/manifeste.js, sur les textes
* joints ligne à ligne. */
export async function cheminsHorsRacine(_context, { texte, racine }) {
if (!Array.isArray(texte) || texte.some((ligne) => typeof ligne !== 'string')) {
throw new TypeError('cheminsHorsRacine : texte est une liste de chaînes');
}
return cheminsHors(texte.join('\n'), racine);
}
/** Le manifeste de la racine, analysé ; null quand il n'existe pas. */
export async function lireManifeste(_context, { racine = null } = {}) {
return manifesteDe(racineDe(racine));
}
/**
* Photographie l'élément que désigne selecteur dans le cadre de l'épreuve
* et écrit l'image sous le dossier d'attente de la racine, à
* <fichier>.png. Le sélecteur doit désigner un seul élément.
*
* @returns {Promise<{chemin: string}>} le chemin absolu de l'image
*/
export async function prendreCliche(context, { fichier, selecteur, racine = null }) {
exigerNom(fichier);
const base = racineDe(racine);
const element = context.iframe.locator(selecteur);
const nombre = await element.count();
if (nombre !== 1) throw new Error(`capture ${fichier} : le sélecteur « ${selecteur} » désigne ${nombre} éléments, un attendu`);
const octets = await element.screenshot({ animations: 'disabled', caret: 'hide' });
const dossier = join(base, ATTENTE);
await mkdir(dossier, { recursive: true });
const chemin = join(dossier, `${fichier}.png`);
await ecrireAtomique(chemin, octets);
return { chemin };
}
/**
* Dépose l'entrée d'une étape à côté de son image, sous le dossier
* d'attente : l'image que prendreCliche a écrite à chemin, relue ; l'entrée
* complétée de image {sha256, largeur, hauteur} et de son empreinte
* d'étape. Des dimensions autres que cadre × facteur d'échelle refusent le
* dépôt.
*
* @returns {Promise<{empreinte: string}>}
*/
export async function deposerCapture(_context, { fichier, entree, chemin, racine = null }) {
exigerNom(fichier);
const base = racineDe(racine);
const attendu = join(base, ATTENTE, `${fichier}.png`);
if (typeof chemin !== 'string' || resolve(chemin) !== attendu) {
throw new Error(`capture ${fichier} : image attendue à ${relative(base, attendu)}, reçue ${JSON.stringify(chemin)}`);
}
if (entree?.fichier !== fichier) throw new Error(`capture ${fichier} : l’entrée porte le nom ${JSON.stringify(entree?.fichier)}`);
const octets = new Uint8Array(await readFile(attendu));
const { largeur, hauteur } = lireEntetePng(octets);
const complete = { ...entree, image: { sha256: sha256(octets), largeur, hauteur } };
const fautes = fautesDImage(complete, octets);
if (fautes.length > 0) throw new Error(`capture ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`);
complete.empreinte = empreinteEtape(complete);
await ecrireAtomique(join(base, ATTENTE, `${fichier}.json`), JSON.stringify(complete));
return { empreinte: complete.empreinte };
}
// Le texte JSON canonique d'une valeur, pour comparer deux champs.
const canonique = (valeur) => canoniser({ valeur });
// Le premier rang où deux listes de textes diffèrent, et les deux textes.
function premierEcart(avant, apres) {
const longueur = Math.max(avant.length, apres.length);
for (let rang = 0; rang < longueur; rang += 1) {
if (avant[rang] !== apres[rang]) return { rang, avant: avant[rang] ?? null, apres: apres[rang] ?? null };
}
return null;
}
// Un texte relevé en citation, ou « (rien) » au-delà de la fin.
const citer = (texte) => (texte === null ? '(rien)' : `« ${texte} »`);
/**
* Compare l'entrée d'une étape à celle du manifeste de la racine (§ 19.7) :
* l'empreinte d'étape d'abord ; quand elle diffère, chaque champ qui
* diffère est nommé, le texte avec son premier écart. Rien ne s'écrit.
*
* @returns {Promise<{ecarts: string[]}>} vide quand l'empreinte est la même
*/
export async function comparerCapture(_context, { fichier, entree, racine = null }) {
exigerNom(fichier);
const manifeste = await manifesteDe(racineDe(racine));
const nom = `${fichier} (étape ${entree?.etape})`;
if (manifeste === null) return { ecarts: [`${nom} : aucun manifeste, ${CHEMIN_MANIFESTE} — lancer make captures`] };
const precedente = (manifeste.captures ?? []).find((capture) => capture?.fichier === fichier);
if (precedente === undefined) return { ecarts: [`${nom} : absente du manifeste — lancer make captures`] };
if (empreinteEtape(entree) === precedente.empreinte) return { ecarts: [] };
const ecarts = [];
const exclus = new Set(['image', 'empreinte', 'versionReelle']);
const champs = [...new Set([...Object.keys(entree), ...Object.keys(precedente)])].filter((champ) => !exclus.has(champ)).sort();
for (const champ of champs) {
if (canonique(entree[champ] ?? null) === canonique(precedente[champ] ?? null)) continue;
if (champ === 'texte' && Array.isArray(entree.texte) && Array.isArray(precedente.texte)) {
const ecart = premierEcart(precedente.texte, entree.texte);
ecarts.push(`${nom} : écart de texte, rang ${ecart.rang} : ${citer(ecart.apres)} au lieu de ${citer(ecart.avant)}`);
} else {
ecarts.push(`${nom} : écart de ${champ}`);
}
}
if (ecarts.length === 0) ecarts.push(`${nom} : empreinte d’étape différente de celle du manifeste`);
return { ecarts };
}
// Les captures déposées sous le dossier d'attente : { fichier → {png, json} }.
// Un fichier d'une autre forme lève.
async function deposees(attente) {
let noms;
try {
noms = await readdir(attente);
} catch (erreur) {
if (erreur.code === 'ENOENT') return new Map();
throw erreur;
}
const depots = new Map();
for (const nom of noms.sort()) {
const forme = /^(.+)\.(png|json)$/.exec(nom);
if (forme === null || !NOM_CAPTURE.test(forme[1])) throw new Error(`publication : fichier inattendu dans ${ATTENTE} : ${nom}`);
const [, fichier, extension] = forme;
depots.set(fichier, { ...depots.get(fichier), [extension]: join(attente, nom) });
}
return depots;
}
// Le pilote du manifeste : le navigateur et sa version, le mode nommé, le
// facteur d'échelle et le cadre, communs à toutes les captures.
function piloteDe(context, captures) {
const [premiere] = captures;
for (const capture of captures) {
if (capture.facteurEchelle !== premiere.facteurEchelle || canonique(capture.cadre) !== canonique(premiere.cadre)) {
throw new Error(`publication : ${capture.fichier} n’a pas le cadre ni le facteur d’échelle de ${premiere.fichier}`);
}
}
const versionNavigateur = context.page.context().browser()?.version() ?? 'inconnue';
return {
navigateur: 'chromium',
versionNavigateur,
mode: MODE_AFFICHAGE,
facteurEchelle: premiere.facteurEchelle,
cadre: { largeur: premiere.cadre.largeur, hauteur: premiere.cadre.hauteur },
};
}
// Assemble la publication sans rien écrire : le manifeste, les images à
// écrire, les images à retirer. Lève sur la première raison de refuser.
async function preparer(context, base, declarees, racineNeutre) {
if (!Array.isArray(declarees) || declarees.length === 0) {
throw new Error('publication : aucune capture déclarée — zéro capture n’est pas zéro capture périmée');
}
const connues = new Map(CAPTURES.map((capture) => [capture.fichier, capture]));
for (const fichier of declarees) {
if (!connues.has(fichier)) throw new Error(`publication : ${JSON.stringify(fichier)} n’est pas déclarée dans CAPTURES`);
}
if (new Set(declarees).size !== declarees.length) throw new Error('publication : une capture déclarée deux fois');
const depots = await deposees(join(base, ATTENTE));
if (depots.size === 0) throw new Error('publication : aucune capture déposée — rien n’est publié');
const completes = [...depots].filter(([, depot]) => depot.png && depot.json).map(([fichier]) => fichier);
const manquantes = declarees.filter((fichier) => !completes.includes(fichier));
if (manquantes.length > 0) {
throw new Error(`publication : étapes sans capture déposée, rien n’est publié : ${manquantes.join(', ')}`);
}
const enTrop = [...depots.keys()].filter((fichier) => !declarees.includes(fichier));
if (enTrop.length > 0) throw new Error(`publication : captures déposées hors des déclarées : ${enTrop.join(', ')}`);
const ancien = await manifesteDe(base);
const anciennes = new Map((ancien?.captures ?? []).map((capture) => [capture?.fichier, capture]));
const captures = [];
const aEcrire = [];
const gardees = [];
for (const { fichier, etape } of CAPTURES.filter((capture) => declarees.includes(capture.fichier))) {
const { png, json } = depots.get(fichier);
const entree = JSON.parse(await readFile(json, 'utf8'));
const octets = new Uint8Array(await readFile(png));
if (entree.fichier !== fichier || entree.etape !== etape) {
throw new Error(`publication : ${fichier} déposée sous le nom ${entree.fichier}, étape ${entree.etape} ; étape ${etape} déclarée`);
}
if (entree.empreinte !== empreinteEtape(entree)) throw new Error(`publication : ${fichier} : l’empreinte déposée ne répond pas à l’entrée`);
const fautes = fautesDImage(entree, octets);
if (fautes.length > 0) throw new Error(`publication : ${fichier} : ${fautes.map(({ faute }) => faute).join(' ; ')}`);
const precedente = anciennes.get(fichier);
const presente = await lireSiPresent(join(base, cheminImage(fichier)));
if (precedente?.empreinte === entree.empreinte && presente !== null && sha256(presente) === precedente.image?.sha256) {
captures.push({ ...entree, image: precedente.image });
gardees.push(fichier);
} else {
captures.push(entree);
aEcrire.push({ fichier, octets });
}
}
const manifeste = { format: 1, pilote: piloteDe(context, captures), captures, reportees: REPORTEES.map((r) => ({ ...r })) };
const aVenir = CAPTURES.map(({ fichier }) => fichier).filter((fichier) => !declarees.includes(fichier));
const fautes = [...verifierOrdre(manifeste, { aVenir }), ...fautesDeMachine(manifeste, racineNeutre)];
if (fautes.length > 0) {
throw new Error(`publication refusée :\n${fautes.map(({ chemin, faute }) => `${chemin} : ${faute}`).join('\n')}`);
}
let presentes;
try {
presentes = await readdir(join(base, DOSSIER_CAPTURES));
} catch (erreur) {
if (erreur.code !== 'ENOENT') throw erreur;
presentes = [];
}
const retirees = presentes
.filter((nom) => nom.endsWith('.png'))
.map((nom) => nom.slice(0, -'.png'.length))
.filter((fichier) => !declarees.includes(fichier))
.sort();
return { texte: canoniser(manifeste), aEcrire, gardees, retirees };
}
/**
* Publie d'un bloc les captures déposées (§ 19.5) : declarees, les noms de
* CAPTURES que cette publication porte — les autres comptent comme à venir
* au contrôle d'ordre ; racineNeutre, la racine de travail que les textes
* relevés peuvent nommer (fautesDeMachine). Le dossier d'attente est jeté,
* que la publication réussisse ou non.
*
* @returns {Promise<{ecrites: string[], gardees: string[], retirees: string[], manifeste: boolean}>}
* manifeste : vrai quand ses octets ont changé
*/
export async function publierCaptures(context, { declarees, racine = null, racineNeutre } = {}) {
const base = racineDe(racine);
try {
const { texte, aEcrire, gardees, retirees } = await preparer(context, base, declarees, racineNeutre);
await mkdir(join(base, DOSSIER_CAPTURES), { recursive: true });
for (const { fichier, octets } of aEcrire) await ecrireAtomique(join(base, cheminImage(fichier)), octets);
for (const fichier of retirees) await rm(join(base, cheminImage(fichier)));
const ancien = await lireSiPresent(join(base, CHEMIN_MANIFESTE), 'utf8');
if (ancien !== texte) await ecrireAtomique(join(base, CHEMIN_MANIFESTE), texte);
return { ecrites: aEcrire.map(({ fichier }) => fichier), gardees, retirees, manifeste: ancien !== texte };
} finally {
await rm(join(base, ATTENTE), { recursive: true, force: true });
}
}
/** Vide le dossier d'attente de la racine : un parcours en écriture part
* de lui vide, et le dépôt d'un passage interrompu ne se publie jamais
* avec les captures d'un autre. */
export async function viderAttente(_context, { racine = null } = {}) {
await rm(join(racineDe(racine), ATTENTE), { recursive: true, force: true });
}
/** Crée un dossier d'épreuve neuf, sous le dossier temporaire du système,
* et rend son chemin absolu : une racine que les autres commandes
* acceptent jusqu'à effacerDossierEpreuve. */
export async function dossierEpreuve() {
const dossier = await mkdtemp(join(tmpdir(), 'gtt-captures-'));
DOSSIERS_EPREUVE.add(dossier);
return dossier;
}
/** Efface un dossier que dossierEpreuve a créé, et lui seul. */
export async function effacerDossierEpreuve(_context, racine) {
if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`);
DOSSIERS_EPREUVE.delete(racine);
await rm(racine, { recursive: true, force: true });
}
/**
* Copie un fichier d'un dossier d'épreuve sur un autre chemin du même
* dossier, les deux sous doc/captures : une image posée là où la
* publication ne l'a pas mise. La racine du projet est refusée.
*
* @param {Object} _context
* @param {{racine: string, depuis: string, vers: string}} chemins relatifs à racine
*/
export async function copierDansEpreuve(_context, { racine, depuis, vers }) {
if (!DOSSIERS_EPREUVE.has(racine)) throw new Error(`captures : « ${racine} » n’est pas un dossier d’épreuve vivant`);
const captures = join(racine, DOSSIER_CAPTURES);
const dedans = (chemin) => {
const absolu = resolve(racine, String(chemin));
const relatif = relative(captures, absolu);
if (relatif === '' || relatif.startsWith('..') || isAbsolute(relatif)) {
throw new Error(`captures : ${JSON.stringify(chemin)} hors de ${DOSSIER_CAPTURES}`);
}
return absolu;
};
const source = dedans(depuis);
const cible = dedans(vers);
await mkdir(dirname(cible), { recursive: true });
await ecrireAtomique(cible, await readFile(source));
}
/** Les fichiers sous doc/captures de la racine, dossier d'attente compris :
* [chemin relatif à la racine, SHA-256], rangés par chemin. */
export async function empreintesDossier(_context, { racine = null } = {}) {
const base = racineDe(racine);
const fichiers = [];
async function parcourir(dossier) {
let entrees;
try {
entrees = await readdir(dossier, { withFileTypes: true });
} catch (erreur) {
if (erreur.code === 'ENOENT') return;
throw erreur;
}
for (const entree of entrees) {
const chemin = join(dossier, entree.name);
if (entree.isDirectory()) await parcourir(chemin);
else fichiers.push([relative(base, chemin).split('\\').join('/'), sha256(await readFile(chemin))]);
}
}
await parcourir(join(base, DOSSIER_CAPTURES));
return fichiers.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
}

View file

@ -0,0 +1,40 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La feuille de style que le pilotage injecte (§ 19.2, § 19.4) : elle annule
// toute transition et toute animation, l'icône de chargement de l'accueil
// comprise. Une capture d'écran prise à mi-transition serait une image
// différente à chaque exécution, et un écran qui attendrait transitionend
// ne viendrait jamais : le parcours photographié et l'épreuve transversale
// posent la même feuille, d'ici seul.
//
// La feuille posée porte l'attribut data-feuille-capture : photographier
// (captures.js) la cherche dans le document et refuse une capture sans elle.
/** Le texte de la feuille : transitions et animations annulées sur tout
* élément et ses pseudo-éléments. */
export const SANS_MOUVEMENT = '*,*::before,*::after{transition:none!important;animation:none!important}';
/** L'attribut qui désigne la feuille posée. */
export const ATTRIBUT_FEUILLE = 'data-feuille-capture';
/**
* Pose la feuille dans <head> et rend la fonction qui la retire. Deux poses
* posent deux feuilles, chacune retirée par sa propre fonction.
*
* @param {Document} [document]
* @returns {() => void}
*/
export function poserFeuille(document = globalThis.document) {
const feuille = document.createElement('style');
feuille.setAttribute(ATTRIBUT_FEUILLE, '');
feuille.textContent = SANS_MOUVEMENT;
document.head.append(feuille);
return () => feuille.remove();
}
/** Vrai quand une feuille posée par poserFeuille est dans le document, au
* texte de SANS_MOUVEMENT. */
export function feuillePosee(document = globalThis.document) {
return [...document.head.querySelectorAll(`style[${ATTRIBUT_FEUILLE}]`)].some((feuille) => feuille.textContent === SANS_MOUVEMENT);
}

View file

@ -15,9 +15,16 @@
// construction du livrable, sans seconde configuration à tenir
// d'accord avec la première. Il joue les épreuves de src/ et
// celles de l'outillage partagé de test/navigateur/.
// Chromium y tourne sans réseau hors de la boucle locale, au
// facteur d'échelle 1 (test/chromium.js). Les commandes des
// captures d'écran (test/navigateur/commandes_documentation.js)
// y sont déclarées, et le mode des captures, que GTT_CAPTURES
// choisit — verifier par défaut, ecrire sous make captures —,
// arrive à la page par provide (§ 19.5).
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { optionsChromium } from './test/chromium.js';
import { optionsNavigateur } from './test/chromium.js';
import * as commandesDocumentation from './test/navigateur/commandes_documentation.js';
import { coeursDisponibles, reglagesSeries } from './test/machine.js';
// Les séries node suivent le nombre de cœurs de la machine (test/machine.js) :
@ -33,7 +40,11 @@ const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers };
// plan, version affichée, graphe des couches, version qu'App reçoit par ses
// propriétés —, celle qui importe App.svelte pour en voir l'échec sous
// node, et la garde du banc, qui balaie l'arbre à côté des motifs de ce
// fichier.
// fichier. S'y ajoutent les épreuves qui chargent ce fichier : il nomme les
// composants dans son motif, et son graphe porte les commandes des captures
// d'écran, qui balaient doc/captures ; la garde des lecteurs par le disque
// (test/arborescence.test.js), textuelle, les compte parmi les lecteurs des
// composants.
const RELANCES_D_UN_COMPOSANT = Object.freeze([
'test/arborescence.test.js',
'test/sources_interface.test.js',
@ -44,6 +55,9 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([
'src/interface/plan/ecouteurs.test.js',
'src/interface/apropos.test.js',
'scripts/banc/banc.test.js',
'test/chromium.test.js',
'test/machine.test.js',
'test/rapporteur.test.js',
]);
// Les épreuves node qui lisent la feuille des jetons par le système de
@ -158,8 +172,10 @@ export default defineConfig({
test: {
name: 'navigateur',
include: ['src/**/*.navigateur.test.js', 'test/**/*.navigateur.test.js'],
browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)),
instances: [{ browser: 'chromium' }] } } },
provide: { captures: { mode: commandesDocumentation.modeCaptures(process.env) } },
browser: { enabled: true, headless: true, provider: playwright(optionsNavigateur(process.env)),
instances: [{ browser: 'chromium' }],
commands: Object.fromEntries(commandesDocumentation.COMMANDES.map((nom) => [nom, commandesDocumentation[nom]])) } } },
],
},
});