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