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')}

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

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

+ {/if} + +

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

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

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

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

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

+ + {/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')}

+ + {#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":[]} + ]} + } +}