From 71b9e75363db1261f60623431e4e8cb73cbef6c8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 14:06:56 -0400 Subject: [PATCH] [ADD] interface: cross-screen rendering checks, frame marks, badge plate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screen walker reaches every screen of the iteration by gestures; on each, in both themes, text holds 4.5:1 and marks 3:1 against what is painted under them, every text passes through the translation tables, the conflict hue paints only conflict marks, and no state differs by colour alone. Mode frames now carry their mark, the count badge sits on a plate over the conflict hatch, the name list clears the focus square, the participant form is novalidate. Demonstrations space columns so a name list never runs over a neighbour's chairs. Checked: 2676 node, 489 browser, 60 long tests. --- FR --- [ADD] interface : rendu transversal, marques des cadres, socle, pastille Un parcours atteint par gestes chaque écran de l'itération ; sur chacun, dans les deux thèmes, le texte tient 4,5:1 et les marques 3:1 sur ce qui est peint dessous, tout texte passe par les tables de libellés, la teinte de conflit ne peint que les marques de conflit, et aucun état ne diffère par la seule couleur. Les cadres de mode portent leur marque, la pastille pose sur un socle au-dessus de la trame, la liste des noms s'écarte du focus, le formulaire est novalidate. Les démonstrations espacent leurs colonnes : une liste ne court plus sur les chaises voisines. Vérifié : 2676, 489, 60. Assisted-by: Claude Opus 5.5 --- scripts/engendrer_livrables.js | 32 +- scripts/engendrer_livrables.test.js | 14 +- src/demo/livrees.test.js | 21 + .../livrees/grande-sans-exception.gtt.json | 54 +- src/demo/livrees/grande.gtt.json | 54 +- src/demo/livrees/petite-conflit.gtt.json | 4 +- src/demo/livrees/petite.gtt.json | 4 +- src/geometrie/tables.js | 7 +- src/geometrie/tables.test.js | 8 +- src/interface/evenement/CadreMode.svelte | 5 +- src/interface/participants/Formulaire.svelte | 2 +- src/interface/plan/Dessin.navigateur.test.js | 11 +- .../plan/Panneaux.navigateur.test.js | 5 +- src/interface/plan/TablePlan.svelte | 14 +- src/interface/plan/modele.js | 5 + src/interface/plan/modele.test.js | 38 +- src/interface/transversal.navigateur.test.js | 1241 +++++++++++++++++ test/navigateur/ecrans.js | 582 ++++++++ test/navigateur/rendu.js | 6 +- test/navigateur/rendu.navigateur.test.js | 18 + 20 files changed, 2041 insertions(+), 84 deletions(-) create mode 100644 src/interface/transversal.navigateur.test.js create mode 100644 test/navigateur/ecrans.js diff --git a/scripts/engendrer_livrables.js b/scripts/engendrer_livrables.js index 4bc5162..9637f22 100644 --- a/scripts/engendrer_livrables.js +++ b/scripts/engendrer_livrables.js @@ -28,9 +28,11 @@ // plus grande à égalité ; une table d'une autre capacité porte la sienne // en surcharge (§ 6.1) ; // - les tables rondes, rangées en grille dans l'ordre de la configuration, -// rangée par rangée, leurs centres espacés de 300 cm ; la grille a la plus -// petite largeur dont le carré contient toutes les tables, et seule sa -// dernière rangée est incomplète ; +// rangée par rangée, leurs rangées espacées de 300 cm ; la grille a la +// plus petite largeur dont le carré contient toutes les tables, et seule +// sa dernière rangée est incomplète. Ses colonnes s'écartent assez pour +// que le bloc de noms d'une table, de toute sa largeur, tienne entre deux +// colonnes : aucune liste ne passe sur une autre table (§ 7.4) ; // - les contraintes de la configuration, les sièges non attribués, la // génération par défaut de la recherche ; // - chaque réservation, celle d'un animateur, au siège 1 de sa table pour @@ -47,6 +49,8 @@ import { decoder } from '../src/csv/encodage.js'; import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js'; import { decouper } from '../src/csv/lecture.js'; import { CATALOGUE } from '../src/demo/catalogue.js'; +import { constante } from '../src/geometrie/constantes.js'; +import { ECART_LISTE, geometrieTable } from '../src/geometrie/tables.js'; import { serialiser, serialiserCharge } from '../src/stockage/canonique.js'; import { analyser, creerCharge } from '../src/stockage/document.js'; import { VERSION } from '../src/version.genere.js'; @@ -66,8 +70,8 @@ const DEMONSTRATIONS_EN_CSV = ['petite', 'grande']; // Séparateur de la variante windows-1252 (§ 10.3). const VIRGULE = ','; -// Écart entre les centres de deux tables voisines de la grille, en cm. -const ECART_CM = 300; +// Écart entre les centres de deux rangées de la grille, en cm. +const ECART_RANGEES_CM = 300; // Le siège de l'animateur, et le titre qu'il porte. const SIEGE_ANIMATEUR = 1; @@ -96,6 +100,21 @@ function colonnesDe(nombre) { return colonnes; } +// Écart entre les centres de deux colonnes de la grille, en cm entiers : le +// bloc de noms d'une table commence à ECART_LISTE de son emprise et s'étend +// sur LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège ; il s'achève à +// ECART_LISTE de l'emprise de la colonne suivante, pour la plus grande +// emprise des capacités données. Aucune emprise, d'aucune rangée, ne +// rencontre ainsi la bande du bloc. +function ecartColonnes(capacites) { + const emprise = Math.max( + ...capacites.map( + (places) => geometrieTable({ id: 0, numero: 0, forme: 'ronde', position: { x: 0, y: 0 } }, places).rayonEmprise, + ), + ); + return Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')); +} + // Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste // vide. const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0); @@ -134,6 +153,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) { const animateurs = animateursDe(reservations, cle); const reserves = new Set(animateurs.values()); const colonnes = colonnesDe(tables.length); + const ecart = ecartColonnes(tables.map(({ capacite }) => capacite)); const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes; return { ...neuve, @@ -154,7 +174,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) { numero, sieges: capacite === siegesParDefaut ? null : capacite, forme: 'ronde', - position: { x: (rang % colonnes) * ECART_CM, y: Math.floor(rang / colonnes) * ECART_CM }, + position: { x: (rang % colonnes) * ecart, y: Math.floor(rang / colonnes) * ECART_RANGEES_CM }, })), reservations: [...animateurs].map(([table, participant]) => ({ participant, diff --git a/scripts/engendrer_livrables.test.js b/scripts/engendrer_livrables.test.js index 2be9eaf..39dfe81 100644 --- a/scripts/engendrer_livrables.test.js +++ b/scripts/engendrer_livrables.test.js @@ -209,18 +209,22 @@ describe("engendrer_livrables : la charge d'une démonstration (§ 15)", () => { }); }); - test('les tables : rondes, en grille rangée par rangée, centres à 300 cm, la dernière rangée seule incomplète', () => { + test('les tables : rondes, en grille rangée par rangée, la dernière rangée seule incomplète ; rangées à 300 cm, colonnes assez écartées pour le bloc de noms', () => { + // Écart des colonnes : 2 × l'emprise de la plus grande table, deux écarts + // de liste (36 cm) et la largeur du bloc de noms (500 cm), arrondi au cm + // supérieur — petite, 3 places, emprise 79 : 158 + 72 + 500 = 730 ; + // grande, 8 places, emprise 120,39 : ⌈240,79 + 72 + 500⌉ = 813. assert.deepEqual(chargeDeDemonstration(entreeDe('petite')).tables, [ { id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }, - { id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } }, + { id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 730, y: 0 } }, { id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 0, y: 300 } }, - { id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 300, y: 300 } }, + { id: 4, numero: 4, sieges: null, forme: 'ronde', position: { x: 730, y: 300 } }, ]); // 33 tables : six colonnes, cinq rangées pleines, puis trois tables. const { tables } = chargeDeDemonstration(entreeDe('grande')); const positions = tables.map(({ position: { x, y } }) => [x, y]); - assert.deepEqual(positions.slice(0, 7), [[0, 0], [300, 0], [600, 0], [900, 0], [1200, 0], [1500, 0], [0, 300]]); - assert.deepEqual(positions.slice(-4), [[1500, 1200], [0, 1500], [300, 1500], [600, 1500]]); + assert.deepEqual(positions.slice(0, 7), [[0, 0], [813, 0], [1626, 0], [2439, 0], [3252, 0], [4065, 0], [0, 300]]); + assert.deepEqual(positions.slice(-4), [[4065, 1200], [0, 1500], [813, 1500], [1626, 1500]]); assert.equal(new Set(positions.map(String)).size, 33); assert.ok(tables.every(({ forme }) => forme === 'ronde')); assert.deepEqual(tables.map(({ id, numero }) => [id, numero]), positions.map((_, rang) => [rang + 1, rang + 1])); diff --git a/src/demo/livrees.test.js b/src/demo/livrees.test.js index 28b8d2d..22ca076 100644 --- a/src/demo/livrees.test.js +++ b/src/demo/livrees.test.js @@ -19,6 +19,8 @@ import { versionVoisine } from '../../test/version_voisine.js'; import { apercevoir } from '../csv/apercu.js'; import { decoder } from '../csv/encodage.js'; import { exporterParticipants } from '../csv/export.js'; +import { constante } from '../geometrie/constantes.js'; +import { geometrieTable } from '../geometrie/tables.js'; import { serialiser } from '../stockage/canonique.js'; import { analyser } from '../stockage/document.js'; import { examiner } from '../stockage/placements.js'; @@ -155,6 +157,25 @@ describe('démonstrations livrées : les fichiers (§ 15.5, point 5)', () => { assert.deepEqual([charge.propositions, charge.retenu], [[], null], cle); } }); + + test("le bloc de noms d'une table, de toute sa largeur, ne rencontre l'emprise d'aucune autre table, d'aucune rangée (§ 7.4)", () => { + // Le bloc s'étend de origineListe.x sur LARGEUR_BLOC_NOMS_CM, où toute + // ligne s'abrège ; sa hauteur, mesurée sur la police, ne se connaît pas + // ici : l'épreuve exige la bande verticale entière libre de toute emprise. + const largeur = constante('LARGEUR_BLOC_NOMS_CM'); + for (const { cle, texte } of LIVREES) { + const { charge } = analyser(texte); + const geometries = charge.tables.map((table) => geometrieTable(table, table.sieges ?? charge.evenement.siegesParDefaut)); + const rencontres = geometries.flatMap((g) => + geometries + .filter((autre) => autre !== g) + .filter((autre) => autre.centre.x - autre.rayonEmprise < g.origineListe.x + largeur && autre.centre.x + autre.rayonEmprise > g.origineListe.x) + .map((autre) => `table ${g.numero} sur la table ${autre.numero}`), + ); + assert.ok(geometries.length > 1, cle); + assert.deepEqual(rencontres, [], cle); + } + }); }); describe('démonstrations livrées : aller-retour sur la petite (§ 8.9, § 14.4)', () => { diff --git a/src/demo/livrees/grande-sans-exception.gtt.json b/src/demo/livrees/grande-sans-exception.gtt.json index 3e4c42a..c5407bc 100644 --- a/src/demo/livrees/grande-sans-exception.gtt.json +++ b/src/demo/livrees/grande-sans-exception.gtt.json @@ -268,38 +268,38 @@ ], "tables": [ {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, - {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, - {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}}, - {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}}, - {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}}, - {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}}, + {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":813,"y":0}}, + {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":1626,"y":0}}, + {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":2439,"y":0}}, + {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":3252,"y":0}}, + {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":4065,"y":0}}, {"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, - {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}, - {"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}}, - {"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}}, - {"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}}, - {"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}}, + {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":813,"y":300}}, + {"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":1626,"y":300}}, + {"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":2439,"y":300}}, + {"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":3252,"y":300}}, + {"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":4065,"y":300}}, {"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}}, - {"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}}, - {"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}}, - {"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}}, - {"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}}, - {"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}}, + {"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":813,"y":600}}, + {"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":1626,"y":600}}, + {"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":2439,"y":600}}, + {"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":3252,"y":600}}, + {"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":4065,"y":600}}, {"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}}, - {"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}}, - {"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}}, - {"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}}, - {"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}}, - {"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}}, + {"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":813,"y":900}}, + {"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":1626,"y":900}}, + {"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":2439,"y":900}}, + {"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":3252,"y":900}}, + {"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":4065,"y":900}}, {"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}}, - {"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}}, - {"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}}, - {"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}}, - {"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}}, - {"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":1500,"y":1200}}, + {"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":813,"y":1200}}, + {"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1200}}, + {"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":2439,"y":1200}}, + {"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":3252,"y":1200}}, + {"id":30,"numero":30,"sieges":null,"forme":"ronde","position":{"x":4065,"y":1200}}, {"id":31,"numero":31,"sieges":null,"forme":"ronde","position":{"x":0,"y":1500}}, - {"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":300,"y":1500}}, - {"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":600,"y":1500}} + {"id":32,"numero":32,"sieges":null,"forme":"ronde","position":{"x":813,"y":1500}}, + {"id":33,"numero":33,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1500}} ], "reservations": [ {"participant":2,"table":17,"siege":1,"portee":"tous","tour":null}, diff --git a/src/demo/livrees/grande.gtt.json b/src/demo/livrees/grande.gtt.json index 905bfc7..05c5f6b 100644 --- a/src/demo/livrees/grande.gtt.json +++ b/src/demo/livrees/grande.gtt.json @@ -268,38 +268,38 @@ ], "tables": [ {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, - {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, - {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}}, - {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}}, - {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":1200,"y":0}}, - {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":1500,"y":0}}, + {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":813,"y":0}}, + {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":1626,"y":0}}, + {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":2439,"y":0}}, + {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":3252,"y":0}}, + {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":4065,"y":0}}, {"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, - {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}, - {"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}}, - {"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}}, - {"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":1200,"y":300}}, - {"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":1500,"y":300}}, + {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":813,"y":300}}, + {"id":9,"numero":9,"sieges":null,"forme":"ronde","position":{"x":1626,"y":300}}, + {"id":10,"numero":10,"sieges":null,"forme":"ronde","position":{"x":2439,"y":300}}, + {"id":11,"numero":11,"sieges":null,"forme":"ronde","position":{"x":3252,"y":300}}, + {"id":12,"numero":12,"sieges":null,"forme":"ronde","position":{"x":4065,"y":300}}, {"id":13,"numero":13,"sieges":null,"forme":"ronde","position":{"x":0,"y":600}}, - {"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":300,"y":600}}, - {"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":600,"y":600}}, - {"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":900,"y":600}}, - {"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":1200,"y":600}}, - {"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":1500,"y":600}}, + {"id":14,"numero":14,"sieges":null,"forme":"ronde","position":{"x":813,"y":600}}, + {"id":15,"numero":15,"sieges":null,"forme":"ronde","position":{"x":1626,"y":600}}, + {"id":16,"numero":16,"sieges":null,"forme":"ronde","position":{"x":2439,"y":600}}, + {"id":17,"numero":17,"sieges":null,"forme":"ronde","position":{"x":3252,"y":600}}, + {"id":18,"numero":18,"sieges":null,"forme":"ronde","position":{"x":4065,"y":600}}, {"id":19,"numero":19,"sieges":null,"forme":"ronde","position":{"x":0,"y":900}}, - {"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":300,"y":900}}, - {"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":600,"y":900}}, - {"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":900,"y":900}}, - {"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":1200,"y":900}}, - {"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":1500,"y":900}}, + {"id":20,"numero":20,"sieges":null,"forme":"ronde","position":{"x":813,"y":900}}, + {"id":21,"numero":21,"sieges":null,"forme":"ronde","position":{"x":1626,"y":900}}, + {"id":22,"numero":22,"sieges":null,"forme":"ronde","position":{"x":2439,"y":900}}, + {"id":23,"numero":23,"sieges":null,"forme":"ronde","position":{"x":3252,"y":900}}, + {"id":24,"numero":24,"sieges":null,"forme":"ronde","position":{"x":4065,"y":900}}, {"id":25,"numero":25,"sieges":null,"forme":"ronde","position":{"x":0,"y":1200}}, - {"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":300,"y":1200}}, - {"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":600,"y":1200}}, - {"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":900,"y":1200}}, - {"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":1200,"y":1200}}, - {"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":1500,"y":1200}}, + {"id":26,"numero":26,"sieges":null,"forme":"ronde","position":{"x":813,"y":1200}}, + {"id":27,"numero":27,"sieges":null,"forme":"ronde","position":{"x":1626,"y":1200}}, + {"id":28,"numero":28,"sieges":null,"forme":"ronde","position":{"x":2439,"y":1200}}, + {"id":29,"numero":29,"sieges":null,"forme":"ronde","position":{"x":3252,"y":1200}}, + {"id":30,"numero":30,"sieges":7,"forme":"ronde","position":{"x":4065,"y":1200}}, {"id":31,"numero":31,"sieges":7,"forme":"ronde","position":{"x":0,"y":1500}}, - {"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":300,"y":1500}}, - {"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":600,"y":1500}} + {"id":32,"numero":32,"sieges":7,"forme":"ronde","position":{"x":813,"y":1500}}, + {"id":33,"numero":33,"sieges":7,"forme":"ronde","position":{"x":1626,"y":1500}} ], "reservations": [ {"participant":2,"table":17,"siege":1,"portee":"tous","tour":null}, diff --git a/src/demo/livrees/petite-conflit.gtt.json b/src/demo/livrees/petite-conflit.gtt.json index 9c320c2..a288009 100644 --- a/src/demo/livrees/petite-conflit.gtt.json +++ b/src/demo/livrees/petite-conflit.gtt.json @@ -20,9 +20,9 @@ ], "tables": [ {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, - {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, + {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":730,"y":0}}, {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, - {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}} + {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}} ], "reservations": [], "titres": [], diff --git a/src/demo/livrees/petite.gtt.json b/src/demo/livrees/petite.gtt.json index b5aa42b..44f928b 100644 --- a/src/demo/livrees/petite.gtt.json +++ b/src/demo/livrees/petite.gtt.json @@ -20,9 +20,9 @@ ], "tables": [ {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, - {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, + {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":730,"y":0}}, {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, - {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}} + {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":730,"y":300}} ], "reservations": [], "titres": [], diff --git a/src/geometrie/tables.js b/src/geometrie/tables.js index 361d8b7..e418136 100644 --- a/src/geometrie/tables.js +++ b/src/geometrie/tables.js @@ -35,8 +35,11 @@ export const RAYON_CHAISE = 22; export const RAYON_POIGNEE = 15; /** Distance entre l'emprise et le bord de la poignée, en cm. */ export const ECART_POIGNEE = 8; -/** Distance entre l'emprise et le bloc de noms, en cm. */ -export const ECART_LISTE = 10; +/** Distance entre l'emprise et le bloc de noms, en cm : le carré du focus + * que l'interface pose autour de l'emprise, ou d'une chaise, s'étend à + * 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et + * aucune marque ne couvre la liste. */ +export const ECART_LISTE = 36; /** Distance entre deux cases de la grille des tables créées, en cm. */ export const PAS_GRILLE = 300; diff --git a/src/geometrie/tables.test.js b/src/geometrie/tables.test.js index 0b2c09a..befc550 100644 --- a/src/geometrie/tables.test.js +++ b/src/geometrie/tables.test.js @@ -49,7 +49,7 @@ describe('constantes', () => { assert.equal(RAYON_CHAISE, 22); assert.equal(RAYON_POIGNEE, 15); assert.equal(ECART_POIGNEE, 8); - assert.equal(ECART_LISTE, 10); + assert.equal(ECART_LISTE, 36); assert.equal(PAS_GRILLE, 300); }); }); @@ -119,7 +119,7 @@ describe('geometrieTable, ronde', () => { proche(g.rayonEmprise, r + 44, 'rayon d’emprise'); const ecart = (r + 44 + 23) / Math.SQRT2; procheDuPoint(g.poignee, { x: 300 + ecart, y: 600 + ecart }, 'poignée'); - procheDuPoint(g.origineListe, { x: 300 + r + 54, y: 600 - r }, 'origine de la liste'); + procheDuPoint(g.origineListe, { x: 300 + r + 80, y: 600 - r }, 'origine de la liste'); }); test('ronde de 3 : plancher de 70 cm, pas 70π/3', () => { @@ -163,10 +163,10 @@ describe('geometrieTable, carrée', () => { }); }); - test('emprise 104, poignée au-delà du coin, liste en (414, 540)', () => { + test('emprise 104, poignée au-delà du coin, liste en (440, 540)', () => { assert.equal(g.rayonEmprise, 104); procheDuPoint(g.poignee, { x: 404 + 23 / Math.SQRT2, y: 704 + 23 / Math.SQRT2 }, 'poignée'); - procheDuPoint(g.origineListe, { x: 414, y: 540 }, 'origine de la liste'); + procheDuPoint(g.origineListe, { x: 440, y: 540 }, 'origine de la liste'); }); test('carrée de 3 : côtés garnis 1, 1, 1, 0 et pas 70', () => { diff --git a/src/interface/evenement/CadreMode.svelte b/src/interface/evenement/CadreMode.svelte index 35030cd..d4f6bfa 100644 --- a/src/interface/evenement/CadreMode.svelte +++ b/src/interface/evenement/CadreMode.svelte @@ -38,12 +38,15 @@ // forme de la lecture, et n'a pas de libellé. const cadre = $derived(cadreDeMode($seance)); const forme = $derived(cadre?.forme ?? 'tirets'); + // Le trait du cadre est une marque (§ 14.5) : data-marque nomme l'entrée + // du registre (marques.js) qui porte le jeton de sa couleur. + const MARQUE_DE_LA_FORME = { tirets: 'cadreLecture', plein: 'cadreEcriture', double: 'cadreBloque' }; const commande = $derived(commandeFixe($seance)); const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null); const reprise = $derived(repriseOfferte($seance)); -
+
diff --git a/src/interface/participants/Formulaire.svelte b/src/interface/participants/Formulaire.svelte index 04b1b3e..e5340ca 100644 --- a/src/interface/participants/Formulaire.svelte +++ b/src/interface/participants/Formulaire.svelte @@ -86,7 +86,7 @@ onDestroy(() => geste.liberer()); -
+

