[ADD] interface: plan gestures — drag a person, a table, a handle

The plan is where the operator changes the seating. A drag starts only
past a movement threshold, so a click is never a drag (§ 8.4), and Escape
cancels it; a person dropped on a chair is moved or swapped through the
places commands, a table is moved, and a handle resizes it after the
capacity check. A refusal appears on the place where the gesture ended.
A new gesture waits for the previous write, so a second drag never starts
from a stale plan, and nothing reads layout while a drag runs.
Checked: 2499 node and 436 browser tests.

--- FR ---

[ADD] interface : gestes du plan — glisser une personne, une table

Le plan est l'endroit où l'opérateur change le placement. Un glissement ne
commence qu'au-delà d'un seuil de mouvement — un clic n'en est jamais un
(§ 8.4) — et Échap l'annule ; une personne déposée sur une chaise est
déplacée ou échangée par les commandes des places, une table se déplace,
et une poignée la redimensionne après le contrôle de capacité. Un refus se
pose sur la place où le geste a fini. Un geste attend l'écriture du
précédent, si bien qu'un second glissement ne part jamais d'un plan
périmé, et rien ne lit la mise en page pendant un glissement.
Vérifié : 2499 node et 436 navigateur.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 10:01:21 -04:00
parent 626159db5f
commit 7f3dad24af
13 changed files with 2758 additions and 15 deletions

View file

