From 811cd16b764f55376f1a4cc972ca7c9f3b2ecad3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 11:08:59 -0400 Subject: [PATCH] [ADD] interface: plan keyboard, tooltip, side panels, person menu, lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gesture the pointer offers on the plan is reachable from the keyboard (§ 7.3): one stop enters the plan, arrows move between tables and seats, and the table and place panels choose a seat and keep the focus. A tooltip names who sits where; a per-person menu moves, assigns or reserves without dragging; the reserve and the work lists choose a person to designate on the plan. Escape, a tour change and the executed command forget the choice, and a click never moves the view. Checked: 2537 node, 466 browser (one expected failure kept), 60 node-long. --- FR --- [ADD] interface : clavier du plan, infobulle, panneaux, menu, listes Chaque geste que le pointeur offre sur le plan s'atteint au clavier (§ 7.3) : un seul arrêt entre dans le plan, les flèches vont de table en table et de siège en siège, et les panneaux de table et de place choisissent un siège en gardant le focus. Une infobulle nomme qui est assis où ; un menu par personne déplace, assigne ou réserve sans glisser ; la réserve et les listes de travail choisissent une personne à désigner sur le plan. Échap, un changement de tour et la commande exécutée oublient le choix, et un clic ne déplace jamais la vue. Vérifié : 2537 node, 466 navigateur (un échec attendu gardé), 60 node-long. Assisted-by: Claude Opus 5.5 --- src/interface/etat_ui.js | 85 +- src/interface/etat_ui.test.js | 94 ++ src/interface/libelles/plan.js | 80 +- src/interface/plan/Infobulle.svelte | 74 + src/interface/plan/ListesTravail.svelte | 163 +- src/interface/plan/MenuPersonne.svelte | 124 ++ src/interface/plan/PanneauPlace.svelte | 235 ++- src/interface/plan/PanneauTable.svelte | 214 ++- .../plan/Panneaux.navigateur.test.js | 1387 +++++++++++++++++ src/interface/plan/Plan.svelte | 343 +++- src/interface/plan/RefusPlace.svelte | 8 +- src/interface/plan/Reserve.svelte | 83 +- src/interface/plan/TablePlan.svelte | 64 +- src/interface/plan/clavier.js | 152 ++ src/interface/plan/clavier.test.js | 158 ++ src/interface/plan/modele.js | 177 ++- src/interface/plan/modele.test.js | 240 +++ test/fixtures/plan/conflit_table_un.gtt.json | 36 + 18 files changed, 3652 insertions(+), 65 deletions(-) create mode 100644 src/interface/plan/Infobulle.svelte create mode 100644 src/interface/plan/MenuPersonne.svelte create mode 100644 src/interface/plan/Panneaux.navigateur.test.js create mode 100644 src/interface/plan/clavier.js create mode 100644 src/interface/plan/clavier.test.js create mode 100644 test/fixtures/plan/conflit_table_un.gtt.json diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js index fe942c7..ef2cca2 100644 --- a/src/interface/etat_ui.js +++ b/src/interface/etat_ui.js @@ -4,7 +4,8 @@ // L'état de navigation de l'interface : la section ouverte, le panneau « À // propos », le tour montré, la personne mise en évidence, la proposition // demandée au plan, le texte d'un import en attente, les listes de noms, le -// plein écran, la place sélectionnée, la fiche ouverte. Regarder n'écrit jamais (§ 8.4) : cet +// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente +// du plan et le refus posé sur une place. Regarder n'écrit jamais (§ 8.4) : cet // état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et // rien de ce qu'il tient ne quitte la mémoire. // @@ -26,6 +27,17 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js'; * @property {boolean} pleinEcran * @property {Object|null} selection la place sélectionnée * @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte + * @property {null|{genre: 'personne'|'reservation', participant: number, + * depuis: {table: number, siege: number|null}|null, + * de?: {table: number, portee: 'tous'|'tour', tour: number|null}}} choix + * le choix en attente du plan (§ 5.8, § 7.1) : la personne choisie, ou sa + * réservation qui tient la place depuis ; depuis, sa place, null hors du + * dessin ; de, pour une réservation, ce qui la désigne dans la commande + * deplacerReservation + * @property {null|{place: {table: number, siege?: number|null}, refus: Object|null, + * avertissements: Object[]}} pose + * le refus, ou les avertissements, posés sur une place du plan, que + * Plan.svelte montre sur la place (§ 5.9) */ // L'état de navigation d'un événement qui s'ouvre, panneau « À propos » @@ -40,8 +52,42 @@ const DEPART = Object.freeze({ pleinEcran: false, selection: null, fiche: null, + choix: null, + pose: null, }); +const GENRES_CHOIX = new Set(['personne', 'reservation']); +const PORTEES = new Set(['tous', 'tour']); + +const entierPositif = (n) => Number.isInteger(n) && n >= 1; + +// Vrai pour une place {table, siege|null} : table entière positive, siège +// entier positif ou null. +const estPlace = (place) => + place !== null && typeof place === 'object' && entierPositif(place.table) && (place.siege === null || entierPositif(place.siege)); + +// Le choix du contrat, figé ; RangeError hors du contrat. +function choixValide(choix) { + const forme = choix !== null && typeof choix === 'object'; + const valide = + forme && + GENRES_CHOIX.has(choix.genre) && + entierPositif(choix.participant) && + Object.hasOwn(choix, 'depuis') && + (choix.depuis === null || estPlace(choix.depuis)) && + (choix.genre === 'personne' || + (choix.de !== null && + typeof choix.de === 'object' && + entierPositif(choix.de.table) && + PORTEES.has(choix.de.portee) && + (choix.de.portee === 'tous' ? choix.de.tour === null : entierPositif(choix.de.tour)))); + if (!valide) throw new RangeError(`choix hors du contrat : ${JSON.stringify(choix)}`); + const depuis = choix.depuis === null ? null : Object.freeze({ table: choix.depuis.table, siege: choix.depuis.siege }); + const fige = { genre: choix.genre, participant: choix.participant, depuis }; + if (choix.genre === 'reservation') fige.de = Object.freeze({ table: choix.de.table, portee: choix.de.portee, tour: choix.de.tour }); + return Object.freeze(fige); +} + /** * Un état de navigation neuf, à son départ. * @@ -51,7 +97,8 @@ const DEPART = Object.freeze({ * montrerProposition: Function, ouvrirImport: Function, * prendreImport: () => null|{texte: string, origine: string}, * basculerListes: Function, basculerPleinEcran: Function, - * selectionner: Function, ouvrirFiche: Function}} + * selectionner: Function, ouvrirFiche: Function, choisir: Function, + * poserSurPlace: Function}} */ export function creerEtatUi() { let etat = Object.freeze({ ...DEPART, apropos: false }); @@ -75,10 +122,11 @@ export function creerEtatUi() { }; }, etat: () => etat, - /** Ouvre une section ; RangeError hors de SECTIONS. */ + /** Ouvre une section ; RangeError hors de SECTIONS. Une autre section + * que la courante retire la pose : elle ne revient pas avec le plan. */ allerA(section) { if (!SECTIONS.includes(section)) throw new RangeError(`section inconnue : ${JSON.stringify(section)}`); - changer({ section }); + changer(section === etat.section ? {} : { section, pose: null }); }, /** Le départ d'un événement qui s'ouvre ; le panneau « À propos » reste tel. */ reinitialiser() { @@ -90,16 +138,19 @@ export function creerEtatUi() { fermerApropos() { changer({ apropos: false }); }, - /** Montre le tour r, entier positif ; RangeError sinon. */ + /** Montre le tour r, entier positif ; RangeError sinon. Le choix en + * attente s'oublie, et la pose, qui tient à l'état des places d'un tour. */ montrerTour(r) { if (!Number.isInteger(r) || r < 1) throw new RangeError(`tour : entier positif attendu, reçu ${String(r)}`); - changer({ tour: r }); + changer({ tour: r, choix: null, pose: null }); }, mettreEnEvidence(id) { changer({ miseEnEvidence: id }); }, + /** Demande la proposition id au plan, ou aucune (null) ; la pose, lue + * sur l'état des places d'avant, se retire. */ montrerProposition(id) { - changer({ proposition: id }); + changer({ proposition: id, pose: null }); }, /** Garde un texte à importer et ouvre la section de l'import. */ ouvrirImport({ texte, origine }) { @@ -117,8 +168,26 @@ export function creerEtatUi() { basculerPleinEcran() { changer({ pleinEcran: !etat.pleinEcran }); }, + /** Sélectionne une place ; null retire la sélection et oublie le choix en attente. */ selectionner(place) { - changer({ selection: place }); + changer(place === null ? { selection: null, choix: null } : { selection: place }); + }, + /** Pose le choix en attente (EtatUi.choix), ou l'oublie (null) ; RangeError hors du contrat. */ + choisir(choix) { + changer({ choix: choix === null ? null : choixValide(choix) }); + }, + /** Pose sur une place un refus, ou des avertissements, que le plan montre ; null les retire. + * RangeError sans place. */ + poserSurPlace(pose) { + if (pose === null) { + changer({ pose: null }); + return; + } + const { place, refus = null, avertissements = [] } = pose; + if (place === null || typeof place !== 'object' || !entierPositif(place.table)) { + throw new RangeError(`pose sans place : ${JSON.stringify(pose)}`); + } + changer({ pose: Object.freeze({ place, refus, avertissements }) }); }, /** Ouvre la fiche de la personne id, entier positif, dans la section des * participants ; null referme la fiche, la section reste. RangeError diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js index 9725f6e..083dcf2 100644 --- a/src/interface/etat_ui.test.js +++ b/src/interface/etat_ui.test.js @@ -21,6 +21,8 @@ const DEPART = { pleinEcran: false, selection: null, fiche: null, + choix: null, + pose: null, }; // Un état et le relevé de ce que reçoit son abonné. @@ -89,6 +91,8 @@ describe('état de navigation : chaque transition', () => { ui.basculerPleinEcran(); ui.selectionner({ table: 1, siege: 2 }); ui.ouvrirFiche(4); + ui.choisir({ genre: 'personne', participant: 2, depuis: null }); + ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] }); ui.ouvrirApropos(); ui.reinitialiser(); assert.deepEqual(ui.etat(), { ...DEPART, apropos: true }); @@ -174,3 +178,93 @@ describe('état de navigation : chaque transition', () => { assert.equal(recus.length, 1); }); }); + +describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => { + const PERSONNE = { genre: 'personne', participant: 2, depuis: { table: 4, siege: 1 } }; + const DEPUIS_LA_RESERVE = { genre: 'personne', participant: 3, depuis: null }; + const RESERVATION = { + genre: 'reservation', + participant: 5, + depuis: { table: 3, siege: 2 }, + de: { table: 3, portee: 'tous', tour: null }, + }; + + test('choisir pose un choix de chaque genre, figé, puis null l’oublie', () => { + const ui = creerEtatUi(); + for (const choix of [PERSONNE, DEPUIS_LA_RESERVE, RESERVATION]) { + ui.choisir(choix); + assert.deepEqual(ui.etat(), { ...DEPART, choix }); + assert.ok(Object.isFrozen(ui.etat().choix)); + ui.choisir(null); + assert.deepEqual(ui.etat(), DEPART); + } + }); + + test('un choix hors du contrat lève RangeError sans rien changer', () => { + const ui = creerEtatUi(); + ui.choisir(PERSONNE); + const avant = ui.etat(); + const fautes = [ + { genre: 'table', participant: 2, depuis: null }, + { genre: 'personne', participant: 0, depuis: null }, + { genre: 'personne', participant: '2', depuis: null }, + { genre: 'personne', participant: 2 }, + { genre: 'personne', participant: 2, depuis: { siege: 1 } }, + { genre: 'personne', participant: 2, depuis: { table: 1, siege: 0 } }, + { genre: 'reservation', participant: 5, depuis: { table: 3, siege: 2 } }, + { genre: 'reservation', participant: 5, depuis: null, de: { table: 3, portee: 'parfois', tour: null } }, + undefined, + ]; + for (const faute of fautes) assert.throws(() => ui.choisir(faute), RangeError, JSON.stringify(faute)); + assert.equal(ui.etat(), avant); + }); + + test('montrerTour et selectionner(null) oublient le choix ; selectionner une place le garde', () => { + const ui = creerEtatUi(); + ui.choisir(PERSONNE); + ui.selectionner({ table: 2, siege: 3 }); + assert.deepEqual(ui.etat().choix, PERSONNE); + ui.selectionner(null); + assert.equal(ui.etat().choix, null); + ui.choisir(RESERVATION); + ui.montrerTour(2); + assert.equal(ui.etat().choix, null); + ui.choisir(PERSONNE); + ui.montrerTour(2); + assert.equal(ui.etat().choix, null, 'le même tour remontré oublie aussi'); + }); + + test('poserSurPlace pose un refus ou des avertissements sur une place, puis null les retire ; aucune transition ne reçoit la séance', () => { + const ui = creerEtatUi(); + const pose = { + place: { table: 3, siege: null }, + refus: { code: 'PERSONNE_EXCLUE', details: { participant: 1 } }, + avertissements: [{ code: 'TABLE_GELEE', details: { table: 3 } }], + }; + ui.poserSurPlace(pose); + assert.deepEqual(ui.etat(), { ...DEPART, pose }); + assert.ok(Object.isFrozen(ui.etat().pose)); + ui.poserSurPlace(null); + assert.deepEqual(ui.etat(), DEPART); + assert.throws(() => ui.poserSurPlace({ place: null, refus: null, avertissements: [] }), RangeError); + assert.equal(ui.choisir.length, 1); + assert.equal(ui.poserSurPlace.length, 1); + }); + + test('montrerTour, montrerProposition et un changement de section retirent la pose ; la section remontrée la garde', () => { + const ui = creerEtatUi(); + const pose = { place: { table: 7, siege: 1 }, refus: { code: 'PLACE_RESERVEE', details: { participant: 4 } }, avertissements: [] }; + ui.poserSurPlace(pose); + ui.montrerTour(2); + assert.equal(ui.etat().pose, null, 'montrerTour'); + ui.poserSurPlace(pose); + ui.montrerProposition(1); + assert.equal(ui.etat().pose, null, 'montrerProposition'); + ui.allerA('plan'); + ui.poserSurPlace(pose); + ui.allerA('plan'); + assert.deepEqual(ui.etat().pose, pose, 'la même section'); + ui.allerA('tables'); + assert.equal(ui.etat().pose, null, 'une autre section'); + }); +}); diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index d60ae92..dbedf2f 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -9,7 +9,7 @@ // entre ce qui le précède et ce qui le suit. Le nom et le libellé du titre // sont des données : aucun texte de cette table ne les porte. La tête finit // par une espace quand un nom la suit (avecNom). -import { compte } from '../../application/libelles/aides.js'; +import { compte, enumeration } from '../../application/libelles/aides.js'; export const TABLE = new Map([ // --- Le dessin d'une table (§ 7.5, § 7.6) ---------------------------------- @@ -97,8 +97,86 @@ export const TABLE = new Map([ ['plan.refus.siege', ({ numero, siege }) => `Table ${numero}, siège ${siege}`], ['plan.refus.fermer', 'Fermer'], + // --- Le clavier et l'infobulle (§ 7.1, § 7.3, § 13.5) ---------------------- + // Le plan est un seul arrêt de tabulation : sa description dit comment s'y + // déplacer. L'infobulle d'une table en conflit nomme chaque règle violée, + // puis ses groupes : les noms sont des données, chacun dans son nœud, + // reliés par « et » dans une paire et séparés d'un groupe à l'autre. + ['plan.vue.role', 'plan de salle interactif'], + ['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`], + ['plan.infobulle.separerAppartenances', 'appartenances réunies : '], + ['plan.infobulle.nouveauxVoisins', 'voisins revus : '], + ['plan.infobulle.nouvelleTable', 'table revue : '], + ['plan.infobulle.et', ' et '], + ['plan.infobulle.separateur', ', '], + // --- Les panneaux latéraux (§ 2.1, étape 8) -------------------------------- ['plan.panneaux', 'Panneaux du plan'], ['plan.listesTravail.titre', 'Listes de travail'], ['plan.reserve.titre', 'Réserve'], + + // --- Le choix en attente (§ 5.8, § 7.1) ------------------------------------ + // Le nom de la personne choisie est une donnée, dans son propre nœud. + ['plan.choix.personne', 'Personne choisie : '], + ['plan.choix.reservation', 'Réservation choisie : '], + ['plan.choix.oublier', 'Oublier le choix'], + [ + 'plan.choix.inviter', + "Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.", + ], + + // --- Le panneau d'une place (§ 7.1, § 2.1 étape 6, § 4) -------------------- + ['plan.action.assigner', 'Assigner'], + ['plan.action.reserver', 'Réserver'], + ['plan.action.donnerTitre', 'Donner un titre'], + ['plan.action.designer', 'Désigner'], + ['plan.action.retirerReservation', 'Retirer la réservation'], + ['plan.action.retirerTitre', 'Retirer le titre'], + ['plan.action.deplacerReservationIci', 'Déplacer la réservation ici'], + ['plan.portee.etiquette', 'Portée'], + ['plan.portee.tour', ({ tour }) => `Tour ${tour}`], + ['plan.portee.tous', 'Tous les tours'], + ['plan.titre.etiquette', 'Libellé du titre'], + + // --- Le panneau d'une table (§ 7.3, § 2.1 étape 6) ------------------------- + ['plan.panneauTable.sieges', 'Sièges'], + [ + 'plan.panneauTable.titres', + ({ titres, pourvus }) => `${compte(titres, 'titre', 'titres')} dont ${compte(pourvus, 'pourvu', 'pourvus')}`, + ], + ['plan.panneauTable.siege', 'Siège'], + + // --- Le menu par personne (§ 7.1) ------------------------------------------ + // Le nom de la personne, titre du menu, est une donnée dans son nœud. + ['plan.menu.titre', 'Menu de '], + ['plan.menu.deplacer', 'Déplacer'], + ['plan.menu.echanger', 'Échanger'], + ['plan.menu.retirerDuTour', 'Retirer du tour'], + ['plan.menu.reserverIci', 'Réserver ici'], + ['plan.menu.deplacerReservation', 'Déplacer sa réservation'], + ['plan.menu.exclure', 'Exclure'], + ['plan.menu.reintegrer', 'Réintégrer'], + ['plan.menu.ouvrirFiche', 'Ouvrir la fiche'], + + // --- Les listes de travail (§ 5.8, § 4.1, § 4.2) --------------------------- + // Le libellé d'un titre, le titre pressenti et le nom d'une personne sont + // des données, chacun dans son nœud, entre ces textes. + ['plan.listesTravail.titres', 'Titres de place non pourvus'], + ['plan.listesTravail.pressentis', 'Titres pressentis sans réservation'], + ['plan.listesTravail.conversions', 'Conversions proposées'], + ['plan.listesTravail.aucun', 'Aucun.'], + ['plan.listesTravail.titreNonPourvu', ({ numero }) => `table ${numero} : titre `], + ['plan.listesTravail.nonPourvu', ' non pourvu'], + [ + 'plan.listesTravail.pourvuAuxTours', + ({ tours }) => ` (pourvu ${tours.length === 1 ? 'au tour' : 'aux tours'} ${enumeration(tours.map(String))})`, + ], + ['plan.listesTravail.pressenti', ' \u{2014} titre pressenti '], + ['plan.listesTravail.conversion', ({ numero }) => ` : une réservation à chaque tour, table ${numero}`], + ['plan.listesTravail.convertir', 'Convertir en « tous les tours »'], + ['plan.listesTravail.ignorer', 'Ignorer'], + + // --- La réserve (§ 7.1, § 5.8) --------------------------------------------- + ['plan.reserve.tour', ({ tour }) => `Personnes en attente au tour ${tour}`], + ['plan.reserve.vide', 'Personne en attente à ce tour.'], ]); diff --git a/src/interface/plan/Infobulle.svelte b/src/interface/plan/Infobulle.svelte new file mode 100644 index 0000000..f669b38 --- /dev/null +++ b/src/interface/plan/Infobulle.svelte @@ -0,0 +1,74 @@ + + + + + + + diff --git a/src/interface/plan/ListesTravail.svelte b/src/interface/plan/ListesTravail.svelte index 5678a32..a0cfce4 100644 --- a/src/interface/plan/ListesTravail.svelte +++ b/src/interface/plan/ListesTravail.svelte @@ -2,21 +2,170 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('plan.listesTravail.titre')}