{t('participants.formulaire.titre')}

{#each CHAMPS as cle (cle)} diff --git a/src/interface/plan/Dessin.navigateur.test.js b/src/interface/plan/Dessin.navigateur.test.js index 5753fee..77ab05c 100644 --- a/src/interface/plan/Dessin.navigateur.test.js +++ b/src/interface/plan/Dessin.navigateur.test.js @@ -31,7 +31,7 @@ import { rechercher } from '../../moteur/recherche.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { versFichier } from '../../stockage/placements.js'; import { fixerCadre } from '../../../test/navigateur/entrees.js'; -import { composantes, couleursPeintes, sousTheme, visible } from '../../../test/navigateur/rendu.js'; +import { composantes, couleursPeintes, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js'; import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw'; import { TABLES_INTERFACE } from '../libelles.js'; import BandeauPlan from './BandeauPlan.svelte'; @@ -510,6 +510,11 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => { expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille)); expect(visible(pastilles[0].querySelector('text'))).toBe(true); expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]); + // La pastille se pose sur un socle du fond du plateau : la trame ne + // passe pas sous elle (§ 14.5), et reste peinte autour du socle. + expect(fondPeintSous(pastilles[0].querySelector('[data-marque=pastille]'))).toEqual([jeton('--couleur-table-fond')]); + const socle = trames[0].closest('[data-table]').querySelector('[data-socle]'); + expect(Number(socle.getAttribute('r'))).toBeLessThan(Number(enConflit.getAttribute('r'))); expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none'); demonterTout(); @@ -638,7 +643,9 @@ describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => { describe('dessin et désignation (§ 7.3, § 13.5)', () => { test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => { - const charge = chargeDeTables([8, 8], { pas: 200 }); + // La table 2 si près que la liste de la table 1 passe sur ses chaises de + // gauche. + const charge = chargeDeTables([8, 8], { pas: 280 }); const vue = { k: 1.5, tx: 300, ty: 350 }; const modele = modeleDe(charge, { k: vue.k }); const { svg, cadre, ecran } = monterPlan(modele, vue); diff --git a/src/interface/plan/Panneaux.navigateur.test.js b/src/interface/plan/Panneaux.navigateur.test.js index 33c8257..7aa5842 100644 --- a/src/interface/plan/Panneaux.navigateur.test.js +++ b/src/interface/plan/Panneaux.navigateur.test.js @@ -465,7 +465,10 @@ describe('le plan est un seul arrêt de tabulation (§ 7.3, § 13.5)', () => { const tables = charge(m).tables; const premiere = tables[0]; expect(actif(m)).toBe(`table-${premiere.id}`); - const voisine = tables.find(({ position }) => position.x === premiere.position.x + 300 && position.y === premiere.position.y); + // La voisine de droite : la plus proche à droite, sur la même rangée. + const voisine = tables + .filter(({ position }) => position.y === premiere.position.y && position.x > premiere.position.x) + .reduce((plus, table) => (plus === undefined || table.position.x < plus.position.x ? table : plus), undefined); expect(voisine).toBeDefined(); await touche('ArrowRight'); flushSync(); diff --git a/src/interface/plan/TablePlan.svelte b/src/interface/plan/TablePlan.svelte index 6394f26..2dda2ed 100644 --- a/src/interface/plan/TablePlan.svelte +++ b/src/interface/plan/TablePlan.svelte @@ -19,7 +19,11 @@ // // Une table en conflit, quand le plan signale, porte un contour épaissi // (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa - // pastille porte le nombre de violations, signalée ou non (§ 7.5). + // pastille porte le nombre de violations, signalée ou non (§ 7.5). Sur la + // trame, un socle [data-socle] du fond du plateau, centré sous la + // pastille, la sépare des traits de la trame : la pastille se mesure + // contre le fond du plateau (§ 14.5), et la trame reste peinte autour. + // Le socle précède la marque de gel, qui se peint sur lui. // // Le groupe de la table porte id="table-", celui d'une chaise // id="siege--" : la racine du plan, seul arrêt de tabulation, @@ -39,6 +43,7 @@ ECART_FOCUS, ECART_MISE_EN_EVIDENCE, RAYON_PASTILLE, + RAYON_SOCLE_PASTILLE, TAILLE_TEXTE_PASTILLE_CM, TRACE_GEL, formesTable, @@ -87,6 +92,7 @@ {:else} {/if} + {/if} {#if table.gelee} @@ -180,6 +186,12 @@ pointer-events: none; } + .socle { + fill: var(--couleur-table-fond); + stroke: none; + pointer-events: none; + } + .gel { fill: var(--couleur-gel); pointer-events: none; diff --git a/src/interface/plan/modele.js b/src/interface/plan/modele.js index a0331f0..2d04445 100644 --- a/src/interface/plan/modele.js +++ b/src/interface/plan/modele.js @@ -19,6 +19,11 @@ export const LARGEUR_ECHELLE_PX = 120; /** Rayon du disque de la pastille, en cm de dessin : sous la marque de gel * d'une table de 2, la plus petite. */ export const RAYON_PASTILLE = 15; +/** Rayon du socle de la pastille, en cm de dessin : un disque du fond du + * plateau, posé sur la trame d'une table signalée, sous la pastille, qui + * l'en sépare (§ 14.5). Plus petit que le plateau le plus petit, de rayon + * PLANCHER_DIAMETRE / 2 : la trame reste peinte autour. */ +export const RAYON_SOCLE_PASTILLE = 20; /** Corps du chiffre de la pastille, en cm de dessin. */ export const TAILLE_TEXTE_PASTILLE_CM = 18; diff --git a/src/interface/plan/modele.test.js b/src/interface/plan/modele.test.js index 73a864c..f57e61f 100644 --- a/src/interface/plan/modele.test.js +++ b/src/interface/plan/modele.test.js @@ -16,7 +16,7 @@ import { creerTraduction } from '../../application/traduction.js'; import { texteLivre } from '../../demo/livrees.js'; import { constante } from '../../geometrie/constantes.js'; import { regimeCibles } from '../../geometrie/designation.js'; -import { geometrieTable } from '../../geometrie/tables.js'; +import { PLANCHER_DIAMETRE, RAYON_POIGNEE, geometrieTable } from '../../geometrie/tables.js'; import { indexerPlan } from '../../moteur/configuration.js'; import { conflitsParTable } from '../../moteur/conflits.js'; import { diagnostiquer } from '../../moteur/diagnostic.js'; @@ -26,7 +26,13 @@ import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage import { planDepuisFichier, versFichier } from '../../stockage/placements.js'; import { TABLES_INTERFACE } from '../libelles.js'; import { + ECART_ANNEAU, + ECART_DESIGNATION, + ECART_FOCUS, + ECART_MISE_EN_EVIDENCE, LARGEUR_ECHELLE_PX, + RAYON_PASTILLE, + RAYON_SOCLE_PASTILLE, actionsPersonne, actionsPlace, ajusterLigne, @@ -708,6 +714,36 @@ describe('formesTable : les formes d\u{2019}une table, en attributs SVG (§ 7.1, }); }); +describe('les marques autour d\u{2019}une table laissent la liste de noms à découvert (§ 7.4, § 14.5)', () => { + // Le trait du carré du focus fait 3 px à l'écran (TablePlan) : sa moitié + // extérieure couvre au plus 1,5 px, soit 1,5 / k cm, et les listes ne se + // rendent qu'à k ≥ K_LISTES. + const DEMI_TRAIT_FOCUS_CM = 1.5 / K_LISTES; + + test('le focus, de la table ou de sa chaise la plus à droite, et la poignée finissent avant le bloc de noms', () => { + for (const forme of ['ronde', 'carree']) { + for (const places of [2, 3, 5, 8, 12, 16]) { + const g = geometrieTable({ id: 1, numero: 1, forme, position: { x: 40, y: -70 } }, places); + const droite = Math.max(...g.chaises.map((chaise) => chaise.x + chaise.rayon)); + const bords = { + 'focus de la table': g.centre.x + g.rayonEmprise + ECART_FOCUS + DEMI_TRAIT_FOCUS_CM, + 'focus de la chaise': droite + ECART_FOCUS + DEMI_TRAIT_FOCUS_CM, + poignée: g.poignee.x + RAYON_POIGNEE, + }; + for (const [quoi, bord] of Object.entries(bords)) { + assert.ok(bord < g.origineListe.x, `${forme} de ${places} : ${quoi} jusqu'à ${bord}, liste dès ${g.origineListe.x}`); + } + } + } + }); + + test('les autres marques d\u{2019}une chaise restent sous le focus ; le socle de la pastille, entre elle et le plus petit plateau', () => { + for (const ecart of [ECART_ANNEAU, ECART_MISE_EN_EVIDENCE, ECART_DESIGNATION]) assert.ok(ecart < ECART_FOCUS, String(ecart)); + assert.ok(RAYON_PASTILLE < RAYON_SOCLE_PASTILLE); + assert.ok(RAYON_SOCLE_PASTILLE < PLANCHER_DIAMETRE / 2); + }); +}); + describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => { test('chaque partie présente, nommée ; les absentes omises', () => { const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null }; diff --git a/src/interface/transversal.navigateur.test.js b/src/interface/transversal.navigateur.test.js new file mode 100644 index 0000000..8ff5b7b --- /dev/null +++ b/src/interface/transversal.navigateur.test.js @@ -0,0 +1,1241 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le rendu transversal, sur chaque écran de l'itération (§ 14.5, § 14.6, +// § 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. +// +// 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 +// dans le thème clair, puis dans le sombre que pose le réglage du bandeau ; +// chaque garde lit ensuite les relevés de tous les écrans et échoue en +// listant chaque faute, écran et élément nommés, en un seul message. Un +// relevé lit le rendu — style calculé, boîte, élément qu'atteint un point —, +// jamais une classe (§ 14.2). Chaque garde affirme un ensemble mesuré non +// vide avant de juger, et s'éprouve d'abord sur un fragment d'épreuve bâti +// ici, où elle doit relever la faute qu'on y a mise. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { TABLES_APPLICATION } from '../application/libelles.js'; +import { nomAffiche } from '../application/personnes.js'; +import { etatPlaces } from '../application/places.js'; +import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js'; +import { decoder } from '../csv/encodage.js'; +import { CATALOGUE } from '../demo/catalogue.js'; +import { decouper } from '../csv/lecture.js'; +import { analyser } from '../stockage/document.js'; +import { lireJournal } from '../stockage/journal.js'; +import { SUFFIXES } from '../stockage/noms.js'; +import { VERSION } from '../version.genere.js'; +import INDEX from '../../index.html?raw'; +import { + ECRANS, + NOM_CAS_LIMITES, + attendre, + centre, + chargePrincipale, + cliquer, + creerContexte, + octetsCasLimites, + ouvrirEvenement, +} from '../../test/navigateur/ecrans.js'; +import { fixerCadre, pointer } from '../../test/navigateur/entrees.js'; +import { + attenuations, + composantes, + contraste, + couleurTexte, + couleursPeintes, + designation, + fondPeintSous, + porteursDeTexte, + visible, +} from '../../test/navigateur/rendu.js'; +import { provenanceVisible } from './apropos.js'; +import { TABLES_INTERFACE } from './libelles.js'; +import { MARQUES } from './marques.js'; + +const { t: tTemoin } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' }); +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']; + +// Éléments dont le texte ne se peint jamais. +const TEXTES_NON_PEINTS = new Set(['script', 'style', 'template', 'noscript', 'title', 'desc', 'metadata']); + +// Conteneurs SVG qui ne peignent qu'à travers une référence. +const CONTENEURS_DE_REFERENCE = 'defs, pattern, clipPath, mask, marker, symbol, linearGradient, radialGradient'; + +// Les marques que la teinte du conflit peint seules (§ 12.7, § 7.5). +const MARQUES_DU_CONFLIT = new Set(['contourConflit', 'trameConflit']); + +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 ------------------------------------------ + +/** + * Ce qu'un écran peut montrer sans la marque de la langue témoin, parce que + * c'est une donnée (§ 14.6, « La traduction ») : noms, prénoms, noms + * affichés, appartenances, courriels, titres et notes des personnes, nom et + * base des événements du disque, libellés des titres, libellés figés du + * journal, version, provenance, identifiant et instant d'un verrou, cellules + * du CSV servi, nom de chaque démonstration du catalogue — le nom que + * l'événement chargé porte, que l'accueil reçoit comme une donnée — ; un + * chemin sous une racine du système simulé. + * → { valeurs: string[], racines: string[] }, valeurs de la plus longue à la + * plus courte. + */ +async function donneesConnues(contexte, { cellules = [] } = {}) { + const valeurs = new Set([VERSION.affichee, VERSION.windows, PROVENANCE, ...CATALOGUE.map(({ nom }) => nom), ...cellules]); + const racines = new Set(); + const personne = (p) => { + for (const v of [p.nom, p.prenom, p.appartenance, p.courriel, p.titrePressenti, p.notes]) if (v) valeurs.add(v); + if (p.nom) valeurs.add(nomAffiche(p)); + }; + for (const m of contexte.montages) { + const etat = m.seance.etat(); + if (etat.evenement !== null) { + valeurs.add(etat.evenement.nom); + valeurs.add(etat.evenement.base); + } + for (const p of etat.charge?.participants ?? []) personne(p); + for (const titre of etat.charge?.titres ?? []) valeurs.add(titre.libelle); + if (etat.verrouAutre !== null) for (const v of [etat.verrouAutre.seance, etat.verrouAutre.depuis]) if (v) valeurs.add(v); + for (const racine of Object.values(await m.fs.racines())) if (racine?.chemin) racines.add(racine.chemin); + // Le disque simulé range ses nœuds sous une clé repliée ; nom garde le + // nom du fichier tel qu'il s'écrit. + for (const noeud of m.disque.values()) { + if (noeud?.type !== 'fichier' || typeof noeud.texte !== 'string') continue; + if (noeud.nom.endsWith(SUFFIXES.etat)) { + try { + const { charge } = analyser(noeud.texte); + valeurs.add(charge.evenement.nom); + for (const p of charge.participants) personne(p); + for (const titre of charge.titres) valeurs.add(titre.libelle); + } catch { + // Un état illisible ne porte aucune donnée affichée. + } + } + if (noeud.nom.endsWith(SUFFIXES.journal)) { + const journal = lireJournal(noeud.texte); + for (const ligne of [...(journal.entrees ?? []), ...(journal.jalons ?? [])]) { + for (const v of [ligne.libelle, ligne.nom]) if (typeof v === 'string') valeurs.add(v); + } + } + } + } + return { + valeurs: [...valeurs] + .filter((v) => typeof v === 'string' && v.trim().length > 1) + .map((v) => v.replace(/\s+/g, ' ').trim()) + .sort((a, b) => b.length - a.length), + racines: [...racines], + }; +} + +/** Le nom du CSV des cas limites et ses cellules, telles que la découpe + * les rend. */ +async function cellulesCasLimites() { + const { texte } = decoder(await octetsCasLimites()); + return [NOM_CAS_LIMITES, ...decouper(texte, ';').enregistrements.flat().map((c) => c.trim()).filter((c) => c !== '')]; +} + +// --- La langue témoin et les emoji --------------------------------------------- + +/** + * Les textes affichés sous racine : chaque nœud de texte rendu, chaque + * attribut annoncé d'un élément rendu, le content textuel de chaque + * ::before et ::after. → [{ ou, texte }], ou nommant le porteur. + */ +export function textesAffiches(racine) { + const textes = []; + const marcheur = document.createTreeWalker(racine, NodeFilter.SHOW_TEXT); + for (let noeud = marcheur.nextNode(); noeud !== null; noeud = marcheur.nextNode()) { + const parent = noeud.parentElement; + if (parent === null || TEXTES_NON_PEINTS.has(parent.localName) || parent.closest('svg title, svg desc') !== null) continue; + if (noeud.textContent.trim() === '' || !rendu(parent)) continue; + textes.push({ ou: designation(parent), texte: noeud.textContent }); + } + for (const element of [racine, ...racine.querySelectorAll('*')]) { + if (!rendu(element)) continue; + for (const nom of ATTRIBUTS_ANNONCES) { + const valeur = element.getAttribute(nom); + if (valeur !== null && valeur.trim() !== '') textes.push({ ou: `${designation(element)}[${nom}]`, texte: valeur }); + } + if (!(element instanceof HTMLElement)) continue; + for (const pseudo of ['::before', '::after']) { + const contenu = getComputedStyle(element, pseudo).content; + const litteral = /^"((?:[^"\\]|\\.)*)"$/.exec(contenu); + if (litteral !== null && litteral[1].trim() !== '') textes.push({ ou: `${designation(element)}${pseudo}`, texte: litteral[1] }); + else if (contenu !== 'none' && contenu !== 'normal' && litteral === null) textes.push({ ou: `${designation(element)}${pseudo}`, texte: contenu }); + } + } + return textes; +} + +const MARQUE_ENTIERE = new RegExp(`${OUVERTURE}[^${OUVERTURE}${FERMETURE}]*${FERMETURE}`, 'gu'); + +/** Les morceaux d'un texte hors de toute paire de marques de la langue + * témoin, imbrications comprises, qui portent une lettre ou une marque + * orpheline. */ +export function horsMarques(texte) { + let reste = texte; + for (let avant = null; avant !== reste; ) { + avant = reste; + reste = reste.replace(MARQUE_ENTIERE, '\u{0}'); + } + return reste + .split('\u{0}') + .map((morceau) => morceau.replace(/\s+/g, ' ').trim()) + .filter((morceau) => LETTRE.test(morceau) || morceau.includes(OUVERTURE) || morceau.includes(FERMETURE)); +} + +/** Le morceau n'est-il fait que de données connues ? Un chemin sous une + * racine en est une ; sinon, chaque donnée connue retirée, il ne reste + * aucune lettre. */ +export function estDonnee(morceau, connues) { + if (connues.racines.some((racine) => morceau.startsWith(racine))) return true; + let reste = morceau; + for (const valeur of connues.valeurs) reste = reste.split(valeur).join(' '); + return !LETTRE.test(reste) && !reste.includes(OUVERTURE) && !reste.includes(FERMETURE); +} + +/** Fautes de la langue témoin sous racine, et dans titre quand il est + * donné : chaque morceau hors marques qui n'est pas une donnée connue. */ +export function fautesTemoin(racine, connues, { titre = null } = {}) { + const textes = textesAffiches(racine); + if (titre !== null) textes.push({ ou: 'document.title', texte: titre }); + const fautes = []; + for (const { ou, texte } of textes) { + for (const morceau of horsMarques(texte)) { + if (!estDonnee(morceau, connues)) fautes.push(`${ou} : « ${extrait(morceau)} »`); + } + } + return { fautes, lus: textes.length }; +} + +/** Fautes d'emoji sous racine (§ 7.6) : un point de code + * Extended_Pictographic dans un texte affiché. → { fautes, lus }, lus le + * nombre de textes affichés lus. */ +export function fautesEmoji(racine, { titre = null } = {}) { + const textes = textesAffiches(racine); + if (titre !== null) textes.push({ ou: 'document.title', texte: titre }); + const fautes = textes.filter(({ texte }) => PICTOGRAPHIQUE.test(texte)).map(({ ou, texte }) => `${ou} : « ${extrait(texte)} »`); + 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 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 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 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; + } + vues.add(nom); + 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. + const modale = document.querySelector('dialog:modal'); + if (modale !== null && !(element instanceof SVGElement) && !modale.contains(element)) continue; + 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 ----------------------------------------------- + +// Les marques de désignation passagère que le clavier et le pointeur posent +// autour d'une table ou d'une chaise : elles ne sont pas la table. +const DESIGNATIONS = new Set(['focus', 'selection', 'miseEnEvidence']); + +// Côté de la grille de points qui couvre l'intersection de deux boîtes. +const COTE_GRILLE = 7; + +// La boîte à l'écran d'une forme SVG, son trait compris : la boîte que rend +// getBoundingClientRect ne compte que la géométrie, et s'élargit ici de +// l'épaisseur entière du trait, à l'échelle de la forme, au moins un pixel. +function boiteAvecTrait(forme) { + const b = forme.getBoundingClientRect(); + const m = forme.getScreenCTM(); + const marge = (parseFloat(getComputedStyle(forme).strokeWidth) || 0) * Math.max(1, Math.hypot(m.a, m.b)) + 1; + return { left: b.left - marge, top: b.top - marge, right: b.right + marge, bottom: b.bottom + marge }; +} + +// La forme couvre-t-elle un point de la grille qui pave le rectangle de la +// fenêtre, bords compris ? Le point passe dans le repère propre de la forme ; +// isPointInFill et isPointInStroke lisent sa géométrie, non sa peinture : +// l'intérieur d'une chaise sans remplissage compte. +function couvreUnPoint(forme, { left, top, right, bottom }) { + const versForme = forme.getScreenCTM().inverse(); + for (let i = 0; i < COTE_GRILLE; i += 1) { + for (let j = 0; j < COTE_GRILLE; j += 1) { + const ecran = new DOMPoint(left + ((right - left) * i) / (COTE_GRILLE - 1), top + ((bottom - top) * j) / (COTE_GRILLE - 1)); + const point = ecran.matrixTransform(versForme); + if (forme.isPointInFill(point) || forme.isPointInStroke(point)) return true; + } + } + return false; +} + +/** + * Les listes de noms posées sur une table (§ 7.4) : chaque partie rendue + * d'une ligne de [data-liste], par sa boîte à l'écran entière, contre + * chaque forme marquée sous un [data-table] — emprise, plateau, trame, gel, + * chaise et ses marques —, hors désignations passagères. Une partie est + * posée sur une forme quand un point de la grille qui pave l'intersection + * de leurs boîtes tombe dans la forme : le coin d'une boîte qui frôle un + * cercle ne compte pas, un texte qui le traverse loin de son centre compte. + * → { fautes, lues }, lues le nombre de parties lues. + */ +export function releverListes(racine) { + const formes = [...racine.querySelectorAll('[data-table] [data-marque]')] + .filter((f) => f instanceof SVGGeometryElement && !DESIGNATIONS.has(f.dataset.marque) && rendu(f)) + .map((forme) => ({ forme, boite: boiteAvecTrait(forme) })); + const fautes = []; + let lues = 0; + for (const partie of racine.querySelectorAll('[data-liste] tspan[data-partie]')) { + if (!rendu(partie) || partie.textContent.trim() === '') continue; + const b = partie.getBoundingClientRect(); + if (b.width === 0 || b.height === 0) continue; + lues += 1; + const sous = new Set(); + for (const { forme, boite } of formes) { + const commune = { left: Math.max(b.left, boite.left), top: Math.max(b.top, boite.top), right: Math.min(b.right, boite.right), bottom: Math.min(b.bottom, boite.bottom) }; + if (commune.left > commune.right || commune.top > commune.bottom) continue; + if (couvreUnPoint(forme, commune)) sous.add(situer(forme)); + } + for (const forme of sous) fautes.push(`${situer(partie)} « ${extrait(partie.textContent)} » sur ${forme}`); + } + return { fautes, lues }; +} + +// --- La teinte réservée -------------------------------------------------------------- + +// Un élément HTML porte-t-il lui-même un nœud de texte non vide ? +const porteTexte = (element) => [...element.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== ''); + +// Les peintures d'un élément : [{ propriete, couleurs }]. Une forme ou un +// texte SVG : fill et stroke, un motif par ses couleurs ; un élément HTML ou +// un extérieur : sa couleur de texte quand il en porte, son fond, ses +// bords, son contour, son soulignement, et ceux de ses pseudo-éléments à +// content. +function peinturesDe(element) { + if (element instanceof SVGGeometryElement || element instanceof SVGTextContentElement) { + const style = getComputedStyle(element); + const { fill, stroke } = couleursPeintes(element); + return [ + { propriete: 'fill', couleurs: fill }, + { propriete: 'stroke', couleurs: parseFloat(style.strokeWidth) > 0 ? stroke : [] }, + ]; + } + if (element instanceof SVGElement && element.ownerSVGElement !== null) return []; + const peintures = []; + const lire = (style, suffixe, texte) => { + if (texte) peintures.push({ propriete: `color${suffixe}`, couleurs: [composantes(style.color)] }); + const fond = composantes(style.backgroundColor); + if (fond[3] > 0) peintures.push({ propriete: `background-color${suffixe}`, couleurs: [fond] }); + peintures.push({ propriete: `border-color${suffixe}`, couleurs: bordsPeints(style) }); + if (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) { + peintures.push({ propriete: `outline-color${suffixe}`, couleurs: [composantes(style.outlineColor)] }); + } + if (texte && style.textDecorationLine !== 'none') { + peintures.push({ propriete: `text-decoration-color${suffixe}`, couleurs: [composantes(style.textDecorationColor)] }); + } + }; + lire(getComputedStyle(element), '', porteTexte(element) || element.matches(CHAMPS)); + for (const pseudo of ['::before', '::after']) { + const style = getComputedStyle(element, pseudo); + if (style.content !== 'none' && style.content !== 'normal') lire(style, pseudo, true); + } + return peintures; +} + +/** Fautes de la teinte réservée sous racine (§ 12.7, § 7.5) : un élément + * rendu qui peint la couleur de conflit sans être une marque de conflit. + * → { fautes, lus }, lus le nombre d'éléments rendus, hors marques de + * conflit, qui peignent au moins une couleur. */ +export function fautesTeinte(racine, conflit) { + const fautes = []; + let lus = 0; + for (const element of [racine, ...racine.querySelectorAll('*')]) { + if (element.closest(CONTENEURS_DE_REFERENCE) !== null || !rendu(element)) continue; + if (MARQUES_DU_CONFLIT.has(element.dataset?.marque)) continue; + const peintures = peinturesDe(element); + if (peintures.some(({ couleurs }) => couleurs.length > 0)) lus += 1; + for (const { propriete, couleurs } of peintures) { + if (couleurs.some((c) => egales(c, conflit))) fautes.push(`${situer(element)} : ${propriete} ${rgb(conflit)}`); + } + } + return { fautes, lus }; +} + +// --- Jamais la seule couleur ------------------------------------------------------------ + +/** + * La signature non chromatique d'un état : pour chaque élément rendu des + * racines, hors des conteneurs de référence, sa balise, l'épaisseur et les + * tirets de son trait, la sorte de son remplissage (aplat ou motif), le + * style et l'épaisseur de ses bords, et son texte propre. Aucune couleur, + * aucun attribut data-… : deux états qui ne diffèrent que par la couleur + * ont la même signature. + */ +export function signature(racines) { + const parties = []; + for (const racine of racines) { + for (const n of [racine, ...racine.querySelectorAll('*')]) { + if (n.closest(CONTENEURS_DE_REFERENCE) !== null || !rendu(n)) continue; + const s = getComputedStyle(n); + const svg = n instanceof SVGElement; + const texte = [...n.childNodes].filter((c) => c.nodeType === Node.TEXT_NODE).map((c) => c.textContent.trim()).join(' '); + parties.push( + [ + n.localName, + svg && s.stroke !== 'none' ? `trait ${s.strokeWidth} ${s.strokeDasharray}` : '', + svg ? `remplissage ${s.fill === 'none' ? 'aucun' : /^url\(/.test(s.fill) ? 'motif' : 'aplat'}` : '', + svg ? '' : `bords ${s.borderTopStyle} ${s.borderTopWidth}`, + texte, + ].join('|'), + ); + } + } + return parties.join(' ; '); +} + +// Les états du plan montré : tables saines et en conflit, places libres, +// réservées et à titre non pourvu, portées tour et tous, chacun par sa +// signature, classé par l'état des places que l'application calcule. +function releverEtats(m) { + const racine = m.racine; + const etats = { saine: [], conflit: [], libre: [], reservee: [], nonPourvu: [], tour: [], tous: [] }; + const charge = m.seance.etat().charge; + if (charge === null || racine.querySelector('[data-plan]') === null) return etats; + const { tour, proposition } = m.ui.etat(); + for (const table of etatPlaces(charge, { tour, proposition }).tables) { + const groupe = racine.querySelector(`[data-table="${table.id}"]`); + if (groupe === null) continue; + const propres = [...groupe.children].filter((n) => !n.hasAttribute('data-siege')); + const pastille = racine.querySelector(`[data-pastille="${table.id}"]`); + const sig = signature([...propres, ...(pastille === null ? [] : [pastille])]); + etats[(table.conflits?.nombre ?? 0) > 0 ? 'conflit' : 'saine'].push({ ou: `table ${table.id}`, sig }); + for (const place of table.chaises) { + const element = racine.querySelector(`[data-siege="${table.id}:${place.siege}"]`); + if (element === null) continue; + const entree = { ou: `place ${table.id}:${place.siege}`, sig: signature([element]) }; + if (place.reservation !== null) { + etats.reservee.push(entree); + etats[place.reservation.portee].push(entree); + } else if (place.titre !== null && !place.titre.pourvu) etats.nonPourvu.push(entree); + else if (place.titre === null) etats.libre.push(entree); + } + } + return etats; +} + +// Le cadre de mode : sa signature, son libellé compris. +function signatureCadre(racine) { + const cadre = racine.querySelector('[data-mode]'); + if (cadre === null) return null; + const s = getComputedStyle(cadre); + const libelle = racine.querySelector('[data-mode-libelle="cadre"]')?.textContent.trim() ?? ''; + return `bords ${s.borderTopStyle} ${s.borderTopWidth} | ${libelle}`; +} + +/** Fautes d'une paire d'états : chaque élément du premier dont la signature + * égale celle d'un élément du second ; une paire absente est une faute. */ +export function fautesPaire(nom, premiers, seconds) { + if (premiers.length === 0 || seconds.length === 0) return [`${nom} : paire absente (${premiers.length} et ${seconds.length})`]; + const fautes = []; + for (const a of premiers) { + for (const b of seconds) if (a.sig === b.sig) fautes.push(`${nom} : ${a.ou} et ${b.ou} ne diffèrent que par la couleur`); + } + return fautes; +} + +// --- Le bandeau et les formulaires ------------------------------------------------------ + +// true quand un élément est rendu, de boîte non vide, dans la fenêtre ; +// sinon la raison. +function dansLaFenetre(element) { + if (!rendu(element)) return 'non rendu'; + const b = element.getBoundingClientRect(); + if (!(b.width > 0 && b.height > 0)) return `boîte vide : ${b.width} × ${b.height}`; + if (b.left < 0 || b.top < 0 || b.right > innerWidth || b.bottom > innerHeight) return 'hors de la fenêtre'; + return true; +} + +/** Fautes du bandeau d'un écran (§ 8.4, § 18.6) : unique, visible, portant + * la version et la provenance ; le libellé de mode absent sans événement, + * présent et égal à celui du cadre sinon. */ +export function fautesBandeau(racine, mode, { version = VERSION.affichee, provenance = provenanceVisible(PROVENANCE, tTemoin) } = {}) { + const bandeaux = racine.querySelectorAll('[data-bandeau]'); + if (bandeaux.length !== 1) return [`${bandeaux.length} bandeaux`]; + const bandeau = bandeaux[0]; + const fautes = []; + // Une fenêtre modale rend la page inerte, et le test d'atteinte la + // traverse : sous elle, à fond de recouvrement transparent, le bandeau se + // juge rendu et dans la fenêtre. + const voir = document.querySelector('dialog:modal') === null ? visible : dansLaFenetre; + const vu = voir(bandeau); + if (vu !== true) fautes.push(`bandeau non visible : ${vu}`); + if (!bandeau.textContent.includes(version)) fautes.push(`la version ${version} manque au bandeau`); + if (!bandeau.textContent.includes(provenance)) fautes.push(`la provenance « ${provenance} » manque au bandeau`); + const libelle = bandeau.querySelector('[data-mode-libelle="bandeau"]'); + if (mode === null) { + if (libelle !== null) fautes.push(`libellé de mode « ${libelle.textContent.trim()} » sans événement ouvert`); + return fautes; + } + const cadre = racine.querySelector('[data-mode-libelle="cadre"]'); + if (libelle === null) fautes.push(`libellé de mode absent du bandeau (${mode})`); + else if (voir(libelle) !== true) fautes.push(`libellé de mode du bandeau non visible : ${voir(libelle)}`); + if (cadre === null) fautes.push(`libellé de mode absent du cadre (${mode})`); + if (libelle !== null && cadre !== null && libelle.textContent.trim() !== cadre.textContent.trim()) { + fautes.push(`bandeau « ${libelle.textContent.trim()} », cadre « ${cadre.textContent.trim()} »`); + } + return fautes; +} + +/** Fautes des formulaires sous racine (§ 2, § 14.10) : un
sans + * novalidate, un input[type=file] rendu. → { fautes, formulaires }, + * formulaires le nombre de
lus. */ +export function fautesFormulaires(racine) { + const fautes = []; + const formulaires = racine.querySelectorAll('form'); + for (const form of formulaires) if (!form.noValidate) fautes.push(`${designation(form)} sans novalidate`); + for (const champ of racine.querySelectorAll('input[type="file"]')) if (rendu(champ)) fautes.push(`${designation(champ)} visible`); + return { fautes, formulaires: formulaires.length }; +} + +// --- Le parcours et ses relevés ---------------------------------------------------------- + +// Les messages de refus d'un écran, et les motifs des lignes refusées d'un +// aperçu d'import : { code, texte, debloquer }. +function releverRefus(racine) { + const refus = [...racine.querySelectorAll('[data-refus]')] + .filter(rendu) + .map((element) => ({ + code: element.dataset.refus, + texte: element.querySelector(':scope > p')?.textContent ?? element.textContent, + debloquer: element.querySelector('[data-commande="debloquer"]')?.textContent ?? null, + })); + for (const motif of racine.querySelectorAll('[data-refusees] [data-motif]')) { + refus.push({ code: 'LIGNE_CSV', texte: motif.textContent, debloquer: null }); + } + return refus; +} + +// Relève, dans le thème courant, ce que chaque garde lit d'un écran. +function releverTheme(racine) { + try { + const textes = releverTextes(racine); + const marques = releverMarques(racine); + return { textes, marques, teinte: fautesTeinte(racine, couleurDeJeton('--couleur-conflit')) }; + } finally { + window.scrollTo(0, 0); + } +} + +const releves = []; + +beforeAll(async () => { + await fixerCadre(1366, 700); + const cellules = await cellulesCasLimites(); + for (const ecran of ECRANS) { + const contexte = creerContexte({ langue: 'temoin', theme: 'clair' }); + const releve = { nom: ecran.nom, mode: ecran.mode, echec: null }; + releves.push(releve); + try { + const racine = await ecran.atteindre(contexte); + const m = contexte.montage; + const connues = await donneesConnues(contexte, { cellules: ecran.nom.startsWith('import.') ? cellules : [] }); + releve.temoin = fautesTemoin(racine, connues, { titre: document.title }); + releve.emoji = fautesEmoji(racine, { titre: document.title }); + releve.bandeau = fautesBandeau(racine, ecran.mode); + releve.formulaires = fautesFormulaires(racine); + releve.refus = releverRefus(racine).map((r) => ({ ...r, restes: horsMarques(r.texte).filter((x) => !estDonnee(x, connues)) })); + releve.etats = releverEtats(m); + releve.listes = releverListes(racine); + releve.cadre = signatureCadre(racine); + releve.clair = releverTheme(racine); + await cliquer(m, '[data-bandeau] [data-commande="theme"][data-theme-choix="sombre"]'); + await attendre('le thème sombre', () => document.documentElement.getAttribute('data-theme') === 'sombre'); + releve.sombre = releverTheme(racine); + releve.erreurs = contexte.erreurs(); + } catch (erreur) { + releve.echec = erreur.message; + } finally { + await contexte.defaire(); + } + } +}, 900_000); + +afterAll(async () => { + await fixerCadre(1366, 700); +}); + +// Les relevés des écrans atteints. +const atteints = () => releves.filter((r) => r.echec === null); + +// Échoue en listant chaque faute, une par ligne. +function aucuneFaute(fautes, quoi) { + expect(fautes, `${quoi} — ${fautes.length} faute(s) :\n${fautes.join('\n')}`).toEqual([]); +} + +// --- Les gardes, éprouvées sur un fragment d'épreuve ----------------------------------- + +// Pose un fragment dans le corps du document, le temps d'une lecture. +function avecFragment(html, lire) { + const conteneur = document.createElement('div'); + conteneur.innerHTML = html; + document.body.append(conteneur); + try { + return lire(conteneur); + } finally { + conteneur.remove(); + } +} + +describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () => { + test('langue témoin : un mot écrit dans le gabarit, un attribut annoncé et le rendu d’un module .js qui contourne la table sont relevés ; une donnée connue et un texte marqué passent', () => { + const connues = { valeurs: ['Iris Ombrelle'], racines: ['C:\\Documents'] }; + // Ce que rendrait un module de présentation qui composerait son texte + // sans la table de traduction. + const etiquette = (numero) => `Table ${numero}`; + const { fautes, lus } = avecFragment( + `

${OUVERTURE}Libellé${FERMETURE}

Bonjour

Iris OmbrelleC:\\Documents\\x.gtt.json` + + `

${etiquette(3)}

` + + `

${OUVERTURE}Table${FERMETURE} 3 ${OUVERTURE}place${FERMETURE}

`, + (c) => fautesTemoin(c, connues), + ); + expect(lus).toBeGreaterThan(0); + expect(fautes).toEqual(['p : « Bonjour »', 'p : « Table 3 »', 'button[aria-label] : « Fermer »']); + }); + + test('emoji : un pictogramme dans un texte est relevé', () => { + const { fautes, lus } = avecFragment(`

${OUVERTURE}Prêt${FERMETURE} \u{1F642}

${OUVERTURE}2 + 2${FERMETURE}

`, (c) => fautesEmoji(c)); + expect(lus).toBe(2); + expect(fautes).toHaveLength(1); + }); + + test('contraste et atténuation : un gris clair sur blanc et une opacité partielle sont relevés', () => { + const { fautes, attenuations: voiles, mesures } = avecFragment( + '

pâle

voilé

', + (c) => releverTextes(c), + ); + expect(mesures).toBeGreaterThan(1); + expect(fautes.some((f) => f.includes('pâle'))).toBe(true); + expect(voiles.some((f) => f.includes('opacity'))).toBe(true); + }); + + test('marques : une marque hors registre, une marque qui ne peint pas son jeton et une marque noyée dans son fond sont relevées', () => { + const { fautes, vues } = avecFragment( + '' + + '' + + '' + + '', + (c) => releverMarques(c), + ); + expect(vues).toEqual(['gel', 'poignee']); + expect(fautes).toHaveLength(3); + expect(fautes[0]).toContain('hors du registre'); + expect(fautes[1]).toContain('ne peint pas son jeton'); + expect(fautes[2]).toContain('1.00:1'); + }); + + test('teinte réservée : un texte peint de la couleur de conflit est relevé', () => { + const { fautes, lus } = avecFragment('

alerte

', (c) => fautesTeinte(c, couleurDeJeton('--couleur-conflit'))); + expect(lus).toBeGreaterThan(0); + expect(fautes).toHaveLength(1); + }); + + test('listes : une partie qui traverse une chaise loin de son centre est relevée ; une boîte qui n’entre que dans le coin d’un cercle passe', () => { + const R = 20; + const { fautes, lues } = avecFragment( + '' + + `` + + 'Wxyz Quorvel Tamberlune' + + 'Ab', + (c) => { + const cercle = c.querySelector('circle'); + const [premiere, seconde] = c.querySelectorAll('tspan[data-partie]'); + // Le cercle se pose sur la fin de la première partie, dont le centre + // reste hors de lui ; la seconde partie se pose, par le coin haut + // gauche de sa boîte, en un point de la boîte du cercle à 1,13 rayon + // de son centre, hors de son trait. + const p = premiere.getBBox(); + expect(p.width).toBeGreaterThan(80); + const cx = p.x + p.width - 8; + const cy = p.y + p.height / 2; + cercle.setAttribute('cx', String(cx)); + cercle.setAttribute('cy', String(cy)); + const s = seconde.getBBox(); + seconde.closest('text').setAttribute('transform', `translate(${cx + 0.8 * R - s.x}, ${cy + 0.8 * R - s.y})`); + return releverListes(c); + }, + ); + expect(lues).toBe(2); + expect(fautes).toEqual(['tspan[data-partie="nom"] dans text[data-liste="1"] « Wxyz Quorvel Tamberlune » sur circle[data-marque="contourChaise"] dans g[data-siege="2:1"]']); + }); + + test('jamais la seule couleur : deux formes qui ne diffèrent que par la couleur ont la même signature', () => { + const fautes = avecFragment( + '' + + '', + (c) => fautesPaire('paire', [{ ou: 'a', sig: signature([c.querySelector('#a')]) }], [{ ou: 'b', sig: signature([c.querySelector('#b')]) }]), + ); + expect(fautes).toEqual(['paire : a et b ne diffèrent que par la couleur']); + expect(fautesPaire('vide', [], [{ ou: 'b', sig: '' }])).toHaveLength(1); + }); + + test('bandeau et formulaires : une version absente, un formulaire validé par le navigateur et un champ de fichier visible sont relevés', () => { + const bandeau = avecFragment('
Nom
', (c) => fautesBandeau(c, null, { version: 'version-epreuve', provenance: 'épreuve' })); + expect(bandeau).toHaveLength(2); + const formulaires = avecFragment('
', (c) => fautesFormulaires(c)); + expect(formulaires.formulaires).toBe(2); + expect(formulaires.fautes).toHaveLength(2); + }); + + test('transitions annulées : sous la feuille injectée, un écran qui attendrait transitionend ne viendrait jamais', async () => { + const attendTransition = async () => { + const element = document.createElement('div'); + element.style.transition = 'opacity 20ms linear'; + element.style.opacity = '1'; + document.body.append(element); + let finie = false; + element.addEventListener('transitionend', () => (finie = true)); + getComputedStyle(element).opacity; + element.style.opacity = '0.5'; + for (let image = 0; image < 60 && !finie; image += 1) await new Promise((r) => requestAnimationFrame(r)); + element.remove(); + 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); + try { + expect(await attendTransition()).toBe(false); + } finally { + feuille.remove(); + } + }); +}); + +// --- Les gardes, sur chaque écran ---------------------------------------------------------- + +describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => { + test('chaque écran s’atteint depuis le premier, et le collecteur ne reçoit rien', () => { + expect(releves.length).toBe(ECRANS.length); + const fautes = [ + ...releves.filter((r) => r.echec !== null).map((r) => `${r.nom} : ${r.echec}`), + ...atteints().flatMap((r) => r.erreurs.map((e) => `${r.nom} : collecteur ${JSON.stringify(e)}`)), + ]; + aucuneFaute(fautes, 'parcours'); + }); + + test('langue témoin : chaque texte affiché porte la marque, ou est une donnée connue', () => { + expect(atteints().reduce((n, r) => n + r.temoin.lus, 0)).toBeGreaterThan(0); + const fautes = [...new Set(atteints().flatMap((r) => r.temoin.fautes.map((f) => `${r.nom} — ${f}`)))]; + aucuneFaute(fautes, 'langue témoin'); + }); + + test('les erreurs par leur libellé : MODE_LECTURE, LECTURE_SEULE d’un plan bloqué, VERROU_PRIS, ETAT_ILLISIBLE, une ligne CSV refusée, PLACE_RESERVEE — chacune marquée, aucun err.message', () => { + const attendus = [ + ['refus.modeLecture', 'MODE_LECTURE'], + ['refus.bloque', 'LECTURE_SEULE'], + ['refus.verrou', 'VERROU_PRIS'], + ['recuperation', 'ETAT_ILLISIBLE'], + ['import.apercu', 'LIGNE_CSV'], + ['plan.refusPlace', 'PLACE_RESERVEE'], + ]; + const fautes = []; + for (const [nom, code] of attendus) { + const releve = atteints().find((r) => r.nom === nom); + const messages = releve?.refus.filter((r) => r.code === code) ?? []; + if (messages.length === 0) { + fautes.push(`${nom} : aucun message ${code}`); + continue; + } + for (const { texte, restes } of messages) { + if (!texte.includes(OUVERTURE)) fautes.push(`${nom} ${code} : message sans marque « ${extrait(texte)} »`); + for (const reste of restes) fautes.push(`${nom} ${code} : hors marque « ${extrait(reste)} »`); + if (code !== 'LIGNE_CSV' && (texte.includes(code) || texte.includes('{"'))) fautes.push(`${nom} ${code} : err.message affiché « ${extrait(texte)} »`); + } + if (code === 'LECTURE_SEULE') { + const nomme = messages.some((m) => m.debloquer !== null && m.debloquer.includes(tTemoin('communs.remede.debloquer'))); + if (!nomme) fautes.push(`${nom} : le refus ne nomme pas « ${tTemoin('communs.remede.debloquer')} »`); + } + } + aucuneFaute(fautes, 'erreurs par leur libellé'); + }); + + test('contrastes : chaque texte tient 4,5:1 sur son fond peint, dans les deux thèmes posés par le réglage ; les couleurs relues diffèrent d’un thème à l’autre', () => { + expect(atteints().reduce((n, r) => n + r.clair.textes.mesures + r.sombre.textes.mesures, 0)).toBeGreaterThan(0); + const fautes = []; + for (const r of atteints()) { + for (const theme of THEMES) fautes.push(...r[theme].textes.fautes.map((f) => `${r.nom} [${theme}] — ${f}`)); + for (const [cle, couleurs] of r.clair.textes.couleurs) { + if (r.sombre.textes.couleurs.get(cle) === couleurs) fautes.push(`${r.nom} — ${cle} : ${couleurs} dans les deux thèmes`); + } + } + aucuneFaute([...new Set(fautes)], 'contrastes'); + }); + + test('marques : chaque sorte de MARQUES est rendue au moins une fois, et chacune tient 3:1 contre ce qui est peint dessous, dans les deux thèmes', () => { + expect(atteints().reduce((n, r) => n + r.clair.marques.mesures, 0)).toBeGreaterThan(0); + const vues = new Set(atteints().flatMap((r) => [...r.clair.marques.vues, ...r.sombre.marques.vues])); + const fautes = MARQUES.filter(({ nom }) => !vues.has(nom)).map(({ nom }) => `${nom} : rendue sur aucun écran`); + for (const r of atteints()) { + for (const theme of THEMES) fautes.push(...r[theme].marques.fautes.map((f) => `${r.nom} [${theme}] — ${f}`)); + } + aucuneFaute([...new Set(fautes)], 'marques'); + }); + + test('aucune liste de noms sur une table : chaque partie d’une ligne, de toute sa boîte, hors des chaises, plateaux et emprises de toute table', () => { + expect(atteints().reduce((n, r) => n + r.listes.lues, 0), 'parties de listes lues').toBeGreaterThan(0); + aucuneFaute(atteints().flatMap((r) => r.listes.fautes.map((f) => `${r.nom} — ${f}`)), 'listes sur une table'); + }); + + test('aucune atténuation : ni opacité partielle, ni filtre, ni fusion, ni masque sur un texte, une marque ou leur ascendance, dans les deux thèmes', () => { + for (const theme of THEMES) { + const mesures = atteints().reduce((n, r) => n + r[theme].textes.mesures + r[theme].marques.mesures, 0); + expect(mesures, `textes et marques mesurés [${theme}]`).toBeGreaterThan(0); + } + const fautes = []; + for (const r of atteints()) { + for (const theme of THEMES) { + fautes.push(...[...r[theme].textes.attenuations, ...r[theme].marques.attenuations].map((f) => `${r.nom} [${theme}] — ${f}`)); + } + } + aucuneFaute([...new Set(fautes)], 'atténuations'); + }); + + test('jamais la seule couleur : table saine et en conflit, place libre, réservée et à titre non pourvu, portée tour et tous, cadre en lecture, en écriture et bloqué', () => { + const plan = atteints().find((r) => r.nom === 'evenement.plan.ecriture'); + expect(plan, 'le plan en écriture relevé').toBeDefined(); + const e = plan.etats; + const fautes = [ + ...fautesPaire('table saine / en conflit', e.saine, e.conflit), + ...fautesPaire('place libre / réservée', e.libre, e.reservee), + ...fautesPaire('place libre / titre non pourvu', e.libre, e.nonPourvu), + ...fautesPaire('place réservée / titre non pourvu', e.reservee, e.nonPourvu), + ...fautesPaire('portée tour / tous', e.tour, e.tous), + ]; + const cadres = ['lecture', 'ecriture', 'bloque'].map((mode) => { + const r = atteints().find((x) => x.nom === `evenement.plan.${mode}`); + return { ou: `cadre ${mode}`, sig: r?.cadre ?? null }; + }); + for (const c of cadres) if (c.sig === null) fautes.push(`${c.ou} : absent`); + for (let i = 0; i < cadres.length; i += 1) { + for (let j = i + 1; j < cadres.length; j += 1) fautes.push(...fautesPaire(`${cadres[i].ou} / ${cadres[j].ou}`, [cadres[i]], [cadres[j]])); + } + aucuneFaute(fautes, 'jamais la seule couleur'); + }); + + test('teinte réservée : la couleur calculée de conflit ne peint que les marques de conflit, dans les deux thèmes', () => { + for (const theme of THEMES) { + expect(atteints().reduce((n, r) => n + r[theme].teinte.lus, 0), `éléments peints lus [${theme}]`).toBeGreaterThan(0); + } + const fautes = atteints().flatMap((r) => THEMES.flatMap((theme) => r[theme].teinte.fautes.map((f) => `${r.nom} [${theme}] — ${f}`))); + aucuneFaute([...new Set(fautes)], 'teinte réservée'); + }); + + test('aucun emoji au rendu', () => { + expect(atteints().reduce((n, r) => n + r.emoji.lus, 0), 'textes affichés lus').toBeGreaterThan(0); + aucuneFaute(atteints().flatMap((r) => r.emoji.fautes.map((f) => `${r.nom} — ${f}`)), 'emoji'); + }); + + test('le bandeau sur tout écran : visible, version et provenance ; libellé de mode absent de la liste, égal à celui du cadre sur un événement', () => { + expect(atteints().some((r) => r.mode === null) && atteints().some((r) => r.mode !== null)).toBe(true); + aucuneFaute(atteints().flatMap((r) => r.bandeau.map((f) => `${r.nom} — ${f}`)), 'bandeau'); + }); + + test('formulaires : chaque
porte novalidate, aucun input[type=file] visible ; la page se déclare en français', () => { + expect(atteints().reduce((n, r) => n + r.formulaires.formulaires, 0), ' lus').toBeGreaterThan(0); + const fautes = [...new Set(atteints().flatMap((r) => r.formulaires.fautes.map((f) => `${r.nom} — ${f}`)))]; + if (!/]*\slang="fr"/.test(INDEX)) fautes.push('index.html : sans lang="fr"'); + aucuneFaute(fautes, 'formulaires'); + }); +}); + +// --- Le balisage littéral ------------------------------------------------------------------- + +describe('balisage littéral (§ 13.1)', () => { + const INJECTION = ''; + + test('une personne nommée par du balisage : son nom littéral dans la grille, la fiche, le plan et l’infobulle ; aucun img créé ; rien d’exécuté', async () => { + await fixerCadre(1366, 700); + const charge = chargePrincipale(); + // Les cinq personnes de l'appartenance en conflit portent le nom + // injecté : l'infobulle d'une table en conflit les nomme. + const appartenance = charge.participants[0].appartenance; + for (const p of charge.participants) { + if (p.appartenance === appartenance) { + p.nom = INJECTION; + p.prenom = null; + } + } + const contexte = creerContexte({ langue: 'fr' }); + try { + const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'participants' }); + const imgs = () => document.querySelectorAll('img').length; + const ligne = m.racine.querySelector('[data-participant="1"]'); + expect(ligne?.textContent).toContain(INJECTION); + await cliquer(m, '[data-participant="1"] [data-commande="fiche"]'); + expect((await attendre('la fiche', () => m.racine.querySelector('[data-fiche="1"]'))).textContent).toContain(INJECTION); + await cliquer(m, '[data-commande="section"][data-cible="plan"]'); + const listes = await attendre('les listes de noms', () => { + const l = [...m.racine.querySelectorAll('[data-liste]')]; + return l.length > 0 && l; + }); + expect(listes.some((l) => l.textContent.includes(INJECTION))).toBe(true); + const plateau = m.racine.querySelector('[data-table="1"] [data-marque="contourConflit"]'); + expect(plateau, 'la table 1 en conflit').not.toBeNull(); + const { x, y } = await centre(plateau); + await pointer('bouger', x, y); + const infobulle = await attendre('l’infobulle', () => { + const element = m.racine.querySelector('[role="tooltip"]'); + return element !== null && !element.hidden && element; + }); + expect(infobulle.textContent).toContain(INJECTION); + expect(imgs()).toBe(0); + expect(globalThis.__injecte).toBeUndefined(); + expect(contexte.erreurs()).toEqual([]); + } finally { + await contexte.defaire(); + } + }); +}); + +// --- Les transitions annulées ---------------------------------------------------------------- + +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 fautes = []; + let atteintes = 0; + try { + for (const ecran of ECRANS) { + const contexte = creerContexte({ langue: 'fr' }); + try { + await ecran.atteindre(contexte); + atteintes += 1; + } catch (erreur) { + fautes.push(`${ecran.nom} : ${erreur.message}`); + } finally { + await contexte.defaire(); + } + } + } finally { + feuille.remove(); + } + expect(atteintes + fautes.length).toBe(ECRANS.length); + aucuneFaute(fautes, 'transitions annulées'); + }, 900_000); +}); diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js new file mode 100644 index 0000000..bad4bc0 --- /dev/null +++ b/test/navigateur/ecrans.js @@ -0,0 +1,582 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les écrans de l'itération, chacun atteint depuis le premier écran de +// l'application montée sur le système simulé (§ 14.5, § 14.6, § 19.11). Un +// écran est { nom, mode, atteindre(contexte) } : atteindre monte App par le +// contexte, pose sur le disque simulé le fichier d'état qu'il lui faut, puis +// mène l'application jusqu'à l'écran par les gestes de l'opérateur — le clic +// d'un bouton désigné par son attribut data-…, une entrée réelle de +// test/navigateur/entrees.js sur le plan, une touche — et rend l'élément qui +// porte l'écran entier, bandeau compris : le conteneur du montage. mode dit +// ce que le bandeau et le cadre en annoncent : null sans événement ouvert, +// 'lecture', 'ecriture' ou 'bloque' sinon. +// +// Aucun délai fixe (§ 19.3) : chaque étape attend une condition du document, +// image après image, et lève en la nommant quand elle ne vient pas. Les +// données sont tirées des démonstrations livrées et de commandes de +// l'application, sans aléa : deux parcours rendent le même écran. +// +// Deux états s'atteignent sans geste de l'opérateur : la mise en évidence +// d'une personne passe par l'état de navigation du montage +// (ui.mettreEnEvidence), qui n'écrit rien ; la question du dossier de +// travail, qui se pose au démarrage, par les options du système simulé. +import { flushSync } from 'svelte'; +import { appliquerCommande } from '../../src/application/commandes.js'; +import { ErreurAnnulee } from '../../src/application/generation.js'; +import { etatPlaces } from '../../src/application/places.js'; +import { texteLivre } from '../../src/demo/livrees.js'; +import { rechercher } from '../../src/moteur/recherche.js'; +import { serialiser } from '../../src/stockage/canonique.js'; +import { analyser, configurationDepuisCharge } from '../../src/stockage/document.js'; +import { SUFFIXES } from '../../src/stockage/noms.js'; +import { VERSION } from '../../src/version.genere.js'; +import urlCasLimites from '../../exemples/participants_cas_limites.csv?url'; +import { pointer, touche } from './entrees.js'; +import { monterApp } from './montage.js'; + +/** La base du fichier d'état de chaque événement d'épreuve. */ +export const BASE = 'soiree'; + +/** Le nom du CSV des cas limites que sert le dialogue d'import. */ +export const NOM_CAS_LIMITES = 'participants_cas_limites.csv'; + +/** Les sections d'un événement, dans l'ordre de la navigation. */ +export const SECTIONS_EVENEMENT = Object.freeze(['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']); + +// L'exécutable publié du système simulé : son dossier data/ refuse +// l'écriture, et la question du dossier de travail se pose au démarrage. +const PUBLIE = 'E:\\soirees-epreuve'; + +// Images attendues au plus pour qu'une condition se réalise : bien au-delà +// d'un rendu, d'une écriture simulée et d'une génération de la petite +// démonstration. +const IMAGES_MAX = 900; + +// --- L'attente et les gestes ------------------------------------------------- + +const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre)); + +/** Attend, image après image, que condition() rende une valeur vraie sans + * lever, et la rend ; lève en nommant quoi après IMAGES_MAX images. */ +export async function attendre(quoi, condition) { + let derniere = null; + for (let image = 0; image < IMAGES_MAX; image += 1) { + flushSync(); + try { + const valeur = condition(); + if (valeur) return valeur; + } catch (erreur) { + derniere = erreur; + } + await uneImage(); + } + throw new Error(`écran : ${quoi} ne vient pas${derniere === null ? '' : ` (${derniere.message})`}`); +} + +// Le premier élément du montage qu'atteint le sélecteur, attendu. +const element = (m, selecteur) => attendre(selecteur, () => m.racine.querySelector(selecteur)); + +/** Clique le bouton du montage que désigne le sélecteur, une fois présent, + * par son clic HTML, puis laisse le rendu suivre. */ +export async function cliquer(m, selecteur) { + const bouton = await element(m, selecteur); + bouton.click(); + flushSync(); + await uneImage(); + return bouton; +} + +/** Le centre de la boîte d'un élément, en coordonnées de la fenêtre, une + * fois l'élément amené dans la fenêtre. */ +export async function centre(cible) { + const dedans = () => { + const b = cible.getBoundingClientRect(); + return b.width > 0 && b.left >= 0 && b.top >= 0 && b.right <= innerWidth && b.bottom <= innerHeight; + }; + if (!dedans()) { + cible.scrollIntoView({ block: 'center', inline: 'center' }); + await attendre('l’élément dans la fenêtre', dedans); + } + const b = cible.getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; +} + +// Le plateau d'une table, qu'il signale un conflit ou non. +const plateau = (m, table) => + m.racine.querySelector(`[data-table="${table}"] [data-marque="contourTable"], [data-table="${table}"] [data-marque="contourConflit"]`); +const chaise = (m, table, siege) => m.racine.querySelector(`[data-siege="${table}:${siege}"]`); + +// Un appui relâché sur place, au centre d'un élément : un clic de pointeur +// réel, sous le seuil de glissement. +async function cliquerPointeur(cible) { + const { x, y } = await centre(cible); + await pointer('presser', x, y); + await pointer('relacher', x, y); + flushSync(); + await uneImage(); +} + +// --- Les événements d'épreuve ----------------------------------------------- + +const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge; +const demonstration = (cle) => analyser(texteLivre(cle)).charge; + +/** + * La petite démonstration, variante conflit, avec une désignation (la + * personne 1, « hôte », à la table 1, siège 1), une réservation d'un tour (la + * personne 2 à la table 2, siège 2, au tour 1), un titre non pourvu + * (« animateur », table 3, siège 3) et une proposition, tirée de la graine 2 + * : au tour 1, les tables 1 et 2 sont en conflit, les tables 3 et 4 saines. + */ +export function chargePrincipale() { + let charge = demonstration('petite-conflit'); + charge = executer(charge, 'designer', { participant: 1, table: 1, siege: 1, libelle: 'hôte' }); + charge = executer(charge, 'reserver', { participant: 2, table: 2, siege: 2, portee: 'tour', tour: 1 }); + charge = executer(charge, 'poserTitre', { table: 3, siege: 3, libelle: 'animateur' }); + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 2, nombre: 1, arret: 300, historique: 1000 }); + return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; +} + +// La principale, sa proposition retenue, puis le plan bloqué. +function chargeBloquee() { + const principale = chargePrincipale(); + const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id }); + return executer(charge, 'changerEtat', { etat: 'bloque' }); +} + +// La principale, sa proposition retenue, puis une personne ajoutée : le +// retenu ne la place pas, et l'ouverture avertit de la dérive. +function chargeEnDerive() { + const principale = chargePrincipale(); + const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id }); + return executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } }); +} + +// La petite démonstration, la table 4 réservée en entier pour tous les +// tours : elle est gelée. +function chargeGelee() { + let charge = demonstration('petite'); + for (const participant of [1, 2, 3]) { + charge = executer(charge, 'reserver', { participant, table: 4, siege: participant, portee: 'tous', tour: null }); + } + return charge; +} + +/** Le fichier d'état d'une charge, posé dans les Documents sous BASE. */ +export const fichierEtat = (charge, base = BASE) => ({ + racine: 'documents', + chemin: `${base}${SUFFIXES.etat}`, + texte: serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }), +}); + +/** Les octets du CSV des cas limites, tels que le fichier les porte. */ +export async function octetsCasLimites() { + return new Uint8Array(await (await fetch(urlCasLimites)).arrayBuffer()); +} + +/** + * Un exécuteur de génération que l'épreuve ne résout pas : la génération + * reste en cours tant que le montage vit ; annuler la rejette par + * ErreurAnnulee. + */ +export function executeurSuspendu() { + return { + lancer() { + let rejeter; + const resultat = new Promise((_, ko) => { + rejeter = ko; + }); + return { + surAvancement: () => () => {}, + resultat, + annuler() { + rejeter(new ErreurAnnulee()); + }, + }; + }, + }; +} + +// --- Les parcours ------------------------------------------------------------- + +/** + * Monte App sur la charge, ouvre l'événement depuis la liste, passe en + * écriture si demandé, montre la première proposition au plan quand la + * charge n'a pas de retenu, puis ouvre la section. Rend le montage. + * + * @param {Object} contexte celui de creerContexte + * @param {Object} charge + * @param {{ecriture?: boolean, section?: string, options?: Object}} [reglages] + * options : celles de monterApp, ses fichiers ajoutés à celui de la charge + */ +export async function ouvrirEvenement(contexte, charge, { ecriture = false, section = 'plan', options = {} } = {}) { + const m = await contexte.monter({ ...options, fichiers: [fichierEtat(charge), ...(options.fichiers ?? [])] }); + await cliquer(m, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`); + await element(m, '[data-ecran="evenement"]'); + if (ecriture) { + await cliquer(m, '[data-place-fixe] [data-commande="modifier"]'); + await element(m, '[data-place-fixe] [data-commande="lecture"]'); + } + if (charge.retenu === null && charge.propositions.length > 0) { + await cliquer(m, '[data-commande="section"][data-cible="propositions"]'); + await cliquer(m, `[data-proposition="${charge.propositions[0].id}"] [data-commande="voir"]`); + await element(m, '[data-section="plan"]'); + } + await cliquer(m, `[data-commande="section"][data-cible="${section}"]`); + await element(m, `[data-section="${section}"]`); + if (section === 'plan') { + await attendre('le plan posé', () => { + const vue = m.racine.querySelector('[data-vue]'); + return vue?.getAttribute('transform') && m.racine.querySelectorAll('[data-table]').length === charge.tables.length; + }); + } + return m; +} + +// Un écran d'événement sur la principale, sur la section donnée, au mode +// donné ; la bloquée pour 'bloque'. +function ecranDeSection(section, mode) { + return { + nom: `evenement.${section}.${mode}`, + mode, + async atteindre(contexte) { + const charge = mode === 'bloque' ? chargeBloquee() : chargePrincipale(); + const m = await ouvrirEvenement(contexte, charge, { ecriture: mode === 'ecriture', section }); + return m.racine; + }, + }; +} + +// La principale en écriture, sur le plan. +const planEnEcriture = (contexte, options) => ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'plan', options }); + +/** + * Le contexte d'un parcours : il monte App dans la langue et le thème + * donnés, garde chaque montage et chaque nettoyage, et défait tout par + * defaire(), les nettoyages d'abord, dans l'ordre inverse. + * + * @param {{langue?: 'fr'|'temoin', theme?: 'clair'|'sombre'}} [reglages] + */ +export function creerContexte({ langue = 'fr', theme = 'clair' } = {}) { + const montages = []; + const nettoyages = []; + return { + langue, + montages, + /** Le dernier montage : celui que l'écran montre. */ + get montage() { + return montages.at(-1) ?? null; + }, + async monter(options = {}) { + const m = await monterApp({ langue, theme, ...options }); + montages.push(m); + return m; + }, + nettoyer(fn) { + nettoyages.push(fn); + }, + /** Ce que le collecteur a reçu depuis chaque montage. */ + erreurs() { + return montages.flatMap((m) => m.erreurs()); + }, + async defaire() { + for (const fn of nettoyages.splice(0).reverse()) await fn(); + for (const m of montages.splice(0).reverse()) m.demonter(); + window.scrollTo(0, 0); + }, + }; +} + +/** Les écrans de l'itération. Leur union rend chaque sorte de MARQUES. */ +export const ECRANS = Object.freeze([ + { + nom: 'accueil.liste', + mode: null, + async atteindre(contexte) { + const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] }); + await element(m, `[data-evenement="${BASE}"]`); + return m.racine; + }, + }, + { + nom: 'accueil.vide', + mode: null, + async atteindre(contexte) { + const m = await contexte.monter(); + await element(m, '[data-ecran="accueil"] [data-dossier-travail]'); + return m.racine; + }, + }, + { + nom: 'apropos', + mode: null, + async atteindre(contexte) { + const m = await contexte.monter(); + await cliquer(m, '[data-bandeau] [data-commande="apropos"]'); + await element(m, '[data-ecran="apropos"]'); + return m.racine; + }, + }, + { + nom: 'recuperation', + mode: null, + async atteindre(contexte) { + const sain = fichierEtat(chargePrincipale()); + const tronque = { ...sain, texte: sain.texte.slice(0, Math.floor(sain.texte.length / 2)) }; + const precedent = { ...sain, chemin: `${BASE}${SUFFIXES.precedent}` }; + const m = await contexte.monter({ fichiers: [tronque, precedent] }); + await cliquer(m, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`); + await element(m, '[data-ecran="recuperation"]'); + return m.racine; + }, + }, + { + nom: 'dossier.question', + mode: null, + async atteindre(contexte) { + const m = await contexte.monter({ + systeme: { executable: PUBLIE, sonde: { portable: 'EACCES' } }, + fichiers: [{ ...fichierEtat(chargePrincipale()), racine: 'portable' }], + }); + await element(m, '[data-question]'); + return m.racine; + }, + }, + ...SECTIONS_EVENEMENT.flatMap((section) => ['lecture', 'ecriture', 'bloque'].map((mode) => ecranDeSection(section, mode))), + { + nom: 'plan.tableGelee', + mode: 'ecriture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargeGelee(), { ecriture: true, section: 'plan' }); + await element(m, '[data-table="4"] [data-marque="gel"]'); + return m.racine; + }, + }, + { + nom: 'plan.miseEnEvidence', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + const { tour, proposition } = m.ui.etat(); + const occupant = etatPlaces(m.seance.etat().charge, { tour, proposition }).tables[3].chaises[0].occupant; + m.ui.mettreEnEvidence(occupant); + await element(m, '[data-marque="miseEnEvidence"]'); + return m.racine; + }, + }, + { + nom: 'plan.focus', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + (await element(m, '[data-commande="pleinEcran"]')).focus(); + await touche('Tab'); + await element(m, '[data-plan] [data-marque="focus"]'); + return m.racine; + }, + }, + { + nom: 'plan.panneauPlace', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + await cliquerPointeur(chaise(m, 3, 3)); + await element(m, '[data-panneau="place"]'); + await element(m, '[data-siege="3:3"] [data-marque="selection"]'); + return m.racine; + }, + }, + { + nom: 'plan.menuPersonne', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + await cliquerPointeur(chaise(m, 4, 1)); + await element(m, '[data-panneau="place"] [data-menu]'); + return m.racine; + }, + }, + { + nom: 'plan.panneauTable', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + await cliquerPointeur(plateau(m, 3)); + await element(m, '[data-panneau="table"]'); + return m.racine; + }, + }, + { + nom: 'plan.infobulle', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + const { x, y } = await centre(plateau(m, 1)); + await pointer('bouger', x, y); + await attendre('l’infobulle', () => { + const infobulle = m.racine.querySelector('[role="tooltip"]'); + return infobulle !== null && !infobulle.hidden && infobulle.getBoundingClientRect().width > 0; + }); + return m.racine; + }, + }, + { + nom: 'plan.glissement', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + const depuis = await centre(chaise(m, 3, 2)); + const vers = await centre(chaise(m, 4, 2)); + await pointer('presser', depuis.x, depuis.y); + contexte.nettoyer(async () => { + await touche('Escape'); + await pointer('relacher', vers.x, vers.y); + }); + for (let pas = 1; pas <= 10; pas += 1) { + await pointer('bouger', depuis.x + ((vers.x - depuis.x) * pas) / 10, depuis.y + ((vers.y - depuis.y) * pas) / 10); + } + await element(m, '[data-fantome] [data-marque="fantome"]'); + return m.racine; + }, + }, + { + nom: 'plan.refusPlace', + mode: 'ecriture', + async atteindre(contexte) { + const m = await planEnEcriture(contexte); + const depuis = await centre(chaise(m, 3, 1)); + const vers = await centre(chaise(m, 1, 1)); + await pointer('presser', depuis.x, depuis.y); + for (let pas = 1; pas <= 10; pas += 1) { + await pointer('bouger', depuis.x + ((vers.x - depuis.x) * pas) / 10, depuis.y + ((vers.y - depuis.y) * pas) / 10); + } + await pointer('relacher', vers.x, vers.y); + await element(m, '[data-surface] [data-refus="PLACE_RESERVEE"]'); + return m.racine; + }, + }, + { + nom: 'participants.refusChamp', + mode: 'ecriture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' }); + await cliquer(m, '[data-section="participants"] [data-commande="ajouter"]'); + await element(m, '[data-section="participants"] [data-refus="NOM_REQUIS"]'); + return m.racine; + }, + }, + { + nom: 'participants.fiche', + mode: 'ecriture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' }); + await cliquer(m, '[data-participant="1"] [data-commande="fiche"]'); + await element(m, '[data-fiche="1"]'); + return m.racine; + }, + }, + { + nom: 'import.apercu', + mode: 'ecriture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'import' }); + const octets = await octetsCasLimites(); + m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets }); + await cliquer(m, '[data-commande="choisirFichier"]'); + await element(m, '[data-apercu] [data-refusees]'); + return m.racine; + }, + }, + { + nom: 'import.rapport', + mode: 'ecriture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'import' }); + const octets = await octetsCasLimites(); + m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets }); + await cliquer(m, '[data-commande="choisirFichier"]'); + await cliquer(m, '[data-apercu] [data-commande="importer"]'); + await attendre('le rapport, ou la confirmation du remplacement', () => + m.racine.querySelector('[data-rapport]') ?? m.racine.querySelector('dialog[open]'), + ); + if (m.racine.querySelector('[data-rapport]') === null) await cliquer(m, 'dialog[open] [data-commande="confirmer"]'); + await element(m, '[data-rapport]'); + return m.racine; + }, + }, + { + nom: 'participants.confirmer', + mode: 'ecriture', + async atteindre(contexte) { + // Supprimer une personne qui tient une réservation se confirme dans + // une fenêtre, qui nomme la réservation. + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' }); + await cliquer(m, '[data-participant="1"] [data-commande="supprimerParticipant"]'); + await element(m, 'dialog[open]'); + return m.racine; + }, + }, + { + nom: 'evenement.avertissements', + mode: 'lecture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargeEnDerive(), { section: 'plan' }); + await element(m, '[data-ecran="evenement"] [data-avertissement]'); + return m.racine; + }, + }, + { + nom: 'generation.enCours', + mode: 'ecriture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargePrincipale(), { + ecriture: true, + section: 'generation', + options: { executeur: executeurSuspendu() }, + }); + await cliquer(m, '[data-section="generation"] [data-commande="generer"]'); + await attendre('la génération lancée, ou sa question', () => + m.racine.querySelector('[data-avancement]') ?? m.racine.querySelector('[data-question="longue"]'), + ); + if (m.racine.querySelector('[data-avancement]') === null) await cliquer(m, '[data-commande="confirmerGeneration"]'); + await element(m, '[data-avancement]'); + return m.racine; + }, + }, + { + nom: 'refus.modeLecture', + mode: 'lecture', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'participants' }); + await cliquer(m, '[data-etat="brouillon"] [data-commande="etat"]'); + await element(m, '[data-refus="MODE_LECTURE"]'); + return m.racine; + }, + }, + { + nom: 'refus.bloque', + mode: 'bloque', + async atteindre(contexte) { + const m = await ouvrirEvenement(contexte, chargeBloquee(), { section: 'participants' }); + await cliquer(m, '[data-etat="propose"] [data-commande="etat"]'); + await element(m, '[data-refus="LECTURE_SEULE"]'); + return m.racine; + }, + }, + { + nom: 'refus.verrou', + mode: 'lecture', + async atteindre(contexte) { + const premier = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' }); + premier.racine.hidden = true; + const second = await contexte.monter({ systeme: { disque: premier.disque } }); + await cliquer(second, `[data-evenement="${BASE}"] [data-commande="ouvrir"]`); + await element(second, '[data-ecran="evenement"]'); + await cliquer(second, '[data-place-fixe] [data-commande="modifier"]'); + await element(second, '[data-refus="VERROU_PRIS"]'); + return second.racine; + }, + }, +]); diff --git a/test/navigateur/rendu.js b/test/navigateur/rendu.js index 58b6dfa..bd47a45 100644 --- a/test/navigateur/rendu.js +++ b/test/navigateur/rendu.js @@ -226,11 +226,13 @@ function couleursDeLaPeinture(valeur, element) { /** fill et stroke calculés d'une forme SVG, chacun en liste de couleurs, sans * doublon : vide pour none ou une couleur transparente ; un motif url(#…) - * rend les couleurs que peignent ses enfants, un dégradé ses arrêts. */ + * rend les couleurs que peignent ses enfants, un dégradé ses arrêts. Une + * n'a pas d'aire : elle ne peint que par son trait, et son fill + * calculé, noir par défaut, rend une liste vide. */ export function couleursPeintes(element) { const style = getComputedStyle(element); return { - fill: couleursDeLaPeinture(style.fill, element), + fill: element instanceof SVGLineElement ? [] : couleursDeLaPeinture(style.fill, element), stroke: couleursDeLaPeinture(style.stroke, element), }; } diff --git a/test/navigateur/rendu.navigateur.test.js b/test/navigateur/rendu.navigateur.test.js index 34ba503..b44e8ff 100644 --- a/test/navigateur/rendu.navigateur.test.js +++ b/test/navigateur/rendu.navigateur.test.js @@ -176,6 +176,24 @@ describe('couleursPeintes', () => { [20, 30, 90, 1], ]); }); + + test("une ligne ne peint que son trait : le noir par défaut de son fill n'est compté ni seul, ni dans un motif", () => { + monter( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + expect(getComputedStyle($('[data-ligne]')).fill).toBe('rgb(0, 0, 0)'); + expect(couleursPeintes($('[data-ligne]'))).toEqual({ fill: [], stroke: [[60, 70, 80, 1]] }); + expect(couleursPeintes($('[data-forme]')).fill).toEqual([ + [250, 240, 200, 1], + [20, 30, 90, 1], + ]); + }); }); describe('fondPeintSous', () => {