@ -85,6 +85,18 @@ export const TABLE = new Map([
['plan.vue.pleinEcran', 'Plein écran'], ['plan.vue.pleinEcran', 'Plein écran'],
['plan.vue.dessin', 'Plan de salle'], ['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) -------------------------------- // --- Les panneaux latéraux (§ 2.1, étape 8) --------------------------------
['plan.panneaux', 'Panneaux du plan'], ['plan.panneaux', 'Panneaux du plan'],
['plan.listesTravail.titre', 'Listes de travail'], ['plan.listesTravail.titre', 'Listes de travail'],

View file

@ -0,0 +1,55 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<svelte:options namespace="svg" />
<script>
// Le fantôme d'une personne glissée (§ 5.8, § 7.1) : un <g data-fantome>
// permanent, enfant du <svg data-plan> hors du groupe de la vue, dont
// l'unité est le pixel CSS — il garde sa taille à tout zoom. Plan.svelte
// écrit son attribut transform à chaque image, directement, et le montre
// par la propriété fantome : le nom de la personne, dans son propre nœud,
// puis le libellé de ce que le geste modifie — la clé que cleFantome
// (gestes.js) tire de la cible de l'état des places. Caché, il ne porte
// rien ; montré ou non, il ne reçoit pas le pointeur.
//
// fantome : {nom, cle} | null ; element : le <g>, lié pour Plan.svelte.
import { getContext } from 'svelte';
let { fantome = null, element = $bindable(null) } = $props();
const t = getContext('t');
</script>
<g class="fantome" class:cache={fantome === null} data-fantome bind:this={element}>
{#if fantome !== null}
<circle class="disque" data-marque="fantome" r="10" />
<text class="texte" x="16" y="-6"
><tspan data-partie="nom">{fantome.nom}</tspan><tspan x="16" dy="1.25em" data-partie="etat">{t(fantome.cle)}</tspan></text
>
{/if}
</g>
<style>
.fantome {
pointer-events: none;
}
.fantome.cache {
display: none;
}
.disque {
fill: none;
stroke: var(--couleur-fantome);
stroke-width: 4;
}
.texte {
fill: var(--couleur-liste-texte);
font-family: var(--police-interface);
font-size: var(--taille-texte);
font-weight: 700;
white-space: pre;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -33,14 +33,37 @@
// //
// diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou // diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou
// null — le bandeau ne porte alors que la phrase des conflits (§ 7.5). // 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 <svg>, 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 { 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 BandeauPlan from './BandeauPlan.svelte';
import BarreVue from './BarreVue.svelte'; import BarreVue from './BarreVue.svelte';
import { deposer } from './depot.js';
import EchellePlan from './EchellePlan.svelte'; import EchellePlan from './EchellePlan.svelte';
import Fantome from './Fantome.svelte';
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
import { modeleDessin } from './modele.js'; import { modeleDessin } from './modele.js';
import PlanDessin from './PlanDessin.svelte'; import PlanDessin from './PlanDessin.svelte';
import RefusPlace from './RefusPlace.svelte';
import { import {
cadreDepuisRect, cadreDepuisRect,
englobantDuTour, englobantDuTour,
@ -60,6 +83,20 @@
let racine = $state(null); let racine = $state(null);
let vue = $state.raw(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 <svg> 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 charge = $derived($seance.charge);
const tours = $derived(charge.evenement.tours); const tours = $derived(charge.evenement.tours);
@ -70,8 +107,16 @@
const modele = $derived( const modele = $derived(
vue === null vue === null
? 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. // Le cadre de la racine, relu maintenant.
const cadreCourant = () => cadreDepuisRect(racine.getBoundingClientRect()); const cadreCourant = () => cadreDepuisRect(racine.getBoundingClientRect());
@ -92,6 +137,110 @@
poserVue(vueAjustee(englobantPlan, cadreCourant())); 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 <svg> 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(() => { $effect(() => {
const element = racine; const element = racine;
// La vue d'un changement de cadre : la vue initiale tant qu'il n'y en a // 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); 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');
});
</script> </script>
<div class="plan"> <div class="plan">
<BarreVue {tour} {tours} {zoomer} {ajuster} /> <BarreVue {tour} {tours} {zoomer} {ajuster} />
<div class="surface" data-surface> <div class="surface" data-surface bind:this={surface}>
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} /> <BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
<svg class="dessin" data-plan aria-label={t('plan.vue.dessin')} bind:this={racine}> <svg class="dessin" data-plan aria-label={t('plan.vue.dessin')} bind:this={racine}>
<g data-vue transform={vue === null ? undefined : transformDe(vue)}> <g data-vue transform={vue === null ? undefined : transformDe(vue)} bind:this={groupeVue}>
{#if modele !== null} {#if modele !== null}
<PlanDessin {modele} /> <PlanDessin {modele} />
{/if} {/if}
</g> </g>
<Fantome {fantome} bind:element={elementFantome} />
</svg> </svg>
{#if poseAffichee !== null}
<RefusPlace pose={poseAffichee} fermer={fermerPose} />
{/if}
<!-- L'échelle occupe sa place avant que la vue n'existe, cachée : sa <!-- L'échelle occupe sa place avant que la vue n'existe, cachée : sa
venue ne change pas le cadre du dessin. --> venue ne change pas le cadre du dessin. -->
<div class="pied" class:cache={vue === null}> <div class="pied" class:cache={vue === null}>
@ -153,6 +470,7 @@
} }
.surface { .surface {
position: relative;
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
flex-direction: column; flex-direction: column;
@ -177,5 +495,6 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
touch-action: none; touch-action: none;
user-select: none;
} }
</style> </style>

View file

@ -0,0 +1,69 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Un refus, ou un avertissement, posé sur une place du plan (§ 5.9, § 6.2,
// § 7.1) : hors du <svg data-plan>, dans la surface qui le porte, à la
// position que Plan.svelte calcule par ecranDepuisDessin au moment du
// refus, puis à chaque changement de vue. Il nomme la place — la table, et
// le siège quand c'en est un, par le libellé que rend libellePlace —, puis
// montre le refus et chacun de details.autres par la primitive Refus,
// chacun avec la commande de son remède, puis chaque avertissement. La
// place le désigne par aria-describedby, vers id.
//
// pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y
// en px de la surface ; fermer : retire la pose.
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
import { libellePlace } from './gestes.js';
let { pose, fermer } = $props();
const t = getContext('t');
const libelle = $derived(libellePlace(pose));
</script>
<div class="refus-place" id={pose.id} style:left="{pose.x}px" style:top="{pose.y}px">
<p class="place">
{t(libelle.cle, libelle.details)}
</p>
{#if pose.refus !== null}
<Refus refus={pose.refus} lever={fermer} />
{/if}
{#each pose.avertissements as avertissement, rang (rang)}
<p class="avertissement" role="status" data-avertissement={avertissement.code}>{t(avertissement.code, avertissement.details)}</p>
{/each}
<button type="button" class="fermer" onclick={fermer}>{t('plan.refus.fermer')}</button>
</div>
<style>
.refus-place {
position: absolute;
z-index: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
box-sizing: border-box;
max-width: 22rem;
padding: var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background: var(--couleur-page-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-1);
font-size: var(--taille-texte);
}
p {
margin: 0;
}
.place {
font-weight: 700;
}
.avertissement {
color: var(--couleur-avis);
}
</style>

109
src/interface/plan/depot.js Normal file
View file

@ -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<typeof commandeDeDepot>, 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 };
}

View file

@ -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 },
});
});
});

View file

@ -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 <svg data-plan>
// — 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.
// <title> 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 <circle> d’une chaise est relevé, un onclick sur un <button> ne l’est pas', () => {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-ecouteurs-'));
try {
writeFileSync(
join(dossier, 'Chaise.svelte'),
[
'<svelte:options namespace="svg" />',
'<script>let { saisir, chaises } = $props();</script>',
'<g data-table="1">',
' {#each chaises as chaise (chaise.siege)}',
' <g data-siege="1:{chaise.siege}"><circle r="22" onpointerdown={saisir} /></g>',
' {/each}',
'</g>',
'{#if chaises.length > 0}<path d="M0,0" on:click={saisir} />{/if}',
'',
].join('\n'),
);
writeFileSync(
join(dossier, 'Barre.svelte'),
['<script>let { zoomer } = $props();</script>', '<div><button onclick={zoomer}>+</button></div>', ''].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 <svg> racine qui écoute est relevé aussi', () => {
assert.deepEqual(releverGabarit('<svg data-plan onwheel={() => {}}><g data-vue></g></svg>').ecouteurs, ['svg onwheel']);
});
});

View file

@ -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 } };
}

