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', () => {