From b3d59f207972d854c9e0cfbc802872dee0476e6a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 16:02:27 -0400 Subject: [PATCH] [ADD] interface: operator walk, reading order, windows, read-only mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One walk at 1366 x 700, by gestures only from a fresh folder: create an event, import the edge-case CSV, sort and filter, tables, default and tours, the plan at scale, two hosts, a refusal on its place, three propositions retained, an adjustment, then reading mode where every plan gesture leaves the state untouched and undo is refused. Non-destructive gestures open no window; destructive ones name exactly what leaves, and Cancel writes nothing. In reading mode and on a blocked plan, every gesture family writes nothing. Checked: 517 browser tests, from the index alone. --- FR --- [ADD] interface : parcours, ordre libre, fenêtres, gestes en lecture Un parcours à 1366 x 700, par gestes depuis un dossier neuf : créer un événement, importer le CSV des cas limites, trier et filtrer, tables, défaut et tours, le plan à l'échelle, deux animateurs, un refus sur sa place, trois propositions dont une retenue, un ajustement, puis la lecture où chaque geste du plan laisse l'état intact et défaire est refusé. Un geste non destructeur n'ouvre aucune fenêtre ; un destructeur nomme exactement ce qui part, et Annuler n'écrit rien. En lecture et sur un plan bloqué, aucune famille de gestes n'écrit. Vérifié : 517 épreuves navigateur, index seul. Assisted-by: Claude Opus 5.5 --- src/interface/parcours.navigateur.test.js | 1249 +++++++++++++++++++++ 1 file changed, 1249 insertions(+) create mode 100644 src/interface/parcours.navigateur.test.js diff --git a/src/interface/parcours.navigateur.test.js b/src/interface/parcours.navigateur.test.js new file mode 100644 index 0000000..60dd43b --- /dev/null +++ b/src/interface/parcours.navigateur.test.js @@ -0,0 +1,1249 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le parcours de l'itération (§ 2.1, § 19.3, § 5.9, § 8.4, § 9) : une soirée +// préparée depuis un répertoire neuf, par les gestes de l'opérateur seuls — +// clic d'un bouton désigné par son attribut data-…, frappe réelle dans un +// champ, entrée réelle du pointeur sur le plan —, sur le système de fichiers +// simulé, l'horloge pilotée et l'exécuteur direct de la génération, au cadre +// de 1 366 × 700. Un seul montage porte toutes les épreuves du fichier, qui +// s'exécutent dans l'ordre : chacune part de l'événement que la précédente a +// laissé, affirme sa condition avant la suivante, et finit sur un collecteur +// d'erreurs vide. +// +// Le parcours suit les captures du § 19.3 qui relèvent de l'itération — 01, +// 01b, 02 à 09, 11, 19, 20 —, puis l'ordre de lecture du § 2.1 sur +// l'événement retenu, les fenêtres — aucune pendant un geste non +// destructeur, une qui nomme ce qui part pour chaque geste qui détruit —, +// enfin chaque famille de geste modifiant en lecture puis sur un plan +// bloqué. Les valeurs attendues viennent du moteur, du CSV et du stockage, +// sollicités une seconde fois (§ 19.1), et le texte de la même traduction +// que celle de l'application. Aucun délai fixe : chaque étape attend un +// état du document (§ 19.3). +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { cdp } from 'vitest/browser'; +import { flushSync } from 'svelte'; +import { bilanRemplacement, cleNormalisee } from '../application/csv.js'; +import { TABLES_APPLICATION } from '../application/libelles.js'; +import { nomAffiche } from '../application/personnes.js'; +import { etatPlaces } from '../application/places.js'; +import { creerTraduction } from '../application/traduction.js'; +import { apercevoir } from '../csv/apercu.js'; +import { decoder } from '../csv/encodage.js'; +import { CATALOGUE } from '../demo/catalogue.js'; +import { echelleGraphique } from '../geometrie/echelle.js'; +import { diametre } from '../geometrie/tables.js'; +import { vueDepuisTransform } from '../geometrie/vue.js'; +import { CRITERES, classer } from '../moteur/classement.js'; +import { indexerPlan, nombrePlaces, normaliser } from '../moteur/configuration.js'; +import { mesurer } from '../moteur/indicateurs.js'; +import { plafondsAPriori } from '../moteur/plafond.js'; +import { rechercher } from '../moteur/recherche.js'; +import { serialiserCharge } from '../stockage/canonique.js'; +import { configurationDepuisCharge } from '../stockage/document.js'; +import { lireJournal } from '../stockage/journal.js'; +import { SUFFIXES } from '../stockage/noms.js'; +import { planDepuisFichier } from '../stockage/placements.js'; +import { VERSION } from '../version.genere.js'; +import { NOM_CAS_LIMITES, attendre, centre, octetsCasLimites } from '../../test/navigateur/ecrans.js'; +import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js'; +import { monterApp } from '../../test/navigateur/montage.js'; +import { visible } from '../../test/navigateur/rendu.js'; +import { ENCODAGES, SEPARATEURS } from './import/modele.js'; +import { TABLES_INTERFACE } from './libelles.js'; +import { LARGEUR_ECHELLE_PX } from './plan/modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +// Le nom de l'événement que le parcours crée ; la graine qu'il écrit et le +// compte réduit de la recherche (§ 5.10). +const NOM_SOIREE = 'Soirée d\u{2019}essai'; +const GRAINE = 4242; +const ARRET = 300; + +// La personne que l'ordre de lecture ajoute : un nom inventé. +const AJOUTEE = { nom: 'Ombrelle', prenom: 'Iris' }; + +// Les gestes non destructeurs que les fenêtres surveillent (§ 2.1, § 5.9), +// chacun exécuté au moins une fois par le parcours. +const GESTES_NON_DESTRUCTEURS = Object.freeze([ + 'ajouter', + 'modifier', + 'exclure', + 'reintegrer', + 'reserver', + 'titrer', + 'designer', + 'surcharger', + 'appliquerDefaut', + 'ajouterTour', +]); + +// Les refus du § 5.9 qui subsistent à la pose, que la capture 09 peut +// photographier (§ 19.3). +const REFUS_DE_POSE = new Set(['SURRESERVATION', 'PERSONNE_EXCLUE', 'DEUX_PLACES_MEME_TOUR', 'PLACE_RESERVEE']); + +// Les éléments qu'une fenêtre ouvre : un dialogue annoncé ou un ouvert. +const FENETRE = '[role=dialog], [role=alertdialog], dialog[open]'; + +const PICTOGRAPHIQUE = /\p{Extended_Pictographic}/u; + +// La durée accordée à chaque épreuve : une étape du parcours compte des +// dizaines de gestes réels, chacun attendu image après image. +const DUREE = 120_000; + +// --- Le montage partagé --------------------------------------------------------- + +// Le montage du parcours, que chaque épreuve reprend où la précédente l'a +// laissé ; les relevés des fenêtres. +let m = null; +const gestesSurveilles = new Set(); +const fenetresVues = []; +// Ce que les étapes se transmettent : la personne désignée, la table et la +// proposition retenue, la charge d'avant l'ajustement de 19. +const memoire = {}; + +beforeAll(async () => { + await fixerCadre(1366, 700); + m = await monterApp(); +}); + +afterEach(() => { + expect(m.erreurs(), 'erreurs reçues par le collecteur').toEqual([]); +}); + +afterAll(async () => { + m?.demonter(); + window.scrollTo(0, 0); +}); + +// --- Les lectures -------------------------------------------------------------------- + +const dans = (selecteur) => m.racine.querySelector(selecteur); +const tous = (selecteur) => [...m.racine.querySelectorAll(selecteur)]; +const etat = () => m.seance.etat(); +const charge = () => etat().charge; +const revision = () => etat().evenement.revision; +const base = () => etat().evenement.base; +const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); + +// Le texte de l'état et du journal de l'événement ouvert, et sa révision. +function fichiers() { + return { + etat: m.fs.contenu('documents', `${base()}${SUFFIXES.etat}`), + journal: m.fs.contenu('documents', `${base()}${SUFFIXES.journal}`), + revision: revision(), + }; +} + +// Les entrées du journal relu de révision supérieure à r. +function entreesApres(r) { + const texte = m.fs.contenu('documents', `${base()}${SUFFIXES.journal}`); + return texte === null ? [] : lireJournal(texte).entrees.filter((entree) => entree.revision > r); +} + +const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre)); +async function images(n = 3) { + for (let i = 0; i < n; i += 1) await uneImage(); + flushSync(); +} + +// Attend exactement une entrée après la révision r : la révision de la +// séance à r + 1, une entrée de plus au journal relu ; rend son libellé. +async function uneEntree(r, geste) { + await attendre(`une entrée pour ${geste}`, () => revision() === r + 1); + await images(2); + expect(revision(), geste).toBe(r + 1); + const entrees = entreesApres(r); + expect(entrees, geste).toHaveLength(1); + return entrees[0].libelle; +} + +// Rien n'a été écrit depuis avant : l'état, le journal et la révision. +async function rienEcrit(avant, geste) { + await images(4); + expect(fichiers(), geste).toEqual(avant); +} + +// L'élément que désigne aria-describedby d'un élément. +const decrit = (element) => { + const id = element?.getAttribute('aria-describedby') ?? null; + return id === null ? null : document.getElementById(id); +}; + +// --- Les gestes ------------------------------------------------------------------------ + +// Un clic HTML sur l'élément, puis le rendu. +async function appuyer(element, quoi) { + expect(element, quoi).not.toBeNull(); + element.click(); + flushSync(); + await images(2); +} + +// Le bouton du montage que désigne le sélecteur, attendu, puis cliqué. +async function cliquer(selecteur) { + const element = await attendre(selecteur, () => dans(selecteur)); + await appuyer(element, selecteur); + return element; +} + +// Remplace le texte d'un champ par une frappe réelle : le champ prend le +// focus, son texte est sélectionné, puis remplacé par l'insertion du texte. +async function taper(entree, texte) { + expect(entree, 'champ à saisir').not.toBeNull(); + entree.focus(); + entree.select(); + if (texte === '') await touche('Backspace'); + else await cdp().send('Input.insertText', { text: texte }); + flushSync(); +} + +// Saisit puis valide par Entrée. +async function saisir(entree, texte) { + await taper(entree, texte); + await touche('Enter'); + flushSync(); + await images(2); +} + +// 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(element) { + const { x, y } = await centre(element); + await pointer('presser', x, y); + await pointer('relacher', x, y); + flushSync(); + await images(2); +} + +// Presse au point depuis et avance par pas jusqu'au point vers, sans relâcher. +async function presserEtBouger(depuis, vers, pas = 10) { + await pointer('presser', depuis.x, depuis.y); + for (let i = 1; i <= pas; i += 1) { + await pointer('bouger', depuis.x + ((vers.x - depuis.x) * i) / pas, depuis.y + ((vers.y - depuis.y) * i) / pas); + await uneImage(); + } + flushSync(); +} + +async function glisserVers(depuis, vers) { + await presserEtBouger(depuis, vers); + await pointer('relacher', vers.x, vers.y); + flushSync(); + await images(3); +} + +// Ouvre une section de l'événement par sa navigation. +async function ouvrirSection(nom) { + await cliquer(`[data-commande="section"][data-cible="${nom}"]`); + await attendre(`la section ${nom}`, () => dans(`[data-section="${nom}"]`)); +} + +// Ouvre le plan et l'ajuste au tour montré : toutes les tables dans le cadre. +async function ouvrirPlan() { + await ouvrirSection('plan'); + await attendre('le plan posé', () => dans('[data-vue]')?.getAttribute('transform') && tous('[data-table]').length === charge().tables.length); + await cliquer('[data-commande="ajuster"]'); + await images(2); +} + +// Un geste non destructeur, sous un MutationObserver qui relève toute +// fenêtre ouverte pendant lui, celles qui y sont déjà comprises ; le geste se +// compte parmi les gestes surveillés. +async function sansFenetre(geste, faire) { + const vues = await sousObservation(faire); + gestesSurveilles.add(geste); + fenetresVues.push(...vues.map((element) => ({ geste, element: element.outerHTML.slice(0, 120) }))); +} + +// Exécute faire sous l'observateur et rend les fenêtres relevées. +async function sousObservation(faire) { + const vues = []; + const relever = (noeud) => { + if (!(noeud instanceof Element)) return; + if (noeud.matches(FENETRE)) vues.push(noeud); + vues.push(...noeud.querySelectorAll(FENETRE)); + }; + const lire = (mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') relever(mutation.target); + else for (const noeud of mutation.addedNodes) relever(noeud); + } + }; + const observateur = new MutationObserver(lire); + observateur.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['open', 'role'] }); + relever(document.body); + try { + await faire(); + await images(2); + } finally { + lire(observateur.takeRecords()); + observateur.disconnect(); + } + return [...new Set(vues)]; +} + +// --- Le plan --------------------------------------------------------------------------- + +const chaise = (table, siege) => dans(`[data-siege="${table}:${siege}"]`); +const plateau = (table) => dans(`[data-table="${table}"] [data-marque="contourTable"], [data-table="${table}"] [data-marque="contourConflit"]`); +const actionPlace = (action) => dans(`[data-panneau="place"] [data-commande="${action}"]`); + +// L'état des places du tour montré, tel que le plan le calcule. +const etatMontre = () => etatPlaces(charge(), { tour: m.ui.etat().tour, proposition: m.ui.etat().proposition }); + +// Les chaises du tour montré, à plat : {table, siege, occupant, reservation, titre}. +function chaisesMontrees() { + return etatMontre().tables.flatMap((table) => table.chaises.map((c) => ({ table: table.id, ...c }))); +} + +// Une chaise libre — ni occupant, ni réservation, ni titre — d'une table, hors des sièges exclus. +function chaiseLibre(table, exclus = []) { + const trouvee = chaisesMontrees().find( + (c) => c.table === table && c.occupant === null && c.reservation === null && c.titre === null && !exclus.includes(c.siege), + ); + expect(trouvee, `une chaise libre à la table ${table}`).toBeDefined(); + return trouvee; +} + +// Une chaise occupée d'une table par une personne qu'aucune réservation n'y tient. +function chaiseOccupee(table) { + const trouvee = chaisesMontrees().find((c) => c.table === table && c.occupant !== null && c.reservation === null); + expect(trouvee, `une chaise occupée à la table ${table}`).toBeDefined(); + return trouvee; +} + +// Avance le zoom jusqu'au régime des sièges : les avertissements de +// l'événement réduisent la hauteur du plan, et au zoom ajusté les tables +// peuvent passer en régime table, sans chaise dessinée. +async function regimeSieges() { + for (let cran = 0; tous('[data-plan] [data-siege]').length === 0; cran += 1) { + expect(cran, 'crans de zoom jusqu’aux sièges').toBeLessThan(4); + await cliquer('[data-commande="zoomer"]'); + } +} + +// Vrai quand le centre de l'élément tombe dans le cadre du plan, à 8 px au +// moins de ses bords : le pointeur l'y atteint. +function dansLeCadre(element) { + if (element === null) return false; + const cadre = dans('[data-plan]').getBoundingClientRect(); + const b = element.getBoundingClientRect(); + const x = b.left + b.width / 2; + const y = b.top + b.height / 2; + return b.width > 0 && x >= cadre.left + 8 && x <= cadre.right - 8 && y >= cadre.top + 8 && y <= cadre.bottom - 8; +} + +// La première chaise du tour montré qui répond au prédicat et dont le +// dessin est dans le cadre. +function chaiseVue(predicat) { + const trouvee = chaisesMontrees().find((c) => predicat(c) && dansLeCadre(chaise(c.table, c.siege))); + expect(trouvee, 'une chaise dans le cadre').toBeDefined(); + return trouvee; +} + +// Choisit une personne dans la réserve visible, ou dans la liste des +// pressentis ; rend son identifiant. +async function choisir(conteneur, participant = null) { + const boutons = tous(`${conteneur} [data-participant]`); + expect(boutons.length, `personnes de ${conteneur}`).toBeGreaterThan(0); + const bouton = participant === null ? boutons[0] : boutons.find((b) => Number(b.dataset.participant) === participant); + expect(bouton, `personne ${participant} dans ${conteneur}`).toBeDefined(); + const id = Number(bouton.dataset.participant); + await appuyer(bouton, `choisir ${id}`); + await attendre('le choix posé', () => m.ui.etat().choix?.participant === id); + return id; +} + +// Oublie le choix en attente, s'il y en a un. +async function oublierChoix() { + if (m.ui.etat().choix === null) return; + const oublier = actionPlace('oublierChoix'); + if (oublier !== null) await appuyer(oublier, 'oublier le choix'); + else m.ui.choisir(null); + await attendre('le choix oublié', () => m.ui.etat().choix === null); +} + +// Le refus d'un geste du plan : la pose de l'état de navigation, neuve après +// le geste, porte le code et nomme la place du geste, attendue — {table, +// siege} de la chaise visée, {table} pour une table ou sa poignée ; cette +// place désigne le message par aria-describedby, et le message porte la +// commande du remède. +async function refusSurLePlan(poseAvant, attendue, code, remede, geste) { + const pose = await attendre(`le refus ${code} du geste ${geste}`, () => { + const lue = m.ui.etat().pose; + return lue !== poseAvant && lue?.refus?.code === code ? lue : null; + }); + const { table, siege = null } = attendue; + expect({ table: pose.place.table, siege: pose.place.siege ?? null }, `${geste} : la place du refus`).toEqual({ table, siege }); + const place = siege === null ? dans(`[data-table="${table}"]`) : chaise(table, siege); + const message = await attendre(`le message de ${geste} sur la place`, () => decrit(place)); + const refus = message.querySelector(`[data-refus="${code}"]`); + expect(refus, geste).not.toBeNull(); + expect(refus.querySelector(`[data-commande="${remede}"]`)?.textContent, geste).toBe(t(`communs.remede.${remede}`)); + expect(dans('[data-plan]').contains(message), geste).toBe(false); + return message; +} + +// Ferme le refus posé sur une place par sa propre commande : posé sur le +// plan, il couvrirait l'élément que vise le geste suivant. +async function fermerLaPose() { + if (m.ui.etat().pose === null) return; + const fermer = [...tous('[data-surface] button')].find((bouton) => texteDe(bouton) === t('plan.refus.fermer')); + if (fermer !== undefined) await appuyer(fermer, 'fermer le refus posé'); + await attendre('la pose fermée', () => m.ui.etat().pose === null); +} + +// Le refus posé à côté d'un élément, dans le porteur donné, avec son remède. +async function refusPres(porteur, code, remede, geste) { + const refus = await attendre(`le refus ${code} de ${geste}`, () => porteur().querySelector(`[data-refus="${code}"]`)); + expect(refus.querySelector(`[data-commande="${remede}"]`)?.textContent, geste).toBe(t(`communs.remede.${remede}`)); + return refus; +} + +// --- Le parcours (§ 2.1, § 19.3) ------------------------------------------------------ + +describe('le parcours, depuis un répertoire neuf, par gestes seulement', { timeout: DUREE }, () => { + test('01 le répertoire vide : chaque démonstration du catalogue offerte, la version, le chemin ; créer « Soirée d’essai » l’ouvre en lecture', async () => { + await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-dossier-travail]')); + expect(tous('[data-evenement]')).toHaveLength(0); + const offertes = tous('[data-demonstration]'); + expect(CATALOGUE.length).toBeGreaterThan(0); + expect(offertes).toHaveLength(CATALOGUE.length); + offertes.forEach((entree, rang) => { + expect(texteDe(entree)).toBe(CATALOGUE[rang].nom); + expect(visible(entree)).toBe(true); + }); + expect(dans('[data-version]').textContent).toContain(VERSION.affichee); + expect(dans('[data-bandeau]').textContent).toContain(VERSION.affichee); + const chemin = dans('[data-dossier-travail]').textContent; + expect(chemin.length).toBeGreaterThan(0); + expect(chemin).toBe(etat().racine.chemin); + + await taper(dans('[data-ecran="accueil"] input[data-champ="nom"]'), NOM_SOIREE); + await cliquer('[data-ecran="accueil"] [data-commande="creer"]'); + await attendre('l’événement ouvert', () => dans('[data-ecran="evenement"]') && etat().evenement !== null); + expect(etat().evenement.nom).toBe(NOM_SOIREE); + expect(etat().mode).toBe('lecture'); + expect(charge().participants).toHaveLength(0); + }); + + test('01b une modification refusée avant « Modifier », à côté de son geste ; acceptée après ; le cadre visible', async () => { + await ouvrirSection('tables'); + const avant = fichiers(); + expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture'); + await cliquer('[data-section="tables"] [data-commande="creerTableRonde"]'); + await refusPres(() => dans('[data-commande="creerTableRonde"]').parentElement, 'MODE_LECTURE', 'modifier', 'créer une table'); + expect(document.querySelectorAll(FENETRE)).toHaveLength(0); + await rienEcrit(avant, 'créer une table en lecture'); + expect(charge().tables).toHaveLength(0); + + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture' && dans('[data-place-fixe] [data-commande="lecture"]')); + const cadre = dans('[data-mode]'); + expect(cadre.dataset.marque).toBe('cadreEcriture'); + expect(visible(cadre)).toBe(true); + const libelle = dans('[data-mode-libelle="cadre"]'); + expect(visible(libelle)).toBe(true); + expect(texteDe(libelle).length).toBeGreaterThan(0); + expect(texteDe(dans('[data-mode-libelle="bandeau"]'))).toBe(texteDe(libelle)); + + const r = revision(); + await cliquer('[data-section="tables"] [data-commande="creerTableRonde"]'); + await uneEntree(r, 'créer une table'); + expect(charge().tables).toHaveLength(1); + expect(charge().tables[0].sieges).toBeNull(); + }); + + test('02 l’aperçu de l’import des cas limites : encodage et séparateur nommés, ceux de l’analyseur ; un groupe d’orthographes fondu, une ligne refusée', async () => { + await ouvrirSection('import'); + const octets = await octetsCasLimites(); + m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets }); + const avant = fichiers(); + await cliquer('[data-section="import"] [data-commande="choisirFichier"]'); + const apercu = await attendre('l’aperçu', () => dans('[data-apercu]')); + const { texte, encodage } = decoder(octets); + expect(texteDe(apercu.querySelector('[data-encodage]'))).toBe(t(ENCODAGES.get(encodage))); + expect(texteDe(apercu.querySelector('[data-separateur]'))).toBe(t(SEPARATEURS.get(apercevoir(texte).separateur))); + expect(apercu.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1); + expect(apercu.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1); + expect(apercu.querySelector('[data-commande="importer"]')).not.toBeNull(); + await rienEcrit(avant, 'l’aperçu'); + }); + + test('03 le rapport : une entrée, les lignes valides comptées, le groupe fondu et la ligne refusée', async () => { + const r = revision(); + await cliquer('[data-apercu] [data-commande="importer"]'); + const rapport = await attendre('le rapport', () => dans('[data-rapport]')); + await uneEntree(r, 'importer'); + const ajoutes = Number(texteDe(rapport.querySelector('[data-compte="ajoutes"] [data-nombre]'))); + expect(ajoutes).toBeGreaterThan(0); + expect(charge().participants).toHaveLength(ajoutes); + expect(rapport.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1); + expect(rapport.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1); + memoire.importes = ajoutes; + }); + + test('04 la grille : le compte égal à l’import, une personne exclue ; trier, filtrer ; modifier, exclure, réintégrer sans fenêtre', async () => { + await ouvrirSection('participants'); + const section = () => dans('[data-section="participants"]'); + const idsAffiches = () => [...section().querySelectorAll('[role="grid"] [data-participant]')].map((l) => Number(l.dataset.participant)); + const n = memoire.importes; + expect(idsAffiches()).toHaveLength(n); + expect(texteDe(section().querySelector('output'))).toBe(t('participants.grille.compte', { total: n, affiches: n })); + const exclue = charge().participants.find((p) => p.exclu); + expect(exclue).toBeDefined(); + const celluleExclu = section().querySelector(`[data-participant="${exclue.id}"] [data-champ="exclu"]`); + expect(texteDe(celluleExclu)).toBe(t('participants.grille.exclue')); + + // Le tri par nom, croissant puis décroissant, sur la clé normalisée, + // départagé par identifiant ; le filtre ; rien ne s'écrit. + const avant = fichiers(); + const parNom = (sens) => + [...charge().participants] + .sort((a, b) => { + const [ka, kb] = [cleNormalisee(a.nom), cleNormalisee(b.nom)]; + return ka === kb ? a.id - b.id : (ka < kb ? -1 : 1) * (sens === 'croissant' ? 1 : -1); + }) + .map((p) => p.id); + await cliquer('[data-section="participants"] [data-commande="trier"][data-cible="nom"]'); + expect(idsAffiches()).toEqual(parNom('croissant')); + await cliquer('[data-section="participants"] [data-commande="trier"][data-cible="nom"]'); + expect(idsAffiches()).toEqual(parNom('decroissant')); + const filtre = 'Hérons'; + const retenus = charge() + .participants.filter((p) => + ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'].some( + (c) => p[c] !== null && cleNormalisee(p[c]).includes(cleNormalisee(filtre)), + ), + ) + .map((p) => p.id); + expect(retenus.length).toBeGreaterThan(0); + expect(retenus.length).toBeLessThan(n); + await taper(section().querySelector('[data-champ="filtre"]'), filtre); + await images(2); + expect([...idsAffiches()].sort((a, b) => a - b)).toEqual(retenus); + await taper(section().querySelector('[data-champ="filtre"]'), ''); + await images(2); + expect(idsAffiches()).toHaveLength(n); + await rienEcrit(avant, 'trier et filtrer'); + + // Modifier une cellule au clavier : une entrée, aucune fenêtre. + const cible = charge().participants.find((p) => !p.exclu && p.courriel === null); + await sansFenetre('modifier', async () => { + const r = revision(); + section().querySelector(`[data-participant="${cible.id}"] [data-champ="courriel"]`).focus(); + await touche('Enter'); + await images(1); + await cdp().send('Input.insertText', { text: 'ondine@exemple.test' }); + await touche('Enter'); + await uneEntree(r, 'modifier une cellule'); + }); + expect(charge().participants.find((p) => p.id === cible.id).courriel).toBe('ondine@exemple.test'); + + // Exclure, puis réintégrer : une entrée chacun, aucune fenêtre. + await sansFenetre('exclure', async () => { + const r = revision(); + await cliquer(`[data-participant="${cible.id}"] [data-commande="exclureParticipant"]`); + await uneEntree(r, 'exclure'); + }); + expect(charge().participants.find((p) => p.id === cible.id).exclu).toBe(true); + await sansFenetre('reintegrer', async () => { + const r = revision(); + await cliquer(`[data-participant="${cible.id}"] [data-commande="reintegrerParticipant"]`); + await uneEntree(r, 'réintégrer'); + }); + expect(charge().participants.find((p) => p.id === cible.id).exclu).toBe(false); + }); + + test('05 deux tables à 8, l’une au défaut, l’autre surchargée à 8 : leurs états diffèrent à valeur égale', async () => { + await ouvrirSection('tables'); + let r = revision(); + await cliquer('[data-section="tables"] [data-commande="creerTableRonde"]'); + await uneEntree(r, 'créer la seconde table'); + const [premiere, seconde] = charge().tables; + const champTable = (id) => dans(`[data-section="tables"] [data-table="${id}"] input[data-champ="sieges-${id}"]`); + await sansFenetre('surcharger', async () => { + r = revision(); + await saisir(champTable(seconde.id), '9'); + await uneEntree(r, 'surcharger à 9'); + r = revision(); + await saisir(champTable(seconde.id), '8'); + await uneEntree(r, 'surcharger à 8'); + }); + expect(charge().tables.map(({ sieges }) => sieges)).toEqual([null, 8]); + expect(champTable(premiere.id).value).toBe('8'); + expect(champTable(seconde.id).value).toBe('8'); + const etatDe = (id) => dans(`[data-section="tables"] [data-table="${id}"] [data-etat-table]`); + expect(visible(etatDe(premiere.id))).toBe(true); + expect(visible(etatDe(seconde.id))).toBe(true); + expect(texteDe(etatDe(premiere.id))).not.toBe(texteDe(etatDe(seconde.id))); + }); + + test('06 l’énoncé d’un changement de défaut, son total égal au moteur ; étape 5, le nombre de tours', async () => { + const avant = fichiers(); + await saisir(dans('[data-section="tables"] input[data-champ="siegesParDefaut"]'), '10'); + const enonce = await attendre('l’énoncé', () => dans('[data-enonce="defaut"]')); + expect(visible(enonce)).toBe(true); + await rienEcrit(avant, 'l’énoncé'); + const phrase = [...enonce.querySelectorAll('[data-phrase]')].map(texteDe).join(' '); + const total = phrase.match(/(\d+)\s*\u{2192}\s*(\d+)/u); + expect(total, phrase).not.toBeNull(); + await sansFenetre('appliquerDefaut', async () => { + const r = revision(); + await cliquer('[data-enonce="defaut"] [data-commande="appliquer"]'); + await uneEntree(r, 'appliquer le défaut'); + }); + const instance = normaliser(configurationDepuisCharge(charge())); + expect(nombrePlaces(instance)).toBe(Number(total[2])); + expect(phrase).toContain(String(instance.N)); + expect(charge().evenement.siegesParDefaut).toBe(10); + expect(charge().tables.map(({ sieges }) => sieges)).toEqual([null, 8]); + + // Étape 5 : trois tours, sans perte, sans fenêtre. + const vues = await sousObservation(async () => { + const r = revision(); + await saisir(dans('[data-section="tables"] input[data-champ="tours"]'), '3'); + await uneEntree(r, 'les tours'); + }); + expect(vues).toEqual([]); + expect(charge().evenement.tours).toBe(3); + }); + + test('07 le plan à l’échelle : chaque plateau mesure son diamètre au zoom lu, l’échelle graphique, la mention « disposition relative »', async () => { + await ouvrirPlan(); + const { k } = vueDepuisTransform(dans('[data-vue]').getAttribute('transform')); + expect(k).toBeGreaterThan(0); + const tables = etatMontre().tables; + expect(tables).toHaveLength(2); + for (const table of tables) { + const largeur = plateau(table.id).getBoundingClientRect().width; + expect(Math.abs(largeur - diametre(table.capacite) * k), `table ${table.numero}`).toBeLessThan(1.5); + } + const barre = dans('[data-surface] [data-marque="echelle"]'); + expect(visible(barre)).toBe(true); + const { longueurCm, largeurPx } = echelleGraphique(k, LARGEUR_ECHELLE_PX); + expect(Math.abs(barre.getBoundingClientRect().width - largeurPx)).toBeLessThan(1); + expect(Math.abs(largeurPx - longueurCm * k)).toBeLessThan(1); + const mention = [...dans('[data-surface]').querySelectorAll('p')].find((p) => texteDe(p) === t('plan.echelle.mention')); + expect(mention).toBeDefined(); + expect(visible(mention)).toBe(true); + }); + + test('08 deux animateurs — cadenas fermé et anneau, hachuré et cadenas ouvert —, leurs lignes, les deux listes de travail', async () => { + const [t1, t2] = charge().tables.map(({ id }) => id); + // Désigner une personne pressentie à la première table. + let r = revision(); + await sansFenetre('designer', async () => { + memoire.designee = await choisir('[data-liste-travail="pressentis"]'); + await cliquerPointeur(chaise(t1, 1)); + await appuyer(actionPlace('designer'), 'désigner'); + await uneEntree(r, 'désigner'); + }); + expect(charge().reservations).toContainEqual(expect.objectContaining({ participant: memoire.designee, table: t1, portee: 'tous' })); + // Réserver une personne de la réserve à la seconde table, pour ce tour. + r = revision(); + await sansFenetre('reserver', async () => { + const id = await choisir('[data-reserve]'); + const libre = chaiseLibre(t2, [1]); + await cliquerPointeur(chaise(t2, libre.siege)); + await appuyer(actionPlace('reserver'), 'réserver'); + await uneEntree(r, 'réserver'); + memoire.reservee = id; + }); + expect(charge().reservations).toContainEqual(expect.objectContaining({ participant: memoire.reservee, table: t2, portee: 'tour', tour: 1 })); + // Un titre que rien ne pourvoit, à la seconde table. + await oublierChoix(); + r = revision(); + await sansFenetre('titrer', async () => { + await cliquerPointeur(chaise(t2, 1)); + await appuyer(actionPlace('donnerTitre'), 'titrer'); + await uneEntree(r, 'titrer'); + }); + expect(charge().titres.filter(({ table }) => table === t2)).toHaveLength(1); + + // Les marques sont des éléments SVG : la place désignée porte cadenas + // fermé et anneau, la place titrée hachure et cadenas ouvert. + await images(2); + const sieges = tous('[data-plan] [data-siege]'); + expect(sieges.length).toBeGreaterThan(0); + const porte = (element, marque) => element.querySelector(`[data-marque="${marque}"]`) !== null; + const designee = sieges.filter((s) => porte(s, 'cadenasFerme') && porte(s, 'anneau')); + const titree = sieges.filter((s) => porte(s, 'hachure') && porte(s, 'cadenasOuvert')); + expect(designee.map((s) => s.dataset.siege.split(':')[0])).toEqual([String(t1)]); + expect(titree.map((s) => s.dataset.siege.split(':')[0])).toEqual([String(t2)]); + for (const s of [...designee, ...titree]) expect(s.querySelector('[data-marque="cadenasFerme"], [data-marque="cadenasOuvert"]').namespaceURI).toBe('http://www.w3.org/2000/svg'); + for (const s of sieges) { + const marques = s.querySelectorAll('[data-marque="cadenasFerme"], [data-marque="cadenasOuvert"], [data-marque="anneau"]'); + expect(marques.length, s.dataset.siege).toBeLessThanOrEqual(2); + } + expect(PICTOGRAPHIQUE.test(dans('[data-plan]').textContent)).toBe(false); + + // Les lignes des listes énoncent les deux états en toutes lettres. + const titre = charge().titres.find(({ table }) => table === t2).libelle; + const liste = (table) => texteDe(dans(`[data-liste="${table}"]`)); + expect(liste(t1)).toContain(nomAffiche(charge().participants.find((p) => p.id === memoire.designee))); + expect(liste(t1)).toContain(t('plan.ligne.reserveeTous')); + expect(liste(t2)).toContain(`${t('plan.ligne.avantTitreNonPourvu').trim()} ${titre}${t('plan.ligne.apresTitreNonPourvu')}`); + + // Les deux listes de travail : le titre non pourvu, les pressentis. + const titres = tous('[data-liste-travail="titres"] [data-titre]'); + expect(titres.map((b) => b.dataset.titre.split(':')[0])).toEqual([String(t2)]); + expect(texteDe(titres[0])).toContain(titre); + expect(tous('[data-liste-travail="pressentis"] [data-participant]').length).toBeGreaterThan(0); + }); + + test('09 un refus sur la place, sa cause et son remède, sans fenêtre ni écriture', async () => { + const [t1] = charge().tables.map(({ id }) => id); + const reservee = chaisesMontrees().find((c) => c.reservation?.participant === memoire.designee); + expect(reservee).toBeDefined(); + const avant = fichiers(); + const poseAvant = m.ui.etat().pose; + const vues = await sousObservation(async () => { + await choisir('[data-reserve]'); + await cliquerPointeur(chaise(t1, reservee.siege)); + await appuyer(actionPlace('assigner'), 'assigner sur une place réservée à un autre'); + }); + const pose = await attendre('le refus sur la place', () => (m.ui.etat().pose !== poseAvant ? m.ui.etat().pose : null)); + expect(REFUS_DE_POSE.has(pose.refus.code), pose.refus.code).toBe(true); + const message = decrit(chaise(t1, reservee.siege)); + expect(message).not.toBeNull(); + const refus = message.querySelector(`[data-refus="${pose.refus.code}"]`); + expect(refus).not.toBeNull(); + expect(texteDe(refus)).toContain(t(pose.refus.code, pose.refus.details).slice(0, 20)); + expect(refus.querySelector('[data-commande]')).not.toBeNull(); + expect(visible(message)).toBe(true); + expect(vues).toEqual([]); + await rienEcrit(avant, 'le refus sur la place'); + await oublierChoix(); + }); + + test('11 générer à la graine écrite et au compte réduit : trois propositions au moins, celles du moteur ; l’ordre écrit, celui de la clé du moteur ; les colonnes de diversité ; retenir', async () => { + await ouvrirSection('generation'); + await taper(dans('[data-section="generation"] input[data-champ="graine"]'), String(GRAINE)); + let r = revision(); + await saisir(dans('[data-section="generation"] input[data-champ="generation.arret"]'), String(ARRET)); + await uneEntree(r, 'le compte réduit'); + expect(charge().reglages.generation.arret).toBe(ARRET); + const configuration = configurationDepuisCharge(charge()); + const { nombre, historique } = charge().reglages.generation; + const attendues = rechercher(configuration, { graine: GRAINE, nombre, arret: ARRET, historique }); + expect(attendues.length).toBeGreaterThanOrEqual(3); + + r = revision(); + await cliquer('[data-section="generation"] [data-commande="generer"]'); + await attendre('la génération, ou sa question', () => dans('[data-issue]') ?? dans('[data-question="longue"]')); + if (dans('[data-issue]') === null) await cliquer('[data-commande="confirmerGeneration"]'); + await attendre('les propositions', () => charge().propositions.length === attendues.length); + await uneEntree(r, 'générer'); + expect(charge().propositions.map(({ graine }) => graine)).toEqual(attendues.map(({ graine }) => graine)); + + await ouvrirSection('propositions'); + const section = dans('[data-section="propositions"]'); + const ids = () => [...section.querySelectorAll('[data-classement] tr[data-proposition]')].map((tr) => Number(tr.dataset.proposition)); + const instance = normaliser(configurationDepuisCharge(charge())); + const aPriori = plafondsAPriori(instance); + const { ordre } = classer( + charge().propositions.map((p) => ({ id: p.id, mesures: mesurer(instance, indexerPlan(instance, planDepuisFichier(p))), plafondsAPriori: aPriori })), + ); + expect(ordre.length).toBeGreaterThanOrEqual(3); + await attendre('le tableau classé', () => ids().length === ordre.length); + expect(ids()).toEqual(ordre); + expect(texteDe(section.querySelector('[data-ordre]'))).toBe(t('propositions.ordre.criteres', { criteres: [...CRITERES] })); + const colonnes = [...section.querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne); + for (const colonne of ['appartenancesVues', 'totalRedondance', 'diversite']) expect(colonnes, colonne).toContain(colonne); + // Le minimum des rencontres seul, jamais la moyenne ; r(p) par sa somme. + expect(colonnes).not.toContain('redondance'); + for (const th of section.querySelectorAll('th')) expect(texteDe(th)).not.toMatch(/moyenne/i); + + r = revision(); + memoire.retenue = ordre[0]; + const vues = await sousObservation(async () => { + await cliquer(`[data-proposition="${ordre[0]}"] [data-commande="retenir"]`); + await uneEntree(r, 'retenir'); + }); + expect(vues).toEqual([]); + expect(charge().retenu.proposition).toBe(ordre[0]); + expect(charge().evenement.etat).toBe('retenu'); + await attendre('la ligne du retenu', () => section.querySelector(`tr[data-retenu="${ordre[0]}"]`)); + }); + + test('19 un ajustement : le glissement dit lequel des deux gestes est en cours, une entrée, le retenu changé', async () => { + await ouvrirPlan(); + memoire.avantAjustement = serialiserCharge(charge()); + const [t1, t2] = charge().tables.map(({ id }) => id); + const depuis = chaiseOccupee(t1); + const vers = chaiseLibre(t2); + const r = revision(); + await presserEtBouger(await centre(chaise(t1, depuis.siege)), await centre(chaise(t2, vers.siege))); + const etatDonnees = await attendre('le libellé de l’état des données', () => dans('[data-fantome] [data-partie="etat"]')); + expect(texteDe(etatDonnees)).toBe(t('plan.fantome.retenu')); + const point = await centre(chaise(t2, vers.siege)); + await pointer('relacher', point.x, point.y); + const libelle = await uneEntree(r, 'ajuster'); + expect(libelle).toContain(nomAffiche(charge().participants.find((p) => p.id === depuis.occupant))); + expect(serialiserCharge(charge())).not.toBe(memoire.avantAjustement); + expect(charge().retenu.proposition).toBe(memoire.retenue); + }); + + test('20 la lecture : glisser une personne, une table, tirer une poignée, poser une réservation — empreinte inchangée ; « Défaire » refusé avec « Modifier », puis rend l’état d’avant 19', async () => { + await cliquer('[data-place-fixe] [data-commande="lecture"]'); + await attendre('la lecture', () => etat().mode === 'lecture'); + expect(dans('[data-mode]').dataset.marque).toBe('cadreLecture'); + expect(visible(dans('[data-mode-libelle="cadre"]'))).toBe(true); + expect(dans('[data-bandeau]').textContent).toContain(VERSION.affichee); + const empreinte = serialiserCharge(charge()); + const avant = fichiers(); + const [t1, t2] = charge().tables.map(({ id }) => id); + + await glisserVers(await centre(chaise(t1, chaiseOccupee(t1).siege)), await centre(chaise(t2, chaiseLibre(t2).siege))); + const table = await centre(plateau(t2)); + await glisserVers(table, { x: table.x + 40, y: table.y + 30 }); + const poignee = await centre(dans(`[data-poignee="${t1}"]`)); + await glisserVers(poignee, { x: poignee.x + 40, y: poignee.y + 40 }); + const poseAvant = m.ui.etat().pose; + await choisir('[data-liste-travail="pressentis"]'); + const visee = chaiseLibre(t2); + await cliquerPointeur(chaise(t2, visee.siege)); + await appuyer(actionPlace('reserver'), 'réserver en lecture'); + await refusSurLePlan(poseAvant, { table: t2, siege: visee.siege }, 'MODE_LECTURE', 'modifier', 'réserver en lecture'); + await oublierChoix(); + + expect(serialiserCharge(charge())).toBe(empreinte); + await rienEcrit(avant, 'les quatre gestes en lecture'); + expect(dans(`[data-table="${t2}"]`).getAttribute('transform')).toBeNull(); + for (const t of etatMontre().tables) expect(tous(`[data-siege^="${t.id}:"]`), `table ${t.numero}`).toHaveLength(t.capacite); + + // « Défaire » en lecture : refusé à côté de lui, « Modifier » pour remède. + const defaire = dans('[data-historique] [data-commande="defaire"]'); + await appuyer(defaire, 'défaire en lecture'); + const refus = await refusPres(() => defaire.parentElement, 'MODE_LECTURE', 'modifier', 'défaire en lecture'); + await rienEcrit(avant, 'défaire en lecture'); + await appuyer(refus.querySelector('[data-commande="modifier"]'), 'le remède'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); + const r = revision(); + await cliquer('[data-historique] [data-commande="defaire"]'); + await uneEntree(r, 'défaire'); + expect(serialiserCharge(charge())).toBe(memoire.avantAjustement); + }); +}); + +// --- L'ordre de lecture (§ 2.1) --------------------------------------------------------- + +describe('l’ordre de lecture, pas une séquence imposée', { timeout: DUREE }, () => { + test('sur l’événement retenu, en écriture : ajouter une personne, créer une table, désigner une animatrice, changer le nombre de tours — une entrée chacun, sans « Suivant »', async () => { + expect(etat().mode).toBe('ecriture'); + expect(charge().retenu).not.toBeNull(); + + await ouvrirSection('participants'); + let r = revision(); + await sansFenetre('ajouter', async () => { + const formulaire = dans('[data-section="participants"] form'); + await taper(formulaire.querySelector('input[data-champ="prenom"]'), AJOUTEE.prenom); + await taper(formulaire.querySelector('input[data-champ="nom"]'), AJOUTEE.nom); + await touche('Enter'); + await uneEntree(r, 'ajouter une personne'); + }); + const ajoutee = charge().participants.at(-1); + expect(ajoutee).toMatchObject(AJOUTEE); + memoire.ajoutee = ajoutee.id; + + await ouvrirSection('tables'); + r = revision(); + await cliquer('[data-section="tables"] [data-commande="creerTableRonde"]'); + await uneEntree(r, 'créer une table'); + const nouvelle = charge().tables.at(-1).id; + + await ouvrirPlan(); + r = revision(); + // L'animatrice est assise par le retenu à une autre table : choisie par + // le menu de sa place, désignée à la nouvelle table. + await regimeSieges(); + const pressentie = (id) => charge().participants.find((p) => p.id === id).titrePressenti !== null; + const assise = chaiseVue((c) => c.table !== nouvelle && c.occupant !== null && c.reservation === null && !pressentie(c.occupant)); + const animatrice = charge().participants.find(({ id }) => id === assise.occupant); + await sansFenetre('designer', async () => { + await cliquerPointeur(chaise(assise.table, assise.siege)); + await cliquer(`[data-panneau="place"] [data-menu="${animatrice.id}"] [data-commande="deplacer"]`); + await attendre('le choix posé', () => m.ui.etat().choix?.participant === animatrice.id); + await cliquerPointeur(chaise(nouvelle, 1)); + await appuyer(actionPlace('designer'), 'désigner l’animatrice'); + await uneEntree(r, 'désigner l’animatrice'); + }); + expect(charge().reservations).toContainEqual(expect.objectContaining({ participant: animatrice.id, table: nouvelle, portee: 'tous' })); + // Le retenu l'assied ailleurs : l'avertissement la nomme, et la table + // par son numéro. + const numero = charge().tables.find(({ id }) => id === nouvelle).numero; + const nonHonoree = await attendre('l’avertissement de la réservation non honorée', () => + dans('[data-avertissement="RESERVATION_NON_HONOREE"]'), + ); + expect(texteDe(nonHonoree)).toContain(nomAffiche(animatrice)); + expect(texteDe(nonHonoree)).toMatch(new RegExp(`\\b${numero}\\b`)); + + await ouvrirSection('tables'); + r = revision(); + await sansFenetre('ajouterTour', async () => { + await saisir(dans('[data-section="tables"] input[data-champ="tours"]'), '4'); + await uneEntree(r, 'ajouter un tour'); + }); + expect(charge().evenement.tours).toBe(4); + + const suivant = [...m.racine.querySelectorAll('button, a, [role="button"]')].filter( + (element) => /suivant/i.test(texteDe(element)) || element.dataset.commande === 'suivant', + ); + expect(suivant).toEqual([]); + }); +}); + +// --- Les fenêtres (§ 2.1, § 5.9) -------------------------------------------------------- + +describe('les fenêtres : seul un geste qui détruit de la saisie en ouvre une', { timeout: DUREE }, () => { + test('aucune fenêtre pendant les gestes non destructeurs du parcours, chacun exécuté', () => { + expect(GESTES_NON_DESTRUCTEURS.length).toBeGreaterThan(0); + expect([...gestesSurveilles].sort()).toEqual([...GESTES_NON_DESTRUCTEURS].sort()); + expect(fenetresVues).toEqual([]); + }); + + test('« Remplacer », « Appliquer et réaligner », retirer un tour réservé, supprimer une personne ou une table réservées, « Retenir » sur un retenu ajusté : une fenêtre qui nomme ce qui part ; Annuler n’écrit rien', async () => { + expect(etat().mode).toBe('ecriture'); + const [t1] = charge().tables.map(({ id }) => id); + + // Préparation : une réservation au tour 4, un ajustement du retenu. + await ouvrirPlan(); + const selecteurTour = dans('[data-champ="tour"]'); + selecteurTour.value = '4'; + selecteurTour.dispatchEvent(new Event('change', { bubbles: true })); + await attendre('le tour 4 montré', () => m.ui.etat().tour === 4); + await images(2); + await regimeSieges(); + let r = revision(); + const auTour4 = await choisir('[data-reserve]'); + const libre4 = chaiseVue((c) => c.occupant === null && c.reservation === null && c.titre === null); + await cliquerPointeur(chaise(libre4.table, libre4.siege)); + await appuyer(actionPlace('reserver'), 'réserver au tour 4'); + await uneEntree(r, 'réserver au tour 4'); + expect(charge().reservations).toContainEqual(expect.objectContaining({ participant: auTour4, portee: 'tour', tour: 4 })); + selecteurTour.value = '1'; + selecteurTour.dispatchEvent(new Event('change', { bubbles: true })); + await attendre('le tour 1 montré', () => m.ui.etat().tour === 1); + await images(2); + await regimeSieges(); + r = revision(); + const depuis = chaiseVue((c) => c.occupant !== null && c.reservation === null); + const vers = chaiseVue((c) => c.table !== depuis.table && c.occupant === null && c.reservation === null && c.titre === null); + await glisserVers(await centre(chaise(depuis.table, depuis.siege)), await centre(chaise(vers.table, vers.siege))); + await uneEntree(r, 'ajuster le retenu'); + + // Chaque geste destructeur : la fenêtre s'ouvre, son titre et ses + // éléments nomment ce qui part ; Annuler la ferme sans rien écrire. + // Ce qui part se calcule de la charge, hors des modules qui bâtissent + // la fenêtre, et se traduit par la même traduction : titre, le texte + // attendu du titre ; elements, la liste exacte des éléments ; parmi, + // des éléments que la liste porte, à côté d'autres que le geste emporte. + const avant = fichiers(); + const ligne = (texte) => texte.replace(/\s+/g, ' ').trim(); + const numeroDe = (table) => charge().tables.find(({ id }) => id === table).numero; + const nomDe = (participant) => nomAffiche(charge().participants.find(({ id }) => id === participant)); + async function fenetreAnnulee(geste, ouvrir, { titre, elements: exacts = null, parmi = [] }) { + expect(exacts !== null || parmi.length > 0, `${geste} : ce qui part, attendu`).toBe(true); + const vues = await sousObservation(ouvrir); + expect(vues.length, `${geste} : une fenêtre`).toBeGreaterThan(0); + const fenetre = await attendre(`la fenêtre de ${geste}`, () => dans('dialog[open]')); + expect(texteDe(fenetre.querySelector('h2')), `${geste} : le titre`).toBe(ligne(titre)); + const elements = [...fenetre.querySelectorAll('li')].map(texteDe); + if (exacts !== null) { + expect(exacts.length, geste).toBeGreaterThan(0); + expect(elements, `${geste} : les éléments`).toEqual(exacts.map(ligne)); + } + expect(elements, `${geste} : les éléments`).toEqual(expect.arrayContaining(parmi.map(ligne))); + await appuyer(fenetre.querySelector('[data-commande="renoncer"]'), `annuler ${geste}`); + await attendre(`la fenêtre de ${geste} fermée`, () => dans('dialog[open]') === null); + await rienEcrit(avant, geste); + } + + await ouvrirSection('import'); + const octets = await octetsCasLimites(); + m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets }); + await cliquer('[data-section="import"] [data-commande="choisirFichier"]'); + await attendre('l’aperçu', () => dans('[data-apercu] [data-champ="mode"]')); + await appuyer(dans('[data-champ="mode"] input[value="remplacer"]'), 'le mode remplacer'); + // Remplacer : les quatre comptes de ce que la liste remplacée emporte. + const bilan = bilanRemplacement(charge()); + expect(bilan.participants).toBeGreaterThan(0); + await fenetreAnnulee('remplacer', () => cliquer('[data-apercu] [data-commande="importer"]'), { + titre: t('import.remplacer.titre'), + elements: [ + t('import.remplacer.participants', { n: bilan.participants }), + t('import.remplacer.reservations', { n: bilan.reservations }), + t('import.remplacer.titres', { n: bilan.titresPourvus }), + t('import.remplacer.propositions', { n: bilan.propositions }), + ], + }); + await cliquer('[data-apercu] [data-commande="fermerApercu"]'); + + await ouvrirSection('tables'); + await saisir(dans('[data-section="tables"] input[data-champ="siegesParDefaut"]'), '9'); + await attendre('l’énoncé', () => dans('[data-enonce="defaut"] [data-commande="appliquerRealigner"]')); + // Réaligner : chaque table surchargée, par numéro, et sa surcharge perdue. + const surchargees = charge() + .tables.filter(({ sieges }) => sieges !== null) + .sort((a, b) => a.numero - b.numero); + expect(surchargees.length).toBeGreaterThan(0); + await fenetreAnnulee('appliquer et réaligner', () => cliquer('[data-enonce="defaut"] [data-commande="appliquerRealigner"]'), { + titre: t('tables.defaut.confirmationRealigner', { n: surchargees.length }), + elements: surchargees.map(({ numero, sieges }) => t('tables.defaut.surchargePerdue', { numero, sieges })), + }); + await cliquer('[data-enonce="defaut"] [data-commande="garder"]'); + + // Retirer le tour 4 : la réservation qui y tient, sa personne, sa table. + const reservation4 = charge().reservations.find(({ participant, tour }) => participant === auTour4 && tour === 4); + await fenetreAnnulee('retirer un tour réservé', () => saisir(dans('[data-section="tables"] input[data-champ="tours"]'), '3'), { + titre: t('tables.tours.confirmation', { avant: 4, apres: 3 }), + parmi: [t('tables.tours.reservation', { nom: nomDe(auTour4), numero: numeroDe(reservation4.table), tour: 4 })], + }); + expect(dans('[data-section="tables"] input[data-champ="tours"]').value).toBe('4'); + + // Supprimer t1 : son numéro, chaque réservation qu'elle tient avec le nom + // de sa personne, chaque titre posé à ses sièges. + const deT1 = [ + ...charge() + .reservations.filter(({ table }) => table === t1) + .map(({ participant, portee, tour, siege }) => t('tables.suppression.reservation', { nom: nomDe(participant), portee, tour, siege })), + ...charge() + .titres.filter(({ table }) => table === t1) + .map(({ siege, libelle }) => t('tables.suppression.titre', { siege, libelle })), + ]; + expect(deT1.length).toBeGreaterThan(0); + await fenetreAnnulee('supprimer une table réservée', () => cliquer(`[data-section="tables"] [data-table="${t1}"] [data-commande="supprimerTable"]`), { + titre: t('tables.suppression.confirmation', { numero: numeroDe(t1) }), + parmi: deT1, + }); + + await ouvrirSection('participants'); + const designee = charge().participants.find((p) => p.id === memoire.designee); + // Supprimer la personne désignée : son nom, chacune de ses réservations. + const siennes = charge().reservations.filter(({ participant }) => participant === designee.id); + expect(siennes.length).toBeGreaterThan(0); + await fenetreAnnulee('supprimer une personne réservée', () => cliquer(`[data-participant="${designee.id}"] [data-commande="supprimerParticipant"]`), { + titre: t('participants.suppression.titre', { personne: nomAffiche(designee) }), + elements: siennes.map((reservation) => t('participants.suppression.reservation', { ...reservation, numero: numeroDe(reservation.table) })), + }); + + await ouvrirSection('propositions'); + const autre = charge().propositions.find(({ id }) => id !== charge().retenu.proposition).id; + // Retenir : le retenu ajusté, par sa proposition d'origine, et celle qui + // le remplace. + await fenetreAnnulee('retenir sur un retenu ajusté', () => cliquer(`[data-proposition="${autre}"] [data-commande="retenir"]`), { + titre: t('propositions.confirmation.titre', { proposition: autre }), + elements: [ + t('propositions.confirmation.retenuAjuste', { proposition: memoire.retenue }), + t('propositions.confirmation.remplacant', { proposition: autre }), + ], + }); + expect(charge().retenu.proposition).toBe(memoire.retenue); + }); +}); + +// --- Lecture et bloqué, toutes familles (§ 8.4, § 9, § 19.3 20) ------------------------- + +describe('lecture et bloqué : chaque famille de geste modifiant refusée, rien d’écrit', { timeout: DUREE }, () => { + // Chaque famille, tentée par son geste : le refus se pose là où le geste + // a eu lieu, avec le remède attendu. + async function toutesFamilles(code, remede) { + const [, t2] = charge().tables.map(({ id }) => id); + const familles = []; + const famille = async (nom, faire) => { + await fermerLaPose(); + await faire(); + familles.push(nom); + }; + + // Les avertissements de dérive réduisent la hauteur du plan : au zoom + // ajusté, les tables passent en régime table. Le zoom s'avance jusqu'au + // régime des sièges, et chaque geste vise un élément dont le centre + // reste dans le cadre. + await ouvrirPlan(); + await regimeSieges(); + await famille('glisser une personne', async () => { + const poseAvant = m.ui.etat().pose; + const depuis = chaiseVue((c) => c.occupant !== null && c.reservation === null); + const vers = chaiseVue((c) => c.table !== depuis.table && c.occupant === null && c.reservation === null && c.titre === null); + await glisserVers(await centre(chaise(depuis.table, depuis.siege)), await centre(chaise(vers.table, vers.siege))); + await refusSurLePlan(poseAvant, { table: vers.table, siege: vers.siege }, code, remede, 'glisser une personne'); + }); + await famille('glisser une table', async () => { + const poseAvant = m.ui.etat().pose; + const table = charge().tables.find(({ id }) => dansLeCadre(plateau(id))); + expect(table, 'une table dans le cadre').toBeDefined(); + const point = await centre(plateau(table.id)); + await glisserVers(point, { x: point.x + 20, y: point.y + 15 }); + await refusSurLePlan(poseAvant, { table: table.id }, code, remede, 'glisser une table'); + }); + await famille('tirer une poignée', async () => { + const poseAvant = m.ui.etat().pose; + const poignee = tous('[data-poignee]').find(dansLeCadre); + expect(poignee, 'une poignée dans le cadre').toBeDefined(); + const point = await centre(poignee); + await glisserVers(point, { x: point.x + 40, y: point.y + 40 }); + await refusSurLePlan(poseAvant, { table: Number(poignee.dataset.poignee) }, code, remede, 'tirer une poignée'); + }); + const libreVue = () => chaiseVue((c) => c.occupant === null && c.reservation === null && c.titre === null); + await famille('poser une réservation', async () => { + const poseAvant = m.ui.etat().pose; + await choisir('[data-reserve]'); + const libre = libreVue(); + await cliquerPointeur(chaise(libre.table, libre.siege)); + await appuyer(actionPlace('reserver'), 'réserver'); + await refusSurLePlan(poseAvant, { table: libre.table, siege: libre.siege }, code, remede, 'poser une réservation'); + await oublierChoix(); + }); + await famille('titrer', async () => { + const poseAvant = m.ui.etat().pose; + const libre = libreVue(); + await cliquerPointeur(chaise(libre.table, libre.siege)); + await appuyer(actionPlace('donnerTitre'), 'titrer'); + await refusSurLePlan(poseAvant, { table: libre.table, siege: libre.siege }, code, remede, 'titrer'); + }); + await famille('désigner', async () => { + const poseAvant = m.ui.etat().pose; + await choisir('[data-reserve]'); + const libre = libreVue(); + await cliquerPointeur(chaise(libre.table, libre.siege)); + await appuyer(actionPlace('designer'), 'désigner'); + await refusSurLePlan(poseAvant, { table: libre.table, siege: libre.siege }, code, remede, 'désigner'); + await oublierChoix(); + }); + // Les objets reviennent : aucune table déplacée, chaque table à sa capacité. + const tables = tous('[data-plan] [data-table]'); + expect(tables.length).toBeGreaterThan(0); + for (const table of tables) expect(table.getAttribute('transform'), `table ${table.dataset.table}`).toBeNull(); + for (const table of etatMontre().tables) expect(tous(`[data-siege^="${table.id}:"]`), `table ${table.numero}`).toHaveLength(table.capacite); + + await ouvrirSection('participants'); + await famille('saisir dans une cellule', async () => { + const personne = charge().participants.find((p) => !p.exclu); + const cellule = () => dans(`[data-section="participants"] [data-participant="${personne.id}"] [data-champ="courriel"]`); + cellule().focus(); + await touche('Enter'); + await refusPres(cellule, code, remede, 'saisir dans une cellule'); + }); + await famille('coller', async () => { + const cellule = dans('[data-section="participants"] [role="grid"] [tabindex="0"]'); + cellule.focus(); + const presse = new DataTransfer(); + presse.setData('text/plain', 'nom;prenom\r\nPervenche;Théo\r\n'); + cellule.dispatchEvent(new ClipboardEvent('paste', { clipboardData: presse, bubbles: true, cancelable: true })); + await attendre('l’aperçu du collage', () => dans('[data-apercu] [data-commande="importer"]')); + await cliquer('[data-apercu] [data-commande="importer"]'); + await refusPres(() => dans('[data-apercu] [data-commande="importer"]').parentElement, code, remede, 'coller'); + await cliquer('[data-apercu] [data-commande="fermerApercu"]'); + }); + await famille('importer', async () => { + const octets = await octetsCasLimites(); + m.fs.choisirFichierAImporter = async () => ({ nom: NOM_CAS_LIMITES, octets }); + await cliquer('[data-section="import"] [data-commande="choisirFichier"]'); + await attendre('l’aperçu', () => dans('[data-apercu] [data-commande="importer"]')); + await cliquer('[data-apercu] [data-commande="importer"]'); + await refusPres(() => dans('[data-apercu] [data-commande="importer"]').parentElement, code, remede, 'importer'); + await cliquer('[data-apercu] [data-commande="fermerApercu"]'); + }); + + await ouvrirSection('tables'); + await famille('créer une table', async () => { + await cliquer('[data-section="tables"] [data-commande="creerTableRonde"]'); + await refusPres(() => dans('[data-commande="creerTableRonde"]').parentElement, code, remede, 'créer une table'); + }); + // Une frappe réelle dans un champ, amené d'abord au milieu de la zone + // qui défile : sous l'en-tête de l'événement, un champ défilé hors de + // vue reste dans la fenêtre, mais le pointeur atteint l'en-tête. + const frapper = async (entree) => { + entree.scrollIntoView({ block: 'center' }); + await cliquerPointeur(entree); + expect(document.activeElement).toBe(entree); + await touche('7'); + flushSync(); + await images(2); + }; + await famille('surcharger', async () => { + await frapper(dans(`[data-section="tables"] [data-table="${t2}"] input[data-champ="sieges-${t2}"]`)); + await refusPres(() => dans(`[data-section="tables"] [data-table="${t2}"]`), code, remede, 'surcharger'); + }); + await famille('changer le défaut', async () => { + await frapper(dans('[data-section="tables"] input[data-champ="siegesParDefaut"]')); + await refusPres(() => dans('[data-defaut]'), code, remede, 'changer le défaut'); + }); + await famille('changer les tours', async () => { + await frapper(dans('[data-section="tables"] input[data-champ="tours"]')); + await refusPres(() => dans('[data-tours]'), code, remede, 'changer les tours'); + }); + await famille('changer l’état', async () => { + await cliquer('[data-etat="brouillon"] [data-commande="etat"]'); + await refusPres(() => dans('[data-etat="brouillon"]'), code, remede, 'changer l’état'); + }); + expect(familles).toHaveLength(14); + } + + // Le tri, le filtre et les listes de travail restent vivants. + async function regarderReste() { + await ouvrirSection('participants'); + const section = () => dans('[data-section="participants"]'); + const idsAffiches = () => [...section().querySelectorAll('[role="grid"] [data-participant]')].map((l) => Number(l.dataset.participant)); + const tri = () => section().querySelector('[data-commande="trier"][data-cible="prenom"]').closest('th').getAttribute('aria-sort'); + await cliquer('[data-section="participants"] [data-commande="trier"][data-cible="prenom"]'); + expect(tri()).toBe('ascending'); + const n = idsAffiches().length; + await taper(section().querySelector('[data-champ="filtre"]'), AJOUTEE.nom); + await images(2); + expect(idsAffiches()).toEqual([memoire.ajoutee]); + await taper(section().querySelector('[data-champ="filtre"]'), ''); + await images(2); + expect(idsAffiches()).toHaveLength(n); + await ouvrirPlan(); + const id = await choisir('[data-liste-travail="pressentis"]'); + expect(dans(`[data-liste-travail="pressentis"] [data-participant="${id}"]`).getAttribute('aria-pressed')).toBe('true'); + await oublierChoix(); + } + + // Les écritures relevées depuis le rang donné, hors du cadrage qui s'écrit + // différé dans reglages_locaux.json (contrainte 4). + const ecrituresDepuis = (rang) => m.trace.slice(rang).filter(([, , chemin]) => chemin !== '' && !chemin.endsWith('reglages_locaux.json')); + + test('en lecture : quatorze familles refusées avec « Modifier », la charge et les fichiers inchangés ; le tri, le filtre, les listes de travail vivants', async () => { + await cliquer('[data-place-fixe] [data-commande="lecture"]'); + await attendre('la lecture', () => etat().mode === 'lecture'); + const empreinte = serialiserCharge(charge()); + const avant = fichiers(); + const rang = m.trace.length; + await toutesFamilles('MODE_LECTURE', 'modifier'); + expect(serialiserCharge(charge())).toBe(empreinte); + await rienEcrit(avant, 'lecture'); + expect(ecrituresDepuis(rang)).toEqual([]); + await regarderReste(); + expect(serialiserCharge(charge())).toBe(empreinte); + await rienEcrit(avant, 'lecture, regarder'); + }); + + test('sur un plan bloqué : quatorze familles refusées avec « Débloquer », la charge et les fichiers inchangés ; le tri, le filtre, les listes de travail vivants', async () => { + await cliquer('[data-place-fixe] [data-commande="modifier"]'); + await attendre('l’écriture', () => etat().mode === 'ecriture'); + const r = revision(); + await cliquer('[data-etat="bloque"] [data-commande="etat"]'); + await uneEntree(r, 'bloquer'); + expect(charge().evenement.etat).toBe('bloque'); + expect(dans('[data-mode]').dataset.marque).toBe('cadreBloque'); + const empreinte = serialiserCharge(charge()); + const avant = fichiers(); + const rang = m.trace.length; + await toutesFamilles('LECTURE_SEULE', 'debloquer'); + expect(serialiserCharge(charge())).toBe(empreinte); + await rienEcrit(avant, 'bloqué'); + expect(ecrituresDepuis(rang)).toEqual([]); + await regarderReste(); + expect(serialiserCharge(charge())).toBe(empreinte); + await rienEcrit(avant, 'bloqué, regarder'); + }); +});