View file

@ -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 <svg>, 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/ });
}
});
});

View file

@ -264,16 +264,28 @@ function bandeauDe(toutesEnConflit, diagnostic) {
return entrees; return entrees;
} }
// Le dessin d'une table de l'état des places au zoom k. // Une chaise libre au siège s : celle qu'ajoute la poignée au-delà de la
function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) { // capacité, que rien ne tient ni n'occupe.
const { id, numero, capacite, gelee, surchargee, conflits } = etatTable; const chaiseLibre = (siege) => ({ siege, occupant: null, reservation: null, titre: null, remplissage: 'normal', marques: [] });
const geometrie = geometrieTable(etatTable, capacite);
// 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 regime = regimeCibles(k, geometrie);
const pastille = conflits === null ? 0 : conflits.nombre; const pastille = conflits === null ? 0 : conflits.nombre;
const etatChaises = chaisesA(etatTable, places);
const chaises = const chaises =
regime === 'table' regime === 'table'
? [] ? []
: etatTable.chaises.map((chaise, i) => { : etatChaises.map((chaise, i) => {
const { x, y, rayon } = geometrie.chaises[i]; const { x, y, rayon } = geometrie.chaises[i];
return { return {
siege: chaise.siege, siege: chaise.siege,
@ -295,11 +307,11 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) {
surchargee, surchargee,
nomAccessible: { nomAccessible: {
cle: 'plan.table.nom', 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, chaises,
enTete: gelee ? { cle: 'plan.liste.gelee', details: {} } : null, 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 * bloc de noms n'est pas dans le modèle : c'est LARGEUR_BLOC_NOMS_CM du
* registre, que le dessin lit. * 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 {Object} etatPlaces EtatPlaces (A5)
* @param {{k: number, listes: boolean, miseEnEvidence: number|null, * @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 * diagnostic : celui que rend diagnostiquer, compléments de M1 compris
* @returns {Object} ModeleDessin * @returns {Object} ModeleDessin
*/ */
export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null }) { export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null }) {
const tables = etatPlaces.tables.map((etatTable) => tableDessin(etatTable, { k, miseEnEvidence, nomDe })); 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); const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit);
return { return {
tables, tables,

View file

@ -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}`);
}
});
});

View file

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