From 7f3dad24af2d66e14540dacaa75400dd052aba03 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 10:01:21 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20interface:=20plan=20gestures=20?= =?UTF-8?q?=E2=80=94=20drag=20a=20person,=20a=20table,=20a=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan is where the operator changes the seating. A drag starts only past a movement threshold, so a click is never a drag (§ 8.4), and Escape cancels it; a person dropped on a chair is moved or swapped through the places commands, a table is moved, and a handle resizes it after the capacity check. A refusal appears on the place where the gesture ended. A new gesture waits for the previous write, so a second drag never starts from a stale plan, and nothing reads layout while a drag runs. Checked: 2499 node and 436 browser tests. --- FR --- [ADD] interface : gestes du plan — glisser une personne, une table Le plan est l'endroit où l'opérateur change le placement. Un glissement ne commence qu'au-delà d'un seuil de mouvement — un clic n'en est jamais un (§ 8.4) — et Échap l'annule ; une personne déposée sur une chaise est déplacée ou échangée par les commandes des places, une table se déplace, et une poignée la redimensionne après le contrôle de capacité. Un refus se pose sur la place où le geste a fini. Un geste attend l'écriture du précédent, si bien qu'un second glissement ne part jamais d'un plan périmé, et rien ne lit la mise en page pendant un glissement. Vérifié : 2499 node et 436 navigateur. Assisted-by: Claude Opus 5.5 --- src/interface/libelles/plan.js | 12 + src/interface/plan/Fantome.svelte | 55 + src/interface/plan/Gestes.navigateur.test.js | 1055 +++++++++++++++++ src/interface/plan/Plan.svelte | 329 ++++- src/interface/plan/RefusPlace.svelte | 69 ++ src/interface/plan/depot.js | 109 ++ src/interface/plan/depot.test.js | 264 +++++ src/interface/plan/ecouteurs.test.js | 116 ++ src/interface/plan/gestes.js | 261 ++++ src/interface/plan/gestes.test.js | 351 ++++++ src/interface/plan/modele.js | 40 +- src/interface/plan/modele.test.js | 58 + .../fixtures/plan/huit_tables_retenu.gtt.json | 54 + 13 files changed, 2758 insertions(+), 15 deletions(-) create mode 100644 src/interface/plan/Fantome.svelte create mode 100644 src/interface/plan/Gestes.navigateur.test.js create mode 100644 src/interface/plan/RefusPlace.svelte create mode 100644 src/interface/plan/depot.js create mode 100644 src/interface/plan/depot.test.js create mode 100644 src/interface/plan/ecouteurs.test.js create mode 100644 src/interface/plan/gestes.js create mode 100644 src/interface/plan/gestes.test.js create mode 100644 test/fixtures/plan/huit_tables_retenu.gtt.json diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index 333eeed..d60ae92 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -85,6 +85,18 @@ export const TABLE = new Map([ ['plan.vue.pleinEcran', 'Plein écran'], ['plan.vue.dessin', 'Plan de salle'], + // --- Les gestes du pointeur (§ 5.8, § 7.1, § 7.3) ------------------------- + // Le fantôme d'une personne glissée dit ce que le geste modifie : l'état + // des données, non le geste (§ 5.8). Le nom de la personne est une donnée, + // dans son propre nœud. + ['plan.fantome.reservations', 'pose une réservation'], + ['plan.fantome.retenu', 'déplace dans le placement retenu'], + ['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'], + // La place où un refus ou un avertissement du plan se pose. + ['plan.refus.table', ({ numero }) => `Table ${numero}`], + ['plan.refus.siege', ({ numero, siege }) => `Table ${numero}, siège ${siege}`], + ['plan.refus.fermer', 'Fermer'], + // --- Les panneaux latéraux (§ 2.1, étape 8) -------------------------------- ['plan.panneaux', 'Panneaux du plan'], ['plan.listesTravail.titre', 'Listes de travail'], diff --git a/src/interface/plan/Fantome.svelte b/src/interface/plan/Fantome.svelte new file mode 100644 index 0000000..cc2ae70 --- /dev/null +++ b/src/interface/plan/Fantome.svelte @@ -0,0 +1,55 @@ + + + + + + + + {#if fantome !== null} + + {fantome.nom}{t(fantome.cle)} + {/if} + + + diff --git a/src/interface/plan/Gestes.navigateur.test.js b/src/interface/plan/Gestes.navigateur.test.js new file mode 100644 index 0000000..b4b2790 --- /dev/null +++ b/src/interface/plan/Gestes.navigateur.test.js @@ -0,0 +1,1055 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les gestes du pointeur sur le plan, dans un vrai moteur de rendu (§ 5.8, +// § 5.9, § 6.1, § 6.2, § 7.1, § 7.2, § 7.3, § 8.4, § 8.5, § 9) : la capture +// sur la racine, le modèle confié au relâcher, aucune lecture de mise en page +// pendant un glissement, un seul écouteur, le déplacement de vue, le dépôt +// par la géométrie, les annulations, les gestes d'une personne sous les deux +// états des données, la table glissée, la poignée et ses refus, la lecture, +// l'inactivité, le dépôt refusé sur une table pleine. +// +// 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) ; +// elle lit le rendu — attributs data-…, transform, 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, sollicitées une +// seconde fois (§ 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 { DELAI_REGLAGES_MS, INACTIVITE_MS } from '../../application/seance.js'; +import { constante } from '../../geometrie/constantes.js'; +import { surLeSol } from '../../geometrie/designation.js'; +import { diametre, geometrieTable } from '../../geometrie/tables.js'; +import { dessinDepuisEcran, ecranDepuisDessin, kAjuste, 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 { fixerCadre, glisser, pointer, touche } from '../../../test/navigateur/entrees.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; +import { cadreDepuisRect, englobantDuTour } from './vue_plan.js'; + +const BASE = 'soiree'; +const SEUIL = constante('SEUIL_GLISSER_PX'); + +// --- 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'], +]; + +// La charge du fichier d'épreuve : huit rondes de trois, vingt personnes, +// trois tours, la proposition 1 retenue ; Iris Ombrelle (1) à la table 4 +// au tour 2, la table 7 assied 19 et 20 à ce tour. +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; +} + +const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, i) => de + i); + +// Trois tables de huit, dix-huit personnes, retenu : la table 3 assied +// sept personnes au seul tour 3, six aux deux autres ; la table 1, six, +// six, puis cinq. +const troisDeHuit = () => + chargeDe({ + id: 'evt-trois-de-huit', + places: [null, null, null], + personnes: 18, + tours: [ + [suite(1, 6), suite(7, 12), suite(13, 18)], + [suite(13, 18), suite(1, 6), suite(7, 12)], + [suite(1, 5), suite(6, 11), suite(12, 18)], + ], + }); + +// --- 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([]); +}); + +// Démonte un montage avant la fin de l'épreuve, et relève son collecteur : +// le montage suivant prend sa place dans la fenêtre. +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', horloge } = {}) { + const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); + const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }], horloge }); + 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; +} + +// 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); +} + +// --- 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 fantomeEl = (m) => dans(m, '[data-fantome]'); +// Le fantôme se montre : son style calculé n'est pas display: none, et il +// porte son dessin. checkVisibility ne sert pas ici : Chromium le rend vrai +// pour un SVG en display: none. +const fantomeMontre = (m) => getComputedStyle(fantomeEl(m)).display !== 'none' && fantomeEl(m).childElementCount > 0; +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 chaisesDe = (m, id) => tous(m, `[data-siege^="${id}:"]`); + +// 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 }); + +// La géométrie d'une table du tour montré, à sa capacité. +function geometrieDe(m, id) { + const table = etatMontre(m).tables.find((candidate) => candidate.id === id); + return geometrieTable(table, table.capacite); +} + +// Le point écran d'un point de dessin, sous la vue relue. +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]); +const pointPoignee = (m, id) => ecranDe(m, geometrieDe(m, id).poignee); + +// Un point du sol : près du coin haut gauche du cadre, hors de toute emprise. +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); + expect(document.elementFromPoint(point.x, point.y)).toBe(racinePlan(m)); + return point; +} + +// Un point à (dx, dy) d'un autre. +const decale = ({ x, y }, dx, dy) => ({ x: x + dx, y: y + dy }); + +// Le texte de l'état et du journal de l'événement ouvert, et sa révision. +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, + }; +} + +// Les entrées du journal relu de révision supérieure à r. +function entreesApres(m, r) { + const texte = m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`); + return texte === null ? [] : lireJournal(texte).entrees.filter((entree) => entree.revision > r); +} + +// Attend exactement une entrée après la révision r, l'en-tête de l'état relu +// à r + 1 ; rend son libellé. +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; +} + +// Rien n'a été écrit depuis avant : l'état, le journal et la révision. +async function rienEcrit(m, avant) { + await images(4); + expect(fichiers(m)).toEqual(avant); +} + +// Les refus posés sur le plan, et celui que désigne aria-describedby d'un élément. +const decrit = (element) => { + const id = element.getAttribute('aria-describedby'); + return id === null ? null : document.getElementById(id); +}; + +// Les boîtes de deux éléments se recoupent. +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; +} + +// Le pointerId du prochain appui sur la racine du plan. +function pointeurDuProchainAppui(m) { + const lu = { id: null }; + racinePlan(m).addEventListener('pointerdown', (evenement) => { + lu.id = evenement.pointerId; + }, { once: true }); + return lu; +} + +// Glisse par pas, une image entre deux pas, sans relâcher. +async function presserEtBouger(depuis, vers, { pas = 6 } = {}) { + await pointer('presser', depuis.x, depuis.y); + for (let i = 1; i <= pas; i += 1) { + const t = i / pas; + await pointer('bouger', depuis.x + (vers.x - depuis.x) * t, depuis.y + (vers.y - depuis.y) * t); + await uneImage(); + } + flushSync(); +} + +// Espionne les appels d'une méthode d'un prototype ; rend le compteur. +function espionner(prototype, methode) { + const originale = prototype[methode]; + const compte = { appels: 0 }; + prototype[methode] = function espion(...parametres) { + compte.appels += 1; + return Reflect.apply(originale, this, parametres); + }; + nettoyages.push(() => { + prototype[methode] = originale; + }); + return compte; +} + +// Relève les commandes que le plan 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; +} + +// --- La capture et le modèle au relâcher (§ 7.3, § 8.2) --------------------- + +describe('la capture du pointeur et le modèle au relâcher (§ 7.3, § 8.2)', () => { + test('après un appui sur une table, la racine capture le pointeur, la table non ; un mouvement hors de la fenêtre et un relâcher dehors terminent le glissement', async () => { + const m = await ouvrir(huitRetenue()); + const pointeur = pointeurDuProchainAppui(m); + const depart = pointTable(m, 1); + await pointer('presser', depart.x, depart.y); + expect(pointeur.id).not.toBeNull(); + expect(racinePlan(m).hasPointerCapture(pointeur.id)).toBe(true); + expect(tableEl(m, 1).hasPointerCapture(pointeur.id)).toBe(false); + const r = fichiers(m).revision; + await pointer('bouger', depart.x + 30, depart.y + 10); + await pointer('bouger', innerWidth + 300, innerHeight + 300); + await pointer('relacher', innerWidth + 300, innerHeight + 300); + await uneEntree(m, r); + await attendre(() => expect(tableEl(m, 1).getAttribute('transform')).toBeNull()); + expect(racinePlan(m).hasPointerCapture(pointeur.id)).toBe(false); + const position = { ...charge(m).tables[0].position }; + await pointer('bouger', depart.x + 80, depart.y + 80); + await images(3); + expect(tableEl(m, 1).getAttribute('transform')).toBeNull(); + expect(charge(m).tables[0].position).toEqual(position); + }); + + test('pendant le glissement d’une table, ni la charge, ni la révision, ni le disque ne bougent ; le transform change ; racine et table restent les mêmes nœuds ; au relâcher, une entrée « Déplacé la table … »', async () => { + const m = await ouvrir(huitRetenue()); + const racine = racinePlan(m); + const table = tableEl(m, 3); + const avant = fichiers(m); + const texteCharge = serialiserCharge(charge(m)); + const ecritures = m.fs.compteurs.ecritures; + const depart = pointTable(m, 3); + await presserEtBouger(depart, decale(depart, 50, 30)); + expect(table.getAttribute('transform')).not.toBeNull(); + expect(serialiserCharge(charge(m))).toBe(texteCharge); + expect(fichiers(m)).toEqual(avant); + expect(m.fs.compteurs.ecritures).toBe(ecritures); + expect(racinePlan(m)).toBe(racine); + expect(tableEl(m, 3)).toBe(table); + await pointer('relacher', depart.x + 50, depart.y + 30); + expect(await uneEntree(m, avant.revision)).toBe('Déplacé la table 3'); + await attendre(() => expect(table.getAttribute('transform')).toBeNull()); + expect(tableEl(m, 3)).toBe(table); + expect(racinePlan(m)).toBe(racine); + }); +}); + +// --- Aucune lecture de mise en page, un écouteur (§ 7.2, § 7.3) -------------- + +describe('aucune lecture de mise en page pendant un glissement ; un seul écouteur (§ 7.2, § 7.3)', () => { + test('glissement réel d’une table, puis de la vue, dix mouvements chacun : aucun getBBox, getScreenCTM, getBoundingClientRect', async () => { + const m = await ouvrir(huitRetenue()); + for (const [nom, depart] of [['table', pointTable(m, 2)], ['vue', pointSol(m)]]) { + const transformAvant = (nom === 'table' ? tableEl(m, 2) : groupe(m)).getAttribute('transform'); + await pointer('presser', depart.x, depart.y); + // Les espions se posent après l'appui, qui relit le cadre, et avant le + // premier mouvement : celui qui franchit le seuil et fait de l'appui un + // glissement est relevé comme les suivants. + const espions = [ + espionner(SVGGraphicsElement.prototype, 'getBBox'), + espionner(SVGGraphicsElement.prototype, 'getScreenCTM'), + espionner(Element.prototype, 'getBoundingClientRect'), + ]; + await pointer('bouger', depart.x + 8, depart.y + 4); + await uneImage(); + for (let i = 1; i <= 10; i += 1) { + await pointer('bouger', depart.x + 8 + 3 * i, depart.y + 4 + 2 * i); + await uneImage(); + } + const transformApres = (nom === 'table' ? tableEl(m, 2) : groupe(m)).getAttribute('transform'); + const appels = espions.map(({ appels }) => appels); + for (const nettoyer of nettoyages.splice(0).reverse()) nettoyer(); + expect(appels, nom).toEqual([0, 0, 0]); + expect(transformApres, nom).not.toBe(transformAvant); + await pointer('relacher', depart.x + 38, depart.y + 24); + await images(3); + } + }); + + test('l’espion d’addEventListener : sur les tables et les chaises, aucun écouteur de pointeur ni de molette ; sur la racine, les cinq, la molette non passive ; dix pointermove d’une même tâche n’écrivent qu’une fois le transform', async () => { + const releves = []; + const originale = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function espion(type, ecouteur, options) { + releves.push({ cible: this, type, options }); + return Reflect.apply(originale, this, [type, ecouteur, options]); + }; + nettoyages.push(() => { + EventTarget.prototype.addEventListener = originale; + }); + const m = await ouvrir(huitRetenue()); + const capacites = charge(m).tables.length * 3; + const elements = [...tous(m, '[data-table]'), ...tous(m, '[data-siege]')]; + expect(elements.length).toBe(charge(m).tables.length + capacites); + expect(elements.length).toBeGreaterThan(0); + const DU_POINTEUR = /^(pointer|wheel)/; + const surDessin = releves.filter(({ cible, type }) => elements.includes(cible) && DU_POINTEUR.test(type)); + expect(surDessin).toEqual([]); + for (const element of elements) { + for (const propriete of ['onpointerdown', 'onpointermove', 'onpointerup', 'onpointercancel', 'onwheel']) { + expect(element[propriete], propriete).toBeNull(); + } + } + const surRacine = releves.filter(({ cible }) => cible === racinePlan(m)); + const types = surRacine.map(({ type }) => type); + for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'wheel']) expect(types, type).toContain(type); + expect(surRacine.find(({ type }) => type === 'wheel').options).toEqual({ passive: false }); + // Contrôle d'échec : un écouteur que l'épreuve pose sur une chaise est relevé. + const chaise = dans(m, '[data-siege]'); + chaise.addEventListener('pointerdown', () => {}); + expect(releves.filter(({ cible, type }) => cible === chaise && DU_POINTEUR.test(type))).toHaveLength(1); + + const table = tableEl(m, 2); + const pointeur = pointeurDuProchainAppui(m); + const depart = pointTable(m, 2); + await pointer('presser', depart.x, depart.y); + await images(1); + const ecritures = []; + const observateur = new MutationObserver((liste) => ecritures.push(...liste.filter(({ attributeName }) => attributeName === 'transform'))); + observateur.observe(table, { attributes: true }); + for (let i = 1; i <= 10; i += 1) { + racinePlan(m).dispatchEvent( + new PointerEvent('pointermove', { pointerId: pointeur.id, clientX: depart.x + 10 * i, clientY: depart.y, bubbles: true }), + ); + } + await Promise.resolve(); + expect(ecritures.length).toBeLessThanOrEqual(1); + await uneImage(); + await Promise.resolve(); + expect(ecritures.length).toBeLessThanOrEqual(1); + expect(table.getAttribute('transform')).not.toBeNull(); + observateur.disconnect(); + await pointer('relacher', depart.x + 100, depart.y); + await images(3); + }); +}); + +// --- Le déplacement de vue (§ 7.1, § 7.2, § 8.5) ------------------------------- + +describe('le déplacement de vue, geste du sol (§ 7.1, § 7.2, § 8.5)', () => { + test('(60, −40) px avancent tx et ty d’autant, k inchangé ; seul le transform du groupe change ; le cadrage s’écrit après DELAI_REGLAGES_MS ; en écriture, en lecture, sur un plan bloqué', async () => { + const m = await ouvrir(huitRetenue()); + const parcourir = async (nom) => { + const avant = fichiers(m); + const ecritures = m.fs.compteurs.ecritures; + const vue = vueLue(m); + const depart = pointSol(m); + const mutations = []; + const observateur = new MutationObserver((liste) => mutations.push(...liste)); + observateur.observe(racinePlan(m), { attributes: true, childList: true, subtree: true, characterData: true }); + await glisser(depart, decale(depart, 60, -40), { pas: 10 }); + await images(2); + observateur.disconnect(); + expect(mutations.length, nom).toBeGreaterThan(0); + for (const mutation of mutations) { + expect(mutation.type, nom).toBe('attributes'); + expect(mutation.attributeName, nom).toBe('transform'); + expect(mutation.target, nom).toBe(groupe(m)); + } + const apres = vueLue(m); + expect(apres.k, nom).toBe(vue.k); + expect(apres.tx, nom).toBeCloseTo(vue.tx + 60, 6); + expect(apres.ty, nom).toBeCloseTo(vue.ty - 40, 6); + expect(fichiers(m), nom).toEqual(avant); + expect(m.fs.compteurs.ecritures, nom).toBe(ecritures); + m.horloge.avancer(DELAI_REGLAGES_MS - 1); + await images(2); + expect(m.fs.compteurs.ecritures, nom).toBe(ecritures); + m.horloge.avancer(1); + await attendre(() => expect(m.fs.compteurs.ecritures, nom).toBe(ecritures + 1)); + const ecrit = JSON.parse(m.fs.contenu('documents', 'reglages_locaux.json')).cadrages.find( + ({ evenement }) => evenement === charge(m).evenement.id, + ); + expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }, nom).toEqual(vueLue(m)); + expect(fichiers(m), nom).toEqual(avant); + }; + await parcourir('écriture'); + await m.seance.passerEnLecture(); + flushSync(); + await parcourir('lecture'); + await m.seance.passerEnEcriture(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + await parcourir('bloqué'); + }); + + test('un pointercancel en cours de glissement remet le transform d’origine', async () => { + const m = await ouvrir(huitRetenue()); + const transform = groupe(m).getAttribute('transform'); + const pointeur = pointeurDuProchainAppui(m); + const depart = pointSol(m); + await presserEtBouger(depart, decale(depart, 70, 50)); + expect(groupe(m).getAttribute('transform')).not.toBe(transform); + racinePlan(m).dispatchEvent(new PointerEvent('pointercancel', { pointerId: pointeur.id, bubbles: true })); + flushSync(); + expect(groupe(m).getAttribute('transform')).toBe(transform); + await pointer('relacher', depart.x + 70, depart.y + 50); + await images(2); + expect(groupe(m).getAttribute('transform')).toBe(transform); + }); +}); + +// --- Le dépôt par la géométrie (§ 7.3, § 14.10) ------------------------------ + +describe('le dépôt se désigne par la géométrie (§ 7.3)', () => { + test('relâchée au centre d’une chaise libre d’une autre table, la commande nomme cette table et ce siège ; sur une chaise que recouvre une liste, de même', async () => { + const m = await ouvrir(huitRetenue()); + const commandes = releverCommandes(m); + // Au tour 1, la table 8 n'assied que la personne 20 : ses sièges 2 et 3 sont libres. + const r = fichiers(m).revision; + await glisser(pointChaise(m, 2, 1), pointChaise(m, 8, 2)); + await uneEntree(m, r); + expect(commandes).toEqual([{ nom: 'deplacerDansRetenu', args: { tour: 1, participant: 4, table: 8, siege: 2 } }]); + + // La liste de la table 5 couvre les chaises de la table 6 : la personne 13 + // va au siège 3, libre, de la table 6. + const liste = dans(m, '[data-liste="5"]'); + const visee = pointChaise(m, 6, 3); + const boite = liste.getBoundingClientRect(); + expect(visee.x > boite.left && visee.x < boite.right && visee.y > boite.top && visee.y < boite.bottom).toBe(true); + await glisser(pointChaise(m, 5, 1), visee); + await uneEntree(m, r + 1); + expect(commandes.at(-1)).toEqual({ nom: 'deplacerDansRetenu', args: { tour: 1, participant: 13, table: 6, siege: 3 } }); + }); +}); + +// --- Annuler n'est pas finir (§ 7.3, § 8.4) --------------------------------------- + +describe('annuler n’est pas finir (§ 7.3, § 8.4)', () => { + const verrou = (m) => m.fs.contenu('documents', `${BASE}${SUFFIXES.verrou}`); + + // Trois annulations d'un geste entamé : un pointercancel sur la racine, + // Échap le focus sur , le retour en lecture. montrer entame le + // geste et rend la fonction qui vérifie qu'il est montré ; revenu vérifie + // que tout est revenu. + async function annulerTrois(m, { depart, vers, montre, revenu }) { + for (const cause of ['pointercancel', 'echap', 'lecture']) { + if (m.seance.etat().mode !== 'ecriture') await m.seance.passerEnEcriture(); + flushSync(); + const avant = fichiers(m); + const ecritures = m.fs.compteurs.ecritures; + const pointeur = pointeurDuProchainAppui(m); + await presserEtBouger(depart(), vers()); + montre(cause); + if (cause === 'pointercancel') { + racinePlan(m).dispatchEvent(new PointerEvent('pointercancel', { pointerId: pointeur.id, bubbles: true })); + } else if (cause === 'echap') { + document.activeElement?.blur(); + expect(document.activeElement).toBe(document.body); + await touche('Escape'); + } else { + await m.seance.passerEnLecture(); + } + flushSync(); + revenu(cause); + await pointer('relacher', vers().x, vers().y); + await images(3); + revenu(cause); + expect(fichiers(m), cause).toEqual(avant); + if (cause === 'lecture') { + expect(m.fs.compteurs.ecritures, cause).toBe(ecritures + 1); + expect(verrou(m), cause).toBeNull(); + } else { + expect(m.fs.compteurs.ecritures, cause).toBe(ecritures); + } + } + } + + test('une table : pointercancel, Échap, passage en lecture → le transform d’origine, rien d’écrit', async () => { + const m = await ouvrir(huitRetenue()); + await annulerTrois(m, { + depart: () => pointTable(m, 2), + vers: () => decale(pointTable(m, 2), 60, 40), + montre: (cause) => expect(tableEl(m, 2).getAttribute('transform'), cause).not.toBeNull(), + revenu: (cause) => expect(tableEl(m, 2).getAttribute('transform'), cause).toBeNull(), + }); + }); + + test('une personne : le fantôme se montre, puis se cache ; rien d’écrit', async () => { + const m = await ouvrir(huitRetenue()); + await annulerTrois(m, { + depart: () => pointChaise(m, 2, 1), + vers: () => pointChaise(m, 8, 2), + montre: (cause) => expect(fantomeMontre(m), cause).toBe(true), + revenu: (cause) => expect(fantomeMontre(m), cause).toBe(false), + }); + }); + + test('une poignée : les chaises tirées disparaissent, la table revient à sa capacité ; rien d’écrit', async () => { + const m = await ouvrir(troisDeHuit()); + const k = vueLue(m).k; + const pas = (60 / (2 * Math.PI)) * k / Math.SQRT2; + await annulerTrois(m, { + depart: () => pointPoignee(m, 1), + vers: () => decale(pointPoignee(m, 1), 3 * pas, 3 * pas), + montre: (cause) => expect(chaisesDe(m, 1).length, cause).toBe(11), + revenu: (cause) => expect(chaisesDe(m, 1).length, cause).toBe(8), + }); + }); +}); + +// --- Les gestes d'une personne, les deux états des données (§ 5.8, § 7.1) ------- + +describe('les gestes d’une personne (§ 7.1, § 8.2, § 5.8)', () => { + test('sur un retenu, au tour 2 : déplacer, échanger, retirer → une entrée chacun ; le retenu ne change qu’au tour 2 ; la proposition reste identique', async () => { + const m = await ouvrir(huitRetenue()); + m.ui.montrerTour(2); + flushSync(); + await images(2); + const origine = structuredClone(charge(m)); + let r = fichiers(m).revision; + + await glisser(pointChaise(m, 4, 1), pointChaise(m, 7, 3)); + expect(await uneEntree(m, r)).toBe('Déplacé Iris Ombrelle de la table 4 à la table 7, tour 2'); + r += 1; + // Théo Pervenche (2), à la table 1 au tour 2, prend la place d'Ondine + // Lacasse (3), à la table 2, et l'inverse. + await glisser(pointChaise(m, 1, 1), pointChaise(m, 2, 1)); + expect(await uneEntree(m, r)).toMatch(/^Échangé Théo Pervenche .* et Ondine Lacasse .*, tour 2$/); + r += 1; + await glisser(pointChaise(m, 3, 1), pointSol(m)); + expect(await uneEntree(m, r)).toMatch(/^Retiré Grisaille de la table 3, tour 2$/); + + const apres = charge(m); + expect(apres.propositions).toEqual(origine.propositions); + expect(apres.reservations).toEqual(origine.reservations); + expect(apres.retenu.placement[0]).toEqual(origine.retenu.placement[0]); + expect(apres.retenu.placement[2]).toEqual(origine.retenu.placement[2]); + expect(apres.retenu.placement[1]).not.toEqual(origine.retenu.placement[1]); + expect(etatPlaces(apres, { tour: 2 }).reserve).toContain(4); + for (const tour of [1, 3]) expect(etatPlaces(apres, { tour }).reserve, `tour ${tour}`).not.toContain(4); + }); + + test('le même geste, deux états : avant la retenue une réservation, sur le retenu le retenu ; une proposition montrée sur le retenu refuse sur la place', async () => { + // Avant la retenue, la proposition 1 montrée : Grisaille (4), non réservée, + // vers le siège 2, libre, de la table 8 au tour 1. + const avantRetenue = await ouvrir(huitAvantRetenue()); + avantRetenue.ui.montrerProposition(1); + flushSync(); + await images(2); + const origine = structuredClone(charge(avantRetenue)); + const r = fichiers(avantRetenue).revision; + await presserEtBouger(pointChaise(avantRetenue, 2, 1), pointChaise(avantRetenue, 8, 2)); + expect(fantomeMontre(avantRetenue)).toBe(true); + const libelleReservation = texteDe(fantomeEl(avantRetenue).querySelector('[data-partie="etat"]')); + expect(libelleReservation).toBe('pose une réservation'); + expect(texteDe(fantomeEl(avantRetenue).querySelector('[data-partie="nom"]'))).toBe('Grisaille'); + const vers = pointChaise(avantRetenue, 8, 2); + await pointer('relacher', vers.x, vers.y); + await uneEntree(avantRetenue, r); + expect(charge(avantRetenue).reservations).toEqual([{ participant: 4, table: 8, siege: 2, portee: 'tour', tour: 1 }]); + expect(charge(avantRetenue).propositions).toEqual(origine.propositions); + + // Sur le retenu, le même glissement modifie le retenu. + demonter(avantRetenue); + const retenue = await ouvrir(huitRetenue()); + const origineRetenue = structuredClone(charge(retenue)); + const rr = fichiers(retenue).revision; + await presserEtBouger(pointChaise(retenue, 2, 1), pointChaise(retenue, 8, 2)); + const libelleRetenu = texteDe(fantomeEl(retenue).querySelector('[data-partie="etat"]')); + expect(libelleRetenu).toBe('déplace dans le placement retenu'); + expect(libelleRetenu).not.toBe(libelleReservation); + const versRetenu = pointChaise(retenue, 8, 2); + await pointer('relacher', versRetenu.x, versRetenu.y); + await uneEntree(retenue, rr); + expect(charge(retenue).reservations).toEqual(origineRetenue.reservations); + expect(charge(retenue).propositions).toEqual(origineRetenue.propositions); + expect(charge(retenue).retenu.placement[0]).not.toEqual(origineRetenue.retenu.placement[0]); + + // Le retenu, la proposition 1 montrée : la personne revient, le refus est + // sur la place, et son remède montre le retenu. + retenue.ui.montrerProposition(1); + flushSync(); + await images(2); + const avant = fichiers(retenue); + await glisser(pointChaise(retenue, 3, 1), pointChaise(retenue, 7, 3)); + const place = dans(retenue, '[data-siege="7:3"]'); + await attendre(() => expect(decrit(place)).not.toBeNull()); + const refus = decrit(place).querySelector('[data-refus="PROPOSITION_MONTREE"]'); + expect(refus).not.toBeNull(); + const remede = refus.querySelector('[data-commande="afficherTour"]'); + expect(remede).not.toBeNull(); + await rienEcrit(retenue, avant); + expect(fantomeMontre(retenue)).toBe(false); + remede.click(); + flushSync(); + expect(retenue.ui.etat().proposition).toBeNull(); + await rienEcrit(retenue, avant); + }); +}); + +// --- Glisser une table (§ 7.1, § 9) --------------------------------------------- + +describe('glisser une table (§ 7.1, § 9)', () => { + test('(40, −20) px à k = 2 ajoutent (20, −10) cm à sa position, une entrée ; sur un plan bloqué, la table revient, un refus nomme « Débloquer », rien d’écrit', async () => { + const m = await ouvrir(troisDeHuit()); + const cadre = cadreDe(m); + await avecVue(m, { k: 2, tx: cadre.largeur / 2 - 2 * 300, ty: cadre.hauteur / 2 }); + const r = fichiers(m).revision; + const position = { ...charge(m).tables[1].position }; + const depart = pointTable(m, 2); + await glisser(depart, decale(depart, 40, -20)); + await uneEntree(m, r); + expect(charge(m).tables[1].position).toEqual({ x: position.x + 20, y: position.y - 10 }); + + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + const avant = fichiers(m); + const ici = pointTable(m, 2); + await glisser(ici, decale(ici, 40, -20)); + const table = tableEl(m, 2); + await attendre(() => expect(decrit(table)).not.toBeNull()); + const refus = decrit(table).querySelector('[data-refus="LECTURE_SEULE"]'); + expect(refus).not.toBeNull(); + expect(texteDe(refus.querySelector('[data-commande="debloquer"]'))).toBe('Débloquer'); + expect(table.getAttribute('transform')).toBeNull(); + await rienEcrit(m, avant); + }); + + test('pendant l’écriture d’un déplacement, un second appui sur la table ne fait rien : une seule commande, la table au premier écart, aucun refus ; la vue, elle, se déplace', async () => { + const m = await ouvrir(huitRetenue()); + const commandes = releverCommandes(m); + // L'écriture de l'état reste retenue jusqu'à liberer() : la séance ne + // publie la charge nouvelle qu'une fois l'écriture faite. + const ecrireAtomique = m.fs.ecrireAtomique; + let liberer = null; + const retenue = new Promise((resoudre) => { + liberer = resoudre; + }); + m.fs.ecrireAtomique = async (...parametres) => { + await retenue; + return Reflect.apply(ecrireAtomique, m.fs, parametres); + }; + nettoyages.push(() => { + m.fs.ecrireAtomique = ecrireAtomique; + }); + const r = fichiers(m).revision; + const position = { ...charge(m).tables[0].position }; + const depart = pointTable(m, 1); + const ici = decale(depart, 40, 0); + await glisser(depart, ici); + await attendre(() => expect(commandes).toHaveLength(1)); + const voulue = commandes[0].args.position; + expect(voulue).not.toEqual(position); + const deplacee = tableEl(m, 1).getAttribute('transform'); + expect(deplacee).not.toBeNull(); + + await glisser(ici, decale(ici, 40, 0)); + await images(3); + expect(commandes).toHaveLength(1); + expect(tableEl(m, 1).getAttribute('transform')).toBe(deplacee); + const vueAvant = groupe(m).getAttribute('transform'); + const sol = pointSol(m); + await glisser(sol, decale(sol, 30, 20)); + await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBe(vueAvant)); + + liberer(); + await uneEntree(m, r); + await images(4); + expect(commandes).toHaveLength(1); + expect(charge(m).tables[0].position).toEqual(voulue); + await attendre(() => expect(tableEl(m, 1).getAttribute('transform')).toBeNull()); + expect(decrit(tableEl(m, 1))).toBeNull(); + expect(m.racine.querySelector('[data-refus]')).toBeNull(); + }); +}); + +// --- La poignée (§ 7.1, § 7.7, § 6.1, § 6.2) ---------------------------------------- + +describe('la poignée (§ 7.1, § 7.7, § 6.2)', () => { + // Le décalage écran, sur chaque axe, d'un écart radial de n places. + const pasDe = (m, n) => ((n * 60) / (2 * Math.PI)) * vueLue(m).k / Math.SQRT2; + + test('une table de 8 tirée dehors puis dedans : des nombres entiers successifs de chaises, le rayon rendu à l’échelle, rien d’écrit avant le relâcher, puis une entrée', async () => { + const m = await ouvrir(troisDeHuit()); + const k = vueLue(m).k; + const avant = fichiers(m); + const depart = pointPoignee(m, 1); + await pointer('presser', depart.x, depart.y); + const vus = []; + const plateau = () => dans(m, '[data-table="1"] [data-marque="contourTable"], [data-table="1"] [data-marque="contourConflit"]'); + const lire = () => { + flushSync(); + const n = chaisesDe(m, 1).length; + if (vus.at(-1) !== n) vus.push(n); + expect(plateau().getBoundingClientRect().width / 2).toBeCloseTo((diametre(n) / 2) * k, 0); + }; + for (const n of [0.4, 1, 1.6, 2, 2.6, 3, 2.4, 2, 1.4, 1, 0.5, 0, -0.6]) { + await pointer('bouger', depart.x + pasDe(m, n), depart.y + pasDe(m, n)); + await images(1); + lire(); + } + expect(fichiers(m)).toEqual(avant); + expect(vus.length).toBeGreaterThan(3); + for (let i = 1; i < vus.length; i += 1) expect(Math.abs(vus[i] - vus[i - 1]), `${vus}`).toBe(1); + expect(Math.max(...vus)).toBe(11); + expect(vus.at(-1)).toBe(7); + await pointer('relacher', depart.x + pasDe(m, -0.6), depart.y + pasDe(m, -0.6)); + expect(await uneEntree(m, avant.revision)).toBe('Table 1 : 7 places'); + expect(charge(m).tables[0].sieges).toBe(7); + expect(Number.isInteger(charge(m).tables[0].sieges)).toBe(true); + }); + + test('retenu, sept personnes à la table 3 au seul tour 3 : tirée vers 5, elle s’arrête à 7, et le message sur la table porte « 7 » et « tour 3 »', async () => { + const m = await ouvrir(troisDeHuit()); + const depart = pointPoignee(m, 3); + await presserEtBouger(depart, decale(depart, pasDe(m, -3), pasDe(m, -3))); + expect(chaisesDe(m, 3).length).toBe(7); + const table = tableEl(m, 3); + await attendre(() => expect(decrit(table)).not.toBeNull()); + const refus = decrit(table).querySelector('[data-refus="SOUS_OCCUPES"]'); + expect(texteDe(refus)).toContain('7'); + expect(texteDe(refus)).toContain('tour 3'); + expect(recoupe(decrit(table), table)).toBe(true); + const r = fichiers(m).revision; + await pointer('relacher', depart.x + pasDe(m, -3), depart.y + pasDe(m, -3)); + expect(await uneEntree(m, r)).toBe('Table 3 : 7 places'); + }); + + test('sièges attribués, un titre « animateur » au siège 8 et une réservation au siège 7 : tirée vers 6, elle s’arrête à 8 ; le message liste le siège 8 et son titre ; relâchée à 8, aucune entrée', async () => { + let base = chargeDe({ id: 'evt-attribues', places: [null, null], personnes: 4, attribuer: true }); + base = executer(base, 'poserTitre', { table: 1, siege: 8, libelle: 'animateur' }); + base = executer(base, 'reserver', { participant: 1, table: 1, siege: 7, portee: 'tous', tour: null }); + const m = await ouvrir(base); + const avant = fichiers(m); + const depart = pointPoignee(m, 1); + const vers = decale(depart, pasDe(m, -2), pasDe(m, -2)); + await presserEtBouger(depart, vers); + expect(chaisesDe(m, 1).length).toBe(8); + const table = tableEl(m, 1); + await attendre(() => expect(decrit(table)).not.toBeNull()); + const refus = decrit(table).querySelector('[data-refus="SIEGES_RETIRES"]'); + expect(texteDe(refus)).toContain('siège 8'); + expect(texteDe(refus)).toContain('animateur'); + await pointer('relacher', vers.x, vers.y); + await rienEcrit(m, avant); + expect(chaisesDe(m, 1).length).toBe(8); + }); + + test('surcharge par la poignée : une table qui suit un défaut de 8, passée à 9 puis ramenée à 8, se dit surchargée, jusqu’au réalignement', async () => { + const m = await ouvrir(chargeDe({ id: 'evt-surcharge', places: [null, null], personnes: 4 })); + const nom = (id) => tableEl(m, id).getAttribute('aria-label'); + expect(nom(1)).toBe(nom(2).replace('Table 2', 'Table 1')); + let r = fichiers(m).revision; + for (const n of [1, -1]) { + const depart = pointPoignee(m, 1); + await glisser(depart, decale(depart, pasDe(m, n), pasDe(m, n))); + await uneEntree(m, r); + r += 1; + } + expect(charge(m).tables[0].sieges).toBe(8); + await attendre(() => expect(nom(1)).toContain('surchargée')); + expect(nom(2)).not.toContain('surchargée'); + expect(chaisesDe(m, 1).length).toBe(chaisesDe(m, 2).length); + await m.seance.executer('realignerTable', { id: 1 }); + await attendre(() => expect(nom(1)).not.toContain('surchargée')); + }); + + test('lisibilité : au-delà de SEUIL_SIEGES_LISIBLES, l’avertissement s’affiche sur la table, sans arrêter le nombre', async () => { + const m = await ouvrir(chargeDe({ id: 'evt-lisible', places: [null, null], personnes: 4 })); + const seuil = constante('SEUIL_SIEGES_LISIBLES'); + const depart = pointPoignee(m, 1); + const n = seuil + 1 - 8; + await presserEtBouger(depart, decale(depart, pasDe(m, n), pasDe(m, n)), { pas: 10 }); + expect(chaisesDe(m, 1).length).toBe(seuil + 1); + const table = tableEl(m, 1); + await attendre(() => expect(decrit(table)).not.toBeNull()); + const avertissement = decrit(table).querySelector('[data-avertissement="AU_DELA_LISIBLE"]'); + expect(avertissement).not.toBeNull(); + expect(avertissement.checkVisibility()).toBe(true); + const r = fichiers(m).revision; + await pointer('relacher', depart.x + pasDe(m, n), depart.y + pasDe(m, n)); + expect(await uneEntree(m, r)).toBe(`Table 1 : ${seuil + 1} places`); + }); +}); + +// --- Un clic n'est pas un glisser ; la lecture ; l'inactivité (§ 8.4) --------------- + +describe('un clic n’est pas un glisser, la lecture, l’inactivité (§ 8.4)', () => { + test('appui, mouvement sous le seuil, relâcher → aucune entrée ; au-delà → une entrée ; Échap pendant le glissement → objet remis, aucune entrée', async () => { + expect(SEUIL).toBeGreaterThan(0); + const m = await ouvrir(huitRetenue()); + const avant = fichiers(m); + const depart = pointTable(m, 5); + await glisser(depart, decale(depart, SEUIL - 1, 0), { pas: 2 }); + await rienEcrit(m, avant); + expect(m.ui.etat().selection).toEqual({ table: 5 }); + await glisser(depart, decale(depart, SEUIL + 6, 0), { pas: 2 }); + await uneEntree(m, avant.revision); + const apres = fichiers(m); + const ici = pointTable(m, 5); + await presserEtBouger(ici, decale(ici, 40, 0)); + await touche('Escape'); + flushSync(); + expect(tableEl(m, 5).getAttribute('transform')).toBeNull(); + await pointer('relacher', ici.x + 40, ici.y); + await rienEcrit(m, apres); + }); + + test('en lecture : glisser une personne, une table, tirer une poignée → charge inchangée, rien d’écrit, objets revenus ; le zoom et le changement de tour opèrent', async () => { + const m = await ouvrir(troisDeHuit(), { mode: 'lecture' }); + const texteCharge = serialiserCharge(charge(m)); + const avant = fichiers(m); + const ecritures = m.fs.compteurs.ecritures; + await glisser(pointChaise(m, 1, 1), pointChaise(m, 3, 8)); + await glisser(pointTable(m, 2), decale(pointTable(m, 2), 50, 50)); + const poignee = pointPoignee(m, 1); + await glisser(poignee, decale(poignee, pasDeLecture(m), pasDeLecture(m))); + await images(4); + expect(serialiserCharge(charge(m))).toBe(texteCharge); + await rienEcrit(m, avant); + expect(m.fs.compteurs.ecritures).toBe(ecritures); + expect(tableEl(m, 2).getAttribute('transform')).toBeNull(); + expect(chaisesDe(m, 1).length).toBe(8); + expect(fantomeMontre(m)).toBe(false); + const transform = groupe(m).getAttribute('transform'); + dans(m, '[data-commande="zoomer"]').click(); + flushSync(); + expect(groupe(m).getAttribute('transform')).not.toBe(transform); + const select = dans(m, '[data-champ="tour"]'); + select.value = '2'; + select.dispatchEvent(new Event('change', { bubbles: true })); + flushSync(); + expect(m.ui.etat().tour).toBe(2); + expect(m.seance.etat().mode).toBe('lecture'); + }); + + test('inactivité : le délai dépassé pendant un glissement laisse en écriture ; après le relâcher et le délai, lecture ; un déplacement de vue relance le compte ; « Revenir en lecture » au clavier ramène la table', async () => { + const m = await ouvrir(huitRetenue()); + const depart = pointTable(m, 6); + await presserEtBouger(depart, decale(depart, 30, 20)); + m.horloge.avancer(INACTIVITE_MS + 1); + await images(2); + expect(m.seance.etat().mode).toBe('ecriture'); + expect(tableEl(m, 6).getAttribute('transform')).not.toBeNull(); + const r = fichiers(m).revision; + await pointer('bouger', depart.x + 40, depart.y + 20); + await pointer('relacher', depart.x + 40, depart.y + 20); + await uneEntree(m, r); + m.horloge.avancer(INACTIVITE_MS + 1); + await attendre(() => expect(m.seance.etat().mode).toBe('lecture')); + + await m.seance.passerEnEcriture(); + flushSync(); + m.horloge.avancer(INACTIVITE_MS - 1000); + const sol = pointSol(m); + await glisser(sol, decale(sol, 30, 30)); + await images(2); + m.horloge.avancer(INACTIVITE_MS - 1); + await images(2); + expect(m.seance.etat().mode).toBe('ecriture'); + + const avant = fichiers(m); + const ici = pointTable(m, 6); + await presserEtBouger(ici, decale(ici, 50, 0)); + expect(tableEl(m, 6).getAttribute('transform')).not.toBeNull(); + const lecture = dans(m, '[data-commande="lecture"]'); + lecture.focus(); + expect(document.activeElement).toBe(lecture); + await touche('Enter'); + await attendre(() => expect(m.seance.etat().mode).toBe('lecture')); + expect(tableEl(m, 6).getAttribute('transform')).toBeNull(); + await pointer('relacher', ici.x + 50, ici.y); + await images(3); + expect(entreesApres(m, avant.revision)).toEqual([]); + expect(fichiers(m).etat).toBe(avant.etat); + }); +}); + +// L'écart d'une poignée tirée de trois places, au zoom relu. +const pasDeLecture = (m) => ((3 * 60) / (2 * Math.PI)) * vueLue(m).k / Math.SQRT2; + +// --- Le dépôt sur une table pleine (§ 5.9, § 7.3) ----------------------------------- + +describe('le dépôt sur une table pleine (§ 5.9, § 7.3)', () => { + test('k = 0,39 : la ronde de 3 en régime sièges, les tables de 8 en régime table ; une personne déposée sur une table de 8 pleine revient, un refus rattaché à la table porte sa cause et son numéro, rien d’écrit', async () => { + const base = chargeDe({ + id: 'evt-pleine', + places: [3, null, null], + ecart: 1200, + personnes: 16, + nombreTours: 2, + tours: [ + [[1, 2, 3], suite(4, 11), suite(12, 16)], + [[4, 5, 6], [1, 2, 3, 12, 13, 14, 15, 16], suite(7, 11)], + ], + }); + const m = await ouvrir(base); + const cadre = cadreDe(m); + const kA = kAjuste(englobantDuTour(etatMontre(m)), cadre); + expect(0.39).toBeGreaterThanOrEqual(kA / 2); + const englobant = englobantDuTour(etatMontre(m)); + const k = 0.39; + await avecVue(m, { + k, + tx: cadre.largeur / 2 - (k * (englobant.minX + englobant.maxX)) / 2, + ty: cadre.hauteur / 2 - (k * (englobant.minY + englobant.maxY)) / 2, + }); + expect(chaisesDe(m, 1).length).toBe(3); + expect(chaisesDe(m, 2).length).toBe(0); + const avant = fichiers(m); + await glisser(pointChaise(m, 1, 1), pointTable(m, 2)); + const table = tableEl(m, 2); + await attendre(() => expect(decrit(table)).not.toBeNull()); + const refus = decrit(table); + expect(refus.querySelector('[data-refus="TABLE_PLEINE"]')).not.toBeNull(); + expect(texteDe(refus)).toContain('Table 2'); + expect(texteDe(refus)).toContain('pleine'); + expect(recoupe(refus, table)).toBe(true); + expect(fantomeMontre(m)).toBe(false); + await rienEcrit(m, avant); + }); +}); diff --git a/src/interface/plan/Plan.svelte b/src/interface/plan/Plan.svelte index 006728b..1187115 100644 --- a/src/interface/plan/Plan.svelte +++ b/src/interface/plan/Plan.svelte @@ -33,14 +33,37 @@ // // diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou // null — le bandeau ne porte alors que la phrase des conflits (§ 7.5). - import { getContext } from 'svelte'; + // + // Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul + // écouteur de chaque type, sur la racine : pointerdown identifie la source + // — poignée, chaise, table, sinon le sol — par closest, relit le cadre, et + // la machine de gestes.js capture le pointeur sur la racine ; les + // mouvements se regroupent par image (requestAnimationFrame) et ne lisent + // aucune mise en page ; pointerup achève, par au plus une commande de la + // séance ; pointercancel, Échap — écouté sur document, en phase de + // capture, le temps du geste — et le passage en lecture (surAnnulation) + // restaurent. Pendant le glissement, le composant écrit l'attribut + // transform directement — groupe de la vue, groupe d'une table et ses + // frères, fantôme — et ne confie le résultat au modèle qu'au relâcher : + // aucun élément capturant n'est remplacé. La poignée passe son nombre au + // modèle de dessin (option poignee), sans rien écrire. Un refus — du + // dépôt, du contrôle de la poignée, de la commande — se pose sur la place + // 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'; + import { controlerCapacite } from '../../application/capacite.js'; import { etatPlaces } from '../../application/places.js'; - import { ajuster as vueAjustee, transformDe } from '../../geometrie/vue.js'; + import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; import BandeauPlan from './BandeauPlan.svelte'; import BarreVue from './BarreVue.svelte'; + 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 PlanDessin from './PlanDessin.svelte'; + import RefusPlace from './RefusPlace.svelte'; import { cadreDepuisRect, englobantDuTour, @@ -60,6 +83,20 @@ let racine = $state(null); let vue = $state.raw(null); + let surface = $state(null); + let groupeVue = $state(null); + let elementFantome = $state(null); + // Le nombre que la poignée tirée montre, {table, places}, ou null. + 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); + + const prefixe = $props.id(); + const idPose = `${prefixe}-pose`; const charge = $derived($seance.charge); const tours = $derived(charge.evenement.tours); @@ -70,8 +107,16 @@ const modele = $derived( vue === null ? null - : modeleDessin(etat, { k: vue.k, listes: $ui.listes, miseEnEvidence: $ui.miseEnEvidence, nomDe, diagnostic }), + : modeleDessin(etat, { k: vue.k, listes: $ui.listes, miseEnEvidence: $ui.miseEnEvidence, nomDe, diagnostic, poignee }), ); + // La pose à sa position dans la surface, sous la vue courante. + 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); + const numero = etat.tables.find(({ id }) => id === place.table)?.numero ?? null; + return { id: idPose, numero, siege: place.siege ?? null, refus, avertissements, x, y }; + }); // Le cadre de la racine, relu maintenant. const cadreCourant = () => cadreDepuisRect(racine.getBoundingClientRect()); @@ -92,6 +137,110 @@ poserVue(vueAjustee(englobantPlan, cadreCourant())); } + // --- Les gestes du pointeur --------------------------------------------------- + + // L'état d'une table du tour montré. + const tableDeEtat = (id) => etat.tables.find((table) => table.id === id); + + // La source d'un appui, lue sur l'élément pressé et sur l'état des places. + function sourceDe(element) { + const saisie = element.closest('[data-poignee]'); + if (saisie !== null) { + const table = Number(saisie.dataset.poignee); + return { genre: 'poignee', table, siege: null, participant: null, places: tableDeEtat(table).capacite }; + } + const chaise = element.closest('[data-siege]'); + if (chaise !== null) { + const [table, siege] = chaise.dataset.siege.split(':').map(Number); + const participant = tableDeEtat(table).chaises[siege - 1]?.occupant ?? null; + return { genre: 'personne', table, siege, participant }; + } + const groupe = element.closest('[data-table]'); + if (groupe !== null) { + const table = Number(groupe.dataset.table); + const { x, y } = tableDeEtat(table).position; + return { genre: 'table', table, siege: null, participant: null, position: { x, y } }; + } + return { genre: 'sol', table: null, siege: null, participant: null }; + } + + // Les éléments qu'un glissement de table déplace : son groupe, et ses + // frères désignés par son identifiant. + const elementsDeTable = (table) => + racine.querySelectorAll(`[data-table="${table}"], [data-poignee="${table}"], [data-pastille="${table}"], [data-liste="${table}"]`); + + // L'élément d'une place : la chaise, ou la table quand la place n'a pas + // de siège ou que sa chaise n'est pas dessinée. + function elementDePlace({ table, siege = null }) { + const chaise = siege === null ? null : racine.querySelector(`[data-siege="${table}:${siege}"]`); + return chaise ?? racine.querySelector(`[data-table="${table}"]`); + } + + // Le point de dessin d'une place : le centre de sa chaise, ou de sa table. + function ancreDe({ table, siege = null }) { + const { geometrie } = modele.tables.find(({ id }) => id === table); + 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 }; + + // 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 }; + } + + const fermerPose = () => { + pose = null; + }; + + // Restaure ce qu'un geste a montré. + function restaurer(cible) { + if (cible === 'vue') groupeVue.setAttribute('transform', transformDe(vue)); + else if (cible === 'fantome') { + fantome = null; + elementFantome.removeAttribute('transform'); + } else if (cible.genre === 'table') { + for (const element of elementsDeTable(cible.table)) element.removeAttribute('transform'); + } else poignee = null; + } + + // Vrai de l'envoi de la commande d'un geste à son règlement, la cible + // apres restaurée. La séance ne publie la charge qu'une fois l'écriture + // faite : jusque-là, l'état des places et les positions que lirait un + // nouvel appui sont ceux d'avant la commande, et la restauration de apres + // retirerait le transform d'un second glissement de la même table. + let commandeEnCours = false; + + // Confie la commande d'un geste à la séance ; un refus se pose sur la + // place. La cible apres se restaure une fois la charge rendue : la table + // se dessine alors à sa nouvelle position, ou revient. + async function executerGeste({ commande, arguments: args, place, apres }) { + commandeEnCours = true; + try { + await seance.executer(commande, args); + } catch (erreur) { + if (typeof erreur?.code !== 'string') throw erreur; + poserSurPlace(place, { code: erreur.code, details: erreur.details ?? {} }, []); + } finally { + try { + if (apres !== null) { + await tick(); + restaurer(apres); + } + } finally { + commandeEnCours = false; + } + } + } + $effect(() => { const element = racine; // La vue d'un changement de cadre : la vue initiale tant qu'il n'y en a @@ -121,19 +270,187 @@ element.removeEventListener('wheel', surMolette); }; }); + + $effect(() => { + const element = racine; + let etatGeste = REPOS; + let contexte = null; + // Le dernier point d'un mouvement en attente de son image. + let image = null; + let enAttente = null; + + const oublierImage = () => { + if (image !== null) cancelAnimationFrame(image); + image = null; + enAttente = null; + }; + + // Applique un pas de la machine : son état, puis chaque effet. + function appliquer({ etat: suivant, effets }) { + const pointeur = etatGeste.pointeur; + etatGeste = suivant; + for (const effet of effets) { + switch (effet.type) { + case 'capturer': + element.setPointerCapture(effet.pointeur); + document.addEventListener('keydown', surTouche, true); + break; + case 'debuterGeste': + seance.debuterGeste(); + break; + case 'finirGeste': + oublierImage(); + document.removeEventListener('keydown', surTouche, true); + if (element.hasPointerCapture(pointeur)) element.releasePointerCapture(pointeur); + seance.finirGeste(); + break; + case 'ecrireTransform': + if (effet.cible === 'vue') groupeVue.setAttribute('transform', effet.transform); + else if (effet.cible === 'fantome') elementFantome.setAttribute('transform', effet.transform); + else for (const cible of elementsDeTable(effet.cible.table)) cible.setAttribute('transform', effet.transform); + break; + case 'montrerFantome': + fantome = { nom: nomDe(effet.participant), cle: cleFantome(etat.cible) }; + break; + case 'montrerPlaces': + poignee = { table: effet.table, places: effet.places }; + poserSurPlace({ table: effet.table }, pose?.refus ?? null, effet.avertissements); + break; + case 'montrerRefus': + poserSurPlace(effet.place, effet.refus, pose?.avertissements ?? []); + break; + case 'restaurer': + restaurer(effet.cible); + break; + case 'poserVue': + poserVue(effet.vue); + break; + case 'selectionner': + ui.selectionner(effet.place); + break; + case 'executer': + executerGeste(effet); + break; + default: + throw new TypeError(`effet de geste inconnu : ${JSON.stringify(effet.type)}`); + } + } + } + + const annulerGeste = (cause) => { + if (etatGeste.phase !== 'repos') appliquer(geste(etatGeste, { type: 'annuler', cause }, contexte)); + }; + + function surTouche(evenement) { + if (evenement.key !== 'Escape' || etatGeste.phase === 'repos') return; + evenement.stopPropagation(); + evenement.preventDefault(); + annulerGeste('echap'); + } + + // Un appui au repos, du bouton principal, sur un plan dessiné. Tant + // 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) => { + 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; + // 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; + const etatDuGeste = etat; + const geometries = modele.tables.map((table) => table.geometrie); + const k = vue.k; + contexte = { + controlerPlaces: memoriser((table, places) => controlerCapacite(chargeDuGeste, table, places, { origine: 'poignee' })), + deposer: (source, point) => + deposer({ + etatPlaces: etatDuGeste, + tour: etatDuGeste.tour, + personne: source.participant, + depuis: { table: source.table, siege: source.siege }, + point, + geometries, + k, + }), + }; + const point = { x: evenement.clientX, y: evenement.clientY }; + appliquer(geste(etatGeste, { type: 'presser', source, pointeur: evenement.pointerId, point, vue, cadre }, contexte)); + }; + + const livrer = () => { + image = null; + const point = enAttente; + enAttente = null; + if (point !== null && etatGeste.phase !== 'repos') { + appliquer(geste(etatGeste, { type: 'bouger', pointeur: etatGeste.pointeur, point }, contexte)); + } + }; + + const surMouvement = (evenement) => { + if (etatGeste.phase === 'repos' || 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; + oublierImage(); + const point = { x: evenement.clientX, y: evenement.clientY }; + appliquer(geste(etatGeste, { type: 'relacher', pointeur: evenement.pointerId, point }, contexte)); + }; + + const surAnnulationPointeur = (evenement) => { + if (etatGeste.phase !== 'repos' && evenement.pointerId === etatGeste.pointeur) annulerGeste('pointercancel'); + }; + + const desabonner = seance.surAnnulation(() => annulerGeste('lecture')); + element.addEventListener('pointerdown', surAppui); + element.addEventListener('pointermove', surMouvement); + element.addEventListener('pointerup', surRelache); + element.addEventListener('pointercancel', surAnnulationPointeur); + return () => { + desabonner(); + element.removeEventListener('pointerdown', surAppui); + element.removeEventListener('pointermove', surMouvement); + element.removeEventListener('pointerup', surRelache); + element.removeEventListener('pointercancel', surAnnulationPointeur); + oublierImage(); + document.removeEventListener('keydown', surTouche, true); + if (etatGeste.phase !== 'repos') seance.finirGeste(); + }; + }); + + // La place qui porte la pose la désigne par aria-describedby ; l'effet + // suit le modèle, qui peut redessiner la chaise. + $effect(() => { + if (poseAffichee === null || modele === null) return; + const element = elementDePlace(pose.place); + if (element === null) return; + element.setAttribute('aria-describedby', idPose); + return () => element.removeAttribute('aria-describedby'); + });
-
+
- + {#if modele !== null} {/if} + + {#if poseAffichee !== null} + + {/if}
@@ -153,6 +470,7 @@ } .surface { + position: relative; display: flex; flex: 1 1 auto; flex-direction: column; @@ -177,5 +495,6 @@ padding: 0; overflow: hidden; touch-action: none; + user-select: none; } diff --git a/src/interface/plan/RefusPlace.svelte b/src/interface/plan/RefusPlace.svelte new file mode 100644 index 0000000..bb9778d --- /dev/null +++ b/src/interface/plan/RefusPlace.svelte @@ -0,0 +1,69 @@ + + + + +
+

