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 @@
+
+
+
+
+
+ {#if infobulle !== null}
+
{t('plan.infobulle.entete', { numero: infobulle.detail.numero, nombre: infobulle.detail.nombre })}
+ {#each infobulle.detail.regles as regle, rang (rang)}
+
+ {t(regle.cle)} {#each regle.groupes as groupe, g (g)}{#if g > 0}{t('plan.infobulle.separateur')} {/if}{#each groupe as nom, n (n)}{#if n > 0}{t('plan.infobulle.et')} {/if}{nom} {/each}{/each}
+
+ {/each}
+ {/if}
+
+
+
diff --git a/src/interface/plan/ListesTravail.svelte b/src/interface/plan/ListesTravail.svelte
index 5678a32..a0cfce4 100644
--- a/src/interface/plan/ListesTravail.svelte
+++ b/src/interface/plan/ListesTravail.svelte
@@ -2,21 +2,170 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
- {t('plan.listesTravail.titre')}
+
+
+
+ {t('plan.listesTravail.titre')}
+ {#if charge !== null}
+ {t('plan.listesTravail.titres')}
+
+ {#each titres as titre (`${titre.table}:${titre.siege}:${titre.libelle}`)}
+
+ allerAuTitre(evenement, titre)}
+ >{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })} {titre.libelle} {t('plan.listesTravail.nonPourvu')} {#if titre.pourvuAuxTours.length > 0}{t('plan.listesTravail.pourvuAuxTours', { tours: titre.pourvuAuxTours })} {/if}
+
+ {/each}
+
+ {#if titres.length === 0}
+ {t('plan.listesTravail.aucun')}
+ {/if}
+
+ {t('plan.listesTravail.pressentis')}
+
+ {#each pressentis as { participant, titrePressenti } (participant)}
+
+ basculer(participant)}
+ >{nomDe(participant)} {t('plan.listesTravail.pressenti')} {titrePressenti}
+
+ {/each}
+
+ {#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')}
+
+ {t('plan.portee.etiquette')}
+ (portee = evenement.currentTarget.value)}>
+ {t('plan.portee.tour', { tour })}
+ {t('plan.portee.tous')}
+
+
+ {/if}
+ {#if actions.includes('donnerTitre') || actions.includes('designer')}
+
+ {t('plan.titre.etiquette')}
+ (saisi = evenement.currentTarget.value)} />
+
+ {/if}
+
+ {#each actions as action (action)}
+
+ {/each}
+ {#if choix !== null}
+ ui.choisir(null)} />
+ {/if}
+
+ {#if menuDe !== null}
+
+ {/if}
+
+{/if}
+
+
diff --git a/src/interface/plan/PanneauTable.svelte b/src/interface/plan/PanneauTable.svelte
index 0c53c02..e4b2dec 100644
--- a/src/interface/plan/PanneauTable.svelte
+++ b/src/interface/plan/PanneauTable.svelte
@@ -2,6 +2,216 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
+
+{#if etatTable !== undefined}
+
+
+
+ {t(nommee.cle, nommee.details)}
+ {t('plan.panneauTable.titres', comptes)}
+ {#if etatTable.gelee}
+ {t('plan.liste.gelee')}
+ {/if}
+ {t('plan.panneauTable.sieges')}
+
+ {#each lignes as parties, rang (rang)}
+
+ choisirSiege(evenement, rang + 1)}
+ >
+ {#each parties as { partie, texte } (partie)}{texte} {/each}
+
+
+ {/each}
+
+ {#if choix !== null}
+
+ {t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')} {nomDe(choix.participant)}
+
+ {:else}
+ {t('plan.choix.inviter')}
+ {/if}
+ {#if etat.siegesAttribues}
+
+ {t('plan.panneauTable.siege')}
+ (siege = Number(evenement.currentTarget.value))}>
+ {#each lignes as _, rang (rang)}
+ {rang + 1}
+ {/each}
+
+
+ {/if}
+
+ {t('plan.titre.etiquette')}
+ (saisi = evenement.currentTarget.value)} />
+
+
+
+
+
+
+{/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)}
+
+ basculer(id)}>{nomDe(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":[]}
+ ]}
+ }
+}