[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
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ ios/
|
|||
.vitest/
|
||||
coverage/
|
||||
scripts/banc/sorties/
|
||||
doc/captures/.attente/
|
||||
|
|
|
|||
7
Makefile
|
|
@ -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
|
||||
|
|
|
|||
BIN
doc/captures/01-premier-ecran.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
doc/captures/01b-passer-en-ecriture.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
doc/captures/02-import-apercu.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
doc/captures/02b-import-cp1252.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
doc/captures/03-import-rapport.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
doc/captures/04-grille-participants.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
doc/captures/05-tables-etat.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
doc/captures/06-defaut-consequence.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
doc/captures/07-plan-tables.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
doc/captures/07a-cibles-sieges.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
doc/captures/07a-cibles-tables.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
doc/captures/07b-nombre-de-tours.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
doc/captures/08-animateurs-designes.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
doc/captures/09-refus-sur-place.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
doc/captures/10-diagnostic.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
doc/captures/11-propositions.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
doc/captures/19-ajustement.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
doc/captures/20-mode-lecture.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
29
doc/captures/manifeste.json
Normal 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é.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
103
test/chromium.js
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 };
|
||||
}
|
||||
478
test/navigateur/commandes_documentation.js
Normal 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));
|
||||
}
|
||||
40
test/navigateur/feuilles.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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]])) } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||