+ {t(libelle.cle, libelle.details)} +

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

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

+ {/each} + +
+ + diff --git a/src/interface/plan/depot.js b/src/interface/plan/depot.js new file mode 100644 index 0000000..fdedcc3 --- /dev/null +++ b/src/interface/plan/depot.js @@ -0,0 +1,109 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le dépôt d'une personne glissée sur le plan (§ 5.8, § 5.9, § 7.1, § 7.3, +// § 14.10), pur : la place se désigne par la géométrie des tables — celles +// mêmes que le dessin pose —, jamais dans le DOM, et la commande se lit sur +// la cible de l'état des places (EtatPlaces.cible, A5), jamais sur la +// présence d'un retenu. L'interface ne choisit rien : la cible dit ce que +// le geste modifie, et la commande juge la pose et la capacité. +import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js'; + +// La chaise d'une table de l'état des places, ou undefined. +function chaiseDe(etatPlaces, { table, siege }) { + return etatPlaces.tables.find(({ id }) => id === table)?.chaises[siege - 1]; +} + +// Le premier siège libre d'une table au tour affiché, ou null : sous le +// retenu, une chaise que personne n'occupe ; sous les réservations, une +// chaise qu'aucune réservation ne tient — l'occupant non réservé d'une +// proposition montrée ne la prend pas. +function premierLibre(etatPlaces, table, libre) { + const chaise = etatPlaces.tables.find(({ id }) => id === table).chaises.find(libre); + return chaise === undefined ? null : chaise.siege; +} + +/** + * La commande d'un dépôt de personne, lue sur etatPlaces.cible (contrat, + * « Source ») : + * - 'reservations' — avant la retenue, ou à un tour que le retenu ne porte + * pas — : une personne que sa chaise d'origine tient réservée déplace sa + * réservation (deplacerReservation), qui garde portée et tour ; une + * autre reçoit une réservation de portée « tour » au tour affiché + * (reserver) ; sur le sol, la réservation glissée part + * (retirerReservation), et rien pour une personne non réservée. Un siège + * qu'occupe une personne non réservée de la proposition montrée vaut un + * siège libre : la proposition reste identique, et dérive ; + * - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une + * autre → echangerDansRetenu, le sol → retirerDuTour ; + * - null — proposition montrée après la retenue, retenu fautif — : un dépôt + * sur un siège, une table ou le sol rend { refus: etatPlaces.refus }, et + * rien n'est exécuté. + * En régime table — cible {table} —, le premier siège libre de la table au + * tour affiché, sinon la table sans siège (siege: null), dont la commande + * juge la capacité. Null quand le dépôt manque — entre deux chaises, sur un + * plateau, sur la chaise d'origine — : la personne revient. + * + * @param {Object} options + * @param {Object} options.etatPlaces EtatPlaces du tour affiché + * @param {number} options.tour le tour affiché + * @param {number} options.personne l'identifiant de la personne glissée + * @param {{table: number, siege: number}|null} options.depuis sa chaise d'origine + * @param {{table: number, siege?: number}|null} options.cible le rendu de designer + * @param {boolean} options.surLeSol le rendu de surLeSol + * @returns {{commande: string, arguments: Object}|{refus: Object}|null} + */ +export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, surLeSol }) { + if (cible === null && !surLeSol) return null; + if (etatPlaces.cible === null) return { refus: etatPlaces.refus }; + const surSiege = cible !== null && cible.siege !== undefined; + if (surSiege && depuis !== null && cible.table === depuis.table && cible.siege === depuis.siege) return null; + + if (etatPlaces.cible === 'retenu') { + if (cible === null) return { commande: 'retirerDuTour', arguments: { tour, participant: personne } }; + const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ occupant }) => occupant === null); + const occupant = siege === null ? null : chaiseDe(etatPlaces, { table: cible.table, siege }).occupant; + if (occupant !== null && occupant !== personne) { + return { commande: 'echangerDansRetenu', arguments: { tour, a: personne, b: occupant } }; + } + return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } }; + } + + const origine = depuis === null ? undefined : chaiseDe(etatPlaces, depuis); + const tenue = origine?.reservation?.participant === personne ? origine.reservation : null; + if (cible === null) { + if (tenue === null) return null; + return { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } }; + } + const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ reservation }) => reservation === null); + if (tenue !== null) { + return { + commande: 'deplacerReservation', + arguments: { + participant: personne, + de: { table: depuis.table, portee: tenue.portee, tour: tenue.tour }, + vers: { table: cible.table, siege }, + }, + }; + } + return { commande: 'reserver', arguments: { participant: personne, table: cible.table, siege, portee: 'tour', tour } }; +} + +/** + * Le dépôt au point de dessin : la place que désignent designer et surLeSol + * sur les géométries du modèle de dessin, au zoom k, et la commande de + * commandeDeDepot. place est la place où poser un refus : la place + * désignée — chaise ou table —, sinon la chaise d'origine. + * + * @param {Object} options ceux de commandeDeDepot, cible et surLeSol exceptés, plus : + * @param {{x: number, y: number}} options.point cm + * @param {Object[]} options.geometries TableDessin.geometrie de chaque table, dans l'ordre du dessin + * @param {number} options.k + * @returns {{resultat: ReturnType, place: Object|null}} + */ +export function deposer({ etatPlaces, tour, personne, depuis, point, geometries, k }) { + const cible = designer(point, geometries, k); + const sol = cible === null && horsDesTables(point, geometries); + const resultat = commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, surLeSol: sol }); + return { resultat, place: cible ?? depuis }; +} diff --git a/src/interface/plan/depot.test.js b/src/interface/plan/depot.test.js new file mode 100644 index 0000000..d2f3797 --- /dev/null +++ b/src/interface/plan/depot.test.js @@ -0,0 +1,264 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du dépôt d'une personne glissée (§ 5.8, § 5.9, § 7.1, § 7.3, +// § 14.10), sans DOM : la commande se lit sur la cible de l'état des places +// (EtatPlaces.cible, A5), jamais sur la présence d'un retenu ; la place se +// désigne par la géométrie des tables. Les charges d'épreuve se construisent +// par les commandes de l'application et l'état des places se calcule par +// etatPlaces : la même implémentation que celle du plan (§ 19.1). Les noms +// sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { appliquerCommande } from '../../application/commandes.js'; +import { etatPlaces } from '../../application/places.js'; +import { geometrieTable } from '../../geometrie/tables.js'; +import { creerCharge } from '../../stockage/document.js'; +import { commandeDeDepot, deposer } from './depot.js'; + +const NOMS = [ + ['Ombrelle', 'Iris'], + ['Pervenche', 'Théo'], + ['Lacasse', 'Ondine'], + ['Grisaille', null], + ['Tourmaline', 'Basile'], + ['Arrosoir', 'Capucine'], + ['Cerfeuil', 'Aurèle'], +]; + +// Sept personnes, trois rondes de trois places, deux tours, et une +// proposition à sièges non attribués : au tour 1, la table 1 est pleine +// (1, 2, 3), la table 2 assied 4 et 5, la table 3 assied 6 et 7 ; au +// tour 2, 1, 4, 6 ; 2, 5, 7 ; 3. +function chargeDepot() { + const charge = creerCharge({ id: 'evt-depot', nom: 'Club des Merles', siegesParDefaut: 3, tours: 2 }); + charge.participants = NOMS.map(([nom, prenom], rang) => ({ + id: rang + 1, + nom, + prenom, + appartenance: null, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, + })); + charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } })); + charge.prochainsIds = { participant: 8, table: 4, proposition: 2 }; + charge.propositions = [ + { + id: 1, + graine: 1, + arret: 40, + historique: 1000, + produitVersion: 'epreuve', + siegesAttribues: false, + tables: [1, 2, 3], + capacites: [3, 3, 3], + tours: 2, + participants: [1, 2, 3, 4, 5, 6, 7], + placement: [ + { sieges: [[1, 2, 3], [4, 5], [6, 7]], reserve: [] }, + { sieges: [[1, 4, 6], [2, 5, 7], [3]], reserve: [] }, + ], + }, + ]; + charge.evenement.etat = 'propose'; + return charge; +} + +const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge; +const retenue = (charge) => executer(charge, 'retenirProposition', { id: 1 }); + +// Le dépôt de la personne assise à depuis, lu sur l'état des places du tour. +function depot(etat, depuis, cible, surLeSol = false) { + const table = etat.tables.find(({ id }) => id === depuis.table); + const personne = table.chaises[depuis.siege - 1].occupant; + assert.notEqual(personne, null, `une personne est assise à ${depuis.table}:${depuis.siege}`); + return commandeDeDepot({ etatPlaces: etat, tour: etat.tour, personne, depuis, cible, surLeSol }); +} + +describe('commandeDeDepot sous la cible « reservations » : avant la retenue (§ 5.8)', () => { + const avant = () => etatPlaces(chargeDepot(), { tour: 1, proposition: 1 }); + + test('l’état des places le dit : une proposition montrée avant la retenue a pour cible les réservations', () => { + const etat = avant(); + assert.equal(etat.source, 'proposition'); + assert.equal(etat.cible, 'reservations'); + }); + + test('une personne non réservée sur un siège libre → reserver au tour affiché, portée « tour »', () => { + assert.deepEqual(depot(avant(), { table: 2, siege: 1 }, { table: 3, siege: 3 }), { + commande: 'reserver', + arguments: { participant: 4, table: 3, siege: 3, portee: 'tour', tour: 1 }, + }); + }); + + test('sur un siège qu’occupe une personne non réservée de la proposition → reserver, comme sur un siège libre', () => { + assert.deepEqual(depot(avant(), { table: 2, siege: 1 }, { table: 3, siege: 1 }), { + commande: 'reserver', + arguments: { participant: 4, table: 3, siege: 1, portee: 'tour', tour: 1 }, + }); + }); + + test('une personne réservée → deplacerReservation, la réservation désignée par sa table, sa portée et son tour', () => { + const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 }); + const etat = etatPlaces(charge, { tour: 1, proposition: 1 }); + assert.deepEqual(depot(etat, { table: 1, siege: 1 }, { table: 3, siege: 3 }), { + commande: 'deplacerReservation', + arguments: { participant: 1, de: { table: 1, portee: 'tour', tour: 1 }, vers: { table: 3, siege: 3 } }, + }); + const tous = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }); + assert.deepEqual(depot(etatPlaces(tous, { tour: 2, proposition: 1 }), { table: 1, siege: 1 }, { table: 2, siege: 3 }), { + commande: 'deplacerReservation', + arguments: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 3 } }, + }); + }); + + test('sur le sol → retirerReservation pour une personne réservée ; null pour une personne qui ne l’est pas', () => { + const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 }); + const etat = etatPlaces(charge, { tour: 1, proposition: 1 }); + assert.deepEqual(depot(etat, { table: 1, siege: 1 }, null, true), { + commande: 'retirerReservation', + arguments: { participant: 1, table: 1, portee: 'tour', tour: 1 }, + }); + assert.equal(depot(etat, { table: 2, siege: 1 }, null, true), null); + }); + + test('régime table → le premier siège qu’aucune réservation ne tient ; table pleine de réservations → la même commande, siege: null', () => { + assert.deepEqual(depot(avant(), { table: 2, siege: 1 }, { table: 3 }), { + commande: 'reserver', + arguments: { participant: 4, table: 3, siege: 1, portee: 'tour', tour: 1 }, + }); + let charge = chargeDepot(); + for (const [participant, siege] of [[1, 1], [2, 2], [3, 3]]) { + charge = executer(charge, 'reserver', { participant, table: 1, siege, portee: 'tour', tour: 1 }); + } + assert.deepEqual(depot(etatPlaces(charge, { tour: 1, proposition: 1 }), { table: 2, siege: 1 }, { table: 1 }), { + commande: 'reserver', + arguments: { participant: 4, table: 1, siege: null, portee: 'tour', tour: 1 }, + }); + }); + + test('un tour que le retenu ne porte pas : la cible est « reservations », la commande reserver, quoiqu’un retenu existe', () => { + const charge = executer(retenue(chargeDepot()), 'changerTours', { tours: '3' }); + const etat = etatPlaces(charge, { tour: 3 }); + assert.notEqual(charge.retenu, null); + assert.equal(etat.cible, 'reservations'); + const personne = etat.reserve[0]; + assert.equal(personne, 1); + const rendu = commandeDeDepot({ etatPlaces: etat, tour: 3, personne, depuis: null, cible: { table: 2, siege: 2 }, surLeSol: false }); + assert.deepEqual(rendu, { commande: 'reserver', arguments: { participant: 1, table: 2, siege: 2, portee: 'tour', tour: 3 } }); + }); +}); + +describe('commandeDeDepot sous la cible « retenu » (§ 5.8, § 7.1)', () => { + const apres = () => etatPlaces(retenue(chargeDepot()), { tour: 1 }); + + test('siège libre → deplacerDansRetenu ; siège occupé → echangerDansRetenu ; sol → retirerDuTour', () => { + const etat = apres(); + assert.equal(etat.cible, 'retenu'); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 3 }), { + commande: 'deplacerDansRetenu', + arguments: { tour: 1, participant: 4, table: 3, siege: 3 }, + }); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 1 }), { + commande: 'echangerDansRetenu', + arguments: { tour: 1, a: 4, b: 6 }, + }); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, null, true), { + commande: 'retirerDuTour', + arguments: { tour: 1, participant: 4 }, + }); + }); + + test('régime table → le premier siège libre au tour affiché ; table pleine → la même commande, siege: null', () => { + const etat = apres(); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3 }), { + commande: 'deplacerDansRetenu', + arguments: { tour: 1, participant: 4, table: 3, siege: 3 }, + }); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 1 }), { + commande: 'deplacerDansRetenu', + arguments: { tour: 1, participant: 4, table: 1, siege: null }, + }); + }); + + test('la commande rendue s’exécute : la personne change de table au tour affiché seulement', () => { + const charge = retenue(chargeDepot()); + const etat = etatPlaces(charge, { tour: 1 }); + const { commande, arguments: args } = depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 3 }); + const suivante = executer(charge, commande, args); + assert.deepEqual(suivante.retenu.placement[0].sieges, [[1, 2, 3], [5], [4, 6, 7]]); + assert.deepEqual(suivante.retenu.placement[1], charge.retenu.placement[1]); + }); +}); + +describe('commandeDeDepot : ce qui ne désigne rien, et la cible null (contrat, « Source »)', () => { + test('entre deux chaises, ou sur le plateau : null, la personne revient', () => { + for (const etat of [etatPlaces(chargeDepot(), { tour: 1, proposition: 1 }), etatPlaces(retenue(chargeDepot()), { tour: 1 })]) { + assert.equal(depot(etat, { table: 2, siege: 1 }, null, false), null); + } + }); + + test('relâchée sur sa propre chaise : null', () => { + assert.equal(depot(etatPlaces(retenue(chargeDepot()), { tour: 1 }), { table: 2, siege: 1 }, { table: 2, siege: 1 }), null); + }); + + test('une proposition montrée après la retenue → { refus } PROPOSITION_MONTREE, remède afficherTour, sur un siège, une table comme sur le sol', () => { + const etat = etatPlaces(retenue(chargeDepot()), { tour: 1, proposition: 1 }); + assert.equal(etat.cible, null); + const attendu = { + refus: { code: 'PROPOSITION_MONTREE', details: { proposition: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } } }, + }; + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 3 }), attendu); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3 }), attendu); + assert.deepEqual(depot(etat, { table: 2, siege: 1 }, null, true), attendu); + }); + + test('un retenu fautif → { refus } RETENU_FAUTIF', () => { + const charge = retenue(chargeDepot()); + charge.retenu.placement[0].sieges[1].push(1); + const etat = etatPlaces(charge, { tour: 1 }); + assert.equal(etat.cible, null); + assert.equal(etat.refus.code, 'RETENU_FAUTIF'); + const rendu = commandeDeDepot({ etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, cible: { table: 3, siege: 3 }, surLeSol: false }); + assert.deepEqual(rendu, { refus: etat.refus }); + }); +}); + +describe('deposer : la place se désigne par la géométrie (§ 7.3, § 14.10)', () => { + const geometries = (etat) => etat.tables.map((table) => geometrieTable(table, table.capacite)); + + test('au centre d’une chaise libre d’une autre table → la commande nomme cette table et ce siège, et la place est ce siège', () => { + const etat = etatPlaces(retenue(chargeDepot()), { tour: 1 }); + const g = geometries(etat); + assert.equal(g.length, 3); + const chaise = g[2].chaises[2]; + const rendu = deposer({ etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, point: { x: chaise.x, y: chaise.y }, geometries: g, k: 1 }); + assert.deepEqual(rendu, { + resultat: { commande: 'deplacerDansRetenu', arguments: { tour: 1, participant: 4, table: 3, siege: 3 } }, + place: { table: 3, siege: 3 }, + }); + }); + + test('sur le plateau : rien, la place est celle d’où vient la personne ; loin des tables : le sol', () => { + const etat = etatPlaces(retenue(chargeDepot()), { tour: 1 }); + const g = geometries(etat); + const commun = { etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, geometries: g, k: 1 }; + assert.deepEqual(deposer({ ...commun, point: g[2].centre }), { resultat: null, place: { table: 2, siege: 1 } }); + assert.deepEqual(deposer({ ...commun, point: { x: 150, y: 400 } }), { + resultat: { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } }, + place: { table: 2, siege: 1 }, + }); + }); + + test('au zoom où la table est la cible, le point au centre désigne la table, et la place est la table', () => { + const etat = etatPlaces(retenue(chargeDepot()), { tour: 1 }); + const g = geometries(etat); + const rendu = deposer({ etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, point: g[0].centre, geometries: g, k: 0.1 }); + assert.deepEqual(rendu, { + resultat: { commande: 'deplacerDansRetenu', arguments: { tour: 1, participant: 4, table: 1, siege: null } }, + place: { table: 1 }, + }); + }); +}); diff --git a/src/interface/plan/ecouteurs.test.js b/src/interface/plan/ecouteurs.test.js new file mode 100644 index 0000000..94c6a24 --- /dev/null +++ b/src/interface/plan/ecouteurs.test.js @@ -0,0 +1,116 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Garde des sources du plan (§ 7.3) : un seul écouteur, sur la racine, que +// Plan.svelte pose par addEventListener ; aucun élément SVG d'un composant +// de src/interface/plan/ ne déclare d'écouteur dans son gabarit — ni +// attribut on…, ni directive on:. Les contrôles HTML hors du +// — boutons de la barre de vue, des panneaux — portent les leurs, comme tout +// composant. Les gabarits se lisent par l'analyseur de Svelte +// (svelte/compiler), jamais par une expression régulière : un attribut se +// reconnaît à sa place dans l'arbre, non à son texte. +import assert from 'node:assert/strict'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'svelte/compiler'; +import { describe, test } from '../../../test/lanceur.js'; + +const DOSSIER_PLAN = fileURLToPath(new URL('.', import.meta.url)); + +// Les éléments SVG qu'un gabarit du plan peut écrire, la racine comprise. +// en est exclu : son nom est aussi celui d'un élément HTML. +const ELEMENTS_SVG = new Set([ + 'svg', 'g', 'circle', 'rect', 'path', 'text', 'tspan', 'line', 'polyline', 'polygon', 'ellipse', + 'defs', 'pattern', 'use', 'symbol', 'clipPath', 'mask', 'marker', 'image', 'foreignObject', + 'linearGradient', 'radialGradient', 'stop', 'desc', +]); + +// Vrai pour un attribut qui pose un écouteur : on… en Svelte 5, on: en +// Svelte 4. +const ecoute = (attribut) => + attribut.type === 'OnDirective' || (attribut.type === 'Attribute' && /^on[a-z]/i.test(attribut.name)); + +/** + * Les éléments SVG d'un gabarit et les écouteurs qu'ils déclarent : elements, + * le compte des éléments SVG ; ecouteurs, « élément attribut » pour chacun, + * dans l'ordre du texte. Le parcours visite tout l'arbre de l'analyseur, + * blocs {#if}, {#each}, {#key} et composants compris. + */ +function releverGabarit(source) { + const arbre = parse(source, { modern: true }); + const vus = new WeakSet(); + let elements = 0; + const ecouteurs = []; + const visiter = (noeud) => { + if (noeud === null || typeof noeud !== 'object' || vus.has(noeud)) return; + vus.add(noeud); + if (noeud.type === 'RegularElement' && ELEMENTS_SVG.has(noeud.name)) { + elements += 1; + for (const attribut of noeud.attributes) { + if (ecoute(attribut)) ecouteurs.push({ debut: attribut.start, texte: `${noeud.name} ${attribut.name}` }); + } + } + for (const valeur of Object.values(noeud)) visiter(valeur); + }; + visiter(arbre.fragment); + return { elements, ecouteurs: ecouteurs.sort((a, b) => a.debut - b.debut).map(({ texte }) => texte) }; +} + +/** Le relevé des composants .svelte d'un dossier, triés par nom : le compte + * des éléments SVG et « fichier : élément attribut » pour chaque écouteur. */ +function releverDossier(dossier) { + const fichiers = readdirSync(dossier).filter((nom) => nom.endsWith('.svelte')).sort(); + let elements = 0; + const ecouteurs = []; + for (const fichier of fichiers) { + const releve = releverGabarit(readFileSync(join(dossier, fichier), 'utf8')); + elements += releve.elements; + ecouteurs.push(...releve.ecouteurs.map((texte) => `${fichier} : ${texte}`)); + } + return { fichiers: fichiers.length, elements, ecouteurs }; +} + +describe('aucun écouteur sur un élément SVG du plan (§ 7.3)', () => { + test('les composants de src/interface/plan/ : balayage non vide, aucun écouteur dans le dessin', () => { + const { fichiers, elements, ecouteurs } = releverDossier(DOSSIER_PLAN); + assert.ok(fichiers >= 5, `${fichiers} composants balayés`); + assert.ok(elements >= 20, `${elements} éléments SVG balayés`); + assert.deepEqual(ecouteurs, []); + }); + + test('contrôle d’échec : un onpointerdown sur le d’une chaise est relevé, un onclick sur un ', ''].join('\n'), + ); + const releve = releverDossier(dossier); + assert.equal(releve.fichiers, 2); + assert.equal(releve.elements, 4); + assert.deepEqual(releve.ecouteurs, ['Chaise.svelte : circle onpointerdown', 'Chaise.svelte : path click']); + } finally { + rmSync(dossier, { recursive: true, force: true }); + } + }); + + test('un racine qui écoute est relevé aussi', () => { + assert.deepEqual(releverGabarit(' {}}>').ecouteurs, ['svg onwheel']); + }); +}); diff --git a/src/interface/plan/gestes.js b/src/interface/plan/gestes.js new file mode 100644 index 0000000..2acece5 --- /dev/null +++ b/src/interface/plan/gestes.js @@ -0,0 +1,261 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La machine d'un geste du plan (§ 7.1, § 7.2, § 7.3, § 8.4), pure : un +// état, un événement, un contexte, et ce qu'il faut faire — les effets —, +// que Plan.svelte applique au DOM, à la séance et à l'état de navigation. +// Aucune fonction ne lit le DOM ni l'heure. +// +// Un geste naît d'un appui (presser) sur une source — une table, une +// personne assise sur sa chaise, la poignée d'une table, le sol —, au repos +// seulement : un second pointeur pendant un geste ne fait rien, et les +// mouvements d'un autre pointeur non plus. L'appui capture le pointeur et +// suspend le compte d'inactivité (debuterGeste) ; le relâcher et toute +// annulation le font repartir (finirGeste). L'appui devient glissement +// quand le pointeur s'éloigne de son point de départ de plus de +// SEUIL_GLISSER_PX ; en deçà, relâcher sélectionne la place et n'exécute +// rien. Tout se calcule en écart depuis l'appui (§ 7.2), sur la vue et le +// cadre relus à l'appui : aucun mouvement ne demande de mise en page. +// +// Pendant le glissement, le modèle ne change pas : une table et la vue se +// déplacent par l'attribut transform, écrit à chaque image ; la personne se +// montre par un fantôme qui suit le pointeur ; la poignée montre un nombre +// de places que le dessin prend sans rien écrire. Le relâcher confie au +// plus une commande à la séance (executer) ; une annulation — pointercancel, +// Échap, passage en lecture — restaure et n'exécute rien. +import { constante } from '../../geometrie/constantes.js'; +import { placesSousPoignee } from '../../geometrie/tables.js'; +import { deplacer, deplacerVue, dessinDepuisEcran, transformDe } from '../../geometrie/vue.js'; + +/** L'état d'aucun geste. */ +export const REPOS = Object.freeze({ phase: 'repos' }); + +const SEUIL = constante('SEUIL_GLISSER_PX'); + +// La place que désigne une source : la chaise, la table — celle de la +// poignée comprise —, null pour le sol. +function placeDe({ genre, table, siege }) { + if (genre === 'sol') return null; + if (genre === 'personne') return { table, siege }; + return { table }; +} + +// Ce que restaure une annulation : le groupe de la table, le fantôme, le +// nombre de la poignée, la vue. +function cibleDe({ genre, table }) { + if (genre === 'table' || genre === 'poignee') return { genre, table }; + return genre === 'personne' ? 'fantome' : 'vue'; +} + +// Un nombre écrit sans −0 : l'arrondi d'une petite valeur négative le rend. +const arrondi = (valeur) => Math.round(valeur) + 0; + +// Le nombre que la poignée montre au point, arrêté au plus proche que le +// contrôle accepte entre le nombre sous le pointeur et le nombre initial, +// avec le refus du nombre qui l'arrête et les avertissements du nombre +// montré. L'écart radial est la projection du mouvement sur la diagonale de +// la poignée, ramenée en cm. +function nombreDePoignee(etat, point, contexte) { + const { source, depart, vue } = etat; + const initial = source.places; + const ecartRadial = (point.x - depart.x + (point.y - depart.y)) / Math.SQRT2 / vue.k; + const voulu = placesSousPoignee(initial, ecartRadial); + const pas = Math.sign(initial - voulu); + let refus = null; + let places = initial; + for (let n = voulu; n !== initial; n += pas) { + const controle = contexte.controlerPlaces(source.table, n); + if (controle.refus === null) { + places = n; + break; + } + refus = controle.refus; + } + const { avertissements } = contexte.controlerPlaces(source.table, places); + return { places, refus, avertissements }; +} + +// Les effets d'un mouvement au point, l'appui devenu glissement, et l'état +// qui en résulte. +function glisser(etat, point, contexte, entree) { + const { source, depart, vue, cadre } = etat; + const effets = []; + let suivant = etat; + if (source.genre === 'table') { + const dx = (point.x - depart.x) / vue.k; + const dy = (point.y - depart.y) / vue.k; + effets.push({ type: 'ecrireTransform', cible: cibleDe(source), transform: `translate(${dx},${dy})` }); + } else if (source.genre === 'sol') { + effets.push({ type: 'ecrireTransform', cible: 'vue', transform: transformDe(deplacerVue(vue, depart, point)) }); + } else if (source.genre === 'personne') { + if (source.participant !== null) { + if (entree) effets.push({ type: 'montrerFantome', participant: source.participant, depuis: placeDe(source) }); + const transform = `translate(${point.x - cadre.gauche},${point.y - cadre.haut})`; + effets.push({ type: 'ecrireTransform', cible: 'fantome', transform }); + } + } else { + const montre = nombreDePoignee(etat, point, contexte); + const refusChange = JSON.stringify(montre.refus) !== JSON.stringify(etat.refus); + const change = + refusChange || montre.places !== etat.places || JSON.stringify(montre.avertissements) !== JSON.stringify(etat.avertissements); + if (change) { + effets.push({ type: 'montrerPlaces', table: source.table, places: montre.places, avertissements: montre.avertissements }); + if (refusChange) effets.push({ type: 'montrerRefus', place: placeDe(source), refus: montre.refus }); + suivant = { ...etat, ...montre }; + } + } + return { etat: { ...suivant, phase: 'glisse' }, effets }; +} + +// Un mouvement au point : rien sous le seuil tant que l'appui n'a pas +// glissé ; sinon les effets du glissement. +function bouger(etat, point, contexte) { + if (etat.phase === 'appui' && Math.hypot(point.x - etat.depart.x, point.y - etat.depart.y) <= SEUIL) { + return { etat, effets: [] }; + } + return glisser(etat, point, contexte, etat.phase === 'appui'); +} + +// La fin d'un glissement : la commande que le geste confie à la séance, +// ou la restauration de ce qu'il a montré. +function achever(etat, point, contexte) { + const { source, depart, vue, cadre } = etat; + if (source.genre === 'table') { + const position = deplacer(source.position, depart, point, vue.k); + return [ + { + type: 'executer', + commande: 'deplacerTable', + arguments: { id: source.table, position: { x: arrondi(position.x), y: arrondi(position.y) } }, + place: placeDe(source), + apres: cibleDe(source), + }, + ]; + } + if (source.genre === 'sol') return [{ type: 'poserVue', vue: deplacerVue(vue, depart, point) }]; + if (source.genre === 'poignee') { + if (etat.places === source.places) return [{ type: 'restaurer', cible: cibleDe(source) }]; + return [ + { + type: 'executer', + commande: 'changerPlacesTable', + arguments: { id: source.table, sieges: etat.places, origine: 'poignee' }, + place: placeDe(source), + apres: cibleDe(source), + }, + ]; + } + if (source.participant === null) return []; + const { resultat, place } = contexte.deposer(source, dessinDepuisEcran(vue, cadre, point.x, point.y)); + const effets = [{ type: 'restaurer', cible: 'fantome' }]; + if (resultat === null) return effets; + if (Object.hasOwn(resultat, 'refus')) return [...effets, { type: 'montrerRefus', place, refus: resultat.refus }]; + return [...effets, { type: 'executer', commande: resultat.commande, arguments: resultat.arguments, place, apres: null }]; +} + +/** + * Un pas de la machine d'un geste du plan. + * + * Événements, chacun par son type : + * presser {source, pointeur, point, vue, cadre} — source {genre: 'table'|'personne'|'poignee'|'sol', + * table, siege, participant}, plus position (le centre de la table, cm) pour une table et + * places (sa capacité) pour une poignée ; participant null pour une chaise vide, qui se + * sélectionne et ne glisse rien ; point en px de la fenêtre ; + * bouger {point, pointeur}, relacher {point, pointeur} — un autre pointeur ne fait rien ; + * annuler {cause: 'pointercancel'|'echap'|'lecture'}. + * Contexte : controlerPlaces(table, places) → {refus, avertissements}, le contrôle de + * capacité d'origine 'poignee' sur la charge du geste ; deposer(source, point) → {resultat, + * place}, le dépôt d'une personne au point de dessin (depot.js). + * Effets, chacun par son type : capturer {pointeur}, debuterGeste, finirGeste, + * ecrireTransform {cible, transform}, montrerFantome {participant, depuis}, + * montrerPlaces {table, places, avertissements}, montrerRefus {place, refus} — refus null + * efface —, restaurer {cible}, poserVue {vue}, selectionner {place}, executer {commande, + * arguments, place, apres} — place, où poser un refus de la commande ; apres, la cible à + * restaurer une fois la commande achevée, ou null. Une cible est 'vue', 'fantome', + * {genre: 'table', table} ou {genre: 'poignee', table}. + * + * Le relâcher joue d'abord le mouvement jusqu'à son point : un appui relâché loin de son + * départ glisse, même sans mouvement livré entre-temps. + * + * @param {Object} etat REPOS, ou l'état qu'un pas précédent a rendu + * @param {Object} evenement + * @param {Object} contexte + * @returns {{etat: Object, effets: Object[]}} + */ +export function geste(etat, evenement, contexte) { + const rien = { etat, effets: [] }; + if (evenement.type === 'presser') { + if (etat.phase !== 'repos') return rien; + const { source, pointeur, point, vue, cadre } = evenement; + const suivant = { phase: 'appui', source, pointeur, depart: point, vue, cadre }; + if (source.genre === 'poignee') Object.assign(suivant, { places: source.places, refus: null, avertissements: [] }); + return { etat: suivant, effets: [{ type: 'capturer', pointeur }, { type: 'debuterGeste' }] }; + } + if (etat.phase === 'repos') return rien; + if (evenement.type === 'annuler') { + return { etat: REPOS, effets: [{ type: 'restaurer', cible: cibleDe(etat.source) }, { type: 'finirGeste' }] }; + } + if (evenement.pointeur !== etat.pointeur) return rien; + if (evenement.type === 'bouger') return bouger(etat, evenement.point, contexte); + if (evenement.type !== 'relacher') throw new TypeError(`événement de geste inconnu : ${JSON.stringify(evenement.type)}`); + const mouvement = bouger(etat, evenement.point, contexte); + const fin = { type: 'finirGeste' }; + if (mouvement.etat.phase === 'appui') { + return { etat: REPOS, effets: [{ type: 'selectionner', place: placeDe(etat.source) }, fin] }; + } + return { etat: REPOS, effets: [...mouvement.effets, ...achever(mouvement.etat, evenement.point, contexte), fin] }; +} + +/** + * Le contrôle de places d'un geste, mémorisé : un nombre ne se contrôle + * qu'une fois par table. Plan.svelte en crée un à chaque appui, sur la + * charge de ce moment : un contrôle de capacité coûte un calcul des places + * de chaque tour. + * + * @param {(table: number, places: number) => Object} controler + * @returns {(table: number, places: number) => Object} + */ +export function memoriser(controler) { + const rendus = new Map(); + return (table, places) => { + const cle = `${table}:${places}`; + if (!rendus.has(cle)) rendus.set(cle, controler(table, places)); + return rendus.get(cle); + }; +} + +// Les clés du libellé du fantôme, par cible de l'état des places : ce que +// le dépôt modifie, ou rien sous une cible null. +const CLES_FANTOME = new Map([ + ['reservations', 'plan.fantome.reservations'], + ['retenu', 'plan.fantome.retenu'], + [null, 'plan.fantome.vueSeule'], +]); + +/** + * La clé du libellé que le fantôme porte pendant le glissement d'une + * personne (§ 5.8) : « pose une réservation » sous la cible 'reservations', + * « déplace dans le placement retenu » sous 'retenu', la vue seule sous une + * cible null. Une autre valeur lève : elle ne prend aucun libellé par défaut. + * + * @param {'reservations'|'retenu'|null} cible EtatPlaces.cible + * @returns {string} + */ +export function cleFantome(cible) { + if (!CLES_FANTOME.has(cible)) throw new TypeError(`cible du fantôme hors du contrat : ${JSON.stringify(cible)}`); + return CLES_FANTOME.get(cible); +} + +/** + * Le libellé de la place où un refus ou un avertissement se pose : la table + * seule quand la place n'a pas de siège, la table et le siège sinon. Un + * siège qui n'est ni null ni un entier positif lève. + * + * @param {{numero: number|null, siege: number|null}} place + * @returns {{cle: string, details: Object}} + */ +export function libellePlace({ numero, siege }) { + if (siege === null) return { cle: 'plan.refus.table', details: { numero } }; + if (!Number.isInteger(siege) || siege < 1) throw new TypeError(`siège d’une place hors du contrat : ${JSON.stringify(siege)}`); + return { cle: 'plan.refus.siege', details: { numero, siege } }; +} diff --git a/src/interface/plan/gestes.test.js b/src/interface/plan/gestes.test.js new file mode 100644 index 0000000..78b0fe1 --- /dev/null +++ b/src/interface/plan/gestes.test.js @@ -0,0 +1,351 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la machine d'un geste du plan (§ 7.1, § 7.2, § 7.3, § 8.4), +// sans DOM : un appui sous le seuil sélectionne, au-delà il glisse ; le +// modèle ne reçoit qu'une commande, au relâcher ; chaque annulation +// restaure ; le compte d'inactivité se suspend à l'appui et repart au +// relâcher comme à l'annulation, pour chaque genre de source ; la poignée +// s'arrête au plus proche nombre que le contrôle accepte. Les valeurs +// attendues viennent des fonctions de src/geometrie, sollicitées une seconde +// fois. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { constante } from '../../geometrie/constantes.js'; +import { placesSousPoignee } from '../../geometrie/tables.js'; +import { deplacer, deplacerVue, dessinDepuisEcran, transformDe } from '../../geometrie/vue.js'; +import { TABLE as LIBELLES } from '../libelles/plan.js'; +import { REPOS, cleFantome, geste, libellePlace, memoriser } from './gestes.js'; + +const SEUIL = constante('SEUIL_GLISSER_PX'); +const VUE = { k: 2, tx: 100, ty: 50 }; +const CADRE = { gauche: 10, haut: 20, largeur: 800, hauteur: 600 }; +const DEPART = { x: 400, y: 300 }; +const POINTEUR = 7; + +const SOURCES = { + table: { genre: 'table', table: 3, siege: null, participant: null, position: { x: 120, y: -40 } }, + personne: { genre: 'personne', table: 3, siege: 2, participant: 11, position: null }, + poignee: { genre: 'poignee', table: 3, siege: null, participant: null, places: 8 }, + sol: { genre: 'sol', table: null, siege: null, participant: null }, +}; + +// Un contexte dont le contrôle accepte tout, et dont le dépôt rend la +// commande donnée. +function contexte({ controlerPlaces = () => ({ refus: null, avertissements: [] }), depot = null } = {}) { + const deposes = []; + return { + deposes, + controlerPlaces, + deposer(source, point) { + deposes.push({ source, point }); + return depot ?? { resultat: { commande: 'deplacerDansRetenu', arguments: { tour: 1 } }, place: { table: 4, siege: 1 } }; + }, + }; +} + +// Joue une suite d'événements depuis le repos ; rend l'état final et les +// effets de chaque événement. +function jouer(evenements, ctx = contexte()) { + let etat = REPOS; + const effets = []; + for (const evenement of evenements) { + const rendu = geste(etat, evenement, ctx); + etat = rendu.etat; + effets.push(rendu.effets); + } + return { etat, effets, tous: effets.flat() }; +} + +const presser = (source, point = DEPART) => ({ type: 'presser', source, pointeur: POINTEUR, point, vue: VUE, cadre: CADRE }); +const bouger = (dx, dy) => ({ type: 'bouger', pointeur: POINTEUR, point: { x: DEPART.x + dx, y: DEPART.y + dy } }); +const relacher = (dx, dy) => ({ type: 'relacher', pointeur: POINTEUR, point: { x: DEPART.x + dx, y: DEPART.y + dy } }); +const annuler = (cause) => ({ type: 'annuler', cause }); +const types = (effets) => effets.map(({ type }) => type); +const deType = (effets, type) => effets.filter((effet) => effet.type === type); + +describe('geste : un clic n’est pas un glisser (§ 8.4)', () => { + test('le seuil n’est pas nul, et vaut entre 3 et 5 px', () => { + assert.ok(SEUIL > 3 && SEUIL < 5, `SEUIL_GLISSER_PX = ${SEUIL}`); + }); + + for (const genre of Object.keys(SOURCES)) { + test(`${genre} : appui, 3 px, relâcher → selectionner, aucun executer ; capturer et debuterGeste à l’appui, finirGeste au relâcher`, () => { + const { etat, effets } = jouer([presser(SOURCES[genre]), bouger(3, 0), relacher(3, 0)]); + assert.deepEqual(types(effets[0]), ['capturer', 'debuterGeste']); + assert.equal(effets[0][0].pointeur, POINTEUR); + assert.deepEqual(effets[1], []); + assert.deepEqual(types(effets[2]), ['selectionner', 'finirGeste']); + assert.deepEqual(etat, REPOS); + }); + } + + test('la place sélectionnée : la chaise, la table, la table de la poignée, rien pour le sol', () => { + const place = (genre) => deType(jouer([presser(SOURCES[genre]), relacher(0, 0)]).tous, 'selectionner')[0].place; + assert.deepEqual(place('personne'), { table: 3, siege: 2 }); + assert.deepEqual(place('table'), { table: 3 }); + assert.deepEqual(place('poignee'), { table: 3 }); + assert.equal(place('sol'), null); + }); +}); + +describe('geste : le glissement d’une table (§ 7.1, § 7.2, § 7.3)', () => { + test('5 px → glissement : ecrireTransform à chaque image, en écart, un seul executer au relâcher', () => { + const { effets, tous } = jouer([presser(SOURCES.table), bouger(5, 0), bouger(20, -6), bouger(40, -20), relacher(40, -20)]); + for (const rang of [1, 2, 3]) { + assert.deepEqual(types(effets[rang]), ['ecrireTransform'], `image ${rang}`); + assert.deepEqual(effets[rang][0].cible, { genre: 'table', table: 3 }); + } + assert.equal(effets[1][0].transform, 'translate(2.5,0)'); + assert.equal(effets[3][0].transform, 'translate(20,-10)'); + const executes = deType(tous, 'executer'); + assert.equal(executes.length, 1); + const position = deplacer(SOURCES.table.position, DEPART, { x: DEPART.x + 40, y: DEPART.y - 20 }, VUE.k); + assert.deepEqual(executes[0].arguments, { id: 3, position }); + assert.deepEqual(position, { x: 140, y: -50 }); + assert.equal(executes[0].commande, 'deplacerTable'); + assert.deepEqual(executes[0].place, { table: 3 }); + assert.deepEqual(executes[0].apres, { genre: 'table', table: 3 }); + assert.deepEqual(types(effets[4]), ['ecrireTransform', 'executer', 'finirGeste']); + }); + + test('la position s’arrondit au centimètre', () => { + const { tous } = jouer([presser(SOURCES.table), bouger(13, 7), relacher(13, 7)]); + assert.deepEqual(deType(tous, 'executer')[0].arguments.position, { x: 127, y: -36 }); + }); + + test('un relâcher loin, sans mouvement livré entre-temps, glisse quand même', () => { + const { tous } = jouer([presser(SOURCES.table), relacher(40, -20)]); + assert.equal(deType(tous, 'executer').length, 1); + assert.equal(deType(tous, 'selectionner').length, 0); + }); + + for (const cause of ['pointercancel', 'echap', 'lecture']) { + test(`${cause} pendant le glissement → restaurer la table, finirGeste, aucun executer, puis le repos`, () => { + const { etat, effets, tous } = jouer([presser(SOURCES.table), bouger(30, 0), annuler(cause), relacher(30, 0)]); + assert.deepEqual(effets[2], [{ type: 'restaurer', cible: { genre: 'table', table: 3 } }, { type: 'finirGeste' }]); + assert.deepEqual(effets[3], [], 'le relâcher qui suit une annulation ne fait rien'); + assert.equal(deType(tous, 'executer').length, 0); + assert.deepEqual(etat, REPOS); + }); + } +}); + +describe('geste : chaque genre de source, le compte d’inactivité et les annulations (§ 8.4)', () => { + const CIBLES = { table: { genre: 'table', table: 3 }, personne: 'fantome', poignee: { genre: 'poignee', table: 3 }, sol: 'vue' }; + for (const genre of Object.keys(SOURCES)) { + for (const cause of ['pointercancel', 'echap', 'lecture']) { + test(`${genre}, ${cause} : debuterGeste à l’appui, restaurer et finirGeste à l’annulation, aucun executer`, () => { + const { tous } = jouer([presser(SOURCES[genre]), bouger(25, 25), annuler(cause)]); + assert.equal(deType(tous, 'debuterGeste').length, 1); + assert.equal(deType(tous, 'finirGeste').length, 1); + assert.deepEqual(deType(tous, 'restaurer'), [{ type: 'restaurer', cible: CIBLES[genre] }]); + assert.equal(deType(tous, 'executer').length, 0); + }); + } + test(`${genre} : une annulation sous le seuil restaure aussi, et finit le geste`, () => { + const { tous } = jouer([presser(SOURCES[genre]), annuler('echap')]); + assert.deepEqual(types(tous), ['capturer', 'debuterGeste', 'restaurer', 'finirGeste']); + }); + test(`${genre} : finirGeste au relâcher d’un glissement, une fois`, () => { + const { tous } = jouer([presser(SOURCES[genre]), bouger(25, 25), relacher(25, 25)]); + assert.equal(deType(tous, 'debuterGeste').length, 1); + assert.equal(deType(tous, 'finirGeste').length, 1); + assert.equal(tous.at(-1).type, 'finirGeste'); + }); + } + + test('au repos, bouger, relâcher et annuler ne font rien', () => { + for (const evenement of [bouger(10, 10), relacher(10, 10), annuler('echap')]) { + assert.deepEqual(geste(REPOS, evenement, contexte()), { etat: REPOS, effets: [] }); + } + }); + + test('un second appui pendant un geste est ignoré ; les mouvements d’un autre pointeur aussi', () => { + const premier = jouer([presser(SOURCES.table), bouger(30, 0)]); + const second = geste(premier.etat, { ...presser(SOURCES.sol), pointeur: 9 }, contexte()); + assert.deepEqual(second, { etat: premier.etat, effets: [] }); + const autre = geste(premier.etat, { ...bouger(60, 0), pointeur: 9 }, contexte()); + assert.deepEqual(autre, { etat: premier.etat, effets: [] }); + const relache = geste(premier.etat, { ...relacher(60, 0), pointeur: 9 }, contexte()); + assert.deepEqual(relache, { etat: premier.etat, effets: [] }); + }); +}); + +describe('geste : le déplacement de vue, geste du sol (§ 7.1, § 8.5)', () => { + test('au-delà du seuil, ecrireTransform {cible: vue} à chaque image, poserVue au relâcher, jamais executer', () => { + const { effets, tous } = jouer([presser(SOURCES.sol), bouger(10, -5), bouger(60, -40), relacher(60, -40)]); + const attendue = deplacerVue(VUE, DEPART, { x: DEPART.x + 60, y: DEPART.y - 40 }); + assert.deepEqual(effets[1], [{ type: 'ecrireTransform', cible: 'vue', transform: transformDe(deplacerVue(VUE, DEPART, { x: DEPART.x + 10, y: DEPART.y - 5 })) }]); + assert.deepEqual(effets[2], [{ type: 'ecrireTransform', cible: 'vue', transform: transformDe(attendue) }]); + assert.deepEqual(deType(tous, 'poserVue'), [{ type: 'poserVue', vue: attendue }]); + assert.deepEqual(attendue, { k: 2, tx: 160, ty: 10 }); + assert.equal(deType(tous, 'executer').length, 0); + }); + + test('en deçà du seuil, relâcher sur le sol désélectionne', () => { + const { tous } = jouer([presser(SOURCES.sol), bouger(2, 2), relacher(2, 2)]); + assert.deepEqual(deType(tous, 'selectionner'), [{ type: 'selectionner', place: null }]); + assert.equal(deType(tous, 'poserVue').length, 0); + }); +}); + +describe('geste : glisser une personne (§ 7.1, § 7.3, § 5.8)', () => { + test('le fantôme se montre au franchissement du seuil, suit le pointeur en px du , et le dépôt se demande au point de dessin', () => { + const ctx = contexte(); + const { effets } = jouer([presser(SOURCES.personne), bouger(10, 0), bouger(50, 30), relacher(50, 30)], ctx); + assert.deepEqual(effets[1][0], { type: 'montrerFantome', participant: 11, depuis: { table: 3, siege: 2 } }); + assert.deepEqual(effets[1][1], { type: 'ecrireTransform', cible: 'fantome', transform: `translate(${DEPART.x + 10 - CADRE.gauche},${DEPART.y - CADRE.haut})` }); + assert.equal(ctx.deposes.length, 1); + assert.deepEqual(ctx.deposes[0].point, dessinDepuisEcran(VUE, CADRE, DEPART.x + 50, DEPART.y + 30)); + assert.equal(ctx.deposes[0].source, SOURCES.personne); + assert.deepEqual(types(effets[3]), ['ecrireTransform', 'restaurer', 'executer', 'finirGeste']); + assert.deepEqual(effets[3][1], { type: 'restaurer', cible: 'fantome' }); + assert.deepEqual(effets[3][2], { + type: 'executer', + commande: 'deplacerDansRetenu', + arguments: { tour: 1 }, + place: { table: 4, siege: 1 }, + apres: null, + }); + }); + + test('un dépôt refusé : la personne revient, le refus se montre sur la place, rien n’est exécuté', () => { + const refus = { code: 'PROPOSITION_MONTREE', details: { proposition: 2, remede: { geste: 'afficherTour', tour: 1, proposition: null } } }; + const ctx = contexte({ depot: { resultat: { refus }, place: { table: 5, siege: 3 } } }); + const { effets, tous } = jouer([presser(SOURCES.personne), bouger(50, 30), relacher(50, 30)], ctx); + assert.deepEqual(types(effets[2]), ['ecrireTransform', 'restaurer', 'montrerRefus', 'finirGeste']); + assert.deepEqual(deType(tous, 'montrerRefus'), [{ type: 'montrerRefus', place: { table: 5, siege: 3 }, refus }]); + assert.equal(deType(tous, 'executer').length, 0); + }); + + test('un dépôt manqué (null) : la personne revient, rien d’autre', () => { + const ctx = contexte({ depot: { resultat: null, place: { table: 3, siege: 2 } } }); + const { effets } = jouer([presser(SOURCES.personne), bouger(50, 30), relacher(50, 30)], ctx); + assert.deepEqual(types(effets[2]), ['ecrireTransform', 'restaurer', 'finirGeste']); + }); + + test('une chaise vide se sélectionne, et ne glisse rien', () => { + const vide = { ...SOURCES.personne, participant: null }; + const ctx = contexte(); + const { tous } = jouer([presser(vide), bouger(50, 30), relacher(50, 30)], ctx); + assert.deepEqual(types(tous), ['capturer', 'debuterGeste', 'finirGeste']); + assert.equal(ctx.deposes.length, 0); + }); +}); + +describe('geste : tirer la poignée (§ 7.1, § 6.2)', () => { + // Un écart radial de r cm le long de la diagonale de la poignée, au zoom + // de la vue : (r / √2) × k px sur chaque axe. + const radial = (cm) => (cm / Math.SQRT2) * VUE.k; + + test('le nombre suit placesSousPoignee, en écart projeté sur la diagonale ; le relâcher exécute changerPlacesTable, origine poignee', () => { + const vers = radial(30); + const { effets, tous } = jouer([presser(SOURCES.poignee), bouger(vers, vers), relacher(vers, vers)]); + const attendu = placesSousPoignee(8, 30); + assert.equal(attendu, 11); + assert.deepEqual(effets[1][0], { type: 'montrerPlaces', table: 3, places: attendu, avertissements: [] }); + assert.deepEqual(deType(tous, 'executer'), [ + { + type: 'executer', + commande: 'changerPlacesTable', + arguments: { id: 3, sieges: attendu, origine: 'poignee' }, + place: { table: 3 }, + apres: { genre: 'poignee', table: 3 }, + }, + ]); + }); + + test('un écart perpendiculaire à la diagonale ne change rien', () => { + const { tous } = jouer([presser(SOURCES.poignee), bouger(40, -40), relacher(40, -40)]); + assert.equal(deType(tous, 'executer').length, 0); + assert.deepEqual(deType(tous, 'restaurer'), [{ type: 'restaurer', cible: { genre: 'poignee', table: 3 } }]); + }); + + test('vers un nombre refusé : montrerPlaces au plus proche accepté entre lui et le nombre initial, et montrerRefus du nombre qui l’arrête', () => { + const appels = []; + const sousOccupes = (n) => ({ code: 'SOUS_OCCUPES', details: { table: 3, n, tour: 3, remede: null } }); + const controlerPlaces = (table, places) => { + appels.push([table, places]); + return places < 7 ? { refus: sousOccupes(places), avertissements: [] } : { refus: null, avertissements: [] }; + }; + const vers = radial(-29); + const { tous } = jouer([presser(SOURCES.poignee), bouger(vers, vers), relacher(vers, vers)], contexte({ controlerPlaces })); + assert.equal(placesSousPoignee(8, -29), 5); + assert.deepEqual(deType(tous, 'montrerPlaces')[0], { type: 'montrerPlaces', table: 3, places: 7, avertissements: [] }); + assert.deepEqual(deType(tous, 'montrerRefus')[0], { type: 'montrerRefus', place: { table: 3 }, refus: sousOccupes(6) }); + assert.deepEqual(deType(tous, 'executer')[0].arguments, { id: 3, sieges: 7, origine: 'poignee' }); + assert.ok(appels.some(([, places]) => places === 5)); + }); + + test('arrêté au nombre initial : rien n’est exécuté au relâcher', () => { + const controlerPlaces = (table, places) => (places < 8 ? { refus: { code: 'SIEGES_RETIRES', details: { table, sieges: [] } }, avertissements: [] } : { refus: null, avertissements: [] }); + const vers = radial(-40); + const { tous } = jouer([presser(SOURCES.poignee), bouger(vers, vers), relacher(vers, vers)], contexte({ controlerPlaces })); + assert.deepEqual(deType(tous, 'montrerPlaces')[0].places, 8); + assert.equal(deType(tous, 'montrerRefus')[0].refus.code, 'SIEGES_RETIRES'); + assert.equal(deType(tous, 'executer').length, 0); + }); + + test('un avertissement accompagne le nombre montré sans l’arrêter ; le refus s’efface quand le nombre n’est plus arrêté', () => { + const lisible = { code: 'AU_DELA_LISIBLE', details: { seuil: 16 } }; + const controlerPlaces = (table, places) => { + if (places < 8) return { refus: { code: 'MOINS_DE_DEUX', details: { remede: null } }, avertissements: [] }; + return { refus: null, avertissements: places > 16 ? [lisible] : [] }; + }; + const ctx = contexte({ controlerPlaces }); + const { effets } = jouer([presser(SOURCES.poignee), bouger(radial(-20), radial(-20)), bouger(radial(100), radial(100))], ctx); + assert.deepEqual(deType(effets[1], 'montrerRefus').length, 1); + assert.deepEqual(effets[2], [ + { type: 'montrerPlaces', table: 3, places: placesSousPoignee(8, 100), avertissements: [lisible] }, + { type: 'montrerRefus', place: { table: 3 }, refus: null }, + ]); + }); + + test('un mouvement qui ne change ni le nombre ni le refus n’émet rien', () => { + const { effets } = jouer([presser(SOURCES.poignee), bouger(radial(30), radial(30)), bouger(radial(31), radial(31))]); + assert.deepEqual(effets[2], []); + }); +}); + +describe('memoriser : un contrôle par nombre et par geste', () => { + test('le même nombre ne se contrôle qu’une fois ; chaque nombre rend son propre rendu', () => { + const appels = []; + const controle = memoriser((table, places) => { + appels.push(places); + return { refus: null, avertissements: [], places, table }; + }); + assert.equal(controle(3, 9).places, 9); + assert.equal(controle(3, 9).places, 9); + assert.equal(controle(3, 10).places, 10); + assert.equal(controle(4, 9).table, 4); + assert.deepEqual(appels, [9, 10, 9]); + }); +}); + +describe('les libellés d’un geste : le fantôme et la place d’une pose', () => { + test('cleFantome : la clé du libellé du fantôme, selon la cible de l’état des places', () => { + assert.equal(cleFantome('reservations'), 'plan.fantome.reservations'); + assert.equal(cleFantome('retenu'), 'plan.fantome.retenu'); + assert.equal(cleFantome(null), 'plan.fantome.vueSeule'); + for (const cible of ['reservations', 'retenu', null]) assert.equal(LIBELLES.has(cleFantome(cible)), true); + }); + + test('cleFantome : une cible hors du contrat lève, au lieu de se dire « vue seule »', () => { + for (const cible of ['reservation', 'Retenu', undefined, '', 0]) { + assert.throws(() => cleFantome(cible), { name: 'TypeError', message: /^cible du fantôme hors du contrat/ }); + } + }); + + test('libellePlace : la table seule sans siège, la table et le siège sinon', () => { + assert.deepEqual(libellePlace({ numero: 7, siege: null }), { cle: 'plan.refus.table', details: { numero: 7 } }); + assert.deepEqual(libellePlace({ numero: 7, siege: 3 }), { cle: 'plan.refus.siege', details: { numero: 7, siege: 3 } }); + for (const siege of [null, 3]) assert.equal(LIBELLES.has(libellePlace({ numero: 7, siege }).cle), true); + }); + + test('libellePlace : un siège qui n’est ni null ni un entier positif lève', () => { + for (const siege of [undefined, 0, -1, 2.5, '3']) { + assert.throws(() => libellePlace({ numero: 7, siege }), { name: 'TypeError', message: /^siège d’une place hors du contrat/ }); + } + }); +}); diff --git a/src/interface/plan/modele.js b/src/interface/plan/modele.js index 06a696f..a53400f 100644 --- a/src/interface/plan/modele.js +++ b/src/interface/plan/modele.js @@ -264,16 +264,28 @@ function bandeauDe(toutesEnConflit, diagnostic) { return entrees; } -// Le dessin d'une table de l'état des places au zoom k. -function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) { - const { id, numero, capacite, gelee, surchargee, conflits } = etatTable; - const geometrie = geometrieTable(etatTable, capacite); +// Une chaise libre au siège s : celle qu'ajoute la poignée au-delà de la +// capacité, que rien ne tient ni n'occupe. +const chaiseLibre = (siege) => ({ siege, occupant: null, reservation: null, titre: null, remplissage: 'normal', marques: [] }); + +// Les chaises d'une table de l'état des places à places sièges : les siennes +// jusqu'à sa capacité, des chaises libres au-delà. +function chaisesA(etatTable, places) { + return Array.from({ length: places }, (_, i) => etatTable.chaises[i] ?? chaiseLibre(i + 1)); +} + +// 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) { + 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 chaises = regime === 'table' ? [] - : etatTable.chaises.map((chaise, i) => { + : etatChaises.map((chaise, i) => { const { x, y, rayon } = geometrie.chaises[i]; return { siege: chaise.siege, @@ -295,11 +307,11 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) { surchargee, nomAccessible: { cle: 'plan.table.nom', - details: { numero, places: capacite, conflits: conflits === null ? null : conflits.nombre, gelee, surchargee }, + details: { numero, places, conflits: conflits === null ? null : conflits.nombre, gelee, surchargee }, }, chaises, enTete: gelee ? { cle: 'plan.liste.gelee', details: {} } : null, - lignes: etatTable.chaises.map((chaise) => ligneListe(chaise, nomDe)), + lignes: etatChaises.map((chaise) => ligneListe(chaise, nomDe)), }; } @@ -318,14 +330,22 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) { * bloc de noms n'est pas dans le modèle : c'est LARGEUR_BLOC_NOMS_CM du * registre, que le dessin lit. * + * 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é + * libres ; l'état des places n'est pas touché, et rien ne s'écrit (§ 7.1). + * * @param {Object} etatPlaces EtatPlaces (A5) * @param {{k: number, listes: boolean, miseEnEvidence: number|null, - * nomDe: (id: number) => string, diagnostic?: Object|null}} options + * nomDe: (id: number) => string, diagnostic?: Object|null, + * poignee?: {table: number, places: number}|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 }) { - const tables = etatPlaces.tables.map((etatTable) => tableDessin(etatTable, { k, miseEnEvidence, nomDe })); +export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null }) { + const tables = etatPlaces.tables.map((etatTable) => + tableDessin(etatTable, { k, miseEnEvidence, nomDe }, poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite), + ); const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit); return { tables, diff --git a/src/interface/plan/modele.test.js b/src/interface/plan/modele.test.js index e533073..6b3bc57 100644 --- a/src/interface/plan/modele.test.js +++ b/src/interface/plan/modele.test.js @@ -717,3 +717,61 @@ describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () ]); }); }); + +// --- L'option poignee (I10) ------------------------------------------------------ + +describe('modeleDessin : l\u{2019}option poignee dessine une table au nombre tiré, sans toucher l\u{2019}état (§ 7.1)', () => { + test('sans option, ou poignee null : chaque table à sa capacité', () => { + const charge = petite(); + const etat = etatPlaces(charge, { tour: 1 }); + const sans = modeleDessin(etat, options(charge)); + assert.deepEqual(modeleDessin(etat, options(charge, { poignee: null })), sans); + assert.equal(sans.tables.length, 4); + for (const table of sans.tables) assert.equal(table.lignes.length, etat.tables.find(({ id }) => id === table.id).capacite); + }); + + test('tirée vers plus : la table se dessine à places, chaises 1 à places, celles au-delà de sa capacité libres', () => { + const charge = petite(); + const etat = etatPlaces(charge, { tour: 1 }); + const avant = JSON.stringify(etat); + const cible = etat.tables[1]; + const capacite = cible.capacite; + const places = capacite + 3; + const modele = modeleDessin(etat, options(charge, { poignee: { table: cible.id, places } })); + assert.equal(JSON.stringify(etat), avant, 'l\u{2019}état des places n\u{2019}est pas touché'); + const tiree = modele.tables[1]; + assert.equal(tiree.id, cible.id); + assert.deepEqual(tiree.geometrie, geometrieTable(cible, places)); + assert.equal(tiree.chaises.length, places); + assert.deepEqual(tiree.chaises.map(({ siege }) => siege), Array.from({ length: places }, (_, i) => i + 1)); + for (const chaise of tiree.chaises.slice(capacite)) { + assert.equal(chaise.remplissage, 'normal'); + assert.deepEqual(chaise.marques, []); + assert.equal(chaise.miseEnEvidence, false); + } + assert.equal(tiree.lignes.length, places); + for (const ligne of tiree.lignes.slice(capacite)) { + assert.deepEqual(ligne.etat, { cle: 'plan.ligne.libre', details: {} }); + assert.equal(ligne.nom, null); + } + assert.deepEqual(tiree.lignes.slice(0, capacite), cible.chaises.map((chaise) => ligneListe(chaise, nomsDe(charge)))); + assert.equal(tiree.nomAccessible.details.places, places); + const autres = modeleDessin(etat, options(charge)); + for (const rang of [0, 2, 3]) assert.deepEqual(modele.tables[rang], autres.tables[rang], `table de rang ${rang} inchangée`); + }); + + test('tirée vers moins : chaises et lignes 1 à places ; le régime se lit sur la géométrie tirée', () => { + const charge = petite(); + const etat = etatPlaces(charge, { tour: 1 }); + const cible = etat.tables[0]; + const modele = modeleDessin(etat, options(charge, { poignee: { table: cible.id, places: 2 } })); + const tiree = modele.tables[0]; + assert.equal(tiree.chaises.length, 2); + assert.equal(tiree.lignes.length, 2); + assert.equal(tiree.geometrie.chaises.length, 2); + for (const k of [0.2, 0.5, 1]) { + const auZoom = modeleDessin(etat, options(charge, { k, poignee: { table: cible.id, places: 30 } })).tables[0]; + assert.equal(auZoom.regime, regimeCibles(k, geometrieTable(cible, 30)), `k = ${k}`); + } + }); +}); diff --git a/test/fixtures/plan/huit_tables_retenu.gtt.json b/test/fixtures/plan/huit_tables_retenu.gtt.json new file mode 100644 index 0000000..7b0defe --- /dev/null +++ b/test/fixtures/plan/huit_tables_retenu.gtt.json @@ -0,0 +1,54 @@ +{ + "entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":20,"tables":8,"reservations":0,"titres":0,"propositions":1,"retenu":1}}, + "charge": { + "evenement": {"id":"epreuve:huit-tables-retenu","nom":"Club des Merles","date":null,"siegesParDefaut":3,"tours":3,"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":21,"table":9,"proposition":2}, + "participants": [ + {"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":4,"nom":"Grisaille","prenom":null,"appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":5,"nom":"Tourmaline","prenom":"Basile","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":6,"nom":"Arrosoir","prenom":"Capucine","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":7,"nom":"Cerfeuil","prenom":"Aurèle","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":8,"nom":"Bruyère","prenom":"Mélisande","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":9,"nom":"Galet","prenom":"Firmin","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":10,"nom":"Mistral","prenom":"Clélia","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":11,"nom":"Houblon","prenom":"Anatole","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":12,"nom":"Sarcelle","prenom":"Solveig","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":13,"nom":"Ravenelle","prenom":"Octave","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":14,"nom":"Pimprenelle","prenom":"Albane","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":15,"nom":"Coquelicot","prenom":"Gaspard","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":16,"nom":"Lavandière","prenom":"Ysaline","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":17,"nom":"Châtaigne","prenom":"Bérénice","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":18,"nom":"Murier","prenom":"Lazare","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":19,"nom":"Ciboulette","prenom":"Héloïse","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":20,"nom":"Ardoise","prenom":"Évariste","appartenance":"Cercle des Hérons","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}}, + {"id":5,"numero":5,"sieges":null,"forme":"ronde","position":{"x":0,"y":300}}, + {"id":6,"numero":6,"sieges":null,"forme":"ronde","position":{"x":300,"y":300}}, + {"id":7,"numero":7,"sieges":null,"forme":"ronde","position":{"x":600,"y":300}}, + {"id":8,"numero":8,"sieges":null,"forme":"ronde","position":{"x":900,"y":300}} + ], + "reservations": [], + "titres": [], + "propositions": [ + {"id":1,"graine":1,"arret":40,"historique":1000,"produitVersion":"epreuve","siegesAttribues":false,"tables":[1,2,3,4,5,6,7,8],"capacites":[3,3,3,3,3,3,3,3],"tours":3,"participants":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20],"placement":[ + {"sieges":[[1,2,3],[4,5,6],[7,8,9],[10,11,12],[13,14,15],[16,17],[18,19],[20]],"reserve":[]}, + {"sieges":[[2,5,8],[3,6,9],[4,7,10],[1,11,14],[12,15,17],[13,16,18],[19,20],[]],"reserve":[]}, + {"sieges":[[3,4,11],[1,7,12],[2,13,16],[5,9,17],[6,8,18],[10,19],[14,15,20],[]],"reserve":[]} + ]} + ], + "retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2,3,4,5,6,7,8],"capacites":[3,3,3,3,3,3,3,3],"tours":3,"participants":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20],"placement":[ + {"sieges":[[1,2,3],[4,5,6],[7,8,9],[10,11,12],[13,14,15],[16,17],[18,19],[20]],"reserve":[]}, + {"sieges":[[2,5,8],[3,6,9],[4,7,10],[1,11,14],[12,15,17],[13,16,18],[19,20],[]],"reserve":[]}, + {"sieges":[[3,4,11],[1,7,12],[2,13,16],[5,9,17],[6,8,18],[10,19],[14,15,20],[]],"reserve":[]} + ]} + } +}