// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le clavier, l'infobulle, les panneaux, le menu par personne et les listes // de travail du plan, dans un vrai moteur de rendu (§ 2.1 étape 6, § 4, // § 4.1, § 4.2, § 4.4, § 5.8, § 5.9, § 7.1, § 7.3, § 13.5) : le plan est un // seul arrêt de tabulation ; l'infobulle s'ouvre par le survol, le focus et // la pression longue ; cliquer une chaise la sélectionne et ouvre son // panneau ; le choix en attente relie une personne et une place sans // glisser ; le menu fait ce que fait le geste ; un refus se pose sur la // place ; les listes de travail mènent à la place ou à la personne. // // Chaque épreuve monte App par monterApp, sur le système de fichiers simulé // où un fichier d'état d'épreuve est posé puis ouvert, au cadre de // 1 366 × 700, et agit par des entrées réelles (test/navigateur/entrees.js) // ou par le clic d'un bouton HTML ; elle lit le rendu — attributs data-…, // style calculé, boîte, texte — et le disque simulé : « une entrée » est une // entrée du journal relu de révision supérieure à celle de l'état avant le // geste, l'en-tête de l'état relu portant cette révision plus un. Les points // visés se calculent par la géométrie des tables et la vue relue de // l'attribut (§ 19.1). Les noms d'épreuve sont inventés. // // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import { get, writable } from 'svelte/store'; import { appliquerCommande } from '../../application/commandes.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 { constante } from '../../geometrie/constantes.js'; import { surLeSol } from '../../geometrie/designation.js'; import { geometrieTable } from '../../geometrie/tables.js'; import { dessinDepuisEcran, ecranDepuisDessin, transformDe, vueDepuisTransform } from '../../geometrie/vue.js'; import { serialiser, serialiserCharge } from '../../stockage/canonique.js'; import { analyser, creerCharge } from '../../stockage/document.js'; import { lireJournal } from '../../stockage/journal.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { VERSION } from '../../version.genere.js'; import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?raw'; import TEXTE_CONFLIT from '../../../test/fixtures/plan/conflit_table_un.gtt.json?raw'; import { fixerCadre, glisser, pointer, touche, toucher } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { TABLES_INTERFACE } from '../libelles.js'; import ListesTravail from './ListesTravail.svelte'; import { detailConflits } from './modele.js'; import { appartenanceDeCharge, cadreDepuisRect, englobantDuTour, nomDeCharge } from './vue_plan.js'; const BASE = 'soiree'; const DUREE_PRESSION = constante('DUREE_PRESSION_LONGUE_MS'); // --- Les configurations d'épreuve --------------------------------------------- const NOMS = [ ['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Grisaille', null], ['Tourmaline', 'Basile'], ['Arrosoir', 'Capucine'], ['Cerfeuil', 'Aurèle'], ['Bruyère', 'Mélisande'], ['Galet', 'Firmin'], ['Mistral', 'Clélia'], ['Houblon', 'Anatole'], ['Sarcelle', 'Solveig'], ['Ravenelle', 'Octave'], ['Pimprenelle', 'Albane'], ['Coquelicot', 'Gaspard'], ['Lavandière', 'Ysaline'], ['Châtaigne', 'Bérénice'], ['Murier', 'Lazare'], ]; const IRIS = 1; const THEO = 2; const ONDINE = 3; // La charge de huit_tables_retenu.gtt.json : huit rondes de trois, en deux // rangées de quatre, vingt personnes, trois tours, la proposition 1 // retenue. Au tour 1, la table 2 assied 4, 5 et 6 ; la table 8, la seule // personne 20 : ses sièges 2 et 3 sont libres. const huitRetenue = () => analyser(TEXTE_HUIT).charge; // La même, avant la retenue : la proposition 1 seule, le plan proposé. function huitAvantRetenue() { const charge = huitRetenue(); charge.retenu = null; charge.evenement.etat = 'propose'; return charge; } const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge; /** * Une charge d'épreuve : des rondes posées sur une ligne, aux places données * — null suit le défaut —, les personnes de NOMS, et, quand tours est donné, * une proposition à sièges non attribués de ces listes, retenue si retenir. */ function chargeDe({ id, defaut = 8, places, ecart = 300, personnes, nombreTours = 3, tours = null, retenir = true, attribuer = false }) { const charge = creerCharge({ id, nom: 'Club des Merles', siegesParDefaut: defaut, tours: nombreTours }); charge.reglages.attribuerSieges = attribuer; charge.participants = NOMS.slice(0, personnes).map(([nom, prenom], rang) => ({ id: rang + 1, nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, })); charge.tables = places.map((sieges, rang) => ({ id: rang + 1, numero: rang + 1, sieges, forme: 'ronde', position: { x: ecart * rang, y: 0 } })); charge.prochainsIds = { participant: personnes + 1, table: places.length + 1, proposition: 1 }; if (tours === null) return charge; charge.propositions = [ { id: 1, graine: 1, arret: 40, historique: 1000, produitVersion: 'epreuve', siegesAttribues: false, tables: charge.tables.map((table) => table.id), capacites: places.map((sieges) => sieges ?? defaut), tours: nombreTours, participants: charge.participants.map((personne) => personne.id), placement: tours.map((listes) => ({ sieges: listes, reserve: [] })), }, ]; charge.prochainsIds.proposition = 2; charge.evenement.etat = 'propose'; return retenir ? executer(charge, 'retenirProposition', { id: 1 }) : charge; } // Cinq tables de huit, douze personnes, aucune proposition : chacun attend // dans la réserve, et un geste de personne pose une réservation. const sansProposition = (autres = {}) => chargeDe({ id: 'evt-cinq', places: [null, null, null, null, null], personnes: 12, ...autres }); // --- Le montage --------------------------------------------------------------- let montes = []; let nettoyages = []; let erreursDemontees = []; beforeEach(async () => { await fixerCadre(1366, 700); }); afterEach(async () => { for (const nettoyer of nettoyages.reverse()) nettoyer(); nettoyages = []; const erreurs = [...erreursDemontees, ...montes.flatMap((m) => m.erreurs())]; erreursDemontees = []; for (const m of montes.reverse()) m.demonter(); montes = []; window.scrollTo(0, 0); await fixerCadre(1366, 700); expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); }); function demonter(m) { erreursDemontees.push(...m.erreurs()); m.demonter(); montes = montes.filter((autre) => autre !== m); } // Attend que l'assertion passe, le rendu de Svelte à jour, une image après // l'autre. async function attendre(assertion) { for (let essai = 0; ; essai += 1) { flushSync(); try { return assertion(); } catch (erreur) { if (essai >= 200) throw erreur; await new Promise((resoudre) => requestAnimationFrame(resoudre)); } } } const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre)); async function images(n = 3) { for (let i = 0; i < n; i += 1) await uneImage(); flushSync(); } // La charge posée en fichier d'état, ouverte — en écriture sauf mode // 'lecture' —, la section du plan ouverte et sa vue posée. async function ouvrir(charge, { mode = 'ecriture' } = {}) { const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] }); montes.push(m); await m.seance.ouvrir(BASE); if (mode === 'ecriture') await m.seance.passerEnEcriture(); m.ui.allerA('plan'); flushSync(); await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull()); await attendre(() => expect(tous(m, '[data-table]').length).toBe(charge.tables.length)); return m; } // Une démonstration livrée chargée, sur la section du plan, la vue posée. async function demonstration(cle) { const m = await monterApp(); montes.push(m); await m.facade.chargerDemonstration(cle); flushSync(); m.ui.allerA('plan'); flushSync(); await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull()); return m; } // Pose la vue par le cadrage de la séance, puis remonte le plan, qui la // restitue. async function avecVue(m, vue) { m.seance.demanderCadrage(vue); m.ui.allerA('tables'); flushSync(); m.ui.allerA('plan'); flushSync(); await attendre(() => expect(groupe(m).getAttribute('transform')).toBe(transformDe(vue))); await images(2); } // La vue qui pose le centre de l'englobant du tour au centre du cadre, à k. function vueCentree(m, k) { const cadre = cadreDe(m); const englobant = englobantDuTour(etatMontre(m)); return { k, tx: cadre.largeur / 2 - (k * (englobant.minX + englobant.maxX)) / 2, ty: cadre.hauteur / 2 - (k * (englobant.minY + englobant.maxY)) / 2, }; } // --- Les lectures du rendu et du disque --------------------------------------- const dans = (m, selecteur) => m.racine.querySelector(selecteur); const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)]; const racinePlan = (m) => dans(m, '[data-plan]'); const groupe = (m) => dans(m, '[data-vue]'); const tableEl = (m, id) => dans(m, `[data-table="${id}"]`); const chaiseEl = (m, table, siege) => dans(m, `[data-siege="${table}:${siege}"]`); const panneau = (m, genre) => dans(m, `[data-panneau="${genre}"]`); const cadreDe = (m) => cadreDepuisRect(racinePlan(m).getBoundingClientRect()); const vueLue = (m) => vueDepuisTransform(groupe(m).getAttribute('transform')); const charge = (m) => m.seance.etat().charge; const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const actif = (m) => racinePlan(m).getAttribute('aria-activedescendant'); // Les commandes d'un conteneur : la valeur de data-commande de chaque // bouton, dans l'ordre du document. const commandesDe = (element) => [...element.querySelectorAll('[data-commande]')].map((bouton) => bouton.dataset.commande); // L'état des places du tour montré, tel que le plan le calcule. const etatMontre = (m) => etatPlaces(charge(m), { tour: m.ui.etat().tour, proposition: m.ui.etat().proposition }); function geometrieDe(m, id) { const table = etatMontre(m).tables.find((candidate) => candidate.id === id); return geometrieTable(table, table.capacite); } const ecranDe = (m, { x, y }) => ecranDepuisDessin(vueLue(m), cadreDe(m), x, y); const pointTable = (m, id) => ecranDe(m, geometrieDe(m, id).centre); const pointChaise = (m, id, siege) => ecranDe(m, geometrieDe(m, id).chaises[siege - 1]); function pointSol(m) { const cadre = cadreDe(m); const point = { x: cadre.gauche + 6, y: cadre.haut + 6 }; const geometries = etatMontre(m).tables.map((table) => geometrieTable(table, table.capacite)); expect(surLeSol(dessinDepuisEcran(vueLue(m), cadre, point.x, point.y), geometries)).toBe(true); return point; } // Un appui relâché sur place, sous le seuil : un clic de pointeur réel. async function cliquerPoint({ x, y }) { await pointer('presser', x, y); await pointer('relacher', x, y); await images(2); } // Un clic réel au centre d'une chaise. Quand elle est hors du cadre du // plan — les annonces du cadre de mode, avertissements compris, prennent // leur hauteur à la surface —, la vue se déplace d'abord, par le cadrage de // la séance, pour poser la chaise au centre du cadre, au même k. async function cliquerChaise(m, table, siege) { const atteinte = () => { const { x, y } = pointChaise(m, table, siege); return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false; }; if (!atteinte()) { const vue = vueLue(m); const cadre = cadreDe(m); const { x, y } = pointChaise(m, table, siege); await avecVue(m, { k: vue.k, tx: vue.tx + cadre.gauche + cadre.largeur / 2 - x, ty: vue.ty + cadre.haut + cadre.hauteur / 2 - y, }); await attendre(() => expect(atteinte()).toBe(true)); } await cliquerPoint(pointChaise(m, table, siege)); } // Un clic de pointeur réel au centre d'un élément HTML, amené dans la // fenêtre. async function presserBouton(element) { element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); const boite = element.getBoundingClientRect(); await cliquerPoint({ x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 }); } // Presse un bouton par le vrai pointeur, et dit s'il a reçu l'appui : un // bouton que le pointeur traverse laisse l'appui à ce qui est dessous. async function presseRecue(element) { let recu = false; const noter = () => { recu = true; }; element.addEventListener('pointerdown', noter); try { await presserBouton(element); } finally { element.removeEventListener('pointerdown', noter); } return recu; } // Presse au point depuis et avance jusqu'au point vers sans relâcher : le // fantôme reste montré. async function presserEtBouger(depuis, vers, { pas = 6 } = {}) { await pointer('presser', depuis.x, depuis.y); for (let i = 1; i <= pas; i += 1) { const t = i / pas; await pointer('bouger', depuis.x + (vers.x - depuis.x) * t, depuis.y + (vers.y - depuis.y) * t); await images(1); } flushSync(); } // Clique un bouton par son clic HTML, puis laisse la séance écrire. async function cliquer(element) { expect(element, 'bouton présent').not.toBeNull(); element.click(); flushSync(); await images(2); } function fichiers(m) { return { etat: m.fs.contenu('documents', `${BASE}${SUFFIXES.etat}`), journal: m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`), revision: m.seance.etat().evenement.revision, }; } function entreesApres(m, r) { const texte = m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`); return texte === null ? [] : lireJournal(texte).entrees.filter((entree) => entree.revision > r); } async function uneEntree(m, r) { await attendre(() => expect(m.seance.etat().evenement.revision).toBe(r + 1)); const entrees = entreesApres(m, r); expect(entrees).toHaveLength(1); expect(analyser(m.fs.contenu('documents', `${BASE}${SUFFIXES.etat}`)).entete.revision).toBe(r + 1); return entrees[0].libelle; } async function rienEcrit(m, avant) { await images(4); expect(fichiers(m)).toEqual(avant); } const decrit = (element) => { const id = element.getAttribute('aria-describedby'); return id === null ? null : document.getElementById(id); }; function recoupe(a, b) { const p = a.getBoundingClientRect(); const q = b.getBoundingClientRect(); return p.left < q.right && q.left < p.right && p.top < q.bottom && q.top < p.bottom; } // La boîte d'un élément est dans la fenêtre et dans le cadre du plan. function dansLaFenetre(m, element) { const boite = element.getBoundingClientRect(); const cadre = racinePlan(m).getBoundingClientRect(); return ( boite.width > 0 && boite.left >= Math.max(0, cadre.left) && boite.top >= Math.max(0, cadre.top) && boite.right <= Math.min(innerWidth, cadre.right) && boite.bottom <= Math.min(innerHeight, cadre.bottom) ); } // Une marque se peint : style calculé ni display: none ni visibility // hidden, un trait ou un remplissage, et une boîte non nulle. function peinte(element) { if (element === null) return false; const style = getComputedStyle(element); const boite = element.getBoundingClientRect(); const peint = (style.stroke !== 'none' && parseFloat(style.strokeWidth) > 0) || style.fill !== 'none'; return style.display !== 'none' && style.visibility !== 'hidden' && peint && boite.width > 0 && boite.height > 0; } // Relève les commandes que l'interface confie à la séance. function releverCommandes(m) { const seance = m.seance; const originale = seance.executer; const appels = []; seance.executer = (nom, args) => { appels.push({ nom, args }); return originale(nom, args); }; nettoyages.push(() => { seance.executer = originale; }); return appels; } // L'infobulle de la page : la seule, visible ou non. function infobulles() { return [...document.querySelectorAll('[role="tooltip"]')]; } const infobulleVisible = (element) => element !== undefined && !element.hidden && getComputedStyle(element).display !== 'none' && element.getBoundingClientRect().width > 0; // Choisit une personne de la réserve visible. async function choisirDansLaReserve(m, participant) { const bouton = dans(m, `[data-reserve] [data-participant="${participant}"]`); expect(bouton, `personne ${participant} dans la réserve`).not.toBeNull(); await cliquer(bouton); await attendre(() => expect(m.ui.etat().choix?.participant).toBe(participant)); } // Le bouton d'une action du panneau d'une place. const actionPlace = (m, action) => panneau(m, 'place')?.querySelector(`[data-commande="${action}"]`) ?? null; // Entre dans le plan au clavier : Tab depuis le dernier contrôle de la barre // de vue, l'élément qui le précède. async function entrerAuClavier(m) { dans(m, '[data-commande="pleinEcran"]').focus(); await touche('Tab'); flushSync(); expect(document.activeElement).toBe(racinePlan(m)); } // Mène l'élément actif du plan à la table voulue, flèche après flèche, puis // descend dans ses sièges jusqu'au siège voulu et l'active par l'entrée : // la place est sélectionnée et le focus passe à son panneau. async function activerSiegeAuClavier(m, table, siege) { await entrerAuClavier(m); // Un siège actif remonte d'abord à sa table. if (actif(m).startsWith('siege-')) await touche('Escape'); flushSync(); const cible = geometrieDe(m, table).centre; for (let pas = 0; actif(m) !== `table-${table}`; pas += 1) { expect(pas, 'flèches pour atteindre la table').toBeLessThan(12); const courante = Number(actif(m).replace(/^table-/, '')); const depuis = geometrieDe(m, courante).centre; const dx = cible.x - depuis.x; const dy = cible.y - depuis.y; await touche(Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'ArrowRight' : 'ArrowLeft') : dy > 0 ? 'ArrowDown' : 'ArrowUp'); flushSync(); } await touche('Enter'); flushSync(); for (let s = 1; s < siege; s += 1) await touche('ArrowRight'); flushSync(); expect(actif(m)).toBe(`siege-${table}-${siege}`); await touche('Enter'); await attendre(() => expect(m.ui.etat().selection).toEqual({ table, siege })); await attendre(() => expect(panneau(m, 'place').contains(document.activeElement)).toBe(true)); } // Le refus posé sur une place : la cible de son aria-describedby, hors du // , qui recoupe la table, porte les codes dans l'ordre, chacun // avec une commande ; aucune fenêtre ; rien d'écrit. async function refusSurLaPlace(m, element, table, codes, avant) { await attendre(() => expect(decrit(element)).not.toBeNull()); const message = decrit(element); expect(racinePlan(m).contains(message)).toBe(false); expect(recoupe(message, tableEl(m, table))).toBe(true); const refus = [...message.querySelectorAll('[data-refus]')]; expect(refus.map((r) => r.dataset.refus)).toEqual(codes); for (const r of refus) { expect(r.querySelector('[data-commande]'), r.dataset.refus).not.toBeNull(); expect(texteDe(r).length).toBeGreaterThan(0); } expect(document.querySelector('[role="dialog"], dialog[open]')).toBeNull(); await rienEcrit(m, avant); return message; } // --- Un seul arrêt (§ 7.3, § 13.5) -------------------------------------------- describe('le plan est un seul arrêt de tabulation (§ 7.3, § 13.5)', () => { test('grande démonstration sans exception, 264 sièges, k = 0,5 : Tab entre et sort, les flèches changent de table, l’entrée descend, l’échappement remonte ; à k = 0,39, l’entrée ouvre le panneau de la table et Échap rend le focus', async () => { const m = await demonstration('grande-sans-exception'); await avecVue(m, vueCentree(m, 0.5)); expect(vueLue(m).k).toBe(0.5); expect(tous(m, '[data-siege]').length).toBe(264); const racine = racinePlan(m); const arrets = [racine, ...racine.querySelectorAll('*')].filter((element) => element.tabIndex >= 0); expect(arrets).toEqual([racine]); dans(m, '[data-commande="pleinEcran"]').focus(); await touche('Tab'); expect(document.activeElement).toBe(racine); await touche('Tab'); expect(document.activeElement).not.toBe(racine); expect(racine.contains(document.activeElement)).toBe(false); racine.focus(); flushSync(); const tables = charge(m).tables; const premiere = tables[0]; expect(actif(m)).toBe(`table-${premiere.id}`); // 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(); expect(actif(m)).toBe(`table-${voisine.id}`); expect(document.getElementById(actif(m))).toBe(tableEl(m, voisine.id)); await touche('Enter'); flushSync(); expect(actif(m)).toBe(`siege-${voisine.id}-1`); expect(document.getElementById(actif(m))).toBe(chaiseEl(m, voisine.id, 1)); await touche('ArrowRight'); flushSync(); expect(actif(m)).toBe(`siege-${voisine.id}-2`); await touche('Escape'); flushSync(); expect(actif(m)).toBe(`table-${voisine.id}`); await avecVue(m, vueCentree(m, 0.39)); expect(tous(m, '[data-siege]').length).toBe(0); await entrerAuClavier(m); const active = Number(actif(m).replace(/^table-/, '')); await touche('Enter'); await attendre(() => expect(panneau(m, 'table')).not.toBeNull()); expect(panneau(m, 'table').dataset.tablePanneau).toBe(String(active)); await attendre(() => expect(panneau(m, 'table').contains(document.activeElement)).toBe(true)); expect(document.activeElement.hasAttribute('data-choix-siege')).toBe(true); await touche('Escape'); await attendre(() => expect(panneau(m, 'table')).toBeNull()); expect(document.activeElement).toBe(racinePlan(m)); // Le siège choisi dans le panneau de la table : le panneau de la place // s'ouvre et reçoit le focus, qui ne tombe pas sur le corps du document. await touche('Enter'); await attendre(() => expect(panneau(m, 'table')?.contains(document.activeElement)).toBe(true)); await touche('Enter'); await attendre(() => expect(panneau(m, 'place')).not.toBeNull()); expect(m.ui.etat().selection).toEqual({ table: active, siege: 1 }); await attendre(() => expect(panneau(m, 'place').contains(document.activeElement)).toBe(true)); await touche('Escape'); await attendre(() => expect(panneau(m, 'place')).toBeNull()); expect(document.activeElement).toBe(racinePlan(m)); }); test('noms accessibles : la racine porte tabindex 0, un role et un aria-label ; chaque table, numéro, places et conflits, tirés des libellés', async () => { const m = await ouvrir(analyser(TEXTE_CONFLIT).charge); m.ui.montrerTour(2); flushSync(); await images(2); const racine = racinePlan(m); expect(racine.getAttribute('tabindex')).toBe('0'); expect(racine.getAttribute('role')).toBeTruthy(); expect(racine.getAttribute('aria-label')).toBe('Plan de salle'); const etat = etatMontre(m); expect(etat.tables.length).toBe(4); for (const table of etat.tables) { const conflits = table.conflits.nombre === 0 ? 'aucun conflit' : `${table.conflits.nombre} conflits`; expect(tableEl(m, table.id).getAttribute('aria-label')).toBe(`Table ${table.numero}, 2 places, ${conflits}`); } const chaise = chaiseEl(m, 1, 1); expect(chaise.id).toBe('siege-1-1'); expect(chaise.getAttribute('aria-label')).toBe('1. Iris Ombrelle'); expect(tableEl(m, 1).id).toBe('table-1'); }); }); // --- L'appartenance nommée, la légende des conflits (§ 7.5) ------------------- describe('l’infobulle nomme l’appartenance d’une paire réunie ; la légende des conflits (§ 7.5)', () => { // Deux tables de trois, six personnes, un tour : la table 1 réunit Iris // et Théo, du Club des Merles ; la table 2, Basile et Capucine, d'une // appartenance écrite avec une minuscule et un blanc final, telle que la // liste la porte. function deuxAppartenances(reglages = {}) { const charge = chargeDe({ id: 'evt-appartenances', defaut: 3, places: [null, null], personnes: 6, nombreTours: 1, tours: [[[1, 2, 4], [3, 5, 6]]] }); const appartenances = new Map([[1, 'Club des Merles'], [2, 'Club des Merles'], [5, 'cercle des Hérons '], [6, 'cercle des Hérons ']]); for (const personne of charge.participants) personne.appartenance = appartenances.get(personne.id) ?? null; Object.assign(charge.reglages, reglages); return charge; } test('le survol d’une table en conflit d’appartenance : « Même appartenance (…) : … et … », l’appartenance dans son propre nœud', async () => { const m = await ouvrir(deuxAppartenances()); expect(etatMontre(m).tables.map(({ conflits }) => conflits.nombre)).toEqual([1, 1]); const infobulle = () => document.querySelector('[role="tooltip"]'); // Le pointeur part du sol : un survol commence par un mouvement. await pointer('bouger', pointSol(m).x, pointSol(m).y); for (const [table, appartenance, noms] of [ [1, 'Club des Merles', ['Iris Ombrelle', 'Théo Pervenche']], [2, 'cercle des Hérons ', ['Basile Tourmaline', 'Capucine Arrosoir']], ]) { const point = pointTable(m, table); await pointer('bouger', point.x, point.y); await attendre(() => expect(infobulleVisible(infobulle())).toBe(true)); const lignes = [...infobulle().querySelectorAll('p')]; expect(lignes.map((p) => p.textContent)).toEqual([ t('plan.infobulle.entete', { numero: table, nombre: 1 }), `${t('plan.infobulle.separerAppartenances')}${appartenance}${t('plan.infobulle.finAppartenance')}${noms.join(t('plan.infobulle.et'))}`, ]); expect(lignes[1].textContent).toMatch(/^Même appartenance \(/); const noeuds = [...lignes[1].children].map((e) => e.textContent); expect(noeuds).toContain(appartenance); for (const nom of noms) expect(noeuds).toContain(nom); await pointer('bouger', pointSol(m).x, pointSol(m).y); await attendre(() => expect(infobulleVisible(infobulle())).toBe(false)); } }); for (const mode of ['ecriture', 'lecture']) { test(`mode ${mode} : le bouton « Légende » de la barre de vue ouvre la légende des seules règles actives, et la referme ; rien ne s’écrit`, async () => { const m = await ouvrir(deuxAppartenances({ nouvelleTable: false }), { mode }); const revision = m.seance.etat().evenement.revision; const bouton = dans(m, '[data-commande="legende"]'); expect(bouton).not.toBeNull(); expect(bouton.closest('[role="toolbar"]')).not.toBeNull(); expect(bouton.textContent).toBe(t('plan.legende.ouvrir')); // Un dévoilement : aria-expanded, et non aria-pressed ; aria-controls ne // désigne la légende que lorsqu'elle est dans le DOM. expect(bouton.hasAttribute('aria-pressed')).toBe(false); expect(bouton.getAttribute('aria-expanded')).toBe('false'); expect(bouton.hasAttribute('aria-controls')).toBe(false); expect(dans(m, '[data-legende]')).toBeNull(); await cliquer(bouton); await attendre(() => expect(dans(m, '[data-legende]')).not.toBeNull()); expect(bouton.getAttribute('aria-expanded')).toBe('true'); expect(bouton.getAttribute('aria-controls')).toBe(dans(m, '[data-legende]').id); expect(tous(m, '[data-legende] [data-legende-regle]').map((e) => e.dataset.legendeRegle)).toEqual(['separerAppartenances', 'nouveauxVoisins']); expect(dans(m, '[data-legende] [data-marque="trameTable"]')).toBeNull(); expect(infobulleVisible(dans(m, '[data-legende]'))).toBe(true); await cliquer(bouton); await attendre(() => expect(dans(m, '[data-legende]')).toBeNull()); expect(bouton.getAttribute('aria-expanded')).toBe('false'); expect(bouton.hasAttribute('aria-controls')).toBe(false); expect(m.seance.etat().evenement.revision).toBe(revision); }); } }); // --- L'infobulle (§ 7.1, § 7.3, § 19.4) --------------------------------------- describe('l’infobulle : un composant, trois déclencheurs (§ 7.1, § 7.3)', () => { // Le détail que rend le moteur pour la table 1 au tour 2, nommé par // detailConflits, en mots : chaque ligne de l'infobulle, telle que ses // nœuds la composent. function detailAttendu(m) { const table = etatMontre(m).tables.find(({ id }) => id === 1); expect(table.conflits.nombre).toBeGreaterThan(0); const detail = detailConflits(table, nomDeCharge(charge(m)), appartenanceDeCharge(charge(m))); return [ t('plan.infobulle.entete', { numero: detail.numero, nombre: detail.nombre }), ...detail.lignes.map(({ cle, appartenance, groupes }) => [ t(cle), ...(appartenance === null ? [] : [appartenance, t('plan.infobulle.finAppartenance')]), groupes.map((groupe) => groupe.join(t('plan.infobulle.et'))).join(t('plan.infobulle.separateur')), ] .join('') .replace(/\s+/g, ' ') .trim(), ), ]; } const lignes = (element) => [...element.querySelectorAll('p')].map(texteDe); test('survol, focus par les flèches, pression longue : le même [role=tooltip], seul de son espèce, au détail du moteur ; relâchée avant le terme, rien ; aucun ', async () => { const m = await ouvrir(analyser(TEXTE_CONFLIT).charge); m.ui.montrerTour(2); flushSync(); await images(2); const attendu = detailAttendu(m); expect(attendu.length).toBeGreaterThan(1); expect(infobulles()).toHaveLength(1); const infobulle = infobulles()[0]; expect(infobulleVisible(infobulle)).toBe(false); // (a) le survol, sans bouton enfoncé. const centre = pointTable(m, 1); await pointer('bouger', centre.x, centre.y); await attendre(() => expect(infobulleVisible(infobulles()[0])).toBe(true)); expect(infobulles()).toEqual([infobulle]); expect(lignes(infobulle)).toEqual(attendu); const sol = pointSol(m); await pointer('bouger', sol.x, sol.y); await attendre(() => expect(infobulleVisible(infobulle)).toBe(false)); const saine = pointTable(m, 2); await pointer('bouger', saine.x, saine.y); await images(2); expect(infobulleVisible(infobulle)).toBe(false); await pointer('bouger', sol.x, sol.y); // (b) le focus, par les flèches. await entrerAuClavier(m); expect(actif(m)).toBe('table-1'); await touche('ArrowRight'); flushSync(); expect(actif(m)).toBe('table-2'); await attendre(() => expect(infobulleVisible(infobulle)).toBe(false)); await touche('ArrowLeft'); flushSync(); expect(actif(m)).toBe('table-1'); await attendre(() => expect(infobulleVisible(infobulle)).toBe(true)); expect(infobulles()).toEqual([infobulle]); expect(lignes(infobulle)).toEqual(attendu); racinePlan(m).blur(); await attendre(() => expect(infobulleVisible(infobulle)).toBe(false)); // (c) la pression longue : relâchée avant le terme, rien ; tenue, l'infobulle. await toucher('presser', [centre]); m.horloge.avancer(DUREE_PRESSION - 1); await images(2); expect(infobulleVisible(infobulle)).toBe(false); await toucher('relacher', []); m.horloge.avancer(DUREE_PRESSION); await images(2); expect(infobulleVisible(infobulle)).toBe(false); // Glissée au-delà du seuil, la pression devient un glissement : rien. document.activeElement?.blur(); await toucher('presser', [centre]); await toucher('bouger', [{ x: centre.x + 3 * constante('SEUIL_GLISSER_PX'), y: centre.y }]); await images(2); m.horloge.avancer(DUREE_PRESSION); await images(2); expect(infobulleVisible(infobulle)).toBe(false); await toucher('annuler', []); await images(2); document.activeElement?.blur(); await toucher('presser', [centre]); m.horloge.avancer(DUREE_PRESSION); await attendre(() => expect(infobulleVisible(infobulle)).toBe(true)); expect(infobulles()).toEqual([infobulle]); expect(lignes(infobulle)).toEqual(attendu); await toucher('relacher', []); await images(2); expect(racinePlan(m).querySelector('title')).toBeNull(); }); }); // --- Cliquer une chaise (§ 7.1, § 8.4) ---------------------------------------- describe('cliquer une chaise sélectionne la place (§ 7.1, § 8.4)', () => { test('une chaise libre : la marque selection se peint ; le panneau offre exactement Assigner, Réserver, Donner un titre, Désigner ; rien d’écrit ; une chaise réservée et titrée : Retirer la réservation, Retirer le titre', async () => { const base = executer(huitRetenue(), 'designer', { participant: 9, table: 8, siege: 3, libelle: 'animateur' }); const m = await ouvrir(base); const avant = fichiers(m); await cliquerChaise(m, 8, 2); expect(m.ui.etat().selection).toEqual({ table: 8, siege: 2 }); const marque = chaiseEl(m, 8, 2).querySelector('[data-marque="selection"]'); expect(peinte(marque)).toBe(true); expect(tous(m, '[data-marque="selection"]')).toEqual([marque]); await attendre(() => expect(panneau(m, 'place')).not.toBeNull()); expect(commandesDe(panneau(m, 'place'))).toEqual(['assigner', 'reserver', 'donnerTitre', 'designer']); await rienEcrit(m, avant); await cliquerChaise(m, 8, 3); expect(m.ui.etat().selection).toEqual({ table: 8, siege: 3 }); await attendre(() => expect(commandesDe(panneau(m, 'place'))).toContain('retirerReservation')); const offertes = commandesDe(panneau(m, 'place')); expect(offertes).toContain('retirerTitre'); expect(offertes).not.toContain('donnerTitre'); expect(offertes).not.toContain('designer'); await rienEcrit(m, avant); }); }); // --- Assigner (§ 7.1, § 5.8) ---------------------------------------------------- describe('assigner, par le choix en attente (§ 7.1, § 5.8)', () => { test('avant toute proposition : Théo choisi dans la réserve, puis une chaise libre de la table 4, « Assigner » → reserver, portée « tour », au tour affiché ; l’ordre inverse rend la même charge', async () => { const premier = await ouvrir(sansProposition()); const commandes = releverCommandes(premier); let r = fichiers(premier).revision; await choisirDansLaReserve(premier, THEO); expect(dans(premier, `[data-reserve] [data-participant="${THEO}"]`).getAttribute('aria-pressed')).toBe('true'); await cliquerChaise(premier, 4, 3); await cliquer(actionPlace(premier, 'assigner')); await uneEntree(premier, r); expect(commandes).toEqual([{ nom: 'reserver', args: { participant: THEO, table: 4, siege: 3, portee: 'tour', tour: 1 } }]); expect(premier.ui.etat().choix).toBeNull(); const chargePremier = serialiserCharge(charge(premier)); demonter(premier); const second = await ouvrir(sansProposition()); r = fichiers(second).revision; await cliquerChaise(second, 4, 3); await choisirDansLaReserve(second, THEO); expect(second.ui.etat().selection).toEqual({ table: 4, siege: 3 }); await cliquer(actionPlace(second, 'assigner')); await uneEntree(second, r); expect(serialiserCharge(charge(second))).toBe(chargePremier); }); test('sur un retenu : une personne de la réserve du tour 2 → deplacerDansRetenu, une entrée ; une proposition montrée → PROPOSITION_MONTREE sur la place, aucune entrée', async () => { const base = executer(huitRetenue(), 'retirerDuTour', { tour: 2, participant: ONDINE }); const m = await ouvrir(base); m.ui.montrerTour(2); flushSync(); await images(2); const commandes = releverCommandes(m); const r = fichiers(m).revision; await choisirDansLaReserve(m, ONDINE); await cliquerChaise(m, 8, 1); await cliquer(actionPlace(m, 'assigner')); await uneEntree(m, r); expect(commandes.at(-1)).toEqual({ nom: 'deplacerDansRetenu', args: { tour: 2, participant: ONDINE, table: 8, siege: 1 } }); m.ui.montrerProposition(1); flushSync(); await images(2); const avant = fichiers(m); const assise = etatMontre(m).tables[0].chaises[0].occupant; await cliquerChaise(m, 1, 1); await cliquer(dans(m, `[data-menu="${assise}"] [data-commande="deplacer"]`)); await cliquerChaise(m, 8, 2); expect(m.ui.etat().choix?.participant).toBe(assise); await cliquer(actionPlace(m, 'assigner')); await refusSurLaPlace(m, chaiseEl(m, 8, 2), 8, ['PROPOSITION_MONTREE'], avant); }); }); describe('Échap oublie le choix en attente (§ 7.1, § 7.3)', () => { test('choisie dans la réserve, puis dans les pressentis : Échap sur son bouton l’oublie ; sur le plan, Échap d’un siège remonte à sa table et garde le choix, Échap sur la table l’oublie', async () => { const base = sansProposition(); base.participants[ONDINE - 1].titrePressenti = 'hôte'; const m = await ouvrir(base); const bouton = dans(m, `[data-reserve] [data-participant="${THEO}"]`); await choisirDansLaReserve(m, THEO); bouton.focus(); await touche('Escape'); await attendre(() => expect(m.ui.etat().choix).toBeNull()); expect(bouton.getAttribute('aria-pressed')).toBe('false'); const pressenti = dans(m, `[data-liste-travail="pressentis"] [data-participant="${ONDINE}"]`); await cliquer(pressenti); expect(m.ui.etat().choix?.participant).toBe(ONDINE); pressenti.focus(); await touche('Escape'); await attendre(() => expect(m.ui.etat().choix).toBeNull()); await choisirDansLaReserve(m, THEO); await entrerAuClavier(m); expect(actif(m)).toMatch(/^table-/); await touche('Escape'); await attendre(() => expect(m.ui.etat().choix).toBeNull()); await choisirDansLaReserve(m, THEO); await entrerAuClavier(m); await touche('Enter'); flushSync(); const siege = actif(m); expect(siege).toMatch(/^siege-/); await touche('Escape'); flushSync(); expect(actif(m)).toBe(`table-${siege.split('-')[1]}`); // Au clavier, Échap est le seul chemin d'un siège vers une autre table : // remonter ne perd pas la personne choisie. expect(m.ui.etat().choix?.participant).toBe(THEO); await touche('Escape'); await attendre(() => expect(m.ui.etat().choix).toBeNull()); }); test('choisie d’un vrai clic dans la réserve : Échap sur le plan sans élément actif l’oublie', async () => { const m = await ouvrir(sansProposition()); // Le vrai pointeur ne rend pas le focus suivant focus-visible : la // racine du plan, focalisée ensuite, n'a aucun élément actif. await presserBouton(dans(m, `[data-reserve] [data-participant="${THEO}"]`)); await attendre(() => expect(m.ui.etat().choix?.participant).toBe(THEO)); racinePlan(m).focus(); flushSync(); expect(actif(m)).toBeNull(); await touche('Escape'); await attendre(() => expect(m.ui.etat().choix).toBeNull()); }); }); describe('un clic ne déplace pas la vue (§ 7.3, § 8.4)', () => { test('une chaise à 12 px du bord gauche du cadre, cliquée : la place est sélectionnée, le transform du groupe et le cadrage restent', async () => { const m = await ouvrir(sansProposition()); const cadre = cadreDe(m); const chaise = geometrieDe(m, 1).chaises[0]; await avecVue(m, { k: 1, tx: 12 - chaise.x, ty: cadre.hauteur / 2 - chaise.y }); expect(pointChaise(m, 1, 1).x - cadreDe(m).gauche).toBeCloseTo(12, 6); const transform = groupe(m).getAttribute('transform'); const cadrage = m.seance.cadrage(); await cliquerChaise(m, 1, 1); expect(m.ui.etat().selection).toEqual({ table: 1, siege: 1 }); await images(4); expect(groupe(m).getAttribute('transform')).toBe(transform); expect(m.seance.cadrage()).toEqual(cadrage); }); }); // --- Sous le seuil (§ 7.3) ------------------------------------------------------ describe('sous le seuil, la table est la cible et son panneau choisit le siège (§ 7.3)', () => { test('sans exception, k = 0,39 : un clic à la position d’une chaise sélectionne la table, ouvre son panneau, qui liste ses sièges ; un siège choisi ouvre le panneau de la place', async () => { const m = await demonstration('grande-sans-exception'); await avecVue(m, vueCentree(m, 0.39)); expect(tous(m, '[data-siege]').length).toBe(0); // La table la plus proche du centre du cadre. const cadre = cadreDe(m); const centre = { x: cadre.gauche + cadre.largeur / 2, y: cadre.haut + cadre.hauteur / 2 }; const distance = (id) => Math.hypot(pointTable(m, id).x - centre.x, pointTable(m, id).y - centre.y); const id = charge(m).tables.map((table) => table.id).reduce((a, b) => (distance(b) < distance(a) ? b : a)); await cliquerPoint(pointChaise(m, id, 3)); expect(m.ui.etat().selection).toEqual({ table: id }); await attendre(() => expect(panneau(m, 'table')).not.toBeNull()); const sieges = [...panneau(m, 'table').querySelectorAll('[data-choix-siege]')]; expect(sieges.map((bouton) => bouton.dataset.choixSiege)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8']); await cliquer(sieges[2]); expect(m.ui.etat().selection).toEqual({ table: id, siege: 3 }); await attendre(() => expect(panneau(m, 'place')).not.toBeNull()); expect(tableEl(m, id).querySelector('[data-marque="selection"]')).not.toBeNull(); }); }); // --- Le menu fait ce que fait le geste (§ 7.1, § 10.2) -------------------------- describe('le menu fait ce que fait le geste (§ 7.1, § 10.2)', () => { // Joue les gestes dans un montage, le menu dans un autre, sur le même // fichier d'état ; rend les deux charges et les libellés de leurs entrées. async function comparer(base, preparer, actions) { const rendus = []; for (const facon of ['geste', 'menu']) { const m = await ouvrir(base); preparer(m); flushSync(); await images(2); const r = fichiers(m).revision; for (const action of actions) await action[facon](m); await attendre(() => expect(m.seance.etat().evenement.revision).toBe(r + actions.length)); rendus.push({ charge: serialiserCharge(charge(m)), libelles: entreesApres(m, r).map(({ libelle }) => libelle) }); demonter(m); } expect(rendus[1].libelles).toHaveLength(rendus[0].libelles.length); expect(rendus[1]).toEqual(rendus[0]); return rendus[0]; } // Le menu de l'occupant d'une chaise, atteint au clavier, puis une action // pressée ; une place cliquée ; « Assigner » pressé. const parLeMenu = (table, siege, action, vers = null) => async (m) => { const occupant = etatMontre(m).tables.find(({ id }) => id === table).chaises[siege - 1].occupant; const r = fichiers(m).revision; await activerSiegeAuClavier(m, table, siege); const menu = dans(m, `[data-menu="${occupant}"]`); expect(menu, `menu de ${occupant}`).not.toBeNull(); await presserBouton(menu.querySelector(`[data-commande="${action}"]`)); if (vers !== null) { await cliquerChaise(m, ...vers); await presserBouton(actionPlace(m, 'assigner')); } await uneEntree(m, r); }; const parLeGeste = (depuis, vers) => async (m) => { const r = fichiers(m).revision; await glisser(pointChaise(m, ...depuis), vers === 'sol' ? pointSol(m) : pointChaise(m, ...vers)); await uneEntree(m, r); }; test('sur un retenu : déplacer, échanger, retirer — la même charge, octet pour octet, et les mêmes libellés', async () => { const { libelles } = await comparer(huitRetenue(), () => {}, [ { geste: parLeGeste([2, 1], [8, 2]), menu: parLeMenu(2, 1, 'deplacer', [8, 2]) }, { geste: parLeGeste([1, 1], [3, 1]), menu: parLeMenu(1, 1, 'echanger', [3, 1]) }, { geste: parLeGeste([5, 1], 'sol'), menu: parLeMenu(5, 1, 'retirerDuTour') }, ]); expect(libelles[0]).toMatch(/^Déplacé .* à la table 8, tour 1$/); expect(libelles[1]).toMatch(/^Échangé /); expect(libelles[2]).toMatch(/^Retiré /); }); test('avant la retenue, la proposition 1 montrée au tour 2 : « Réserver ici » du menu → reserver, portée « tour », au tour affiché, une entrée ; la charge que donne « Réserver » du panneau sur la même place', async () => { // Un glissement sur la chaise d'origine ne rend aucune commande // (commandeDeDepot) : la même place se réserve par le panneau. const rendus = []; for (const facon of ['menu', 'panneau']) { const m = await ouvrir(huitAvantRetenue()); m.ui.montrerTour(2); m.ui.montrerProposition(1); flushSync(); await images(2); const occupant = etatMontre(m).tables.find(({ id }) => id === 2).chaises[0].occupant; expect(occupant).not.toBeNull(); const commandes = releverCommandes(m); const r = fichiers(m).revision; await cliquerChaise(m, 2, 1); if (facon === 'menu') { await cliquer(dans(m, `[data-menu="${occupant}"] [data-commande="reserverIci"]`)); } else { await cliquer(dans(m, `[data-menu="${occupant}"] [data-commande="deplacer"]`)); await cliquer(actionPlace(m, 'reserver')); } await uneEntree(m, r); expect(commandes, facon).toEqual([{ nom: 'reserver', args: { participant: occupant, table: 2, siege: 1, portee: 'tour', tour: 2 } }]); rendus.push(serialiserCharge(charge(m))); demonter(m); } expect(rendus[1]).toBe(rendus[0]); }); test('avant la retenue, la proposition montrée : réserver — la même charge et le même libellé', async () => { await comparer(huitAvantRetenue(), (m) => m.ui.montrerProposition(1), [ { geste: parLeGeste([2, 1], [8, 2]), menu: parLeMenu(2, 1, 'deplacer', [8, 2]) }, ]); }); }); // --- Deux titres identiques à une même place (§ 5.8) ----------------------------- describe('les listes de travail tiennent deux titres identiques à une même place (§ 5.8)', () => { test('deux titres « animateur » au siège 2 de la table 1, rendus par titresNonPourvus : deux lignes, aucune erreur de rendu', () => { // Le panneau est monté seul, sur une séance d'épreuve qui porte la // charge telle quelle : une charge que l'analyse admettrait ou non, le // rendu ne doit pas tomber sur elle. const charge = sansProposition(); charge.titres = [ { table: 1, siege: 2, libelle: 'animateur' }, { table: 1, siege: 2, libelle: 'animateur' }, ]; const seance = writable({ charge }); const etatUi = writable({ choix: null, selection: null }); const ui = { subscribe: etatUi.subscribe, etat: () => get(etatUi), selectionner: () => {}, choisir: () => {} }; const conteneur = document.createElement('div'); document.body.prepend(conteneur); const contexte = new Map([['t', t], ['seance', seance], ['ui', ui]]); let composant = null; try { composant = mount(ListesTravail, { target: conteneur, context: contexte }); flushSync(); const lignes = [...conteneur.querySelectorAll('[data-liste-travail="titres"] [data-titre="1:2"]')]; expect(lignes).toHaveLength(2); expect(lignes.map(texteDe)).toEqual(['table 1 : titre animateur non pourvu', 'table 1 : titre animateur non pourvu']); } finally { if (composant !== null) unmount(composant); conteneur.remove(); } }); }); // --- Le plan dit le placement qu'il montre (§ 2.1 étape 11, § 5.8) ---------------- describe('le plan dit le placement qu’il montre, et revient d’une proposition (§ 2.1 étape 11, § 5.8)', () => { const source = (m) => texteDe(dans(m, '[data-source]')); const reserve = (m) => [...dans(m, '[data-reserve]').querySelectorAll('[data-participant]')].map((e) => Number(e.dataset.participant)); test('avant la retenue : « Voir » une proposition, puis revenir aux réservations depuis le plan ; la réserve liste de nouveau les non-réservés', async () => { const m = await ouvrir(huitAvantRetenue()); expect(source(m)).toBe('Réservations'); expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull(); const nonReserves = reserve(m); expect(nonReserves).toHaveLength(charge(m).participants.length); m.ui.montrerProposition(1); flushSync(); await images(2); expect(source(m)).toBe('Proposition n° 1 : un geste y pose une réservation'); expect(reserve(m)).not.toEqual(nonReserves); const quitter = dans(m, '[data-commande="quitterProposition"]'); expect(texteDe(quitter)).toBe('Revenir aux réservations'); await presserBouton(quitter); flushSync(); await images(2); expect(m.ui.etat().proposition).toBeNull(); expect(source(m)).toBe('Réservations'); expect(reserve(m)).toEqual(nonReserves); expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull(); }); test('après la retenue : la proposition montrée se dit en vue seule, et la commande revient au placement retenu', async () => { const m = await ouvrir(huitRetenue()); expect(source(m)).toBe('Placement retenu'); m.ui.montrerProposition(1); flushSync(); await images(2); expect(source(m)).toBe('Proposition n° 1, vue seule'); const quitter = dans(m, '[data-commande="quitterProposition"]'); expect(texteDe(quitter)).toBe('Revenir au placement retenu'); await presserBouton(quitter); flushSync(); await images(2); expect(m.ui.etat().proposition).toBeNull(); expect(source(m)).toBe('Placement retenu'); }); }); // --- Un geste à un tour ne change pas les autres tours (§ 7.1, § 5.8) ----------- describe('une réservation « tous les tours » ne se retire pas par un geste (§ 7.1)', () => { test('sol ou « Retirer du tour » : refus sur la place, rien d’écrit ; le remède la retire ; « ce tour » se retire par le sol', async () => { const base = executer( executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }), 'reserver', { participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }, ); const m = await ouvrir(base); expect(etatMontre(m).cible).toBe('reservations'); const place = () => chaiseEl(m, 3, 1); const refusSur = () => decrit(place())?.querySelector('[data-refus="RESERVATION_TOUS_LES_TOURS"]') ?? null; let avant = fichiers(m); await glisser(pointChaise(m, 3, 1), pointSol(m)); await attendre(() => expect(refusSur()).not.toBeNull()); expect(texteDe(decrit(place()))).toContain(t('RESERVATION_TOUS_LES_TOURS', { participant: IRIS, table: 3 }).replace(/\s+/g, ' ').trim()); expect(texteDe(decrit(place()))).toContain(`Table ${charge(m).tables.find(({ id }) => id === 3).numero}, siège 1`); await rienEcrit(m, avant); expect(charge(m).reservations).toEqual(base.reservations); // Le message laisse passer le pointeur, ses boutons le prennent : « Fermer », // pressé par le vrai pointeur, reçoit l'appui et retire la pose. const fermer = decrit(place()).querySelector('[data-commande="fermerPose"]'); expect(fermer).not.toBeNull(); expect(texteDe(fermer)).toBe(t('plan.refus.fermer')); expect(await presseRecue(fermer)).toBe(true); await attendre(() => expect(refusSur()).toBeNull()); expect(m.ui.etat().pose).toBeNull(); await rienEcrit(m, avant); await activerSiegeAuClavier(m, 3, 1); await presserBouton(dans(m, `[data-menu="${IRIS}"] [data-commande="retirerDuTour"]`)); await attendre(() => expect(refusSur()).not.toBeNull()); await rienEcrit(m, avant); // Le remède, pressé par le vrai pointeur : il reçoit l'appui, et sa // commande écrit une entrée. const remede = refusSur().querySelector('[data-commande="retirerReservation"]'); expect(remede).not.toBeNull(); const r = fichiers(m).revision; expect(await presseRecue(remede)).toBe(true); await uneEntree(m, r); expect(charge(m).reservations).toEqual([{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }]); avant = fichiers(m); await glisser(pointChaise(m, 4, 2), pointSol(m)); await uneEntree(m, avant.revision); expect(charge(m).reservations).toEqual([]); }); test('une personne réservée glissée vers une chaise libre : le fantôme dit qu’elle déplace sa réservation, et sa portée ; « tous les tours » se déplace à chaque tour', async () => { const base = executer( executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }), 'reserver', { participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }, ); const m = await ouvrir(base); expect(etatMontre(m).cible).toBe('reservations'); const etatDuFantome = () => texteDe(dans(m, '[data-fantome] [data-partie="etat"]')); let r = fichiers(m).revision; let vers = pointChaise(m, 4, 3); await presserEtBouger(pointChaise(m, 3, 1), vers); expect(etatDuFantome()).toBe('déplace sa réservation, à tous les tours'); await pointer('relacher', vers.x, vers.y); await uneEntree(m, r); expect(charge(m).reservations).toContainEqual({ participant: IRIS, table: 4, siege: 3, portee: 'tous', tour: null }); r = fichiers(m).revision; vers = pointChaise(m, 4, 4); await presserEtBouger(pointChaise(m, 4, 2), vers); expect(etatDuFantome()).toBe('déplace sa réservation de ce tour'); await pointer('relacher', vers.x, vers.y); await uneEntree(m, r); expect(charge(m).reservations).toContainEqual({ participant: THEO, table: 4, siege: 4, portee: 'tour', tour: 1 }); }); }); // Mène le pointeur, déjà pressé, de depuis à vers en six pas, une image // entre deux. async function mener(depuis, vers, pas = 6) { 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 images(1); } flushSync(); } describe('le fantôme d’une personne réservée au-dessus du sol (§ 5.8)', () => { test('« ce tour » se dit retirée, « tous les tours » refusée ; revenue sur une chaise libre, le fantôme redit le déplacement ; rien d’écrit', async () => { const base = executer( executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }), 'reserver', { participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }, ); const m = await ouvrir(base); expect(etatMontre(m).cible).toBe('reservations'); const etatDuFantome = () => texteDe(dans(m, '[data-fantome] [data-partie="etat"]')); const avant = fichiers(m); const cas = [ { depuis: [3, 1], surLeSol: 'plan.fantome.refuseTous', surUneChaise: 'plan.fantome.reservationTous' }, { depuis: [4, 2], surLeSol: 'plan.fantome.retireReservationTour', surUneChaise: 'plan.fantome.reservationTour' }, ]; for (const { depuis, surLeSol, surUneChaise } of cas) { const origine = pointChaise(m, ...depuis); const libre = pointChaise(m, 4, 3); const sol = pointSol(m); await presserEtBouger(origine, libre); expect(etatDuFantome(), surUneChaise).toBe(t(surUneChaise)); await mener(libre, sol); expect(etatDuFantome(), surLeSol).toBe(t(surLeSol)); await mener(sol, libre); expect(etatDuFantome(), surUneChaise).toBe(t(surUneChaise)); // Rendue à sa chaise, la personne n'a pas bougé. await mener(libre, origine); await pointer('relacher', origine.x, origine.y); await images(2); await attendre(() => expect(dans(m, '[data-fantome] [data-partie="etat"]')).toBeNull()); } await rienEcrit(m, avant); expect(charge(m).reservations).toEqual(base.reservations); }); }); // --- Réserver, titrer, désigner (§ 4, § 2.1 étape 6, § 5.8) --------------------- describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)', () => { test('réserver Théo Pervenche au tour 2 → {participant, table, siege, portee: tour, tour: 2} ; puis Iris pour tous les tours', async () => { const m = await ouvrir(sansProposition()); m.ui.montrerTour(2); flushSync(); let r = fichiers(m).revision; await choisirDansLaReserve(m, THEO); await cliquerChaise(m, 4, 3); await cliquer(actionPlace(m, 'reserver')); await uneEntree(m, r); expect(charge(m).reservations).toEqual([{ participant: THEO, table: 4, siege: 3, portee: 'tour', tour: 2 }]); r += 1; await choisirDansLaReserve(m, IRIS); await cliquerChaise(m, 4, 5); const portee = panneau(m, 'place').querySelector('[data-champ="portee"]'); portee.value = 'tous'; portee.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); await cliquer(actionPlace(m, 'reserver')); await uneEntree(m, r); expect(charge(m).reservations).toContainEqual({ participant: IRIS, table: 4, siege: 5, portee: 'tous', tour: null }); }); test('titrer : « animateur » à la table 5 → un titre {table, siege, libelle}, ni personne ni tour ; un libellé vide refusé sur le champ, rien d’écrit', async () => { const m = await ouvrir(sansProposition({ attribuer: true })); await cliquerChaise(m, 5, 2); const champ = panneau(m, 'place').querySelector('[data-champ="libelle"] input, input[data-champ="libelle"]'); expect(champ).not.toBeNull(); const avant = fichiers(m); champ.value = ' '; champ.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); await cliquer(actionPlace(m, 'donnerTitre')); await attendre(() => expect(panneau(m, 'place').querySelector('[data-refus="LIBELLE_VIDE"]')).not.toBeNull()); expect(decrit(chaiseEl(m, 5, 2))).toBeNull(); await rienEcrit(m, avant); champ.value = 'animateur'; champ.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); await cliquer(actionPlace(m, 'donnerTitre')); await uneEntree(m, avant.revision); expect(charge(m).titres).toEqual([{ table: 5, siege: 2, libelle: 'animateur' }]); expect(charge(m).reservations).toEqual([]); }); test('désigner Iris à la table 3 → une réservation « tous » à la place du titre, un titre, une entrée', async () => { const base = executer(sansProposition({ attribuer: true }), 'poserTitre', { table: 3, siege: 1, libelle: 'animateur' }); const m = await ouvrir(base); const r = fichiers(m).revision; await choisirDansLaReserve(m, IRIS); await cliquerChaise(m, 3, 1); await cliquer(actionPlace(m, 'designer')); await uneEntree(m, r); expect(charge(m).reservations).toEqual([{ participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }]); expect(charge(m).titres).toEqual([{ table: 3, siege: 1, libelle: 'animateur' }]); }); test('après la génération : sur un retenu qui assied Iris à la table 2, la désigner à la table 3 → une réservation et un titre de plus, retenu et propositions identiques, une entrée, RESERVATION_NON_HONOREE visible', async () => { const base = chargeDe({ id: 'evt-apres-generation', places: [4, 4, 4], personnes: 9, tours: [ [[2, 3, 4], [1, 5, 6], [7, 8, 9]], [[5, 7, 9], [1, 2, 8], [3, 4, 6]], [[6, 8, 3], [1, 4, 7], [2, 5, 9]], ], }); const m = await ouvrir(base); const origine = structuredClone(charge(m)); const r = fichiers(m).revision; const siegeIris = etatMontre(m).tables[1].chaises.findIndex(({ occupant }) => occupant === IRIS) + 1; await cliquerChaise(m, 2, siegeIris); await cliquer(dans(m, `[data-menu="${IRIS}"] [data-commande="deplacer"]`)); expect(m.ui.etat().choix).toEqual({ genre: 'personne', participant: IRIS, depuis: { table: 2, siege: siegeIris } }); await cliquerChaise(m, 3, 4); await cliquer(actionPlace(m, 'designer')); await uneEntree(m, r); const apres = charge(m); expect(apres.reservations).toHaveLength(origine.reservations.length + 1); expect(apres.reservations.at(-1)).toMatchObject({ participant: IRIS, table: 3, portee: 'tous', tour: null }); expect(apres.titres).toHaveLength(origine.titres.length + 1); expect(apres.retenu).toEqual(origine.retenu); expect(apres.propositions).toEqual(origine.propositions); expect(m.seance.etat().avertissements).toContainEqual({ code: 'RESERVATION_NON_HONOREE', details: { participant: IRIS, table: 3, tour: 1, personne: 'Iris Ombrelle', numero: 3 }, }); await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull()); }); // L'avertissement nomme Iris et la table 3 : ses détails portent le nom // affiché de la personne et le numéro de la table (avertissementsApres). test('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3', async () => { const base = chargeDe({ id: 'evt-apres-generation', places: [4, 4, 4], personnes: 9, tours: [ [[2, 3, 4], [1, 5, 6], [7, 8, 9]], [[5, 7, 9], [1, 2, 8], [3, 4, 6]], [[6, 8, 3], [1, 4, 7], [2, 5, 9]], ], }); const m = await ouvrir(base); const r = fichiers(m).revision; m.ui.choisir({ genre: 'personne', participant: IRIS, depuis: null }); await cliquerChaise(m, 3, 4); await cliquer(actionPlace(m, 'designer')); await uneEntree(m, r); await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull()); const texte = texteDe(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')); expect(texte).toContain('Iris Ombrelle'); expect(texte).toContain('table 3'); }); }); // --- Le titre reste, le mode table, la capacité (§ 4.1, § 2.1 étape 6, § 5.9) --- describe('le titre appartient à la place (§ 4.1, § 2.1 étape 6, § 5.9)', () => { test('quatre variantes depuis une désignation à la table 3 — réservation retirée, déplacée, personne supprimée, exclue : « table 3 : titre animateur non pourvu » dans la liste de travail et sur la place', async () => { const designee = executer(sansProposition({ attribuer: true }), 'designer', { participant: IRIS, table: 3, siege: 1, libelle: 'animateur' }); const variantes = [ ['retiree', (c) => executer(c, 'retirerReservation', { participant: IRIS, table: 3, portee: 'tous', tour: null })], ['deplacee', (c) => executer(c, 'deplacerReservation', { participant: IRIS, de: { table: 3, portee: 'tous', tour: null }, vers: { table: 4, siege: 1 } })], ['supprimee', (c) => executer(c, 'supprimerParticipant', { id: IRIS })], ['exclue', (c) => executer(c, 'exclureParticipant', { id: IRIS })], ]; expect(variantes.length).toBe(4); for (const [nom, appliquer] of variantes) { const m = await ouvrir(appliquer(structuredClone(designee))); const liste = dans(m, '[data-liste-travail="titres"]'); const elements = [...liste.querySelectorAll('[data-titre]')]; expect(elements.map(texteDe), nom).toEqual(['table 3 : titre animateur non pourvu']); // Sur la place : son nom accessible, la ligne entière, et la hachure. expect(chaiseEl(m, 3, 1).getAttribute('aria-label'), nom).toMatch(/^1\. .*titre animateur non pourvu$/); expect(chaiseEl(m, 3, 1).querySelector('[data-marque="hachure"]'), nom).not.toBeNull(); demonter(m); } }); test('mode table : sans attribution, trois titres à la table 2 et une désignation → « 3 titres dont 1 pourvu », deux éléments pour la table 2, aucun choix de siège ; sièges attribués, le numéro se choisit', async () => { let base = sansProposition(); for (const libelle of ['animateur', 'hôte', 'secrétaire']) base = executer(base, 'poserTitre', { table: 2, siege: null, libelle }); const m = await ouvrir(base); const r = fichiers(m).revision; await cliquerPoint(pointTable(m, 2)); expect(m.ui.etat().selection).toEqual({ table: 2 }); await attendre(() => expect(panneau(m, 'table')).not.toBeNull()); expect(panneau(m, 'table').querySelector('[data-champ="siege"]')).toBeNull(); await choisirDansLaReserve(m, IRIS); await cliquer(panneau(m, 'table').querySelector('[data-commande="designer"]')); await uneEntree(m, r); expect(charge(m).reservations).toEqual([{ participant: IRIS, table: 2, siege: 1, portee: 'tous', tour: null }]); await attendre(() => expect(texteDe(panneau(m, 'table'))).toContain('3 titres dont 1 pourvu')); const pourLaTable = [...dans(m, '[data-liste-travail="titres"]').querySelectorAll('[data-titre]')].filter((e) => e.dataset.titre.startsWith('2:')); expect(pourLaTable).toHaveLength(2); demonter(m); const attribuee = await ouvrir(executer(sansProposition({ attribuer: true }), 'poserTitre', { table: 2, siege: 4, libelle: 'animateur' })); await cliquerPoint(pointTable(attribuee, 2)); await attendre(() => expect(panneau(attribuee, 'table')).not.toBeNull()); expect(panneau(attribuee, 'table').querySelector('[data-champ="siege"]')).not.toBeNull(); // Le numéro choisi est celui que reçoivent le titre, puis la désignation. const poserChamps = (siege, libelle) => { const choixSiege = panneau(attribuee, 'table').querySelector('[data-champ="siege"]'); choixSiege.value = String(siege); choixSiege.dispatchEvent(new Event('change', { bubbles: true })); const champ = panneau(attribuee, 'table').querySelector('[data-champ="libelle"]'); champ.value = libelle; champ.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); }; let ra = fichiers(attribuee).revision; poserChamps(3, 'secrétaire'); await cliquer(panneau(attribuee, 'table').querySelector('[data-commande="donnerTitre"]')); await uneEntree(attribuee, ra); expect(charge(attribuee).titres).toContainEqual({ table: 2, siege: 3, libelle: 'secrétaire' }); ra += 1; await choisirDansLaReserve(attribuee, IRIS); poserChamps(5, 'hôte'); await cliquer(panneau(attribuee, 'table').querySelector('[data-commande="designer"]')); await uneEntree(attribuee, ra); expect(charge(attribuee).titres).toContainEqual({ table: 2, siege: 5, libelle: 'hôte' }); expect(charge(attribuee).reservations).toEqual([{ participant: IRIS, table: 2, siege: 5, portee: 'tous', tour: null }]); }); test('capacité et animateurs : table de 4, la quatrième désignation acceptée et le gel signalé par une forme ; la cinquième refusée sur la place ; des places qui manquent déjà n’ajoutent ni refus ni avertissement ; une table sans animateur n’avertit de rien', async () => { // Huit places pour dix personnes : il en manque deux à chaque tour. const base = chargeDe({ id: 'evt-capacite', places: [4, 2, 2], personnes: 10 }); const m = await ouvrir(base); const rendus = []; const originale = m.seance.executer; m.seance.executer = async (nom, args) => { const rendu = await originale(nom, args); rendus.push(rendu); return rendu; }; nettoyages.push(() => { m.seance.executer = originale; }); const manques = () => [...rendus.flatMap(({ avertissements }) => avertissements), ...m.seance.etat().avertissements].filter( ({ code }) => code === 'PLACES_MANQUANTES', ).length; await cliquerPoint(pointTable(m, 1)); await attendre(() => expect(panneau(m, 'table')).not.toBeNull()); for (const personne of [1, 2, 3, 4]) { const r = fichiers(m).revision; await choisirDansLaReserve(m, personne); await cliquer(panneau(m, 'table').querySelector('[data-commande="designer"]')); await uneEntree(m, r); expect(decrit(tableEl(m, 1))).toBeNull(); expect(manques()).toBe(0); } expect(rendus).toHaveLength(4); expect(charge(m).reservations).toHaveLength(4); const gel = tableEl(m, 1).querySelector('[data-marque="gel"]'); expect(peinte(gel)).toBe(true); expect(gel.tagName).toBe('path'); const avant = fichiers(m); await choisirDansLaReserve(m, 5); await cliquer(panneau(m, 'table').querySelector('[data-commande="designer"]')); await refusSurLaPlace(m, tableEl(m, 1), 1, ['AUCUN_SIEGE_SANS_TITRE'], avant); const autres = m.seance.etat().avertissements.filter(({ details }) => details?.table === 2 || details?.table === 3); expect(autres).toEqual([]); }); }); // --- Le refus sur la place (§ 5.9, § 2.1, § 19.3 09) ---------------------------- describe('le refus se pose sur la place (§ 5.9)', () => { // Avant la retenue, la proposition 1 montrée ; la table 8 réservée pour // tous les tours à ses trois sièges ; Théo Pervenche réservé « tous » à la // table 6 sans siège, que la proposition assied ailleurs. function preparee() { let c = huitAvantRetenue(); for (const [participant, siege] of [[18, 1], [19, 2], [20, 3]]) { c = executer(c, 'reserver', { participant, table: 8, siege, portee: 'tous', tour: null }); } return executer(c, 'reserver', { participant: THEO, table: 6, siege: null, portee: 'tous', tour: null }); } async function monter() { const m = await ouvrir(preparee()); m.ui.montrerProposition(1); flushSync(); await images(2); return m; } test('réserver tous les sièges d’une table pour tous les tours est accepté, et le gel se voit ; k_t > c_t, par glissement puis par le menu : SURRESERVATION puis PLACE_RESERVEE sur la chaise, chacun avec sa commande', async () => { const m = await monter(); expect(peinte(tableEl(m, 8).querySelector('[data-marque="gel"]'))).toBe(true); let avant = fichiers(m); await glisser(pointChaise(m, 1, 1), pointChaise(m, 8, 1)); await refusSurLaPlace(m, chaiseEl(m, 8, 1), 8, ['SURRESERVATION', 'PLACE_RESERVEE'], avant); avant = fichiers(m); await activerSiegeAuClavier(m, 1, 1); await presserBouton(panneau(m, 'place').querySelector('[data-menu] [data-commande="deplacer"]')); await cliquerChaise(m, 8, 1); await cliquer(actionPlace(m, 'assigner')); await refusSurLaPlace(m, chaiseEl(m, 8, 1), 8, ['SURRESERVATION', 'PLACE_RESERVEE'], avant); }); test('deux places sur un même tour, par glissement puis par le menu : DEUX_PLACES_MEME_TOUR sur la chaise, sa commande déplace la réservation', async () => { const m = await monter(); const chaiseTheo = etatMontre(m).tables.flatMap((t) => t.chaises.map((c) => ({ table: t.id, ...c }))).find((c) => c.occupant === THEO); expect(chaiseTheo.table).not.toBe(6); let avant = fichiers(m); await glisser(pointChaise(m, chaiseTheo.table, chaiseTheo.siege), pointChaise(m, 7, 3)); const message = await refusSurLaPlace(m, chaiseEl(m, 7, 3), 7, ['DEUX_PLACES_MEME_TOUR'], avant); expect(message.querySelector('[data-commande="deplacerReservation"]')).not.toBeNull(); avant = fichiers(m); await cliquerChaise(m, chaiseTheo.table, chaiseTheo.siege); await cliquer(dans(m, `[data-menu="${THEO}"] [data-commande="deplacer"]`)); await cliquerChaise(m, 7, 3); await cliquer(actionPlace(m, 'assigner')); await refusSurLaPlace(m, chaiseEl(m, 7, 3), 7, ['DEUX_PLACES_MEME_TOUR'], avant); }); test('une place réservée à un autre, par glissement puis par le menu : PLACE_RESERVEE ; une personne exclue, par le menu : PERSONNE_EXCLUE ; exclue et place réservée à un autre : les deux, dans l’ordre du contrat', async () => { let base = huitAvantRetenue(); base = executer(base, 'reserver', { participant: 19, table: 7, siege: 1, portee: 'tous', tour: null }); const m = await ouvrir(base); m.ui.montrerProposition(1); flushSync(); await images(2); let avant = fichiers(m); await glisser(pointChaise(m, 1, 1), pointChaise(m, 7, 1)); await refusSurLaPlace(m, chaiseEl(m, 7, 1), 7, ['PLACE_RESERVEE'], avant); avant = fichiers(m); await cliquerChaise(m, 1, 1); const occupant = etatMontre(m).tables[0].chaises[0].occupant; await cliquer(dans(m, `[data-menu="${occupant}"] [data-commande="deplacer"]`)); await cliquerChaise(m, 7, 1); await cliquer(actionPlace(m, 'assigner')); await refusSurLaPlace(m, chaiseEl(m, 7, 1), 7, ['PLACE_RESERVEE'], avant); // Exclure l'occupante de la chaise 2 de la table 1 par son menu : son menu // reste, et « Déplacer » la choisit. await cliquerChaise(m, 1, 2); const exclue = etatMontre(m).tables[0].chaises[1].occupant; const r = fichiers(m).revision; await cliquer(dans(m, `[data-menu="${exclue}"] [data-commande="exclure"]`)); await uneEntree(m, r); avant = fichiers(m); await cliquer(dans(m, `[data-menu="${exclue}"] [data-commande="deplacer"]`)); expect(m.ui.etat().choix?.participant).toBe(exclue); await cliquerChaise(m, 8, 3); await cliquer(actionPlace(m, 'assigner')); await refusSurLaPlace(m, chaiseEl(m, 8, 3), 8, ['PERSONNE_EXCLUE'], avant); m.ui.choisir({ genre: 'personne', participant: exclue, depuis: null }); await cliquerChaise(m, 7, 1); await cliquer(actionPlace(m, 'assigner')); await refusSurLaPlace(m, chaiseEl(m, 7, 1), 7, ['PERSONNE_EXCLUE', 'PLACE_RESERVEE'], avant); }); }); // --- Déplacer la réservation (§ 5.9, § 2.1 étape 7) ------------------------------ describe('la pose ne survit ni au tour ni à la section (§ 5.9)', () => { test('PLACE_RESERVEE posée par un glissement : quitter la section puis revenir au plan ne la remontre pas ; un changement de tour la retire', async () => { const base = executer(huitAvantRetenue(), 'reserver', { participant: 19, table: 7, siege: 1, portee: 'tous', tour: null }); const m = await ouvrir(base); m.ui.montrerProposition(1); flushSync(); await images(2); await glisser(pointChaise(m, 1, 1), pointChaise(m, 7, 1)); await attendre(() => expect(dans(m, '[data-refus="PLACE_RESERVEE"]')).not.toBeNull()); m.ui.allerA('tables'); flushSync(); m.ui.allerA('plan'); flushSync(); await attendre(() => expect(tous(m, '[data-table]').length).toBe(8)); await images(2); expect(dans(m, '[data-refus]')).toBeNull(); expect(m.ui.etat().pose).toBeNull(); await glisser(pointChaise(m, 1, 1), pointChaise(m, 7, 1)); await attendre(() => expect(dans(m, '[data-refus="PLACE_RESERVEE"]')).not.toBeNull()); m.ui.montrerTour(2); flushSync(); await images(2); expect(dans(m, '[data-refus]')).toBeNull(); expect(m.ui.etat().pose).toBeNull(); expect(chaiseEl(m, 7, 1).getAttribute('aria-describedby')).toBeNull(); }); }); describe('déplacer la réservation, remède d’une place réservée (§ 5.9, § 2.1 étape 7)', () => { // Basile Tourmaline (5), que les deux sources assoient à la table 2 au // tour 1, désigné à son siège 2 : sa réservation et le titre « animateur ». // Sur le retenu, le dépôt d'Iris sur sa place est un échange, qui le // ferait aussi quitter sa réservation : RESERVEE_AILLEURS suit. const avecDesignation = (charge) => executer(charge, 'designer', { participant: 5, table: 2, siege: 2, libelle: 'animateur' }); for (const [nom, base, preparer, codes] of [ ['avant la retenue', () => avecDesignation(huitAvantRetenue()), (m) => m.ui.montrerProposition(1), ['PLACE_RESERVEE']], ['sur un retenu', () => avecDesignation(huitRetenue()), () => {}, ['PLACE_RESERVEE', 'RESERVEE_AILLEURS']], ]) { test(`${nom} : le dépôt sur la place est refusé, le remède choisit la réservation ; une place libre, puis « Déplacer la réservation ici » → une entrée, la réservation à la nouvelle place, le titre resté`, async () => { const m = await ouvrir(base()); preparer(m); flushSync(); await images(2); const origine = structuredClone(charge(m)); const avant = fichiers(m); expect(etatMontre(m).tables[1].chaises[1]).toMatchObject({ occupant: 5, reservation: { participant: 5, portee: 'tous' } }); await glisser(pointChaise(m, 1, 1), pointChaise(m, 2, 2)); const message = await refusSurLaPlace(m, chaiseEl(m, 2, 2), 2, codes, avant); await cliquer(message.querySelector('[data-refus="PLACE_RESERVEE"] [data-commande="choisirPlace"]')); expect(m.ui.etat().choix).toEqual({ genre: 'reservation', participant: 5, depuis: { table: 2, siege: 2 }, de: { table: 2, portee: 'tous', tour: null }, }); await cliquerChaise(m, 8, 3); await cliquer(actionPlace(m, 'deplacerReservationIci')); await uneEntree(m, avant.revision); expect(charge(m).reservations).toEqual([{ participant: 5, table: 8, siege: 3, portee: 'tous', tour: null }]); expect(charge(m).titres).toEqual([{ table: 2, siege: 2, libelle: 'animateur' }]); expect(charge(m).retenu).toEqual(origine.retenu); expect(m.ui.etat().choix).toBeNull(); }); } }); // --- Les listes de travail (§ 5.8, § 2.1 étape 6) -------------------------------- describe('les listes de travail mènent à la place ou à la personne (§ 5.8)', () => { test('un titre non pourvu à la table 5, Ondine pressentie « hôte » : chaque liste porte son élément ; le titre sélectionne sa place, focalisée et dans la fenêtre ; Ondine devient la personne choisie ; « Désigner » → une entrée, les deux listes vides', async () => { let base = executer(sansProposition({ attribuer: true }), 'poserTitre', { table: 5, siege: 1, libelle: 'animateur' }); base.participants[ONDINE - 1].titrePressenti = 'hôte'; const m = await ouvrir(base); const cadre = cadreDe(m); await avecVue(m, { k: 1, tx: cadre.largeur / 2, ty: cadre.hauteur / 2 }); expect(dansLaFenetre(m, chaiseEl(m, 5, 1))).toBe(false); const titres = dans(m, '[data-liste-travail="titres"]'); const pressentis = dans(m, '[data-liste-travail="pressentis"]'); expect([...titres.querySelectorAll('[data-titre]')].map(texteDe)).toEqual(['table 5 : titre animateur non pourvu']); expect([...pressentis.querySelectorAll('[data-participant]')].map((e) => e.dataset.participant)).toEqual([String(ONDINE)]); await cliquer(titres.querySelector('[data-titre]')); expect(m.ui.etat().selection).toEqual({ table: 5, siege: 1 }); await attendre(() => expect(document.activeElement).toBe(racinePlan(m))); expect(actif(m)).toBe('siege-5-1'); await attendre(() => expect(dansLaFenetre(m, chaiseEl(m, 5, 1))).toBe(true)); const ondine = pressentis.querySelector(`[data-participant="${ONDINE}"]`); await cliquer(ondine); expect(m.ui.etat().choix).toEqual({ genre: 'personne', participant: ONDINE, depuis: null }); expect(ondine.getAttribute('aria-pressed')).toBe('true'); const r = fichiers(m).revision; await cliquer(actionPlace(m, 'designer')); await uneEntree(m, r); expect(charge(m).reservations).toEqual([{ participant: ONDINE, table: 5, siege: 1, portee: 'tous', tour: null }]); await attendre(() => expect(dans(m, '[data-liste-travail="titres"]').querySelectorAll('[data-titre]').length).toBe(0)); expect(dans(m, '[data-liste-travail="pressentis"]').querySelectorAll('[data-participant]').length).toBe(0); }); test('exclusion par le menu : RESERVATIONS_SUSPENDUES nomme Iris, compte sa réservation et le titre ; la liste gagne le titre ; la chaise hachurée à cadenas ouvert ; réservations inchangées ; réintégrer rend tout', async () => { const base = executer(sansProposition({ attribuer: true }), 'designer', { participant: IRIS, table: 3, siege: 1, libelle: 'animateur' }); const m = await ouvrir(base); const reservations = structuredClone(charge(m).reservations); const DESIGNATION = new Set(['selection', 'focus']); const marques = () => [...chaiseEl(m, 3, 1).querySelectorAll('[data-marque]')] .map((e) => e.dataset.marque) .filter((marque) => !DESIGNATION.has(marque)) .sort(); expect(marques()).toEqual(['anneau', 'cadenasFerme', 'remplissagePlein']); await cliquerChaise(m, 3, 1); let r = fichiers(m).revision; await cliquer(dans(m, `[data-menu="${IRIS}"] [data-commande="exclure"]`)); await uneEntree(m, r); await attendre(() => expect(dans(m, '[data-avertissement="RESERVATIONS_SUSPENDUES"]')).not.toBeNull()); const texte = texteDe(dans(m, '[data-avertissement="RESERVATIONS_SUSPENDUES"]')); expect(texte).toContain('Iris Ombrelle'); expect(texte).toContain('1 réservation suspendue'); expect(texte).toContain('1 titre de place'); expect([...dans(m, '[data-liste-travail="titres"]').querySelectorAll('[data-titre]')].map(texteDe)).toEqual([ 'table 3 : titre animateur non pourvu', ]); await attendre(() => expect(marques()).toEqual(['cadenasOuvert', 'hachure'])); expect(charge(m).reservations).toEqual(reservations); r += 1; await cliquer(dans(m, `[data-menu="${IRIS}"] [data-commande="reintegrer"]`)); await uneEntree(m, r); await attendre(() => expect(marques()).toEqual(['anneau', 'cadenasFerme', 'remplissagePlein'])); expect(dans(m, '[data-liste-travail="titres"]').querySelectorAll('[data-titre]').length).toBe(0); expect(charge(m).reservations).toEqual(reservations); }); test('conversion proposée : la R-ième réservation « tour » de Théo à la table 1 la fait paraître, la charge inchangée ; ignorer → rien ; accepter → une entrée, une réservation « tous »', async () => { let base = sansProposition(); for (const tour of [1, 2, 3]) base = executer(base, 'reserver', { participant: THEO, table: 1, siege: 2, portee: 'tour', tour }); const m = await ouvrir(base); const avant = fichiers(m); const conversion = () => dans(m, `[data-conversion="${THEO}:1"]`); await attendre(() => expect(conversion()).not.toBeNull()); await rienEcrit(m, avant); await cliquer(conversion().querySelector('[data-commande="ignorer"]')); expect(conversion()).toBeNull(); await rienEcrit(m, avant); m.ui.allerA('tables'); flushSync(); m.ui.allerA('plan'); flushSync(); await attendre(() => expect(conversion()).not.toBeNull()); await cliquer(conversion().querySelector('[data-commande="convertir"]')); await uneEntree(m, avant.revision); expect(charge(m).reservations).toEqual([{ participant: THEO, table: 1, siege: 2, portee: 'tous', tour: null }]); }); test('réserve visible : sur un retenu, une personne en attente au tour affiché, choisie, une chaise libre, « Assigner » → deplacerDansRetenu ; changer de tour change la réserve et oublie le choix', async () => { const base = executer(executer(huitRetenue(), 'retirerDuTour', { tour: 1, participant: 7 }), 'retirerDuTour', { tour: 2, participant: 9 }); const m = await ouvrir(base); const reserve = () => [...dans(m, '[data-reserve]').querySelectorAll('[data-participant]')].map((e) => Number(e.dataset.participant)); expect(dans(m, '[data-reserve]').dataset.reserve).toBe('1'); expect(reserve()).toEqual([7]); const commandes = releverCommandes(m); const r = fichiers(m).revision; await choisirDansLaReserve(m, 7); await cliquerChaise(m, 8, 3); await cliquer(actionPlace(m, 'assigner')); await uneEntree(m, r); expect(commandes.at(-1)).toEqual({ nom: 'deplacerDansRetenu', args: { tour: 1, participant: 7, table: 8, siege: 3 } }); await attendre(() => expect(reserve()).toEqual([])); m.ui.choisir({ genre: 'personne', participant: 8, depuis: null }); m.ui.montrerTour(2); flushSync(); await images(2); expect(dans(m, '[data-reserve]').dataset.reserve).toBe('2'); expect(reserve()).toEqual([9]); expect(m.ui.etat().choix).toBeNull(); }); }); // --- La mise en évidence d'une personne (§ 7.1, § 8.2, § 8.4) ------------------------ describe('la mise en évidence d’une personne : posée et ôtée par un geste, en lecture, sans rien écrire (§ 7.1, § 8.4)', () => { // La chaise qui porte la marque de mise en évidence, ou null ; la marque // se voit au style calculé. const marquees = (m) => tous(m, '[data-marque="miseEnEvidence"]'); const chaiseMarquee = (m) => { const [marque, ...autres] = marquees(m); expect(autres).toEqual([]); if (marque === undefined) return null; expect(marque.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true); return marque.closest('[data-siege]').dataset.siege; }; // La place qu'occupe une personne au tour montré, « table:siège ». const placeDe = (m, personne) => { for (const table of etatMontre(m).tables) { const chaise = table.chaises.find(({ occupant }) => occupant === personne); if (chaise !== undefined) return `${table.id}:${chaise.siege}`; } return null; }; test('par le menu de la personne : sa chaise marquée, et elle seule, à chaque tour montré ; ôtée par le menu, puis posée et ôtée par la barre de vue', async () => { const m = await ouvrir(huitRetenue(), { mode: 'lecture' }); expect(m.seance.etat().mode).toBe('lecture'); const avant = fichiers(m); await cliquerChaise(m, 2, 1); const personne = etatMontre(m).tables[1].chaises[0].occupant; expect(personne).not.toBeNull(); expect(chaiseMarquee(m)).toBeNull(); await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`)); await attendre(() => expect(chaiseMarquee(m)).toBe('2:1')); expect(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`)).toBeNull(); // La barre de vue nomme la personne mise en évidence. const barre = dans(m, '[data-evidence]'); expect(barre).not.toBeNull(); expect(texteDe(barre)).toContain(nomAffiche(charge(m).participants.find(({ id }) => id === personne))); expect(Number(barre.dataset.evidence)).toBe(personne); for (const tour of [2, 3, 1]) { m.ui.montrerTour(tour); flushSync(); await images(2); expect(chaiseMarquee(m), `tour ${tour}`).toBe(placeDe(m, personne)); } await cliquerChaise(m, 2, 1); await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="oterEvidence"]`)); await attendre(() => expect(chaiseMarquee(m)).toBeNull()); expect(dans(m, '[data-evidence]')).toBeNull(); expect(m.ui.etat().miseEnEvidence).toBeNull(); await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`)); await attendre(() => expect(chaiseMarquee(m)).toBe('2:1')); await cliquer(dans(m, '[data-evidence] [data-commande="oterEvidence"]')); await attendre(() => expect(chaiseMarquee(m)).toBeNull()); expect(fichiers(m)).toEqual(avant); expect(m.seance.etat().mode).toBe('lecture'); }); test('une personne mise en évidence puis supprimée : la barre dit qu’elle n’est plus dans la liste, et offre toujours d’ôter la mise en évidence', async () => { const m = await ouvrir(huitRetenue()); const personne = etatMontre(m).tables[1].chaises[0].occupant; m.ui.mettreEnEvidence(personne); flushSync(); await m.seance.executer('supprimerParticipant', { id: personne }); flushSync(); await images(2); const barre = dans(m, '[data-evidence]'); expect(barre).not.toBeNull(); expect(texteDe(barre)).toContain('une personne qui n’est plus dans la liste'); await cliquer(barre.querySelector('[data-commande="oterEvidence"]')); expect(dans(m, '[data-evidence]')).toBeNull(); }); test('depuis sa ligne de la grille des participants : « Voir sur le plan » ouvre le plan, sa chaise marquée ; rien d’écrit', async () => { const m = await ouvrir(huitRetenue(), { mode: 'lecture' }); const avant = fichiers(m); const personne = etatMontre(m).tables[2].chaises[1].occupant; m.ui.allerA('participants'); flushSync(); const voir = await attendre(() => { const bouton = dans(m, `[data-participant="${personne}"] [data-commande="voirSurLePlan"]`); expect(bouton).not.toBeNull(); return bouton; }); await cliquer(voir); await attendre(() => expect(m.ui.etat().section).toBe('plan')); await attendre(() => expect(tous(m, '[data-table]').length).toBe(8)); await attendre(() => expect(chaiseMarquee(m)).toBe('3:2')); expect(m.ui.etat().miseEnEvidence).toBe(personne); expect(fichiers(m)).toEqual(avant); }); });