+ + +
+

{t('plan.listesTravail.titre')}

+ {#if charge !== null} +

{t('plan.listesTravail.titres')}

+
    + {#each titres as titre (`${titre.table}:${titre.siege}:${titre.libelle}`)} +
  • + +
  • + {/each} +
+ {#if titres.length === 0} +

{t('plan.listesTravail.aucun')}

+ {/if} + +

{t('plan.listesTravail.pressentis')}

+
    + {#each pressentis as { participant, titrePressenti } (participant)} +
  • + +
  • + {/each} +
+ {#if pressentis.length === 0} +

{t('plan.listesTravail.aucun')}

+ {/if} + + {#if conversions.length > 0} +

{t('plan.listesTravail.conversions')}

+
    + {#each conversions as conversion (conversion.cle)} +
  • +

    + {nomDe(conversion.participant)}{t('plan.listesTravail.conversion', { numero: numeroDe(conversion.table) })} +

    + seance.executer('convertirEnTous', { participant: conversion.participant, table: conversion.table })} + /> + +
  • + {/each} +
+ {/if} + {/if}
diff --git a/src/interface/plan/MenuPersonne.svelte b/src/interface/plan/MenuPersonne.svelte new file mode 100644 index 0000000..23be572 --- /dev/null +++ b/src/interface/plan/MenuPersonne.svelte @@ -0,0 +1,124 @@ + + + + +{#if fiche !== undefined} + +{/if} + + diff --git a/src/interface/plan/PanneauPlace.svelte b/src/interface/plan/PanneauPlace.svelte index 5803d8e..0d3c15d 100644 --- a/src/interface/plan/PanneauPlace.svelte +++ b/src/interface/plan/PanneauPlace.svelte @@ -2,6 +2,237 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> + +{#if chaise !== null} + + +
+

{t(nommee.cle, nommee.details)}

+

+ {#each parties as { partie, texte } (partie)}{texte}{/each} +

+ {#if choix !== null} +

+ {t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}{nomDe(choix.participant)} +

+ {:else if actions.includes('assigner')} +

{t('plan.choix.inviter')}

+ {/if} + {#if actions.includes('reserver')} + + {/if} + {#if actions.includes('donnerTitre') || actions.includes('designer')} + + {/if} +
+ {#each actions as action (action)} + + {/each} + {#if choix !== null} + ui.choisir(null)} /> + {/if} +
+ {#if menuDe !== null} + + {/if} +
+{/if} + + diff --git a/src/interface/plan/PanneauTable.svelte b/src/interface/plan/PanneauTable.svelte index 0c53c02..e4b2dec 100644 --- a/src/interface/plan/PanneauTable.svelte +++ b/src/interface/plan/PanneauTable.svelte @@ -2,6 +2,216 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> + +{#if etatTable !== undefined} + + +
+

{t(nommee.cle, nommee.details)}

+

{t('plan.panneauTable.titres', comptes)}

+ {#if etatTable.gelee} +

{t('plan.liste.gelee')}

+ {/if} +

{t('plan.panneauTable.sieges')}

+
    + {#each lignes as parties, rang (rang)} +
  • + +
  • + {/each} +
+ {#if choix !== null} +

+ {t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}{nomDe(choix.participant)} +

+ {:else} +

{t('plan.choix.inviter')}

+ {/if} + {#if etat.siegesAttribues} + + {/if} + +
+ + +
+
+{/if} + + diff --git a/src/interface/plan/Panneaux.navigateur.test.js b/src/interface/plan/Panneaux.navigateur.test.js new file mode 100644 index 0000000..33c8257 --- /dev/null +++ b/src/interface/plan/Panneaux.navigateur.test.js @@ -0,0 +1,1387 @@ +// © 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 } from 'svelte'; +import { appliquerCommande } from '../../application/commandes.js'; +import { etatPlaces } from '../../application/places.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 { cadreDepuisRect, englobantDuTour } 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 du fichier d'épreuve d'I10 : 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 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 un avertissement paru au-dessus +// du plan l'a poussé sous le bas de la fenêtre, la page défile d'abord +// jusqu'à la chaise. +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()) { + chaiseEl(m, table, siege).scrollIntoView({ block: 'center', inline: 'center' }); + 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 }); +} + +// 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}`); + const voisine = tables.find(({ position }) => position.x === premiere.position.x + 300 && position.y === premiere.position.y); + 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'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, en mots. + const LIBELLES = { separerAppartenances: 'appartenances réunies : ', nouveauxVoisins: 'voisins revus : ', nouvelleTable: 'table revue : ' }; + function detailAttendu(m) { + const table = etatMontre(m).tables.find(({ id }) => id === 1); + const nom = (id) => { + const { nom: n, prenom } = charge(m).participants.find((p) => p.id === id); + return prenom === null ? n : `${prenom} ${n}`; + }; + expect(table.conflits.nombre).toBeGreaterThan(0); + return [ + `Table 1 : ${table.conflits.nombre} conflits`, + ...table.conflits.regles.map( + (regle) => `${LIBELLES[regle.regle]}${(regle.paires ?? regle.personnes.map((id) => [id])).map((g) => g.map(nom).join(' et ')).join(', ')}`, + ), + ]; + } + 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 l’oublie depuis une table active comme depuis un siège actif, qui remonte à sa table', 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]}`); + 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]) }, + ]); + }); +}); + +// --- 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 } }); + await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull()); + }); + + // L'avertissement nomme Iris et la table 3 : son texte vient du libellé + // d'A2 et du composant des avertissements d'I4, que le correctif + // rapports/I11-avertissement-nomme.diff complète. L'épreuve échoue tant + // qu'il n'est pas appliqué ; appliqué, test.fails la signale à retourner. + test.fails('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3 (attend le correctif d’A2 et d’I4)', 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(); + }); +}); diff --git a/src/interface/plan/Plan.svelte b/src/interface/plan/Plan.svelte index 1187115..dd96313 100644 --- a/src/interface/plan/Plan.svelte +++ b/src/interface/plan/Plan.svelte @@ -51,17 +51,53 @@ // par RefusPlace, hors du , et la place le désigne par // aria-describedby ; l'appui suivant le retire. Tant que la commande d'un // geste n'est pas réglée, un appui ne saisit que le sol. - import { getContext, tick } from 'svelte'; + // + // La pose — refus ou avertissements sur une place — vit dans l'état de + // navigation (ui.pose) : un panneau latéral y pose le refus d'une commande + // de place, que le plan montre comme celui d'un geste. Son remède + // choisirPlace (PLACE_RESERVEE) fait de la réservation qui tient la place + // le choix en attente (ui.choix). Une commande d'un geste exécutée oublie + // le choix en attente. + // + // Le clavier (§ 7.3, § 13.5) : la racine est le seul arrêt de tabulation + // du plan — tabindex 0, un role, un aria-label — et désigne l'élément + // actif, une table ou un siège, par aria-activedescendant, sur les id que + // TablePlan pose. Les touches passent par toucheDuPlan (clavier.js) : + // flèches, entrée, échappement. L'entrée sur un siège le sélectionne et + // porte le focus au panneau de la place ; en régime table, elle ouvre le + // panneau de la table et porte le focus sur la liste de ses sièges. Le + // panneau rend le focus à la racine par Échap. Échap sur la racine oublie + // le choix en attente, quel que soit l'élément actif. L'élément actif suit + // la place sélectionnée. Une flèche, un focus venu du clavier et une + // sélection venue d'ailleurs que du pointeur — un panneau, une liste de + // travail — amènent l'élément actif dans le cadre (vueQuiMontre) ; la + // sélection d'un appui sur le plan ne déplace pas la vue : la chaise + // cliquée reste sous le pointeur. + // La marque focus ne se montre que sous un focus venu du clavier + // (:focus-visible, puis toute touche) : un clic n'en pose pas. + // + // L'infobulle (§ 7.1, § 7.3) : un seul [role=tooltip], ouvert sur une table + // en conflit par le survol — un pointermove de la racine sans bouton + // enfoncé, hors d'un geste —, par le focus du clavier sur la table ou l'un + // de ses sièges, et par la pression longue d'un doigt, minuterie de + // l'horloge de DUREE_PRESSION_LONGUE_MS, annulée au relâcher, à + // l'annulation, ou quand la machine du geste juge l'appui devenu + // glissement, au-delà du seuil. Elle reste ouverte jusqu'à l'appui + // suivant. + import { getContext, tick, untrack } from 'svelte'; import { controlerCapacite } from '../../application/capacite.js'; import { etatPlaces } from '../../application/places.js'; + import { constante } from '../../geometrie/constantes.js'; import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; import BandeauPlan from './BandeauPlan.svelte'; import BarreVue from './BarreVue.svelte'; + import { toucheDuPlan, vueQuiMontre } from './clavier.js'; import { deposer } from './depot.js'; import EchellePlan from './EchellePlan.svelte'; import Fantome from './Fantome.svelte'; import { REPOS, cleFantome, geste, memoriser } from './gestes.js'; - import { modeleDessin } from './modele.js'; + import Infobulle from './Infobulle.svelte'; + import { detailConflits, modeleDessin } from './modele.js'; import PlanDessin from './PlanDessin.svelte'; import RefusPlace from './RefusPlace.svelte'; import { @@ -80,6 +116,9 @@ const t = getContext('t'); const seance = getContext('seance'); const ui = getContext('ui'); + const horloge = getContext('horloge'); + + const DUREE_PRESSION = constante('DUREE_PRESSION_LONGUE_MS'); let racine = $state(null); let vue = $state.raw(null); @@ -90,13 +129,22 @@ let poignee = $state.raw(null); // Le fantôme montré, {nom, cle}, ou null. let fantome = $state.raw(null); - // Le refus ou les avertissements posés sur une place : {place, refus, - // avertissements, ancre — le point de dessin de la place, cm —, origine — - // le coin du dans la surface, px —}, ou null. - let pose = $state.raw(null); + // Le coin du dans la surface, en cadre dont le bord gauche et le + // haut sont relatifs à la surface : relu à chaque changement de cadre et à + // chaque appui, jamais dans un pointermove. + let origineSvg = $state.raw({ gauche: 0, haut: 0, largeur: 0, hauteur: 0 }); + // L'élément actif du clavier, {table, siege|null}, ou null ; la racine a + // le focus ; le focus vient du clavier. + let actif = $state.raw(null); + let aLeFocus = $state(false); + let auClavier = $state(false); + // Les tables que désignent le survol et la pression longue, ou null. + let survol = $state(null); + let pression = $state(null); const prefixe = $props.id(); const idPose = `${prefixe}-pose`; + const idInfobulle = `${prefixe}-infobulle`; const charge = $derived($seance.charge); const tours = $derived(charge.evenement.tours); @@ -104,19 +152,51 @@ const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition })); const englobantPlan = $derived(englobantDuTour(etat)); const nomDe = $derived(nomDeCharge(charge)); + const selection = $derived($ui.selection); + const pose = $derived($ui.pose); + // L'élément actif que le dessin marque : sous un focus venu du clavier. + const actifMontre = $derived(aLeFocus && auClavier ? actif : null); const modele = $derived( vue === null ? null - : modeleDessin(etat, { k: vue.k, listes: $ui.listes, miseEnEvidence: $ui.miseEnEvidence, nomDe, diagnostic, poignee }), + : modeleDessin(etat, { + k: vue.k, + listes: $ui.listes, + miseEnEvidence: $ui.miseEnEvidence, + nomDe, + diagnostic, + poignee, + selection, + actif: actifMontre, + }), ); - // La pose à sa position dans la surface, sous la vue courante. + // La pose à sa position dans la surface, sous la vue courante ; rien pour + // une place dont la table n'est plus dessinée. const poseAffichee = $derived.by(() => { - if (pose === null || vue === null) return null; - const { place, refus, avertissements, ancre, origine } = pose; - const { x, y } = ecranDepuisDessin(vue, origine, ancre.x, ancre.y); + if (pose === null || vue === null || modele === null) return null; + const { place, refus, avertissements } = pose; + const ancre = ancreDe(place); + if (ancre === null) return null; + const { x, y } = ecranDepuisDessin(vue, origineSvg, ancre.x, ancre.y); const numero = etat.tables.find(({ id }) => id === place.table)?.numero ?? null; return { id: idPose, numero, siege: place.siege ?? null, refus, avertissements, x, y }; }); + // L'infobulle : la table que désignent la pression longue, le survol, puis + // l'élément actif sous un focus venu du clavier ; rien quand elle n'a pas + // de conflit. Posée au coin haut droit de l'emprise. + const infobulleTable = $derived(pression ?? survol ?? (actifMontre === null ? null : actifMontre.table)); + const infobulle = $derived.by(() => { + if (infobulleTable === null || vue === null || modele === null) return null; + const etatTable = etat.tables.find(({ id }) => id === infobulleTable); + const dessin = modele.tables.find(({ id }) => id === infobulleTable); + if (etatTable === undefined || dessin === undefined) return null; + const detail = detailConflits(etatTable, nomDe); + if (detail === null) return null; + const { centre, rayonEmprise } = dessin.geometrie; + const { x, y } = ecranDepuisDessin(vue, origineSvg, centre.x + rayonEmprise, centre.y - rayonEmprise); + return { detail, x, y }; + }); + const infobulleDuFocus = $derived(infobulle !== null && pression === null && survol === null); // Le cadre de la racine, relu maintenant. const cadreCourant = () => cadreDepuisRect(racine.getBoundingClientRect()); @@ -176,31 +256,110 @@ return chaise ?? racine.querySelector(`[data-table="${table}"]`); } - // Le point de dessin d'une place : le centre de sa chaise, ou de sa table. + // Le point de dessin d'une place : le centre de sa chaise, ou de sa + // table ; null pour une table que le dessin n'a pas. function ancreDe({ table, siege = null }) { - const { geometrie } = modele.tables.find(({ id }) => id === table); + const dessin = modele.tables.find(({ id }) => id === table); + if (dessin === undefined) return null; + const { geometrie } = dessin; const chaise = siege === null ? undefined : geometrie.chaises[siege - 1]; return chaise === undefined ? { ...geometrie.centre } : { x: chaise.x, y: chaise.y }; } - // Le coin du dans la surface, relu à l'appui : un cadre dont le - // bord gauche et le haut sont relatifs à la surface. - let origineSurface = { gauche: 0, haut: 0, largeur: 0, hauteur: 0 }; + // Relit le coin du dans la surface, du cadre relu à l'instant. + function relireOrigine(cadre) { + const rect = surface.getBoundingClientRect(); + origineSvg = { gauche: cadre.gauche - rect.left, haut: cadre.haut - rect.top, largeur: cadre.largeur, hauteur: cadre.hauteur }; + } // Pose un refus et des avertissements sur une place ; rien des deux // retire la pose. function poserSurPlace(place, refus, avertissements) { - if (refus === null && avertissements.length === 0) { - pose = null; - return; - } - pose = { place, refus, avertissements, ancre: ancreDe(place), origine: origineSurface }; + ui.poserSurPlace(refus === null && avertissements.length === 0 ? null : { place, refus, avertissements }); } const fermerPose = () => { - pose = null; + ui.poserSurPlace(null); }; + // Les gestes qu'un remède posé sur une place emprunte au plan : choisir + // une place pour la réservation qui tient la place refusée (PLACE_RESERVEE, + // { geste: 'choisirPlace', puis }) — elle devient le choix en attente, et + // « Déplacer la réservation ici » la déplace (PanneauPlace). + const gestesPose = { + choisirPlace: (remede) => { + const place = ui.etat().pose?.place ?? null; + const { participant, de } = remede.puis.arguments; + ui.choisir({ + genre: 'reservation', + participant, + depuis: place === null ? null : { table: place.table, siege: place.siege ?? null }, + de, + }); + return true; + }, + }; + + // Amène une place dans le cadre, k inchangé ; la vue ne change que si la + // place en sort. + function montrer(place) { + if (place === null || vue === null || modele === null) return; + const ancre = ancreDe(place); + if (ancre === null) return; + const suivante = vueQuiMontre(vue, cadreCourant(), ancre); + if (suivante !== vue) poserVue(suivante); + } + + // Porte le focus à un panneau latéral de la section : à son élément + // [data-focus-initial], sinon au panneau. + async function focaliserPanneau(genre) { + await tick(); + const section = racine.closest('[data-section]') ?? document; + const panneau = section.querySelector(`[data-panneau="${genre}"]`); + if (panneau === null) return; + (panneau.querySelector('[data-focus-initial]') ?? panneau).focus(); + } + + // Le contexte de toucheDuPlan : les géométries dessinées, leur régime et + // leur nombre de chaises. + function contexteClavier() { + const dessin = (id) => modele.tables.find((table) => table.id === id); + return { + geometries: modele.tables.map(({ geometrie }) => geometrie), + regimeDe: (id) => dessin(id).regime, + capaciteDe: (id) => dessin(id).geometrie.chaises.length, + }; + } + + // L'élément actif qu'annonce aria-activedescendant : le siège quand sa + // chaise est dessinée, sa table sinon. + const activeDescendant = $derived.by(() => { + if (actif === null || modele === null) return undefined; + const dessin = modele.tables.find(({ id }) => id === actif.table); + if (dessin === undefined) return undefined; + if (actif.siege !== null && dessin.chaises.some(({ siege }) => siege === actif.siege)) return `siege-${actif.table}-${actif.siege}`; + return `table-${actif.table}`; + }); + + // L'élément actif suit la place sélectionnée ; une sélection nouvelle + // vient dans le cadre, sauf celle d'un appui du pointeur, que l'effet + // selectionner d'un geste désigne ici par l'objet même qu'il confie à + // ui.selectionner. La sélection du montage ne déplace pas la vue. + let selectionSuivie = untrack(() => selection); + let selectionDuPointeur = null; + $effect(() => { + const place = selection; + if (place === selectionSuivie) return; + selectionSuivie = place; + const duPointeur = place !== null && place === selectionDuPointeur; + selectionDuPointeur = null; + if (place === null) return; + untrack(() => { + actif = { table: place.table, siege: place.siege ?? null }; + if (!duPointeur) montrer(actif); + }); + }); + // Restaure ce qu'un geste a montré. function restaurer(cible) { if (cible === 'vue') groupeVue.setAttribute('transform', transformDe(vue)); @@ -226,6 +385,7 @@ commandeEnCours = true; try { await seance.executer(commande, args); + ui.choisir(null); } catch (erreur) { if (typeof erreur?.code !== 'string') throw erreur; poserSurPlace(place, { code: erreur.code, details: erreur.details ?? {} }, []); @@ -253,6 +413,7 @@ const recadrer = () => { image = null; const cadre = cadreCourant(); + relireOrigine(cadre); vue = vue === null ? vueInitiale(seance.cadrage(), englobantPlan, cadre) : vueApresCadre(vue, englobantPlan, cadre); }; const observateur = new ResizeObserver(() => { @@ -275,6 +436,12 @@ const element = racine; let etatGeste = REPOS; let contexte = null; + // La minuterie de la pression longue, armée par l'appui d'un doigt. + let minuteriePression = null; + const oublierPression = () => { + if (minuteriePression !== null) horloge.annuler(minuteriePression); + minuteriePression = null; + }; // Le dernier point d'un mouvement en attente de son image. let image = null; let enAttente = null; @@ -289,6 +456,9 @@ function appliquer({ etat: suivant, effets }) { const pointeur = etatGeste.pointeur; etatGeste = suivant; + // Un appui devenu glissement — au-delà du seuil, que la machine juge — + // n'est plus une pression longue. + if (suivant.phase === 'glisse') oublierPression(); for (const effet of effets) { switch (effet.type) { case 'capturer': @@ -314,10 +484,10 @@ break; case 'montrerPlaces': poignee = { table: effet.table, places: effet.places }; - poserSurPlace({ table: effet.table }, pose?.refus ?? null, effet.avertissements); + poserSurPlace({ table: effet.table }, ui.etat().pose?.refus ?? null, effet.avertissements); break; case 'montrerRefus': - poserSurPlace(effet.place, effet.refus, pose?.avertissements ?? []); + poserSurPlace(effet.place, effet.refus, ui.etat().pose?.avertissements ?? []); break; case 'restaurer': restaurer(effet.cible); @@ -326,6 +496,7 @@ poserVue(effet.vue); break; case 'selectionner': + selectionDuPointeur = effet.place; ui.selectionner(effet.place); break; case 'executer': @@ -352,13 +523,24 @@ // qu'une commande d'un geste n'est pas réglée, seul le sol se saisit : // le déplacement de vue ne lit ni la charge ni l'état des places. const surAppui = (evenement) => { + auClavier = false; if (etatGeste.phase !== 'repos' || evenement.button !== 0 || vue === null || modele === null) return; const source = sourceDe(evenement.target); if (commandeEnCours && source.genre !== 'sol') return; const cadre = cadreCourant(); - const rect = surface.getBoundingClientRect(); - origineSurface = { gauche: cadre.gauche - rect.left, haut: cadre.haut - rect.top, largeur: cadre.largeur, hauteur: cadre.hauteur }; - pose = null; + relireOrigine(cadre); + ui.poserSurPlace(null); + survol = null; + pression = null; + oublierPression(); + // Un doigt posé sur une table : sa pression longue ouvre l'infobulle. + if (evenement.pointerType === 'touch' && source.table !== null) { + const table = source.table; + minuteriePression = horloge.planifier(() => { + minuteriePression = null; + pression = table; + }, DUREE_PRESSION); + } // La charge, l'état, les géométries et k du moment de l'appui : le // geste se juge sur eux jusqu'à son terme. const chargeDuGeste = charge; @@ -392,29 +574,103 @@ }; const surMouvement = (evenement) => { - if (etatGeste.phase === 'repos' || evenement.pointerId !== etatGeste.pointeur) return; + if (etatGeste.phase === 'repos') { + // Le survol : une souris ou un stylet sans bouton enfoncé. + if (evenement.pointerType !== 'touch' && evenement.buttons === 0) { + const groupe = evenement.target.closest('[data-table]'); + survol = groupe === null ? null : Number(groupe.dataset.table); + } + return; + } + if (evenement.pointerId !== etatGeste.pointeur) return; enAttente = { x: evenement.clientX, y: evenement.clientY }; if (image === null) image = requestAnimationFrame(livrer); }; const surRelache = (evenement) => { if (etatGeste.phase === 'repos' || evenement.pointerId !== etatGeste.pointeur) return; + oublierPression(); oublierImage(); const point = { x: evenement.clientX, y: evenement.clientY }; appliquer(geste(etatGeste, { type: 'relacher', pointeur: evenement.pointerId, point }, contexte)); }; const surAnnulationPointeur = (evenement) => { + oublierPression(); if (etatGeste.phase !== 'repos' && evenement.pointerId === etatGeste.pointeur) annulerGeste('pointercancel'); }; + // La sortie du pointeur et la perte du focus arrivent aussi pendant + // qu'un bloc démonte le plan — Chromium retire le focus d'un élément + // qu'on détache — : l'état change hors du suivi des dépendances. + const surSortie = () => { + untrack(() => { + survol = null; + }); + }; + + // Le focus de la racine. Venu du clavier, il active la sélection, sinon + // la première table, quand rien n'est actif, et amène l'élément actif + // dans le cadre. Un appui, qui donne aussi le focus, n'écrit rien sous + // la racine et ne déplace pas la vue : la première touche activera. + const surFocus = () => { + aLeFocus = true; + auClavier = element.matches(':focus-visible'); + if (!auClavier || modele === null || modele.tables.length === 0) return; + const existe = actif !== null && modele.tables.some(({ id }) => id === actif.table); + if (!existe) { + const place = selection ?? { table: modele.tables[0].id, siege: null }; + actif = { table: place.table, siege: place.siege ?? null }; + } + if (etatGeste.phase === 'repos') montrer(actif); + }; + const surPerteFocus = () => { + untrack(() => { + aLeFocus = false; + }); + }; + + // Une touche sur la racine, hors d'un geste : Échap d'un geste en cours + // passe d'abord par surTouche, en phase de capture. + const surToucheClavier = (evenement) => { + if (etatGeste.phase !== 'repos' || modele === null || evenement.altKey || evenement.ctrlKey || evenement.metaKey) return; + auClavier = true; + if (evenement.key === 'Escape') ui.choisir(null); + const rendu = toucheDuPlan(actif, evenement.key, contexteClavier()); + if (rendu === null) return; + evenement.preventDefault(); + actif = rendu.actif; + const { effet } = rendu; + if (effet === null) { + montrer(actif); + } else if (effet.type === 'selectionner') { + ui.selectionner(effet.place); + focaliserPanneau('place'); + } else if (effet.type === 'ouvrirTable') { + ui.selectionner({ table: effet.table }); + focaliserPanneau('table'); + } else { + ui.poserSurPlace(null); + ui.selectionner(null); + } + }; + const desabonner = seance.surAnnulation(() => annulerGeste('lecture')); element.addEventListener('pointerdown', surAppui); element.addEventListener('pointermove', surMouvement); element.addEventListener('pointerup', surRelache); element.addEventListener('pointercancel', surAnnulationPointeur); + element.addEventListener('pointerleave', surSortie); + element.addEventListener('focus', surFocus); + element.addEventListener('blur', surPerteFocus); + element.addEventListener('keydown', surToucheClavier); return () => { desabonner(); + oublierPression(); + element.removeEventListener('pointerleave', surSortie); + element.removeEventListener('focus', surFocus); + element.removeEventListener('blur', surPerteFocus); + element.removeEventListener('keydown', surToucheClavier); element.removeEventListener('pointerdown', surAppui); element.removeEventListener('pointermove', surMouvement); element.removeEventListener('pointerup', surRelache); @@ -440,7 +696,21 @@
- + + + {#if modele !== null} @@ -449,8 +719,9 @@ {#if poseAffichee !== null} - + {/if} +
@@ -497,4 +768,16 @@ touch-action: none; user-select: none; } + + /* Un focus venu du clavier se voit sur la racine, contour tracé en + dedans : un contour ne change pas la boîte du cadre. L'élément actif + porte en plus la marque focus. */ + .dessin:focus { + outline: none; + } + + .dessin:focus-visible { + outline: 3px solid var(--couleur-focus); + outline-offset: -3px; + } diff --git a/src/interface/plan/RefusPlace.svelte b/src/interface/plan/RefusPlace.svelte index bb9778d..1b047e9 100644 --- a/src/interface/plan/RefusPlace.svelte +++ b/src/interface/plan/RefusPlace.svelte @@ -12,12 +12,14 @@ // place le désigne par aria-describedby, vers id. // // pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y - // en px de la surface ; fermer : retire la pose. + // en px de la surface ; fermer : retire la pose ; gestes : ceux qu'un + // remède emprunte à l'écran — le choix d'une place sur le plan —, que Refus + // reçoit tels quels. import { getContext } from 'svelte'; import Refus from '../primitives/Refus.svelte'; import { libellePlace } from './gestes.js'; - let { pose, fermer } = $props(); + let { pose, fermer, gestes = {} } = $props(); const t = getContext('t'); const libelle = $derived(libellePlace(pose)); @@ -28,7 +30,7 @@ {t(libelle.cle, libelle.details)}

{#if pose.refus !== null} - + {/if} {#each pose.avertissements as avertissement, rang (rang)}

{t(avertissement.code, avertissement.details)}

diff --git a/src/interface/plan/Reserve.svelte b/src/interface/plan/Reserve.svelte index 5853c51..a7dc6e4 100644 --- a/src/interface/plan/Reserve.svelte +++ b/src/interface/plan/Reserve.svelte @@ -2,21 +2,92 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('plan.reserve.titre')}

+ + +
+

{t('plan.reserve.titre')}

+ {#if charge !== null} +

{t('plan.reserve.tour', { tour })}

+ {#if reserve.length === 0} +

{t('plan.reserve.vide')}

+ {:else} +
    + {#each reserve as id (id)} +
  • + +
  • + {/each} +
+ {/if} + {/if}
diff --git a/src/interface/plan/TablePlan.svelte b/src/interface/plan/TablePlan.svelte index 3bcd917..6394f26 100644 --- a/src/interface/plan/TablePlan.svelte +++ b/src/interface/plan/TablePlan.svelte @@ -20,17 +20,29 @@ // Une table en conflit, quand le plan signale, porte un contour épaissi // (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa // pastille porte le nombre de violations, signalée ou non (§ 7.5). + // + // Le groupe de la table porte id="table-", celui d'une chaise + // id="siege--" : la racine du plan, seul arrêt de tabulation, + // désigne l'élément actif par aria-activedescendant (§ 7.3, § 13.5). Une + // chaise a pour nom accessible sa ligne entière (texteLigne). La place + // sélectionnée porte la marque selection, un anneau en tirets ; l'élément + // actif du clavier, la marque focus, un carré : deux formes, que la + // couleur ne porte pas seule. Sur la chaise que le modèle désigne, ou sur + // l'emprise de la table quand la place n'a pas de chaise dessinée. import { getContext } from 'svelte'; import { RAYON_POIGNEE } from '../../geometrie/tables.js'; import { CADENAS_FERME, CADENAS_OUVERT, ECART_ANNEAU, + ECART_DESIGNATION, + ECART_FOCUS, ECART_MISE_EN_EVIDENCE, RAYON_PASTILLE, TAILLE_TEXTE_PASTILLE_CM, TRACE_GEL, formesTable, + texteLigne, } from './modele.js'; let { table, signaler, prefixe } = $props(); @@ -46,9 +58,17 @@ const signal = $derived(signaler && table.conflit); const hachure = $derived(`url(#${prefixe}-hachure)`); const trame = $derived(`url(#${prefixe}-trame)`); + // Les marques de désignation de la table, autour de l'emprise. + const autour = $derived(formesTable({ ...table.geometrie, rayonEmprise: table.geometrie.rayonEmprise + ECART_DESIGNATION })); + const cadreFocus = $derived(carreAutour(table.geometrie.centre, table.geometrie.rayonEmprise + ECART_FOCUS)); + + // Le carré de demi-côté demi centré sur un point, en attributs SVG. + function carreAutour({ x, y }, demi) { + return { x: x - demi, y: y - demi, width: 2 * demi, height: 2 * demi }; + } - + {#if table.regime === 'table'} {#if formes.ronde} @@ -71,8 +91,23 @@ {#if table.gelee} {/if} + {#if table.selection} + {#if autour.ronde} + + {:else} + + {/if} + {/if} + {#if table.focus} + + {/if} {#each table.chaises as chaise (chaise.siege)} - + {/if} + {#if chaise.selection} + + {/if} + {#if chaise.focus} + + {/if} {/each} @@ -172,6 +213,25 @@ pointer-events: none; } + /* La sélection : un anneau en tirets ; le focus du clavier : un carré + plein trait. Les deux gardent leur épaisseur à l'écran. */ + .selection, + .focus { + fill: none; + vector-effect: non-scaling-stroke; + stroke-width: 3; + pointer-events: none; + } + + .selection { + stroke: var(--couleur-selection); + stroke-dasharray: 6 4; + } + + .focus { + stroke: var(--couleur-focus); + } + .poignee { fill: var(--couleur-poignee); stroke: var(--couleur-poignee); diff --git a/src/interface/plan/clavier.js b/src/interface/plan/clavier.js new file mode 100644 index 0000000..c3ba00a --- /dev/null +++ b/src/interface/plan/clavier.js @@ -0,0 +1,152 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La navigation au clavier du plan (§ 7.3, § 13.5), pure : le plan est un +// seul arrêt de tabulation, et l'élément actif — une table, ou un siège +// d'une table, {table, siege|null} — se déplace par les flèches, selon la +// position des tables sur le plan ; l'entrée descend dans les sièges, +// l'échappement remonte. Aucune fonction ne lit le DOM : Plan.svelte +// applique l'élément actif rendu et l'effet qui l'accompagne. +import { ecranDepuisDessin } from '../../geometrie/vue.js'; + +/** Les directions des flèches, sur l'écran (y vers le bas). */ +const VECTEURS = new Map([ + ['droite', { x: 1, y: 0 }], + ['gauche', { x: -1, y: 0 }], + ['haut', { x: 0, y: -1 }], + ['bas', { x: 0, y: 1 }], +]); + +/** La direction de chaque flèche, et le sens où elle fait tourner les sièges. */ +const FLECHES = new Map([ + ['ArrowRight', { direction: 'droite', sens: 1 }], + ['ArrowDown', { direction: 'bas', sens: 1 }], + ['ArrowLeft', { direction: 'gauche', sens: -1 }], + ['ArrowUp', { direction: 'haut', sens: -1 }], +]); + +const ENTREES = new Set(['Enter', ' ']); + +/** + * La table voisine dans la direction d'une flèche : la plus proche, par la + * distance des centres, de celles dont le centre est dans le cône de ±45° + * autour de la direction — bords compris ; à égalité de distance, le plus + * petit numéro ; aucune → la même. + * + * @param {Array<{id: number, numero: number, centre: {x: number, y: number}}>} geometries + * @param {number} depuis identifiant de la table de départ + * @param {'droite'|'gauche'|'haut'|'bas'} direction + * @returns {number} identifiant + */ +export function tableVoisine(geometries, depuis, direction) { + const vecteur = VECTEURS.get(direction); + if (vecteur === undefined) throw new RangeError(`direction inconnue : ${JSON.stringify(direction)}`); + const origine = geometries.find(({ id }) => id === depuis); + if (origine === undefined) return depuis; + let meilleure = null; + for (const candidate of geometries) { + if (candidate.id === depuis) continue; + const dx = candidate.centre.x - origine.centre.x; + const dy = candidate.centre.y - origine.centre.y; + // Composante le long de la direction et composante transverse : le cône + // de ±45° est l'ensemble où la première est positive et au moins égale + // à la valeur absolue de la seconde. + const long = dx * vecteur.x + dy * vecteur.y; + const travers = Math.abs(dx * vecteur.y - dy * vecteur.x); + if (long <= 0 || long < travers) continue; + const distance = dx * dx + dy * dy; + if ( + meilleure === null || + distance < meilleure.distance || + (distance === meilleure.distance && candidate.numero < meilleure.numero) + ) { + meilleure = { id: candidate.id, numero: candidate.numero, distance }; + } + } + return meilleure === null ? depuis : meilleure.id; +} + +/** + * Le siège suivant (sens +1) ou précédent (sens −1), dans l'ordre des + * numéros de 1 à capacite, circulairement. + * + * @param {number} capacite @param {number} siege @param {1|-1} sens + * @returns {number} + */ +export function siegeVoisin(capacite, siege, sens) { + return ((((siege - 1 + sens) % capacite) + capacite) % capacite) + 1; +} + +/** + * Ce que fait une touche sur l'élément actif du plan (§ 7.3). + * + * Sans élément actif, ou quand sa table n'existe plus, une flèche ou + * l'entrée active la première table des géométries. Sur une table, une + * flèche mène à tableVoisine ; l'entrée — Enter ou l'espace — descend au + * siège 1 en régime sièges, et en régime table, où aucune chaise n'est + * dessinée, rend l'effet ouvrirTable : le siège se choisit dans le panneau + * de la table. Sur un siège, les flèches droite et bas mènent au suivant, + * gauche et haut au précédent ; l'entrée rend l'effet selectionner de la + * place. L'échappement remonte du siège à sa table, puis, sur une table, + * rend l'effet remonter : le choix en attente et la sélection s'oublient. + * Un siège d'une table passée en régime table se lit comme sa table. + * + * @param {{table: number, siege: number|null}|null} actif + * @param {string} cle KeyboardEvent.key + * @param {{geometries: Array<{id: number, numero: number, centre: Object}>, + * regimeDe: (id: number) => 'sieges'|'table', + * capaciteDe: (id: number) => number}} contexte + * @returns {null|{actif: {table: number, siege: number|null}, + * effet: null|{type: 'ouvrirTable', table: number}|{type: 'selectionner', place: Object}|{type: 'remonter'}}} + * null pour une touche que le plan ne prend pas, ou sans table + */ +export function toucheDuPlan(actif, cle, { geometries, regimeDe, capaciteDe }) { + const fleche = FLECHES.get(cle); + const entree = ENTREES.has(cle); + const echap = cle === 'Escape'; + if (fleche === undefined && !entree && !echap) return null; + if (geometries.length === 0) return null; + + const existe = actif !== null && geometries.some(({ id }) => id === actif.table); + if (!existe) { + if (echap) return null; + return { actif: { table: geometries[0].id, siege: null }, effet: null }; + } + const { table } = actif; + const siege = actif.siege !== null && regimeDe(table) === 'sieges' ? actif.siege : null; + + if (siege === null) { + if (fleche !== undefined) return { actif: { table: tableVoisine(geometries, table, fleche.direction), siege: null }, effet: null }; + if (echap) return { actif: { table, siege: null }, effet: { type: 'remonter' } }; + if (regimeDe(table) === 'sieges') return { actif: { table, siege: 1 }, effet: null }; + return { actif: { table, siege: null }, effet: { type: 'ouvrirTable', table } }; + } + if (fleche !== undefined) return { actif: { table, siege: siegeVoisin(capaciteDe(table), siege, fleche.sens) }, effet: null }; + if (echap) return { actif: { table, siege: null }, effet: null }; + return { actif: { table, siege }, effet: { type: 'selectionner', place: { table, siege } } }; +} + +/** Marge qui garde une place à l'intérieur du cadre, en px d'écran. */ +export const MARGE_MONTRER_PX = 24; + +/** + * La vue qui amène un point du dessin dans le cadre, k inchangé : la même + * vue quand le point y est déjà, à MARGE_MONTRER_PX du bord au moins ; sinon + * la vue translatée qui pose le point au centre du cadre. Sans vue, ou dans + * un cadre trop petit pour sa marge, la vue reçue. + * + * @param {{k: number, tx: number, ty: number}|null} vue + * @param {{gauche: number, haut: number, largeur: number, hauteur: number}} cadre + * @param {{x: number, y: number}} point cm + * @returns {{k: number, tx: number, ty: number}|null} + */ +export function vueQuiMontre(vue, cadre, point) { + if (vue === null || cadre.largeur <= 2 * MARGE_MONTRER_PX || cadre.hauteur <= 2 * MARGE_MONTRER_PX) return vue; + const ecran = ecranDepuisDessin(vue, cadre, point.x, point.y); + const x = ecran.x - cadre.gauche; + const y = ecran.y - cadre.haut; + const dedans = + x >= MARGE_MONTRER_PX && x <= cadre.largeur - MARGE_MONTRER_PX && y >= MARGE_MONTRER_PX && y <= cadre.hauteur - MARGE_MONTRER_PX; + if (dedans) return vue; + return { k: vue.k, tx: cadre.largeur / 2 - point.x * vue.k, ty: cadre.hauteur / 2 - point.y * vue.k }; +} diff --git a/src/interface/plan/clavier.test.js b/src/interface/plan/clavier.test.js new file mode 100644 index 0000000..85ae481 --- /dev/null +++ b/src/interface/plan/clavier.test.js @@ -0,0 +1,158 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la navigation au clavier du plan (§ 7.3, § 13.5), sans DOM : +// la table voisine dans la direction d'une flèche, le siège voisin, ce que +// fait une touche sur l'élément actif, et la vue qui amène une place dans +// le cadre. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { ecranDepuisDessin } from '../../geometrie/vue.js'; +import { siegeVoisin, tableVoisine, toucheDuPlan, vueQuiMontre } from './clavier.js'; + +// Une grille de 3 × 3 tables, au pas de 300 cm, numérotées ligne par ligne : +// 1 2 3 / 4 5 6 / 7 8 9 ; l'identifiant vaut dix fois le numéro. +const GRILLE = Array.from({ length: 9 }, (_, rang) => ({ + id: 10 * (rang + 1), + numero: rang + 1, + centre: { x: 300 * (rang % 3), y: 300 * Math.floor(rang / 3) }, +})); + +const idDuNumero = (numero) => 10 * numero; + +describe('tableVoisine : la plus proche dans le cône de la flèche (§ 7.3)', () => { + test('depuis le centre de la grille : chaque flèche mène à la voisine de son côté', () => { + assert.equal(GRILLE.length, 9); + const centre = idDuNumero(5); + assert.equal(tableVoisine(GRILLE, centre, 'droite'), idDuNumero(6)); + assert.equal(tableVoisine(GRILLE, centre, 'gauche'), idDuNumero(4)); + assert.equal(tableVoisine(GRILLE, centre, 'haut'), idDuNumero(2)); + assert.equal(tableVoisine(GRILLE, centre, 'bas'), idDuNumero(8)); + }); + + test('au bord, aucune table dans le cône : la même', () => { + assert.equal(tableVoisine(GRILLE, idDuNumero(6), 'droite'), idDuNumero(6)); + assert.equal(tableVoisine(GRILLE, idDuNumero(1), 'haut'), idDuNumero(1)); + assert.equal(tableVoisine(GRILLE, idDuNumero(7), 'gauche'), idDuNumero(7)); + assert.equal(tableVoisine(GRILLE, idDuNumero(9), 'bas'), idDuNumero(9)); + }); + + test('égalité de distance dans le cône : le plus petit numéro l’emporte, quel que soit l’ordre des géométries', () => { + // Depuis la table 1, vers la droite et en diagonale à 45° : (300, 300) + // et (300, −300) sont à égale distance et sur les bords du cône. + const tables = [ + { id: 1, numero: 1, centre: { x: 0, y: 0 } }, + { id: 2, numero: 7, centre: { x: 300, y: 300 } }, + { id: 3, numero: 4, centre: { x: 300, y: -300 } }, + ]; + assert.equal(tableVoisine(tables, 1, 'droite'), 3); + assert.equal(tableVoisine([...tables].reverse(), 1, 'droite'), 3); + }); + + test('hors du cône, même plus proche, une table n’est pas voisine', () => { + const tables = [ + { id: 1, numero: 1, centre: { x: 0, y: 0 } }, + { id: 2, numero: 2, centre: { x: 100, y: 150 } }, + { id: 3, numero: 3, centre: { x: 900, y: 0 } }, + ]; + assert.equal(tableVoisine(tables, 1, 'droite'), 3); + }); + + test('une direction inconnue lève', () => { + assert.throws(() => tableVoisine(GRILLE, 50, 'nord'), /direction/); + }); +}); + +describe('siegeVoisin : l’ordre des numéros, circulairement', () => { + test('suivant et précédent ; 8 → 1 et 1 → 8 sur une table de 8', () => { + assert.equal(siegeVoisin(8, 8, +1), 1); + assert.equal(siegeVoisin(8, 1, -1), 8); + assert.equal(siegeVoisin(8, 3, +1), 4); + assert.equal(siegeVoisin(8, 3, -1), 2); + assert.equal(siegeVoisin(2, 2, +1), 1); + }); +}); + +describe('toucheDuPlan : ce que fait une touche sur l’élément actif (§ 7.3)', () => { + const regimes = new Map([[10, 'sieges'], [20, 'table']]); + const contexte = { + geometries: [ + { id: 10, numero: 1, centre: { x: 0, y: 0 } }, + { id: 20, numero: 2, centre: { x: 300, y: 0 } }, + ], + regimeDe: (id) => regimes.get(id), + capaciteDe: () => 6, + }; + + test('sans élément actif, une flèche ou l’entrée active la première table ; une autre touche ne fait rien', () => { + assert.deepEqual(toucheDuPlan(null, 'ArrowRight', contexte), { actif: { table: 10, siege: null }, effet: null }); + assert.deepEqual(toucheDuPlan(null, 'Enter', contexte), { actif: { table: 10, siege: null }, effet: null }); + assert.equal(toucheDuPlan(null, 'a', contexte), null); + assert.equal(toucheDuPlan(null, 'ArrowRight', { ...contexte, geometries: [] }), null); + }); + + test('une flèche sur une table mène à sa voisine ; sur un siège, au siège voisin', () => { + assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, 'ArrowRight', contexte), { actif: { table: 20, siege: null }, effet: null }); + assert.deepEqual(toucheDuPlan({ table: 10, siege: 6 }, 'ArrowRight', contexte), { actif: { table: 10, siege: 1 }, effet: null }); + assert.deepEqual(toucheDuPlan({ table: 10, siege: 1 }, 'ArrowUp', contexte), { actif: { table: 10, siege: 6 }, effet: null }); + assert.deepEqual(toucheDuPlan({ table: 10, siege: 2 }, 'ArrowDown', contexte), { actif: { table: 10, siege: 3 }, effet: null }); + }); + + test('l’entrée descend dans les sièges en régime sièges ; en régime table, ouvre le panneau de la table', () => { + assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, 'Enter', contexte), { actif: { table: 10, siege: 1 }, effet: null }); + assert.deepEqual(toucheDuPlan({ table: 20, siege: null }, 'Enter', contexte), { + actif: { table: 20, siege: null }, + effet: { type: 'ouvrirTable', table: 20 }, + }); + assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, ' ', contexte), { actif: { table: 10, siege: 1 }, effet: null }); + }); + + test('l’entrée sur un siège le sélectionne ; l’échappement remonte à la table, puis oublie', () => { + assert.deepEqual(toucheDuPlan({ table: 10, siege: 4 }, 'Enter', contexte), { + actif: { table: 10, siege: 4 }, + effet: { type: 'selectionner', place: { table: 10, siege: 4 } }, + }); + assert.deepEqual(toucheDuPlan({ table: 10, siege: 4 }, 'Escape', contexte), { actif: { table: 10, siege: null }, effet: null }); + assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, 'Escape', contexte), { + actif: { table: 10, siege: null }, + effet: { type: 'remonter' }, + }); + }); + + test('un siège d’une table passée en régime table se lit comme sa table', () => { + assert.deepEqual(toucheDuPlan({ table: 20, siege: 3 }, 'ArrowLeft', contexte), { actif: { table: 10, siege: null }, effet: null }); + assert.deepEqual(toucheDuPlan({ table: 20, siege: 3 }, 'Enter', contexte), { + actif: { table: 20, siege: null }, + effet: { type: 'ouvrirTable', table: 20 }, + }); + }); + + test('un élément actif dont la table n’existe plus repart de la première', () => { + assert.deepEqual(toucheDuPlan({ table: 99, siege: 2 }, 'ArrowRight', contexte), { actif: { table: 10, siege: null }, effet: null }); + }); +}); + +describe('vueQuiMontre : la place dans le cadre, sans changer k (§ 5.8)', () => { + const cadre = { gauche: 40, haut: 30, largeur: 800, hauteur: 600 }; + const vue = { k: 0.5, tx: 100, ty: 50 }; + + test('un point déjà dans le cadre, marge comprise : la même vue', () => { + assert.equal(vueQuiMontre(vue, cadre, { x: 200, y: 200 }), vue); + }); + + test('un point hors du cadre : k inchangé, le point au centre du cadre', () => { + for (const point of [{ x: 3000, y: 100 }, { x: -900, y: 100 }, { x: 100, y: 5000 }, { x: 100, y: -400 }]) { + const montree = vueQuiMontre(vue, cadre, point); + assert.equal(montree.k, vue.k); + const ecran = ecranDepuisDessin(montree, cadre, point.x, point.y); + assert.equal(ecran.x, cadre.gauche + cadre.largeur / 2); + assert.equal(ecran.y, cadre.haut + cadre.hauteur / 2); + } + }); + + test('sans vue, ou dans un cadre nul, rien ne change', () => { + assert.equal(vueQuiMontre(null, cadre, { x: 0, y: 0 }), null); + const nul = { gauche: 0, haut: 0, largeur: 0, hauteur: 0 }; + assert.equal(vueQuiMontre(vue, nul, { x: 9000, y: 0 }), vue); + }); +}); diff --git a/src/interface/plan/modele.js b/src/interface/plan/modele.js index a53400f..a0331f0 100644 --- a/src/interface/plan/modele.js +++ b/src/interface/plan/modele.js @@ -25,6 +25,10 @@ export const TAILLE_TEXTE_PASTILLE_CM = 18; /** Écart entre le bord d'une chaise et son anneau, ou sa mise en évidence, en cm. */ export const ECART_ANNEAU = 6; export const ECART_MISE_EN_EVIDENCE = 12; +/** Écart entre le bord d'une chaise, ou d'une emprise, et la marque de + * sélection (anneau en tirets), puis le carré du focus, en cm. */ +export const ECART_DESIGNATION = 18; +export const ECART_FOCUS = 26; // Les cadenas d'une chaise (§ 7.6), en cm, centrés sur l'origine : un corps // plein et une anse, fermée ou ouverte, remplis d'un seul tenant. Le dessin @@ -141,6 +145,22 @@ export function textesLigne({ numero, etat, titulaire = null, nom, titre }, t) { }; } +/** + * La ligne entière en un seul texte : les parties de textesLigne bout à + * bout, dans l'ordre de la lecture. C'est le nom accessible d'un siège, que + * le plan annonce quand il devient l'élément actif. + * + * @param {ReturnType} ligne + * @param {(cle: string, details?: Object) => string} t + * @returns {string} + */ +export function texteLigne(ligne, t) { + const textes = textesLigne(ligne, t); + return ['tete', 'titulaire', 'suite', 'nom', 'avantTitre', 'titre', 'apresTitre'] + .map((partie) => textes[partie] ?? '') + .join(''); +} + /** * La ligne qui tient dans largeur (§ 7.4) : le nom s'abrège, suivi de la * marque, à la largeur que lui laissent les autres parties ; quand la @@ -274,14 +294,26 @@ function chaisesA(etatTable, places) { return Array.from({ length: places }, (_, i) => etatTable.chaises[i] ?? chaiseLibre(i + 1)); } +// Le porteur d'une place {table, siege|null} dans une table dessinée : le +// siège quand la place en nomme un que le régime sièges dessine, 'table' +// quand elle n'en nomme pas ou que le régime table n'en dessine aucun, null +// pour une place d'une autre table. +function porteurDe(place, id, regime) { + if (place === null || place === undefined || place.table !== id) return null; + const siege = place.siege ?? null; + return siege === null || regime === 'table' ? 'table' : siege; +} + // Le dessin d'une table de l'état des places au zoom k ; places, le nombre // de sièges que la poignée montre, sinon sa capacité. -function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable.capacite) { +function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, actif = null }, places = etatTable.capacite) { const { id, numero, gelee, surchargee, conflits } = etatTable; const geometrie = geometrieTable(etatTable, places); const regime = regimeCibles(k, geometrie); const pastille = conflits === null ? 0 : conflits.nombre; const etatChaises = chaisesA(etatTable, places); + const selectionnee = porteurDe(selection, id, regime); + const active = porteurDe(actif, id, regime); const chaises = regime === 'table' ? [] @@ -295,6 +327,8 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable remplissage: chaise.remplissage, marques: [...chaise.marques], miseEnEvidence: miseEnEvidence !== null && chaise.occupant === miseEnEvidence, + selection: selectionnee === chaise.siege, + focus: active === chaise.siege, }; }); return { @@ -303,6 +337,8 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable regime, conflit: pastille > 0, pastille, + selection: selectionnee === 'table', + focus: active === 'table', gelee, surchargee, nomAccessible: { @@ -330,6 +366,12 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable * bloc de noms n'est pas dans le modèle : c'est LARGEUR_BLOC_NOMS_CM du * registre, que le dessin lit. * + * La place sélectionnée (option selection) et l'élément actif du clavier + * (option actif), {table, siege|null} ou null, marquent leur chaise — + * drapeaux selection et focus d'une chaise — quand la place nomme un siège + * que le régime sièges dessine, leur table sinon — drapeaux selection et + * focus d'une table — ; tout autre porteur garde ses drapeaux faux. + * * Pendant le tirage d'une poignée (option poignee), la table qu'elle désigne * se dessine à places sièges — géométrie, régime, chaises, lignes et nom * accessible —, ses chaises 1 à places, celles au-delà de sa capacité @@ -338,13 +380,22 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable * @param {Object} etatPlaces EtatPlaces (A5) * @param {{k: number, listes: boolean, miseEnEvidence: number|null, * nomDe: (id: number) => string, diagnostic?: Object|null, - * poignee?: {table: number, places: number}|null}} options + * poignee?: {table: number, places: number}|null, + * selection?: {table: number, siege?: number|null}|null, + * actif?: {table: number, siege: number|null}|null}} options * diagnostic : celui que rend diagnostiquer, compléments de M1 compris * @returns {Object} ModeleDessin */ -export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null }) { +export function modeleDessin( + etatPlaces, + { k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null, selection = null, actif = null }, +) { const tables = etatPlaces.tables.map((etatTable) => - tableDessin(etatTable, { k, miseEnEvidence, nomDe }, poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite), + tableDessin( + etatTable, + { k, miseEnEvidence, nomDe, selection, actif }, + poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite, + ), ); const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit); return { @@ -354,3 +405,121 @@ export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, dia listesRendues: listes && k >= constante('K_LISTES'), }; } + +/** + * Le détail des conflits d'une table au tour montré (§ 7.1, § 7.3), que + * l'infobulle énonce : son numéro, le nombre de violations, et chaque règle + * violée dans l'ordre du moteur (conflitsParTable, M1), sa clé de libellé + * plan.infobulle. et ses groupes nommés — une paire de noms pour + * séparer les appartenances et nouveaux voisins, un nom seul pour nouvelle + * table. Les noms sont des données : l'infobulle pose chacun dans son nœud. + * null quand les conflits ne s'évaluent pas ou qu'il n'y en a aucun. + * + * @param {Object} etatTable EtatTable (A5) + * @param {(id: number) => string} nomDe + * @returns {null|{numero: number, nombre: number, regles: Array<{cle: string, groupes: string[][]}>}} + */ +export function detailConflits({ numero, conflits }, nomDe) { + if (conflits === null || conflits.nombre === 0) return null; + return { + numero, + nombre: conflits.nombre, + regles: conflits.regles.map((regle) => ({ + cle: `plan.infobulle.${regle.regle}`, + groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)), + })), + }; +} + +/** + * Les actions qu'offre le panneau d'une place (§ 7.1, § 2.1 étape 6), dans + * l'ordre de l'affichage : sur une place qu'aucune réservation ne tient, + * assigner, réserver et désigner, qui portent sur la personne choisie ; + * assigner aussi sur une place tenue quand une personne est choisie — le + * dépôt du menu, que la pose refuse sur la place comme un glissement ; + * donner un titre sur une place sans titre ; retirer la réservation et + * retirer le titre sur celles qui en portent ; déplacer la réservation ici + * quand le choix en attente est une réservation. + * + * @param {{reservation: Object|null, titre: Object|null}} chaise EtatChaise + * @param {Object|null} choix EtatUi.choix + * @returns {string[]} parmi assigner, reserver, donnerTitre, designer, + * retirerReservation, retirerTitre, deplacerReservationIci + */ +export function actionsPlace({ reservation, titre }, choix) { + const libre = reservation === null; + const personne = choix !== null && choix.genre === 'personne'; + return [ + ['assigner', libre || personne], + ['reserver', libre], + ['donnerTitre', titre === null], + ['designer', libre], + ['retirerReservation', !libre], + ['retirerTitre', titre !== null], + ['deplacerReservationIci', choix !== null && choix.genre === 'reservation'], + ] + .filter(([, offerte]) => offerte) + .map(([action]) => action); +} + +/** + * Le menu d'une personne (§ 7.1), dans l'ordre de l'affichage : déplacer — + * le choix en attente —, toujours ; échanger quand elle est assise au tour + * montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un + * refus (retrait non null) ; réserver ici quand elle est assise sur une + * place qu'aucune réservation ne tient ; déplacer sa réservation quand la + * sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la + * fiche. + * + * @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne + * chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin + * @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci, + * deplacerReservation, exclure, reintegrer, ouvrirFiche + */ +export function actionsPersonne({ exclu, chaise, retrait }) { + const assise = chaise !== null; + const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant; + return [ + ['deplacer', true], + ['echanger', assise], + ['retirerDuTour', retrait !== null], + ['reserverIci', assise && !exclu && chaise.reservation === null], + ['deplacerReservation', tenue], + ['exclure', !exclu], + ['reintegrer', exclu], + ['ouvrirFiche', true], + ] + .filter(([, offerte]) => offerte) + .map(([action]) => action); +} + +/** + * Exécute la commande d'un panneau du plan — panneau d'une place, d'une + * table, menu d'une personne — et pose son refus sur la place, là où le + * geste a lieu (§ 5.9) : la même place qu'un glissement refusé, que + * Plan.svelte montre par ui.pose. Réussie, la commande oublie le choix en + * attente et retire la pose ; elle rend ce que rend la séance. Refusée — une + * erreur qui porte un code —, le refus se pose, et null est rendu. Un code de + * locaux, que l'appelant montre à côté de son champ, une place null et une + * erreur sans code remontent. + * + * @param {{seance: {executer: Function}, ui: {choisir: Function, poserSurPlace: Function}}} dependances + * @param {{table: number, siege?: number|null}|null} place + * @param {string} commande + * @param {Object} args + * @param {{locaux?: string[]}} [options] + * @returns {Promise} + */ +export async function executerSurPlace({ seance, ui }, place, commande, args, { locaux = [] } = {}) { + let rendu; + try { + rendu = await seance.executer(commande, args); + } catch (erreur) { + if (typeof erreur?.code !== 'string' || place === null || locaux.includes(erreur.code)) throw erreur; + ui.poserSurPlace({ place, refus: { code: erreur.code, details: erreur.details ?? {} }, avertissements: [] }); + return null; + } + ui.choisir(null); + ui.poserSurPlace(null); + return rendu; +} diff --git a/src/interface/plan/modele.test.js b/src/interface/plan/modele.test.js index 6b3bc57..73a864c 100644 --- a/src/interface/plan/modele.test.js +++ b/src/interface/plan/modele.test.js @@ -27,13 +27,18 @@ import { planDepuisFichier, versFichier } from '../../stockage/placements.js'; import { TABLES_INTERFACE } from '../libelles.js'; import { LARGEUR_ECHELLE_PX, + actionsPersonne, + actionsPlace, ajusterLigne, + detailConflits, + executerSurPlace, formesTable, libelleLongueur, ligneListe, modeleDessin, ordonneesLignes, partiesLigne, + texteLigne, textesLigne, } from './modele.js'; @@ -775,3 +780,238 @@ describe('modeleDessin : l\u{2019}option poignee dessine une table au nombre tir } }); }); + +// --- La sélection et l'élément actif (I11) --------------------------------------- + +describe('modeleDessin : les options selection et actif marquent leur chaise, ou leur table, et elle seule (§ 7.1, § 7.3)', () => { + // Les porteurs d'une marque : 'table:' ou 'siege::'. + function porteurs(modele, marque) { + const trouves = []; + for (const table of modele.tables) { + if (table[marque]) trouves.push(`table:${table.id}`); + for (const chaise of table.chaises) if (chaise[marque]) trouves.push(`siege:${table.id}:${chaise.siege}`); + } + return trouves; + } + + test('sans option, ni sélection ni focus ; chaque table et chaque chaise porte les deux drapeaux, faux', () => { + const charge = petite(); + const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge)); + assert.equal(modele.tables.length, 4); + let chaises = 0; + for (const table of modele.tables) { + assert.equal(table.selection, false); + assert.equal(table.focus, false); + for (const chaise of table.chaises) { + chaises += 1; + assert.equal(chaise.selection, false); + assert.equal(chaise.focus, false); + } + } + assert.ok(chaises > 0); + }); + + test('régime sièges : un siège marque sa chaise, une place sans siège marque sa table', () => { + const charge = petite(); + const etat = etatPlaces(charge, { tour: 1 }); + const [, deuxieme, troisieme] = etat.tables; + const modele = modeleDessin(etat, options(charge, { selection: { table: deuxieme.id, siege: 2 }, actif: { table: troisieme.id, siege: null } })); + assert.equal(modele.tables[1].regime, 'sieges'); + assert.deepEqual(porteurs(modele, 'selection'), [`siege:${deuxieme.id}:2`]); + assert.deepEqual(porteurs(modele, 'focus'), [`table:${troisieme.id}`]); + const memePlace = modeleDessin(etat, options(charge, { selection: { table: deuxieme.id, siege: 1 }, actif: { table: deuxieme.id, siege: 1 } })); + assert.deepEqual(porteurs(memePlace, 'selection'), [`siege:${deuxieme.id}:1`]); + assert.deepEqual(porteurs(memePlace, 'focus'), [`siege:${deuxieme.id}:1`]); + }); + + test('régime table : un siège, que nulle chaise ne dessine, marque sa table', () => { + const charge = petite(); + const etat = etatPlaces(charge, { tour: 1 }); + const cible = etat.tables[0]; + const modele = modeleDessin(etat, options(charge, { k: 0.2, selection: { table: cible.id, siege: 2 }, actif: { table: cible.id, siege: 3 } })); + assert.equal(modele.tables[0].regime, 'table'); + assert.deepEqual(porteurs(modele, 'selection'), [`table:${cible.id}`]); + assert.deepEqual(porteurs(modele, 'focus'), [`table:${cible.id}`]); + }); + + test('une place d’une table absente ne marque rien', () => { + const charge = petite(); + const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge, { selection: { table: 99, siege: 1 }, actif: { table: 99, siege: null } })); + assert.deepEqual(porteurs(modele, 'selection'), []); + assert.deepEqual(porteurs(modele, 'focus'), []); + }); +}); + +// --- Le détail d'une table en conflit (I11) -------------------------------------- + +describe('detailConflits : le détail que rend le moteur, nommé (§ 7.1, § 7.3)', () => { + test('petite démonstration en conflit : chaque règle violée, ses paires ou ses personnes, dans l’ordre du moteur', () => { + const charge = avecPropositions(petite('petite-conflit')); + const nomDe = nomsDe(charge); + let vus = 0; + for (let tour = 1; tour <= charge.evenement.tours; tour += 1) { + for (const table of etatPlaces(charge, { tour, proposition: 1 }).tables) { + const detail = detailConflits(table, nomDe); + if (table.conflits.nombre === 0) { + assert.equal(detail, null); + continue; + } + vus += 1; + assert.deepEqual(detail, { + numero: table.numero, + nombre: table.conflits.nombre, + regles: table.conflits.regles.map((regle) => ({ + cle: `plan.infobulle.${regle.regle}`, + groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)), + })), + }); + for (const { cle } of detail.regles) assert.ok(typeof t(cle) === 'string' && t(cle).length > 0, cle); + } + } + assert.ok(vus > 0, 'au moins une table en conflit'); + }); + + test('conflits non évalués, ou nuls : null', () => { + const charge = petite(); + const [table] = etatPlaces(charge, { tour: 1 }).tables; + assert.equal(detailConflits({ ...table, conflits: null }, nomsDe(charge)), null); + assert.equal(detailConflits({ ...table, conflits: { nombre: 0, regles: [] } }, nomsDe(charge)), null); + }); +}); + +// --- Le texte entier d'une ligne, et les actions des panneaux (I11) -------------- + +describe('texteLigne : la ligne d’une chaise en un texte, nom accessible du siège', () => { + const nomDe = (id) => ({ 1: 'Iris Ombrelle', 2: 'Théo Pervenche' })[id]; + const chaise = (autres) => ({ siege: 3, occupant: null, reservation: null, titre: null, ...autres }); + + test('les parties de textesLigne, bout à bout', () => { + const cas = [ + [chaise({}), '3. (libre)'], + [chaise({ occupant: 1 }), '3. Iris Ombrelle'], + [ + chaise({ occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'animateur', pourvu: true } }), + '3. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur', + ], + [chaise({ titre: { libelle: 'hôte', pourvu: false } }), '3. (libre) \u{2014} titre hôte non pourvu'], + [chaise({ occupant: 2, reservation: { participant: 1, portee: 'tour', tour: 2 } }), '3. (réservée à Iris Ombrelle, tour 2) Théo Pervenche'], + ]; + for (const [entree, attendu] of cas) assert.equal(texteLigne(ligneListe(entree, nomDe), t), attendu); + }); +}); + +describe('actionsPlace : ce qu’offre le panneau d’une place (§ 7.1, § 2.1 étape 6)', () => { + const libre = { siege: 1, occupant: null, reservation: null, titre: null }; + const tenue = { siege: 2, occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'hôte', pourvu: true } }; + const reservation = { genre: 'reservation', participant: 1, depuis: { table: 1, siege: 2 }, de: { table: 1, portee: 'tous', tour: null } }; + + test('une place libre : assigner, réserver, donner un titre, désigner — exactement', () => { + assert.deepEqual(actionsPlace(libre, null), ['assigner', 'reserver', 'donnerTitre', 'designer']); + assert.deepEqual(actionsPlace(libre, { genre: 'personne', participant: 2, depuis: null }), ['assigner', 'reserver', 'donnerTitre', 'designer']); + }); + + test('une place réservée et titrée : retirer la réservation, retirer le titre ; une personne choisie y ajoute assigner, que la pose refusera', () => { + assert.deepEqual(actionsPlace(tenue, null), ['retirerReservation', 'retirerTitre']); + assert.deepEqual(actionsPlace(tenue, reservation), ['retirerReservation', 'retirerTitre', 'deplacerReservationIci']); + assert.deepEqual(actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null }), ['assigner', 'retirerReservation', 'retirerTitre']); + }); + + test('une place titrée, non tenue : assigner, réserver, désigner, retirer le titre', () => { + assert.deepEqual(actionsPlace({ ...libre, titre: { libelle: 'hôte', pourvu: false } }, null), ['assigner', 'reserver', 'designer', 'retirerTitre']); + }); + + test('une réservation choisie ajoute « Déplacer la réservation ici »', () => { + assert.deepEqual(actionsPlace(libre, reservation), ['assigner', 'reserver', 'donnerTitre', 'designer', 'deplacerReservationIci']); + }); +}); + +describe('actionsPersonne : le menu d’une personne (§ 7.1)', () => { + const assise = { siege: 2, occupant: 4, reservation: null, titre: null }; + const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } }; + + test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, ouvrir la fiche', () => { + assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [ + 'deplacer', + 'echanger', + 'retirerDuTour', + 'reserverIci', + 'exclure', + 'ouvrirFiche', + ]); + }); + + test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => { + const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } }; + assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', 'ouvrirFiche']); + }); + + test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, et la fiche', () => { + assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']); + assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']); + }); +}); + +describe('executerSurPlace : la commande d’un panneau, son refus posé sur la place (§ 5.9)', () => { + // Une séance et un état de navigation d'épreuve, qui relèvent leurs appels. + function dependances(executer) { + const appels = []; + return { + appels, + seance: { executer: async (nom, args) => (appels.push(['executer', nom, args]), executer(nom, args)) }, + ui: { + choisir: (choix) => appels.push(['choisir', choix]), + poserSurPlace: (pose) => appels.push(['poserSurPlace', pose]), + }, + }; + } + const refus = (code, details = {}) => Object.assign(new Error(code), { code, details }); + const place = { table: 2, siege: 3 }; + + test('réussie : rend le rendu de la séance, oublie le choix en attente et retire la pose', async () => { + const d = dependances(() => ({ libelle: 'ok', avertissements: [] })); + assert.deepEqual(await executerSurPlace(d, place, 'reserver', { participant: 1 }), { libelle: 'ok', avertissements: [] }); + assert.deepEqual(d.appels, [['executer', 'reserver', { participant: 1 }], ['choisir', null], ['poserSurPlace', null]]); + }); + + test('refusée : le refus, code et détails, posé sur la place ; rien d’autre ; null', async () => { + const d = dependances(() => { + throw refus('PLACE_RESERVEE', { table: 2, siege: 3 }); + }); + assert.equal(await executerSurPlace(d, place, 'reserver', {}), null); + assert.deepEqual(d.appels.slice(1), [ + ['poserSurPlace', { place, refus: { code: 'PLACE_RESERVEE', details: { table: 2, siege: 3 } }, avertissements: [] }], + ]); + }); + + test('un code local, une place absente, ou une erreur sans code remontent à l’appelant', async () => { + for (const [lieu, erreur, options] of [ + [place, refus('LIBELLE_VIDE'), { locaux: ['LIBELLE_VIDE'] }], + [null, refus('SANS_EFFET'), {}], + [place, new TypeError('défaut'), {}], + ]) { + const d = dependances(() => { + throw erreur; + }); + await assert.rejects(executerSurPlace(d, lieu, 'poserTitre', {}, options), (recue) => recue === erreur); + assert.deepEqual(d.appels.map(([nom]) => nom), ['executer']); + } + }); +}); + +describe('les clés que les panneaux composent existent (§ 14.6)', () => { + test('plan.action., plan.menu., plan.infobulle. : chaque valeur possible a sa clé', () => { + const tenue = { siege: 1, occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'hôte', pourvu: true } }; + const libre = { siege: 1, occupant: null, reservation: null, titre: null }; + const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } }; + const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]); + const menu = new Set([ + ...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }), + ...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }), + ]); + assert.equal(actions.size, 7); + assert.equal(menu.size, 8); + for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action); + for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action); + for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string'); + }); +}); diff --git a/test/fixtures/plan/conflit_table_un.gtt.json b/test/fixtures/plan/conflit_table_un.gtt.json new file mode 100644 index 0000000..bc0d54a --- /dev/null +++ b/test/fixtures/plan/conflit_table_un.gtt.json @@ -0,0 +1,36 @@ +{ + "entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":8,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":1}}, + "charge": { + "evenement": {"id":"evt-conflit-table-un","nom":"Club des Merles","date":null,"siegesParDefaut":2,"tours":2,"unite":"cm","etat":"retenu","filiation":null}, + "reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}}, + "prochainsIds": {"participant":9,"table":5,"proposition":2}, + "participants": [ + {"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":4,"nom":"Grisaille","prenom":null,"appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":5,"nom":"Tourmaline","prenom":"Basile","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":6,"nom":"Arrosoir","prenom":"Capucine","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":7,"nom":"Cerfeuil","prenom":"Aurèle","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":8,"nom":"Bruyère","prenom":"Mélisande","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false} + ], + "tables": [ + {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, + {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, + {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}}, + {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}} + ], + "reservations": [], + "titres": [], + "propositions": [ + {"id":1,"graine":1,"arret":40,"historique":1000,"produitVersion":"epreuve","siegesAttribues":false,"tables":[1,2,3,4],"capacites":[2,2,2,2],"tours":2,"participants":[1,2,3,4,5,6,7,8],"placement":[ + {"sieges":[[1,2],[3,4],[5,6],[7,8]],"reserve":[]}, + {"sieges":[[1,2],[5,7],[3,8],[4,6]],"reserve":[]} + ]} + ], + "retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2,3,4],"capacites":[2,2,2,2],"tours":2,"participants":[1,2,3,4,5,6,7,8],"placement":[ + {"sieges":[[1,2],[3,4],[5,6],[7,8]],"reserve":[]}, + {"sieges":[[1,2],[5,7],[3,8],[4,6]],"reserve":[]} + ]} + } +}