gestion_table_tournante_libre/src/interface/plan/depot.test.js
Mathieu Benoit 7f3dad24af [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
2026-10-07 10:01:21 -04:00

264 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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