[ADD] application: furniture — tables, default, tours, capacity checks

Changing a table, the default seat count or the number of tours reshapes
a plan the operator may already have retained. Eight commands create,
remove and resize tables and set the default and the tours; every change
of capacity first passes controlerCapacite, which refuses to cut a reserved
or titled seat, states who goes to the reserve, and only brings back a
reserved person the display showed seated (§ 6.1, § 6.2). bilans.js states
the figures of a change before it applies.
Checked: 2033 node and 127 browser tests.

--- FR ---

[ADD] application : mobilier — tables, défaut, tours, contrôles

Changer une table, le défaut de sièges ou le nombre de tours remodèle un
plan que l'opérateur a peut-être retenu. Huit commandes créent, retirent et
redimensionnent les tables, et règlent le défaut et les tours ; tout
changement de capacité passe d'abord par controlerCapacite, qui refuse de
couper un siège réservé ou titré, dit qui part en réserve, et ne ramène une
personne réservée que si l'écran la montrait assise (§ 6.1, § 6.2).
bilans.js énonce les chiffres d'un changement avant qu'il s'applique.
Vérifié : 2033 node et 127 navigateur.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 05:12:43 -04:00
parent abae4fa9b8
commit 964532fa19
9 changed files with 2341 additions and 6 deletions

144
src/application/bilans.js Normal file
View file

@ -0,0 +1,144 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5) : ce qu'un changement
// de défaut, de tours ou la suppression d'une table emporterait, compté sur
// la charge courante sans rien écrire, pour l'énoncer avant d'appliquer. La
// commande qui applique lit le même bilan : changerDefaut lève le premier de
// ses refus. Les refus et avertissements d'une capacité sont ceux de
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
// modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { capacite } from '../stockage/document.js';
import { controlerCapacites } from './capacite.js';
import { presents } from './places.js';
const croissant = (a, b) => a - b;
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const parNumero = (a, b) => a.numero - b.numero || a.id - b.id;
// Un nombre entier attendu ; sa lecture depuis une saisie appartient à
// lireEntier.
function exigerEntier(valeur, nom) {
if (!Number.isSafeInteger(valeur)) throw new TypeError(`${nom} : entier attendu, reçu ${JSON.stringify(valeur)}`);
}
// L'enregistrement de la table d'identifiant id, ou TypeError.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// Les identifiants croissants des placements de la liste qui satisfont
// touche ; un placement fautif, de forme quelconque, se lit sans lever.
const identifiants = (placements, touche) =>
placements.filter((placement) => touche(placement)).map(({ id }) => id).sort(croissant);
/**
* Le changement du défaut à sieges places (§ 6.1), énoncé avant d'être
* appliqué : suivent, le nombre de tables qui suivent le défaut ; avant et
* apres, le défaut d'aujourd'hui et sieges ; surchargees, les capacités des
* tables surchargées par numéro de table ; totalAvant et totalApres, la
* somme des capacités avant et après ; participants, les personnes non
* exclues. realigner : les surchargées passent aussi à sieges.
*
* Chaque table dont la capacité change se contrôle comme une saisie au champ
* (§ 6.2) : refus, le premier de chaque table qui refuse, {table, code,
* details}, par ordre de charge.tables ; avertissements, de même, ceux des
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} sieges
* @param {{realigner?: boolean}} [options]
* @returns {{suivent: number, avant: number, apres: number, surchargees: number[], totalAvant: number,
* totalApres: number, participants: number, refus: Array<{table: number|null, code: string, details: Object}>,
* avertissements: Array<{table: number, code: string, details: Object}>}}
* @throws {TypeError} sieges qui n'est pas un entier
*/
export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}) {
exigerEntier(sieges, 'sieges');
const apresDe = (table) => (table.sieges === null || realigner ? sieges : table.sieges);
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
const changements = new Map(
charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]),
);
const controle =
sieges < 2
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] }
: controlerCapacites(charge, changements, { origine: 'champ' });
return {
suivent: charge.tables.filter((table) => table.sieges === null).length,
avant: charge.evenement.siegesParDefaut,
apres: sieges,
surchargees: charge.tables
.filter((table) => table.sieges !== null)
.sort(parNumero)
.map((table) => table.sieges),
totalAvant: somme(charge.tables.map((table) => capacite(charge, table))),
totalApres: somme(charge.tables.map(apresDe)),
participants: presents(charge).size,
refus: controle.refus,
avertissements: controle.avertissements,
};
}
/**
* Le changement du nombre de tours à tours (§ 2.1 étape 5, § 4.3) :
* reservationsDetruites, les réservations « tour désigné » au-delà, dans
* l'ordre canonique ; propositionsTouchees, les identifiants croissants des
* propositions qui ne porteront pas tours tours, et retenuTouche de même
* pour le retenu — ils restent, en dérive TOURS_CHANGES (§ 9). Le nombre de
* tours d'aujourd'hui ne touche rien. La portée « tous » ne se détruit
* jamais : elle n'énumère aucun tour.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} tours
* @returns {{reservationsDetruites: Array<{participant: number, table: number, tour: number}>,
* propositionsTouchees: number[], retenuTouche: boolean}}
* @throws {TypeError} tours qui n'est pas un entier
*/
export function bilanTours(charge, tours) {
exigerEntier(tours, 'tours');
const change = tours !== charge.evenement.tours;
const touche = (placement) => change && placement?.tours !== tours;
return {
reservationsDetruites: charge.reservations
.filter(({ portee, tour }) => portee === 'tour' && tour > tours)
.sort(ordreDesReservations)
.map(({ participant, table, tour }) => ({ participant, table, tour })),
propositionsTouchees: identifiants(charge.propositions, touche),
retenuTouche: charge.retenu !== null && touche(charge.retenu),
};
}
/**
* La suppression de la table id (§ 6.2, § 4) : ses réservations, suspendues
* comprises, dans l'ordre canonique, et ses titres par siège puis libellé,
* qui partent avec elle ; propositionsTouchees, les identifiants croissants
* des propositions qui la déclarent, et retenuTouche de même — ils restent,
* en dérive TABLE_SUPPRIMEE (§ 9). Une table nue rend deux listes vides.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} id
* @returns {{reservations: Array<{participant: number, portee: 'tous'|'tour', tour: number|null, siege: number|null}>,
* titres: Array<{siege: number, libelle: string}>, propositionsTouchees: number[], retenuTouche: boolean}}
* @throws {TypeError} une table inconnue
*/
export function bilanSuppressionTable(charge, id) {
tableDe(charge, id);
const declare = (placement) => Array.isArray(placement?.tables) && placement.tables.includes(id);
return {
reservations: charge.reservations
.filter(({ table }) => table === id)
.sort(ordreDesReservations)
.map(({ participant, portee, tour, siege }) => ({ participant, portee, tour, siege })),
titres: charge.titres
.filter(({ table }) => table === id)
.sort((a, b) => a.siege - b.siege || comparerTextes(a.libelle, b.libelle))
.map(({ siege, libelle }) => ({ siege, libelle })),
propositionsTouchees: identifiants(charge.propositions, declare),
retenuTouche: charge.retenu !== null && declare(charge.retenu),
};
}

View file

@ -0,0 +1,232 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des bilans du mobilier (§ 6.1, § 6.2, § 2.1 étape 5, § 4.3) : ce
// qu'un changement de défaut, de tours ou une suppression de table emporte,
// énoncé avant d'appliquer, sans rien écrire. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { nombrePlaces, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
import { COMMANDES } from './commandes.js';
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Ancolie', 'Mélèze', 'Quenouille', 'Pivoine'];
const personne = (id, autres = {}) => ({
id,
nom: NOMS[(id - 1) % NOMS.length],
prenom: id === 2 ? 'Théo' : null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
// Une charge : les tables données ({id, sieges}, numéro égal à l'identifiant),
// n personnes dont les exclus, R tours, un défaut.
function chargeDe({ tables, n, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] }) {
const charge = creerCharge({ id: 'evt-bilans', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
charge.tables = tables.map(({ id, sieges = null }) => ({ id, numero: id, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 };
return charge;
}
// Ajoute la proposition id, dont les listes de chaque tour suivent l'ordre
// de charge.tables, et la retient quand retenu est vrai.
function placer(charge, listes, { id, retenu = false }) {
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
const plan = {
siegesAttribues: charge.reglages.attribuerSieges,
tables: charge.tables.map(({ id: t }) => t),
capacites: charge.tables.map(({ sieges }) => sieges ?? charge.evenement.siegesParDefaut),
tours: listes.length,
participants,
placement: listes.map((tour) => ({ sieges: tour, reserve: participants.filter((p) => !tour.flat().includes(p)) })),
};
charge.propositions.push({ id, graine: id, arret: 40, historique: 1000, produitVersion: 'essai', ...plan });
charge.prochainsIds.proposition = id + 1;
if (retenu) {
charge.retenu = { proposition: id, ...structuredClone(plan) };
charge.evenement.etat = 'retenu';
} else if (charge.evenement.etat === 'brouillon') {
charge.evenement.etat = 'propose';
}
return charge;
}
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
// Treize tables : neuf suivent 8, les tables 3, 6, 9 et 12 sont surchargées à
// 7, 7, 6 et 12 ; 120 personnes, dont 2 exclues.
function treizeTables(options = {}) {
const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]);
const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null }));
return chargeDe({ tables, n: 120, exclus: [40, 80], ...options });
}
describe('bilans : changer le défaut (§ 6.1)', () => {
test("l'énoncé chiffré du § 6.1 : 9 tables passent de 8 à 10, 4 surchargées ne changent pas, 104 → 122 pour 118", () => {
const charge = treizeTables();
const bilan = bilanChangementDefaut(charge, 10);
assert.deepEqual(bilan, {
suivent: 9,
avant: 8,
apres: 10,
surchargees: [7, 7, 6, 12],
totalAvant: 104,
totalApres: 122,
participants: 118,
refus: [],
avertissements: [],
});
// Le total et les participants sont ceux de l'instance de la charge appliquée.
const apres = COMMANDES.get('changerDefaut')(charge, { sieges: 10, realigner: false }).charge;
const instance = normaliser(configurationDepuisCharge(apres));
assert.equal(bilan.totalApres, nombrePlaces(instance));
assert.equal(bilan.participants, instance.N);
});
test('réaligner : les surchargées changent aussi, et leurs refus en sont', () => {
const charge = treizeTables({ attribuerSieges: true, titres: [{ table: 12, siege: 12, libelle: 'animateur' }] });
const realigne = bilanChangementDefaut(charge, 10, { realigner: true });
assert.equal(realigne.totalApres, 130);
assert.deepEqual(realigne.refus, [
{
table: 12,
code: 'SIEGES_RETIRES',
details: {
table: 12,
sieges: [{ siege: 12, cause: 'SIEGE_TITRE', libelle: 'animateur' }],
remede: { commande: 'retirerTitre', arguments: { table: 12, siege: 12 } },
},
},
]);
assert.deepEqual(bilanChangementDefaut(charge, 10).refus, []);
});
test('une baisse se contrôle table par table : la table 2, sous ses 7 réservations, refuse ; les autres non', () => {
const reservations = [1, 2, 3, 4, 5, 6, 7].map((id) => resa(id, 2, null));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 20, reservations });
const bilan = bilanChangementDefaut(charge, 6);
assert.deepEqual(bilan.refus, [
{ table: 2, code: 'SOUS_RESERVATIONS', details: { table: 2, n: 7, tour: 1, remede: afficherTour(1) } },
]);
assert.equal(bilan.totalApres, 18);
assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]);
});
test('une baisse sous un retenu occupé avertit SURNUMERAIRES : deux personnes de la table 3 au tour 2', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const bilan = bilanChangementDefaut(charge, 6);
assert.deepEqual(bilan.refus, []);
assert.deepEqual(bilan.avertissements, [
{ table: 3, code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [17, 18] } },
]);
});
test("le bilan n'écrit rien : la charge reçue, gelée, ne change pas", () => {
const charge = Object.freeze(treizeTables());
assert.equal(bilanChangementDefaut(charge, 6).totalApres, 9 * 6 + 32);
});
});
describe('bilans : changer les tours (§ 2.1 étape 5, § 4.3)', () => {
// R = 4 : deux réservations au tour 4, une au tour 2, une « tous » ; deux
// propositions et un retenu.
function quatreTours() {
const reservations = [resa(1, 1, null, 4), resa(2, 2, null, 4), resa(3, 1, null, 2), resa(4, 2, null)];
const charge = chargeDe({ tables: [{ id: 1, sieges: 4 }, { id: 2, sieges: 4 }], n: 8, tours: 4, reservations });
const tour = [[1, 2, 3, 5], [4, 6, 7, 8]];
const listes = [tour, [[1, 3, 5, 6], [2, 4, 7, 8]], [[1, 5, 6, 7], [2, 3, 4, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
return charge;
}
test('retirer le tour 4 détruit ses deux réservations « tour » ; propositions et retenu touchés', () => {
const charge = quatreTours();
assert.deepEqual(bilanTours(charge, 3), {
reservationsDetruites: [
{ participant: 1, table: 1, tour: 4 },
{ participant: 2, table: 2, tour: 4 },
],
propositionsTouchees: [1, 2],
retenuTouche: true,
});
});
test("le même nombre ne touche rien ; en ajouter ne détruit rien, mais périme les placements", () => {
const charge = quatreTours();
assert.deepEqual(bilanTours(charge, 4), { reservationsDetruites: [], propositionsTouchees: [], retenuTouche: false });
assert.deepEqual(bilanTours(charge, 5), { reservationsDetruites: [], propositionsTouchees: [1, 2], retenuTouche: true });
});
});
describe("bilans : supprimer une table (§ 6.2, § 4)", () => {
function garnie() {
const charge = chargeDe({
tables: [{ id: 1 }, { id: 2 }, { id: 3 }],
n: 6,
reservations: [resa(2, 2, 2), resa(5, 1, null)],
titres: [{ table: 2, siege: 2, libelle: 'animateur' }, { table: 1, siege: 1, libelle: 'accueil' }],
});
const listes = [[[1, 5], [2, 3], [4, 6]], [[3, 5], [2, 6], [1, 4]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
return charge;
}
test('la table 2 porte la réservation « tous » de Théo Pervenche et un titre : le bilan les nomme, et les placements touchés', () => {
assert.deepEqual(bilanSuppressionTable(garnie(), 2), {
reservations: [{ participant: 2, portee: 'tous', tour: null, siege: 2 }],
titres: [{ siege: 2, libelle: 'animateur' }],
propositionsTouchees: [1, 2],
retenuTouche: true,
});
});
test('une table nue : deux listes vides ; une table créée après les placements ne les touche pas', () => {
const charge = garnie();
const nue = bilanSuppressionTable(charge, 3);
assert.deepEqual([nue.reservations, nue.titres], [[], []]);
const creee = COMMANDES.get('creerTable')(charge, { forme: 'carree' }).charge;
const idCree = creee.tables.at(-1).id;
// creerTable fait entrer la table au retenu ; aucune proposition ne la déclare.
assert.deepEqual(bilanSuppressionTable(creee, idCree), { reservations: [], titres: [], propositionsTouchees: [], retenuTouche: true });
});
test('les titres se rangent par siège puis libellé', () => {
const charge = garnie();
charge.titres.push({ table: 2, siege: 1, libelle: 'hôte' }, { table: 2, siege: 2, libelle: 'accueil' });
assert.deepEqual(bilanSuppressionTable(charge, 2).titres, [
{ siege: 1, libelle: 'hôte' },
{ siege: 2, libelle: 'accueil' },
{ siege: 2, libelle: 'animateur' },
]);
});
test('une table inconnue est une faute du code ; des places ou des tours non entiers aussi', () => {
assert.throws(() => bilanChangementDefaut(garnie(), '6'), TypeError);
assert.throws(() => bilanTours(garnie(), 2.5), TypeError);
assert.throws(() => bilanSuppressionTable(garnie(), 9), TypeError);
});
});

402
src/application/capacite.js Normal file
View file

@ -0,0 +1,402 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le contrôle de capacité (§ 6.2, § 7.1) : ce qu'une capacité de table
// refuse et ce dont elle avertit, et le seul — les commandes du mobilier et
// leurs bilans l'appellent, aucun composant ne le refait (§ 13.2). Aussi la
// seule lecture d'un entier saisi (lireEntier), et la réécriture du retenu
// qu'une capacité changée entraîne (retenuAuxCapacites).
//
// Un refus a la forme { code, details }, le remède dans details.remede. Ils
// se contrôlent dans cet ordre, et le premier seul est rendu :
// MOINS_DE_DEUX ; SOUS_RESERVATIONS, sous le plus grand compte de
// reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui
// porte un titre ou une réservation, suspendue comprise — l'analyse du
// fichier refuse un tel siège (§ 6.2) — ; SOUS_OCCUPES, pour la poignée, sous
// l'occupation de chaque tour telle qu'etatPlaces la montre. Le champ et la
// poignée décrivent deux gestes (§ 6.2, § 7.1) : la poignée, geste de
// géométrie fait sans regarder les autres tours, ne désassoit personne ; le
// champ, saisie délibérée, verse dans la réserve les occupants des chaises
// retirées et le dit (SURNUMERAIRES).
//
// Le retenu suit les capacités : ce qui ne tient plus va dans la réserve de
// chaque tour, puis la capacité s'écrit dans retenu.capacites, de sorte que
// le retenu ne gagne aucune raison de dérive. Sièges attribués, le siège est
// le rang : une personne que la table réserve à ce tour, assise sur une
// chaise que le plan montrait et que la nouvelle capacité retire, revient
// d'abord sur une chaise libre dans la capacité, sinon sur celle du plus
// haut rang qu'occupe un non-réservé ; puis les rangs au-delà de la capacité
// partent, et la liste perd ses chaises vides de fin. Aucune réservation que
// le plan montrait tenue ne cesse ainsi de l'être, et un rang au-delà de la
// capacité courante, que le plan ne montre pas, n'assied personne. Sans
// attribution, la liste garde les personnes que
// le plan montre assises à la table avant et après le geste — l'affichage
// élit d'abord celles que la table réserve —, puis, tant qu'elle dépasse,
// perd ses absents, le plus petit identifiant d'abord : un geste n'assied
// personne qu'il ne nomme. Un retenu fautif ne se réécrit pas. Aucune
// fonction ne modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { constante } from '../geometrie/constantes.js';
import { capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js';
import { reservationsParTour } from './pose.js';
const croissant = (a, b) => a - b;
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Un entier écrit en chiffres décimaux seuls : ni signe, ni séparateur, ni
// exposant.
const CHIFFRES = /^[0-9]+$/;
const ORIGINES = new Set(['champ', 'poignee']);
// Le seuil de lisibilité, nommé dans le registre des constantes mesurées
// (§ 14.1) : un nom de constante, que la table des libellés ne connaît pas.
const SEUIL_LISIBLE = { nom: 'SEUIL_SIEGES_LISIBLES' };
const refusDe = (code, details, remede) => ({ code, details: { ...details, remede } });
const avertissement = (code, details) => ({ code, details });
// Le remède d'une capacité sous un compte : montrer le tour qui l'atteint,
// sur le plan du retenu ou des réservations.
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
/**
* Lit un entier saisi (§ 6.2) : des chiffres décimaux seuls, blancs de bord
* admis, ou un nombre entier ≥ 0 ; tout autre texte — signe, décimale,
* virgule, lettres, vide, ou un entier au-delà de 2^53 − 1 — refuse
* NON_ENTIER {saisie}, remède null. Le domaine de la valeur — au moins 2
* places, au moins un tour — appartient à son contrôle.
*
* @param {string|number} saisie
* @returns {{valeur: number}|{refus: {code: 'NON_ENTIER', details: {saisie: string|number, remede: null}}}}
* @throws {TypeError} une saisie qui n'est ni un texte ni un nombre
*/
export function lireEntier(saisie) {
if (typeof saisie === 'number') {
if (Number.isSafeInteger(saisie) && saisie >= 0) return { valeur: saisie + 0 };
} else if (typeof saisie === 'string') {
const texte = saisie.trim();
const valeur = CHIFFRES.test(texte) ? Number(texte) : Number.NaN;
if (Number.isSafeInteger(valeur)) return { valeur };
} else {
throw new TypeError(`lireEntier : texte ou nombre attendu, reçu ${JSON.stringify(saisie)}`);
}
return { refus: refusDe('NON_ENTIER', { saisie }, null) };
}
// L'enregistrement de la table d'identifiant id : une table inconnue est une
// faute de l'appelant, que l'interface n'offre jamais.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// Le plus grand compte d'une liste par tour, et le premier tour qui
// l'atteint.
function maximum(comptes) {
const n = Math.max(0, ...comptes);
return { n, tour: comptes.indexOf(n) + 1 };
}
// Les sièges au-delà de sieges qui portent une saisie, par numéro
// décroissant ; à un même siège, ses titres par libellé, puis ses
// réservations dans l'ordre canonique. Une réservation suspendue — sa
// personne exclue — y est, sièges attribués ou non.
function siegesPortes(charge, idTable, sieges) {
const ids = presents(charge);
const titres = charge.titres.filter(({ table, siege }) => table === idTable && siege > sieges);
const reservations = charge.reservations.filter(({ table, siege }) => table === idTable && siege !== null && siege > sieges);
const numeros = [...new Set([...titres, ...reservations].map(({ siege }) => siege))].sort((a, b) => b - a);
return numeros.flatMap((numero) => [
...titres
.filter(({ siege }) => siege === numero)
.sort((a, b) => comparerTextes(a.libelle, b.libelle))
.map(({ siege, libelle }) => ({ siege, cause: 'SIEGE_TITRE', libelle })),
...reservations
.filter(({ siege }) => siege === numero)
.sort(ordreDesReservations)
.map(({ siege, participant }) => ({ siege, cause: 'SIEGE_RESERVE', participant, suspendue: !ids.has(participant) })),
]);
}
// Le remède d'un siège retiré : le retrait de son titre, ou de sa
// réservation, désignée par ses quatre arguments.
function remedeDuSiege(charge, idTable, retire) {
if (retire.cause === 'SIEGE_TITRE') return { commande: 'retirerTitre', arguments: { table: idTable, siege: retire.siege } };
const { portee, tour } = charge.reservations
.filter((r) => r.table === idTable && r.siege === retire.siege && r.participant === retire.participant)
.sort(ordreDesReservations)[0];
return { commande: 'retirerReservation', arguments: { participant: retire.participant, table: idTable, portee, tour } };
}
// L'occupation de la table à chaque tour, lue dans etatPlaces : les chaises
// qui portent un occupant ou une réservation ; sièges attribués, le plus
// haut numéro de ces chaises quand il est plus grand.
function occupation(charge, idTable) {
return Array.from({ length: charge.evenement.tours }, (_, rang) => {
const etat = etatPlaces(charge, { tour: rang + 1 });
const { chaises } = etat.tables.find(({ id }) => id === idTable);
const portees = chaises.filter(({ occupant, reservation }) => occupant !== null || reservation !== null);
const plusHaut = portees.length === 0 ? 0 : portees.at(-1).siege;
return etat.siegesAttribues ? Math.max(portees.length, plusHaut) : portees.length;
});
}
// Le premier refus d'une capacité, ou null.
function refusDeCapacite(charge, idTable, sieges, origine) {
if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null);
const reservees = maximum(reservationsParTour(charge, idTable));
if (sieges < reservees.n) {
return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour));
}
const retires = siegesPortes(charge, idTable, sieges);
if (retires.length > 0) {
const titres = charge.reglages.attribuerSieges ? {} : { titres: retires.filter(({ cause }) => cause === 'SIEGE_TITRE').length };
return refusDe('SIEGES_RETIRES', { table: idTable, sieges: retires, ...titres }, remedeDuSiege(charge, idTable, retires[0]));
}
if (origine === 'poignee') {
const occupees = maximum(occupation(charge, idTable));
if (sieges < occupees.n) return refusDe('SOUS_OCCUPES', { table: idTable, ...occupees }, afficherTour(occupees.tour));
}
return null;
}
// Les personnes que le plan montre assises à la table au tour, dans l'ordre
// de leurs chaises : celles d'etatPlaces (etat) pour un tour de la soirée.
// Un tour du retenu que la soirée n'a plus ne se montre pas : il se lit
// selon la même règle, la liste de la source répartie aux places courantes
// par repartirSieges, les réservations « tous » de la table seules
// couvrantes.
function assises(charge, etat, idTable, tour) {
if (etat !== null) {
return etat.tables
.find(({ id }) => id === idTable)
.chaises.map(({ occupant }) => occupant)
.filter((occupant) => occupant !== null);
}
const { retenu } = charge;
const ids = presents(charge);
const places = capacite(charge, tableDe(charge, idTable));
const liste = retenu.placement[tour - 1].sieges[retenu.tables.indexOf(idTable)];
if (retenu.siegesAttribues) return liste.slice(0, places).filter((id) => id !== null && ids.has(id));
const tous = reservationsActives(charge).filter(({ table, portee }) => table === idTable && portee === 'tous');
const siegeDe = new Map();
for (const { participant, siege } of tous) if (siege !== null && !siegeDe.has(participant)) siegeDe.set(participant, siege);
const prioritaires = new Set(tous.map(({ participant }) => participant));
return repartirSieges(liste.filter((id) => ids.has(id)), siegeDe, places, prioritaires).filter((id) => id !== null);
}
// Sièges attribués, la liste de la table réduite à ses limite premiers
// rangs, sans chaise vide de fin. reservees (Map personne → siège réservé ou
// null) : les personnes que la table réserve à ce tour. Chacune, assise à un
// rang que le plan montrait — de limite à montres, la capacité courante — et
// que la réduction retire, revient, dans l'ordre des rangs, à son siège
// réservé quand il est libre, sinon à la première chaise libre — vide, ou
// d'un absent, qui passe alors en réserve —, sinon à la chaise du plus haut
// rang qu'occupe un non-réservé, qui passe en réserve. Un rang à partir de
// montres ne ramène personne : une hausse (limite égale à montres) n'en
// ramène aucun. La capacité a passé SIEGES_RETIRES et SOUS_RESERVATIONS :
// chaque siège réservé est dans limite, et les réservés de la table à ce
// tour n'y sont pas plus nombreux que les chaises ; une chaise se trouve
// toujours.
function ramenerReservees(liste, limite, montres, reservees, ids) {
const reste = Array.from({ length: limite }, (_, rang) => liste[rang] ?? null);
const libre = (occupant) => occupant === null || !ids.has(occupant);
for (const id of liste.slice(limite, montres)) {
if (id === null || !reservees.has(id)) continue;
const voulu = reservees.get(id);
let rang = voulu !== null && libre(reste[voulu - 1]) ? voulu - 1 : reste.findIndex(libre);
if (rang < 0) rang = reste.findLastIndex((occupant) => !reservees.has(occupant));
reste[rang] = id;
}
while (reste.length > 0 && reste.at(-1) === null) reste.pop();
return reste;
}
// Les personnes que la table idTable réserve au tour, chacune avec son siège
// réservé ou null : les réservations actives (actives, dans l'ordre
// canonique) qui couvrent ce tour ; un tour que la soirée n'a plus, l'analyse
// n'admettant aucune portée « tour » au-delà des tours, seules les « tous »
// le couvrent. Le siège réservé d'une personne est le premier siège non nul
// de ses réservations, comme l'affichage le lit.
function reserveesAuTour(actives, idTable, tour) {
const reservees = new Map();
for (const reservation of actives) {
const { table, participant, siege } = reservation;
if (table !== idTable || !couvre(reservation, tour)) continue;
if (!reservees.has(participant) || reservees.get(participant) === null) reservees.set(participant, siege);
}
return reservees;
}
// La charge où chaque table de changements (Map identifiant → places) porte
// ses nouvelles places, le retenu tel quel.
const avecCapacites = (charge, changements) => ({
...charge,
tables: charge.tables.map((table) => (changements.has(table.id) ? { ...table, sieges: changements.get(table.id) } : table)),
});
/**
* Ce que de nouvelles capacités font au retenu de la charge, par table de
* changements qu'il déclare, dans l'ordre de charge.tables, et par tour du
* retenu : la liste qui reste, ce qui en est versé à la réserve, et les
* personnes que le plan du tour montrait assises à la table et qui passent
* dans la réserve, dans l'ordre de leurs chaises — pour un tour que la
* soirée n'a plus, celles qu'assises y lit. null sans retenu, ou pour un
* retenu fautif, qui ne se réécrit pas.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Map<number, number>} changements identifiant de table → places
* @returns {null|Array<{table: number, rang: number, sieges: number,
* tours: Array<{tour: number, liste: Array<number|null>, verses: number[], personnes: number[]}>}>}
*/
function versements(charge, changements) {
const { retenu } = charge;
if (retenu === null || fautes(retenu).length > 0) return null;
const hypothese = avecCapacites(charge, changements);
const ids = presents(charge);
const actives = reservationsActives(charge);
const touchees = charge.tables
.filter(({ id }) => changements.has(id) && retenu.tables.includes(id))
.map(({ id }) => ({ table: id, rang: retenu.tables.indexOf(id), sieges: changements.get(id), tours: [] }));
for (let tour = 1; tour <= retenu.tours; tour += 1) {
const visible = tour <= charge.evenement.tours;
const etatAvant = visible ? etatPlaces(charge, { tour }) : null;
// Sièges attribués, ce qui reste ne se lit pas dans le plan d'après.
const etatApres = visible && !retenu.siegesAttribues ? etatPlaces(hypothese, { tour }) : null;
for (const touchee of touchees) {
const { table, rang, sieges } = touchee;
const liste = retenu.placement[tour - 1].sieges[rang];
const avant = assises(charge, etatAvant, table, tour);
let reste;
if (retenu.siegesAttribues) {
// Le siège est le rang : un rang au-delà des places d'aujourd'hui ne
// se montre pas, et une hausse ne l'assied pas.
const montres = capacite(charge, tableDe(charge, table));
const limite = Math.min(sieges, montres);
reste = ramenerReservees(liste, limite, montres, reserveesAuTour(actives, table, tour), ids);
} else {
const apres = new Set(assises(hypothese, etatApres, table, tour));
const gardees = new Set(avant.filter((id) => apres.has(id)));
reste = liste.filter((id) => !ids.has(id) || gardees.has(id));
const absents = reste.filter((id) => !ids.has(id)).sort(croissant);
// Les présents gardés tiennent dans sieges : ôter les absents suffit.
while (reste.length > sieges && absents.length > 0) {
const absent = absents.shift();
reste = reste.filter((id) => id !== absent);
}
}
const restants = new Set(reste);
const verses = liste.filter((id) => id !== null && !restants.has(id));
const personnes = avant.filter((id) => !restants.has(id));
touchee.tours.push({ tour, liste: reste, verses, personnes });
}
}
return touchees;
}
/**
* Le retenu de la charge aux nouvelles capacités (§ 6.2, § 9) : à chaque
* tour, ce qui ne tient plus versé dans la réserve, puis la capacité de
* chaque table touchée écrite dans retenu.capacites. Un objet neuf ; le
* retenu reçu tel quel sans retenu, pour un retenu fautif, ou quand il ne
* déclare aucune table touchée.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Map<number, number>} changements identifiant de table → places
* @returns {import('../stockage/types.js').Retenu|null}
*/
export function retenuAuxCapacites(charge, changements) {
const touchees = versements(charge, changements);
if (touchees === null || touchees.length === 0) return charge.retenu;
const retenu = structuredClone(charge.retenu);
for (const { rang, sieges, tours } of touchees) {
for (const { tour, liste, verses } of tours) {
const etage = retenu.placement[tour - 1];
etage.sieges[rang] = liste;
etage.reserve = [...etage.reserve, ...verses].sort(croissant);
}
retenu.capacites[rang] = sieges;
}
return retenu;
}
/**
* Les refus et les avertissements de plusieurs capacités à la fois, chacune
* contrôlée comme le ferait controlerCapacite, sur la même charge : les
* tables ne partagent aucune chaise. Les tables de changements dans l'ordre
* de charge.tables ; refus, le premier de chaque table qui refuse ;
* avertissements, ceux des tables qui ne refusent pas.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Map<number, number>} changements identifiant de table → places
* @param {{origine: 'champ'|'poignee'}} options
* @returns {{refus: Array<{table: number, code: string, details: Object}>,
* avertissements: Array<{table: number, code: string, details: Object}>}}
* @throws {TypeError} une table inconnue, des places qui ne sont pas un entier, une origine inconnue
*/
export function controlerCapacites(charge, changements, { origine }) {
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
for (const [idTable, sieges] of changements) {
tableDe(charge, idTable);
if (!Number.isSafeInteger(sieges)) throw new TypeError(`places : entier attendu, reçu ${JSON.stringify(sieges)}`);
}
const ordre = charge.tables.filter(({ id }) => changements.has(id)).map(({ id }) => id);
const refus = [];
const acceptees = new Map();
for (const idTable of ordre) {
const premier = refusDeCapacite(charge, idTable, changements.get(idTable), origine);
if (premier === null) acceptees.set(idTable, changements.get(idTable));
else refus.push({ table: idTable, ...premier });
}
const parTable = new Map(ordre.map((id) => [id, []]));
for (const { table, tours } of versements(charge, acceptees) ?? []) {
for (const { tour, personnes } of tours) {
if (personnes.length > 0) parTable.get(table).push(avertissement('SURNUMERAIRES', { table, tour, personnes }));
}
}
const seuil = constante(SEUIL_LISIBLE.nom);
const avertissements = [];
for (const [idTable, sieges] of acceptees) {
for (const { code, details } of parTable.get(idTable)) avertissements.push({ table: idTable, code, details });
if (sieges > seuil) avertissements.push({ table: idTable, code: 'AU_DELA_LISIBLE', details: { seuil } });
}
return { refus, avertissements };
}
/**
* Le contrôle d'une capacité (§ 6.2, § 7.1), sans rien écrire : sieges
* places pour la table idTable, saisies au champ ou tirées à la poignée.
* refus, le premier dans l'ordre de l'en-tête du module, ou null ;
* avertissements, quand il n'y a pas de refus : SURNUMERAIRES {table, tour,
* personnes}, un par tour du retenu — un tour que la soirée n'a plus
* compris — où il verse dans la réserve des personnes que le plan montrait
* assises à la table, nommées dans
* l'ordre de leurs chaises — la poignée, qui refuse sous l'occupation, n'en
* verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de
* SEUIL_SIEGES_LISIBLES places.
*
* Refus : MOINS_DE_DEUX {}, remède null ; SOUS_RESERVATIONS {table, n,
* tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n,
* remède { geste: 'afficherTour', tour, proposition: null } ;
* SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un
* titre {siege, cause: 'SIEGE_TITRE', libelle} ou une réservation {siege,
* cause: 'SIEGE_RESERVE', participant, suspendue}, par numéro décroissant ;
* titres, le compte des titres, sous des sièges non attribués seulement —,
* remède celui du premier siège listé, retirerTitre ou retirerReservation.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} idTable
* @param {number} sieges
* @param {{origine: 'champ'|'poignee'}} options
* @returns {{refus: {code: string, details: Object}|null, avertissements: Array<{code: string, details: Object}>}}
* @throws {TypeError} une table inconnue, des places qui ne sont pas un entier, une origine inconnue
*/
export function controlerCapacite(charge, idTable, sieges, { origine }) {
const { refus, avertissements } = controlerCapacites(charge, new Map([[idTable, sieges]]), { origine });
if (refus.length > 0) return { refus: { code: refus[0].code, details: refus[0].details }, avertissements: [] };
return { refus: null, avertissements: avertissements.map(({ code, details }) => ({ code, details })) };
}

View file

@ -0,0 +1,284 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du contrôle de capacité (§ 6.2, § 7.1, § 14.1) : la lecture d'un
// entier saisi, puis les refus d'une capacité — moins de deux places, sous
// les réservations, sièges retirés qui portent une saisie, sous l'occupation
// pour la poignée — et l'avertissement de lisibilité. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { constante } from '../geometrie/constantes.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { controlerCapacite, lireEntier } from './capacite.js';
import { COMMANDES } from './commandes.js';
import { libelle } from './libelles.js';
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Bruyère', 'Sarcelle', 'Ancolie', 'Mélèze', 'Quenouille', 'Pivoine'];
const personne = (id, autres = {}) => ({
id,
nom: NOMS[(id - 1) % NOMS.length],
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
// Une charge d'une table d'identifiant 1, numéro 1, qui suit un défaut de
// huit places ; n personnes, R tours.
function chargeUneTable({ n = 8, tours = 3, attribuerSieges = false, reservations = [], titres = [], exclus = [] } = {}) {
const charge = creerCharge({ id: 'evt-capacite', nom: 'Soirée des Merles', siegesParDefaut: 8, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
charge.tables = [{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }];
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: n + 1, table: 2, proposition: 1 };
return charge;
}
// La proposition 1, retenue, dont la table 1 assied listes[r] au tour r + 1 ;
// les autres déclarés attendent en réserve.
function retenir(charge, listes, { siegesAttribues = charge.reglages.attribuerSieges } = {}) {
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id }) => id);
const plan = {
siegesAttribues,
tables: [1],
capacites: [8],
tours: listes.length,
participants,
placement: listes.map((liste) => ({ sieges: [liste], reserve: participants.filter((id) => !liste.includes(id)) })),
};
charge.propositions = [{ id: 1, graine: 7, arret: 40, historique: 1000, produitVersion: 'essai', ...plan }];
charge.retenu = { proposition: 1, ...structuredClone(plan) };
charge.evenement.etat = 'retenu';
charge.prochainsIds.proposition = 2;
return charge;
}
const champ = (charge, sieges) => controlerCapacite(charge, 1, sieges, { origine: 'champ' });
const poignee = (charge, sieges) => controlerCapacite(charge, 1, sieges, { origine: 'poignee' });
describe("capacité : la lecture d'un entier saisi (§ 6.2)", () => {
test('un entier sans signe ni décimale se lit ; tout autre texte refuse NON_ENTIER, remède null', () => {
assert.deepEqual(lireEntier('2'), { valeur: 2 });
assert.deepEqual(lireEntier('0'), { valeur: 0 });
assert.deepEqual(lireEntier(' 12 '), { valeur: 12 });
assert.deepEqual(lireEntier(9), { valeur: 9 });
const refusees = ['-3', '2.5', '2,5', '8abc', 'huit', '', '+4', '1e3', '9007199254740993', -3, 2.5];
assert.equal(refusees.length, 11);
for (const saisie of refusees) {
assert.deepEqual(lireEntier(saisie), { refus: { code: 'NON_ENTIER', details: { saisie, remede: null } } }, String(saisie));
}
});
test("une saisie qui n'est ni un texte ni un nombre est une faute du code", () => {
assert.throws(() => lireEntier(null), TypeError);
assert.throws(() => lireEntier(undefined), TypeError);
});
});
describe("capacité : les refus d'une capacité (§ 6.2)", () => {
test('moins de deux places : MOINS_DE_DEUX, remède null ; deux places passent', () => {
const charge = chargeUneTable();
for (const sieges of [1, 0]) {
assert.deepEqual(champ(charge, sieges).refus, { code: 'MOINS_DE_DEUX', details: { remede: null } });
}
assert.equal(champ(charge, 2).refus, null);
});
test('sous les réservations : le plus grand compte de reservationsParTour, au premier tour qui l\'atteint', () => {
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
assert.deepEqual(champ(cinq, 4).refus, {
code: 'SOUS_RESERVATIONS',
details: { table: 1, n: 5, tour: 1, remede: afficherTour(1) },
});
assert.equal(champ(cinq, 5).refus, null);
// Trois réservations « tour », chacune à un tour différent : une à la fois.
const parTour = chargeUneTable({ reservations: [resa(1, 1, null, 1), resa(2, 1, null, 2), resa(3, 1, null, 3)] });
assert.equal(champ(parTour, 2).refus, null);
// Deux au tour 3 : le premier tour qui atteint deux est le 3.
parTour.reservations.push(resa(4, 1, null, 3));
assert.deepEqual(champ(parTour, 1).refus.code, 'MOINS_DE_DEUX');
parTour.reservations.push(resa(5, 1, null, 3));
assert.deepEqual(champ(parTour, 2).refus, { code: 'SOUS_RESERVATIONS', details: { table: 1, n: 3, tour: 3, remede: afficherTour(3) } });
// La réservation « tous » d'une personne exclue est suspendue : elle ne compte pas.
const exclue = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)), exclus: [5] });
assert.equal(champ(exclue, 4).refus, null);
});
test('sièges retirés : réservation au siège 7 et titre au siège 8, listés par numéro décroissant, remède du premier', () => {
const titre = { table: 1, siege: 8, libelle: 'animateur' };
const charge = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], titres: [titre] });
const reservee = { siege: 7, cause: 'SIEGE_RESERVE', participant: 1, suspendue: false };
const titree = { siege: 8, cause: 'SIEGE_TITRE', libelle: 'animateur' };
assert.deepEqual(champ(charge, 6).refus, {
code: 'SIEGES_RETIRES',
details: { table: 1, sieges: [titree, reservee], remede: { commande: 'retirerTitre', arguments: { table: 1, siege: 8 } } },
});
assert.deepEqual(champ(charge, 7).refus.details.sieges, [titree]);
// Sans le titre : la réservation seule, et son remède s'exécute tel quel.
const sansTitre = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)] });
const refus = champ(sansTitre, 6).refus;
const remede = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } };
assert.deepEqual(refus, { code: 'SIEGES_RETIRES', details: { table: 1, sieges: [reservee], remede } });
const leve = COMMANDES.get(remede.commande)(sansTitre, remede.arguments);
assert.equal(champ(leve.charge, 6).refus, null);
// Une réservation « tour » : le remède porte sa portée et son tour, et
// s'exécute tel quel.
const duTour = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7, 2)] });
const remedeDuTour = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tour', tour: 2 } };
assert.deepEqual(champ(duTour, 6).refus, { code: 'SIEGES_RETIRES', details: { table: 1, sieges: [reservee], remede: remedeDuTour } });
const leveDuTour = COMMANDES.get(remedeDuTour.commande)(duTour, remedeDuTour.arguments);
assert.deepEqual(leveDuTour.charge.reservations, []);
assert.equal(champ(leveDuTour.charge, 6).refus, null);
// La personne du siège 7 exclue : sa réservation suspendue reste listée.
const suspendue = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], exclus: [1] });
assert.deepEqual(champ(suspendue, 6).refus.details.sieges, [{ ...reservee, suspendue: true }]);
// Sièges non attribués : la même liste, et le compte des titres.
const enTable = chargeUneTable({ reservations: [resa(1, 1, 7)], titres: [titre] });
assert.deepEqual(champ(enTable, 6).refus.details, {
table: 1,
sieges: [titree, reservee],
titres: 1,
remede: { commande: 'retirerTitre', arguments: { table: 1, siege: 8 } },
});
});
test('deux titres à un même siège se listent par libellé ; le texte du refus les énonce dans cet ordre', () => {
const titres = [{ table: 1, siege: 8, libelle: 'animateur' }, { table: 1, siege: 8, libelle: 'accueil' }];
const charge = chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], titres, exclus: [1] });
const { refus } = champ(charge, 6);
assert.deepEqual(refus.details.sieges.map(({ siege, libelle: texte }) => [siege, texte ?? null]), [[8, 'accueil'], [8, 'animateur'], [7, null]]);
assert.equal(
libelle(refus.code, refus.details),
'Réduire la table retire des sièges qui portent une saisie : le siège 8 porte le titre « accueil », ' +
'le siège 8 porte le titre « animateur » et le siège 7 est réservé, par une réservation suspendue.',
);
const enTable = chargeUneTable({ reservations: [resa(1, 1, 7)], titres });
const refusEnTable = champ(enTable, 6).refus;
assert.equal(refusEnTable.details.titres, 2);
assert.equal(
libelle(refusEnTable.code, refusEnTable.details),
'Réduire la table retire des sièges qui portent une saisie : le siège 7 est réservé et 2 sièges portent un titre.',
);
});
test('chaque refus et chaque avertissement de capacité se dit en français, ses nombres compris', () => {
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
const textes = [
libelle('MOINS_DE_DEUX', champ(cinq, 1).refus.details),
libelle('SOUS_RESERVATIONS', champ(cinq, 4).refus.details),
libelle('NON_ENTIER', lireEntier('8abc').refus.details),
libelle('NON_ENTIER', lireEntier('').refus.details),
libelle('AU_DELA_LISIBLE', { seuil: 16 }),
libelle('SOUS_OCCUPES', { table: 1, n: 7, tour: 3 }),
libelle('SURNUMERAIRES', { table: 1, tour: 2, personnes: [4] }),
libelle('SURNUMERAIRES', { table: 1, tour: 2, personnes: [4, 5] }),
libelle('MOINS_D_UN_TOUR', { tours: 0 }),
];
assert.deepEqual(textes, [
'Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.',
'La table porte 5 réservations au tour 1 : elle ne descend pas sous 5 places.',
'« 8abc » n\'est pas un nombre entier : un nombre entier, sans signe ni décimale, est attendu.',
'La saisie est vide : un nombre entier, sans signe ni décimale, est attendu.',
"Au-delà de 16 places, la liste des noms de la table cesse d'être lisible sur le plan.",
'Au tour 3, la table occupe 7 places : la poignée ne descend pas en deçà.',
"Au tour 2, 1 personne passe dans la réserve : la table n'a plus de chaise pour elle.",
"Au tour 2, 2 personnes passent dans la réserve : la table n'a plus de chaise pour elles.",
'Une soirée compte au moins un tour.',
]);
});
test("sièges retirés : la charge réduite sans ce refus, suspendue ou en mode table, est celle que l'analyse refuse", () => {
for (const charge of [
chargeUneTable({ attribuerSieges: true, reservations: [resa(1, 1, 7)], exclus: [1] }),
chargeUneTable({ reservations: [resa(1, 1, 7)] }),
]) {
assert.equal(champ(charge, 6).refus.code, 'SIEGES_RETIRES');
const reduite = structuredClone(charge);
reduite.tables[0].sieges = 6;
assert.throws(
() => analyser(serialiser(reduite, { revision: 1, produitVersion: 'essai' })),
(erreur) => erreur.code === 'ETAT_ILLISIBLE' && erreur.details.raison === 'REFERENCE',
);
}
});
test('une table inconnue, ou une capacité qui n\'est pas un entier, est une faute du code', () => {
const charge = chargeUneTable();
assert.throws(() => controlerCapacite(charge, 9, 6, { origine: 'champ' }), TypeError);
assert.throws(() => controlerCapacite(charge, 1, '6', { origine: 'champ' }), TypeError);
assert.throws(() => controlerCapacite(charge, 1, 6, { origine: 'clavier' }), TypeError);
});
});
describe("capacité : la poignée, sous l'occupation de tous les tours (§ 7.1)", () => {
test('sièges non attribués : 5, 5, 7 et 5 personnes aux tours 1 à 4 ; 6 refuse SOUS_OCCUPES {n: 7, tour: 3}', () => {
const cinq = [1, 2, 3, 4, 5];
const charge = retenir(chargeUneTable({ n: 7, tours: 4 }), [cinq, cinq, [1, 2, 3, 4, 5, 6, 7], cinq]);
assert.deepEqual(poignee(charge, 6).refus, {
code: 'SOUS_OCCUPES',
details: { table: 1, n: 7, tour: 3, remede: afficherTour(3) },
});
assert.equal(poignee(charge, 7).refus, null);
// Le champ, lui, accepte et avertit.
assert.equal(champ(charge, 6).refus, null);
assert.deepEqual(poignee(charge, 1).refus, { code: 'MOINS_DE_DEUX', details: { remede: null } });
});
test("sièges attribués : huit occupants au tour 3 refusent 7 ; une réservation ou un titre au siège 8 refusent SIEGES_RETIRES d'abord", () => {
const cinq = [1, 2, 3, 4, 5];
const huit = retenir(chargeUneTable({ n: 8, attribuerSieges: true }), [cinq, cinq, [1, 2, 3, 4, 5, 6, 7, 8]]);
assert.deepEqual(poignee(huit, 7).refus, { code: 'SOUS_OCCUPES', details: { table: 1, n: 8, tour: 3, remede: afficherTour(3) } });
const assise = [1, 2, 3, 4, 5, null, null, 6];
const reservee = retenir(chargeUneTable({ n: 6, attribuerSieges: true, reservations: [resa(6, 1, 8)] }), [assise, assise, assise]);
const refus = poignee(reservee, 7).refus;
assert.equal(refus.code, 'SIEGES_RETIRES');
assert.deepEqual(refus.details.sieges, [{ siege: 8, cause: 'SIEGE_RESERVE', participant: 6, suspendue: false }]);
const titree = retenir(chargeUneTable({ n: 5, attribuerSieges: true, titres: [{ table: 1, siege: 8, libelle: 'accueil' }] }), [cinq, cinq, cinq]);
assert.deepEqual(poignee(titree, 7).refus.details.sieges, [{ siege: 8, cause: 'SIEGE_TITRE', libelle: 'accueil' }]);
// Six occupants, deux chaises vides avant le sixième, aucune saisie : le
// plus haut numéro occupé, 8, compte plus que les six occupants.
const creuse = retenir(chargeUneTable({ n: 6, attribuerSieges: true }), [assise, assise, assise]);
assert.deepEqual(poignee(creuse, 7).refus, { code: 'SOUS_OCCUPES', details: { table: 1, n: 8, tour: 1, remede: afficherTour(1) } });
});
test("la poignée ne reçoit aucun tour affiché : l'occupation se lit à chaque tour du retenu", () => {
const cinq = [1, 2, 3, 4, 5];
const charge = retenir(chargeUneTable({ n: 7, tours: 4 }), [cinq, cinq, cinq, [1, 2, 3, 4, 5, 6, 7]]);
assert.deepEqual(poignee(charge, 6).refus.details, { table: 1, n: 7, tour: 4, remede: afficherTour(4) });
});
});
describe('capacité : la lisibilité (§ 6.2, § 14.1)', () => {
test('S + 1 places avertissent AU_DELA_LISIBLE {seuil: S} ; S places, non', () => {
const seuil = constante('SEUIL_SIEGES_LISIBLES');
assert.ok(Number.isInteger(seuil) && seuil > 2);
const charge = chargeUneTable();
for (const origine of ['champ', 'poignee']) {
const au = controlerCapacite(charge, 1, seuil + 1, { origine });
assert.equal(au.refus, null);
assert.deepEqual(au.avertissements, [{ code: 'AU_DELA_LISIBLE', details: { seuil } }]);
assert.deepEqual(controlerCapacite(charge, 1, seuil, { origine }).avertissements, []);
}
});
});

View file

@ -634,6 +634,14 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['deplacerDansRetenu', { tour: 1, participant: autre, table: tableAutre, siege: null }],
['echangerDansRetenu', { tour: 1, a: voisin, b: autre }],
['retirerDuTour', { tour: 1, participant: autre }],
['creerTable', { forme: 'carree' }],
['supprimerTable', { id: 2 }],
['deplacerTable', { id: 1, position: { x: 40, y: 40 } }],
['changerPlacesTable', { id: 2, sieges: 3, origine: 'champ' }],
['realignerTable', { id: 1 }],
['changerDefaut', { sieges: 3, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
]);
}

View file

@ -1,9 +1,266 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours (§ 6, § 8.2),
// que commandes.js réunit aux autres. Ses imports n'atteignent jamais
// commandes.js : le registre réuni dépend de lui, non l'inverse.
// Le registre des commandes du mobilier — tables, sièges, tours, réglages
// (§ 4, § 6, § 7.1, § 8.2, § 9), que commandes.js réunit aux autres. Ses
// imports n'atteignent jamais commandes.js : le registre réuni dépend de lui,
// non l'inverse.
//
// Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
// intacte ; un refus lève ErreurCommande(code, details), details.remede
// compris, calculé avant toute modification ; un argument de forme fausse ou
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se
// lit par lireEntier, la seule lecture d'un entier saisi.
//
// Une table a un identifiant, tiré de prochainsIds.table, qui ne revient
// jamais, et un numéro affiché, le plus grand plus un, qui ne se renumérote
// pas (§ 4). Les capacités passent par controlerCapacite (capacite.js) ; le
// retenu les suit, ses surnuméraires versés en réserve, et une table créée y
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
// table et changer les tours laissent propositions et retenu en dérive.
import { caseLibre } from '../geometrie/tables.js';
import { SCHEMA, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { leverRefus } from './pose.js';
const FORMES = new Set(['ronde', 'carree']);
const ORIGINES = new Set(['champ', 'poignee']);
// Le plus grand identifiant de table qu'admet la règle du schéma : le
// compteur, qui la suit, n'aurait plus de valeur au-delà de lui.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const IDENTIFIANT_MAX = regleDuChamp(regleDuChamp(regleDuChamp(SCHEMA, 'charge'), 'tables').element, 'id').max;
// Les réglages que modifierReglage change : un interrupteur, ou un entier ≥ 1
// de la génération (§ 5.3, § 5.10).
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']);
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
}
// L'enregistrement de la table d'identifiant id : une table inconnue est une
// faute de l'appelant.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// La valeur d'un entier saisi, ou le refus NON_ENTIER levé.
function entierSaisi(saisie) {
const lu = lireEntier(saisie);
if (lu.refus) throw new ErreurCommande(lu.refus.code, lu.refus.details);
return lu.valeur;
}
// La charge reçue, copiée, chaque table de changements (Map identifiant →
// sieges) à son nombre de sièges, null pour une table qui suit le défaut, et
// le retenu aux capacités qui en résultent.
function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefaut) {
const suivante = structuredClone(charge);
suivante.evenement.siegesParDefaut = defaut;
for (const table of suivante.tables) if (changements.has(table.id)) table.sieges = changements.get(table.id);
const capacites = new Map(suivante.tables.map((table) => [table.id, capacite(suivante, table)]));
const touchees = new Map(
charge.tables
.filter((table) => changements.has(table.id) || capacites.get(table.id) !== capacite(charge, table))
.map(({ id }) => [id, capacites.get(id)]),
);
suivante.retenu = structuredClone(retenuAuxCapacites(charge, touchees));
return suivante;
}
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
// refus levé.
function exigerCapacite(charge, id, sieges, origine) {
const { refus: refuse, avertissements } = controlerCapacite(charge, id, sieges, { origine });
if (refuse !== null) throw new ErreurCommande(refuse.code, refuse.details);
return avertissements;
}
/**
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
* prochainsIds.table, numérotée après la plus grande, posée sur la première
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
* capacité et une liste vide à chaque tour.
*/
function creerTable(charge, { forme } = {}) {
exigerModifiable(charge);
if (!FORMES.has(forme)) throw new TypeError(`forme : 'ronde' ou 'carree' attendue, reçu ${JSON.stringify(forme)}`);
const id = charge.prochainsIds.table;
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
const position = caseLibre(charge.tables.map((table) => table.position));
const suivante = structuredClone(charge);
suivante.tables.push({ id, numero, sieges: null, forme, position });
suivante.prochainsIds.table = id + 1;
const { retenu } = suivante;
if (retenu !== null && fautes(retenu).length === 0) {
retenu.tables.push(id);
retenu.capacites.push(charge.evenement.siegesParDefaut);
for (const etage of retenu.placement) etage.sieges.push([]);
}
return { charge: suivante, libelle: libelle('ENTREE_CREER_TABLE', { numero }), avertissements: [] };
}
/**
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la
* même entrée, ses réservations — suspendues comprises — et ses titres ; les
* autres tables gardent leur numéro, propositions et retenu restent, en
* dérive TABLE_SUPPRIMEE.
*/
function supprimerTable(charge, { id } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
const suivante = structuredClone(charge);
suivante.tables = suivante.tables.filter((candidate) => candidate.id !== id);
suivante.reservations = suivante.reservations.filter((reservation) => reservation.table !== id);
suivante.titres = suivante.titres.filter((titre) => titre.table !== id);
const details = {
numero: table.numero,
reservations: charge.reservations.length - suivante.reservations.length,
titres: charge.titres.length - suivante.titres.length,
};
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] };
}
/** deplacerTable {id, position: {x, y}} (§ 7.1) : la position du centre, arrondie au centimètre. */
function deplacerTable(charge, { id, position } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) {
throw new TypeError(`position : {x, y} finis attendus, reçu ${JSON.stringify(position)}`);
}
const suivante = structuredClone(charge);
// + 0 écrit 0 là où l'arrondi d'une petite valeur négative rend −0.
suivante.tables.find((candidate) => candidate.id === id).position = { x: Math.round(position.x) + 0, y: Math.round(position.y) + 0 };
return { charge: suivante, libelle: libelle('ENTREE_DEPLACER_TABLE', { numero: table.numero }), avertissements: [] };
}
/**
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
* par controlerCapacite selon leur origine ; le retenu suit.
*/
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
const sieges = entierSaisi(saisie);
const avertissements = exigerCapacite(charge, id, sieges, origine);
return {
charge: avecSieges(charge, new Map([[id, sieges]])),
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
avertissements,
};
}
/**
* realignerTable {id} (§ 6.1) : la table suit de nouveau le défaut ; une
* table surchargée au-dessus de lui subit une réduction, sous la règle du
* champ (§ 6.2). Le retenu suit.
*/
function realignerTable(charge, { id } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, id);
const sieges = charge.evenement.siegesParDefaut;
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
return {
charge: avecSieges(charge, new Map([[id, null]])),
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
avertissements,
};
}
/**
* changerDefaut {sieges, realigner: boolean} (§ 6.1) : le défaut change, et
* avec lui chaque table qui le suit ; realigner, les surchargées le suivent
* aussi. Une seule entrée. Chaque table touchée se contrôle avant
* d'appliquer (bilanChangementDefaut) : un seul refus refuse le tout, le
* premier levé, les suivants dans details.autres. Les avertissements sont
* ceux du bilan ; AU_DELA_LISIBLE, de mêmes détails à chaque table, n'est
* dit qu'une fois. Le retenu suit.
*/
function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
exigerModifiable(charge);
if (typeof realigner !== 'boolean') throw new TypeError(`realigner : booléen attendu, reçu ${JSON.stringify(realigner)}`);
const sieges = entierSaisi(saisie);
const bilan = bilanChangementDefaut(charge, sieges, { realigner });
leverRefus(bilan.refus.map(({ code, details }) => ({ code, details })));
const avertissements = [];
for (const { code, details } of bilan.avertissements) {
if (code === 'AU_DELA_LISIBLE' && avertissements.some((deja) => deja.code === code)) continue;
avertissements.push({ code, details });
}
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
return {
charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges),
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
avertissements,
};
}
/**
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, remède null. Retirer des tours
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
* dérive TOURS_CHANGES, jamais tronqués.
*/
function changerTours(charge, { tours: saisie } = {}) {
exigerModifiable(charge);
const tours = entierSaisi(saisie);
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
const suivante = structuredClone(charge);
suivante.evenement.tours = tours;
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
return {
charge: suivante,
libelle: libelle('ENTREE_CHANGER_TOURS', { tours, retirees: charge.reservations.length - suivante.reservations.length }),
avertissements: [],
};
}
/**
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre,
* generation.arret, generation.historique, un entier ≥ 1, que le contrôleur
* de génération a lu et borné. Une autre clé, ou une autre valeur, est une
* faute du code.
*/
function modifierReglage(charge, { cle, valeur } = {}) {
exigerModifiable(charge);
const suivante = structuredClone(charge);
if (INTERRUPTEURS.has(cle)) {
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
suivante.reglages[cle] = valeur;
} else if (ENTIERS_DE_GENERATION.has(cle)) {
if (!Number.isSafeInteger(valeur) || valeur < 1) throw new TypeError(`${cle} : entier ≥ 1 attendu, reçu ${JSON.stringify(valeur)}`);
suivante.reglages.generation[cle.slice('generation.'.length)] = valeur;
} else {
throw new TypeError(`réglage inconnu : ${JSON.stringify(cle)}`);
}
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] };
}
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map();
export const COMMANDES_MOBILIER = new Map([
['creerTable', creerTable],
['supprimerTable', supprimerTable],
['deplacerTable', deplacerTable],
['changerPlacesTable', changerPlacesTable],
['realignerTable', realignerTable],
['changerDefaut', changerDefaut],
['changerTours', changerTours],
['modifierReglage', modifierReglage],
]);

View file

@ -0,0 +1,870 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des commandes du mobilier (§ 4, § 4.3, § 5.9, § 6, § 7.1, § 8.2,
// § 9, § 15.4) : tables, défaut, tours et réglages. Chaque commande acceptée
// rend une charge que l'analyse relit et qui se normalise quand la charge
// reçue se normalisait ; un refus ne change rien. Les gestes qui comptent
// les entrées du journal, ou les avertissements que la séance ajoute,
// passent par seance.executer sur le système de fichiers d'épreuve. Les noms
// d'épreuve sont inventés ; la version vient du module engendré.
import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
import { caseLibre } from '../geometrie/tables.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { derive, examiner, fautes } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { invariantViole } from './avertissements.js';
import { COMMANDES, appliquerCommande } from './commandes.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { ErreurCommande } from './erreurs.js';
import { libelle } from './libelles.js';
import { etatPlaces } from './places.js';
import { creerSeance } from './seance.js';
const V1 = VERSION.affichee;
const NOM = 'Soirée des Merles';
const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' };
const NOMS = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Grisaille', null],
['Bruyère', 'Anouk'],
['Sarcelle', 'Capucine'],
['Ancolie', 'Amandine'],
['Mélèze', 'Basile'],
['Quenouille', 'Hortense'],
['Pivoine', 'Gaspard'],
];
const personne = (id, autres = {}) => ({
id,
nom: NOMS[(id - 1) % NOMS.length][0],
prenom: NOMS[(id - 1) % NOMS.length][1],
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour });
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);
const afficherTour = (tour) => ({ geste: 'afficherTour', tour, proposition: null });
// Une charge : les tables données ({id, sieges}, numéro égal à l'identifiant
// sauf indication), n personnes dont les exclus, R tours, un défaut.
function chargeDe({ tables = [], n = 0, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] } = {}) {
const charge = creerCharge({ id: 'evt-mobilier', nom: NOM, siegesParDefaut: defaut, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, { exclu: exclus.includes(rang + 1) }));
charge.tables = tables.map(({ id, numero = id, sieges = null }) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 };
return charge;
}
// Ajoute la proposition id, ses listes dans l'ordre de charge.tables, et la
// retient quand retenu est vrai.
function placer(charge, listes, { id, retenu = false }) {
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
const plan = {
siegesAttribues: charge.reglages.attribuerSieges,
tables: charge.tables.map(({ id: t }) => t),
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: listes.length,
participants,
placement: listes.map((tour) => ({ sieges: tour, reserve: participants.filter((p) => !tour.flat().includes(p)) })),
};
charge.propositions.push({ id, graine: id, arret: 40, historique: 1000, produitVersion: V1, ...plan });
charge.prochainsIds.proposition = id + 1;
if (retenu) {
charge.retenu = { proposition: id, ...structuredClone(plan) };
charge.evenement.etat = 'retenu';
} else if (charge.evenement.etat === 'brouillon') {
charge.evenement.etat = 'propose';
}
return charge;
}
// Gèle une valeur en profondeur : une commande qui la modifierait lèverait.
function figer(valeur) {
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
Object.freeze(valeur);
for (const element of Object.values(valeur)) figer(element);
}
return valeur;
}
// Vrai quand la configuration de la charge se normalise.
function seNormalise(charge) {
try {
normaliser(configurationDepuisCharge(charge));
return true;
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return false;
throw erreur;
}
}
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
// se relit, et se normalise quand la charge reçue se normalisait.
function accepter(nom, charge, args) {
const recue = figer(structuredClone(charge));
const rendu = appliquerCommande(nom, recue, args);
analyser(serialiser(rendu.charge, { revision: 1, produitVersion: 'essai' }));
if (seNormalise(recue)) assert.ok(seNormalise(rendu.charge), `${nom} : la charge rendue ne se normalise plus`);
return rendu;
}
// Le refus d'une ErreurCommande de ce code ; ses détails contiennent ceux
// donnés.
function refuse(appel, code, attendus = {}) {
let leve = null;
try {
appel();
} catch (erreur) {
leve = erreur;
}
assert.ok(leve instanceof ErreurCommande, `ErreurCommande ${code} attendue, reçu ${leve}`);
assert.equal(leve.code, code, leve.message);
for (const [cle, valeur] of Object.entries(attendus)) assert.deepEqual(leve.details[cle], valeur, cle);
return leve;
}
// Le même refus, rendu par une promesse.
async function refuseAsync(promesse, code, attendus = {}) {
let leve = null;
try {
await promesse;
} catch (erreur) {
leve = erreur;
}
return refuse(() => {
if (leve !== null) throw leve;
}, code, attendus);
}
// --- La séance d'épreuve ----------------------------------------------------
// Une séance sur le système d'épreuve, l'événement de la charge posé par le
// dépôt, ouvert et passé en écriture. entrees() compte les entrées du
// journal ; ecritures() les écritures du système.
async function seanceSur(charge) {
const fs = creerFichiersSimules();
fs.deposerDossier('documents');
await creerDansLeDepot(fs, DOC, charge, {
libelle: libelle('ENTREE_CREATION', { nom: NOM }),
nomJalon: libelle('JALON_CREATION'),
horodatage: '2031-05-17T07:00:00-04:00',
produitVersion: V1,
});
let tirage = 0;
const seance = creerSeance({
fs,
racine: DOC,
horloge: { horodatage: () => '2031-05-17T08:00:00-04:00' },
identifiant: () => {
tirage += 1;
return `seance-${tirage}`;
},
produitVersion: V1,
planifier: () => 1,
annuler: () => {},
});
await seance.ouvrir(NOM);
await seance.passerEnEcriture();
return {
seance,
entrees: () => lireJournal(fs.contenu('documents', `${NOM}${SUFFIXES.journal}`)).entrees.length,
ecritures: () => fs.compteurs.ecritures,
};
}
// --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => {
test('COMMANDES_MOBILIER porte les huit commandes du mobilier, réunies dans COMMANDES', () => {
const noms = ['creerTable', 'supprimerTable', 'deplacerTable', 'changerPlacesTable', 'realignerTable', 'changerDefaut', 'changerTours', 'modifierReglage'];
assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms);
for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom);
});
test('un plan bloqué refuse chacune, PLAN_BLOQUE, remède debloquer', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
charge.evenement.etat = 'bloque';
const gestes = [
['creerTable', { forme: 'ronde' }],
['supprimerTable', { id: 1 }],
['deplacerTable', { id: 1, position: { x: 5, y: 5 } }],
['changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' }],
['realignerTable', { id: 1 }],
['changerDefaut', { sieges: 6, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
];
assert.equal(gestes.length, COMMANDES_MOBILIER.size);
for (const [nom, args] of gestes) {
refuse(() => COMMANDES.get(nom)(charge, args), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
}
});
});
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => {
let charge = chargeDe();
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
assert.deepEqual(charge.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [2, 2], [3, 3]]);
charge = accepter('supprimerTable', charge, { id: 3 }).charge;
const cree = accepter('creerTable', charge, { forme: 'carree' });
assert.equal(cree.libelle, 'Ajouté la table 3');
assert.deepEqual(cree.charge.tables.at(-1), {
id: 4,
numero: 3,
sieges: null,
forme: 'carree',
position: caseLibre(charge.tables.map(({ position }) => position)),
});
assert.equal(cree.charge.prochainsIds.table, 5);
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
assert.equal(deplace.libelle, 'Déplacé la table 3');
assert.deepEqual(deplace.charge.tables.at(-1).position, { x: 612, y: 0 });
assert.ok(Object.is(deplace.charge.tables.at(-1).position.y, 0));
// Une position que l'arrondi ne change pas : le geste est sans effet.
refuse(() => appliquerCommande('deplacerTable', deplace.charge, { id: 4, position: { x: 611.6, y: 0.2 } }), 'SANS_EFFET');
// Supprimer une table du milieu : le numéro suit encore le plus grand,
// et non le nombre de tables.
let milieu = chargeDe();
for (let i = 0; i < 3; i += 1) milieu = accepter('creerTable', milieu, { forme: 'ronde' }).charge;
milieu = accepter('supprimerTable', milieu, { id: 2 }).charge;
const apresMilieu = accepter('creerTable', milieu, { forme: 'ronde' });
assert.equal(apresMilieu.libelle, 'Ajouté la table 4');
assert.deepEqual(apresMilieu.charge.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [3, 3], [4, 4]]);
});
test('une forme inconnue, une table inconnue ou une position non finie sont des fautes du code', () => {
const charge = chargeDe({ tables: [{ id: 1 }] });
assert.throws(() => COMMANDES.get('creerTable')(charge, { forme: 'ovale' }), TypeError);
assert.throws(() => COMMANDES.get('supprimerTable')(charge, { id: 7 }), TypeError);
assert.throws(() => COMMANDES.get('deplacerTable')(charge, { id: 1, position: { x: Number.NaN, y: 0 } }), TypeError);
});
test('une table créée suit le défaut : sieges null, 8 places sous un défaut de 8, 10 sous un défaut de 10', () => {
for (const defaut of [8, 10]) {
const { charge } = accepter('creerTable', chargeDe({ defaut }), { forme: 'ronde' });
assert.equal(charge.tables[0].sieges, null);
assert.equal(capacite(charge, charge.tables[0]), defaut);
}
const huit = accepter('creerTable', chargeDe(), { forme: 'ronde' }).charge;
const dix = accepter('changerDefaut', huit, { sieges: 10, realigner: false }).charge;
assert.equal(capacite(dix, dix.tables[0]), 10);
});
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
const { charge: apres, libelle: texte } = accepter('creerTable', charge, { forme: 'ronde' });
assert.equal(texte, 'Ajouté la table 3');
assert.deepEqual(apres.retenu.tables, [1, 2, 3]);
assert.deepEqual(apres.retenu.capacites, [8, 8, 8]);
for (const { sieges } of apres.retenu.placement) assert.deepEqual(sieges.at(-1), []);
assert.deepEqual(fautes(apres.retenu), []);
assert.deepEqual(derive(apres.retenu, apres), []);
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(examiner(apres).derives, [
{ id: 1, raisons: [{ code: 'TABLE_AJOUTEE', table: 3 }] },
{ id: 2, raisons: [{ code: 'TABLE_AJOUTEE', table: 3 }] },
]);
// Un retenu fautif ne se réécrit pas.
const fautif = structuredClone(charge);
fautif.retenu.capacites = [8];
const sansRetouche = accepter('creerTable', fautif, { forme: 'ronde' }).charge;
assert.deepEqual(sansRetouche.retenu, fautif.retenu);
});
test("identifiant contre numéro : supprimer la table 2 laisse la réservation et les listes de la table 3 du retenu", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 6, reservations: [resa(1, 3, null)] });
placer(charge, [[[2, 3], [4, 5], [1, 6]], [[4, 6], [2, 3], [1, 5]]], { id: 1, retenu: true });
const { charge: apres, libelle: texte } = accepter('supprimerTable', charge, { id: 2 });
assert.equal(texte, 'Supprimé la table 2');
assert.deepEqual(apres.tables.map(({ id, numero }) => [id, numero]), [[1, 1], [3, 3]]);
assert.deepEqual(apres.reservations, [resa(1, 3, null)]);
assert.deepEqual(apres.retenu, charge.retenu);
assert.ok(examiner(apres).retenu.raisons.some(({ code, table }) => code === 'TABLE_SUPPRIMEE' && table === 2));
});
test("supprimer une table garnie retire, dans la même entrée, ses réservations et ses titres", async () => {
const charge = chargeDe({
tables: [{ id: 1 }, { id: 2 }],
n: 4,
reservations: [resa(2, 2, 2), resa(1, 1, null), resa(3, 2, null, 1)],
titres: [{ table: 2, siege: 2, libelle: 'animateur' }, { table: 1, siege: 1, libelle: 'accueil' }],
});
const { seance, entrees } = await seanceSur(charge);
const avant = entrees();
const rendu = await seance.executer('supprimerTable', { id: 2 });
assert.equal(rendu.libelle, 'Supprimé la table 2 ; retirés : 2 réservations et 1 titre');
assert.equal(entrees() - avant, 1);
assert.deepEqual(seance.charge.reservations, [resa(1, 1, null)]);
assert.deepEqual(seance.charge.titres, [{ table: 1, siege: 1, libelle: 'accueil' }]);
// Au singulier : une réservation, un titre ; une table nue ne retire rien.
const seule = await seance.executer('supprimerTable', { id: 1 });
assert.equal(seule.libelle, 'Supprimé la table 1 ; retirés : 1 réservation et 1 titre');
const vide = accepter('supprimerTable', chargeDe({ tables: [{ id: 4 }] }), { id: 4 });
assert.equal(vide.libelle, 'Supprimé la table 4');
});
test('le manque : supprimer une table de 8 sous Σ c = N = 16 avertit PLACES_MANQUANTES {8} ; ramener une table à 6, {2}', async () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 16 });
const un = await seanceSur(charge);
const rendu = await un.seance.executer('supprimerTable', { id: 2 });
assert.equal(nombrePlacesManquantes(normaliser(configurationDepuisCharge(un.seance.charge))), 8);
assert.deepEqual(rendu.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 8 } }]);
const deux = await seanceSur(charge);
const reduit = await deux.seance.executer('changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' });
assert.deepEqual(reduit.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } }]);
});
});
describe("mobilier : les places d'une table, par le champ et par la poignée (§ 6.2, § 7.1)", () => {
test('les saisies : « 1 » et « 0 » refusent MOINS_DE_DEUX ; un texte non entier, NON_ENTIER ; « 2 » passe', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
for (const sieges of ['1', '0']) {
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges, origine: 'champ' }), 'MOINS_DE_DEUX', { remede: null });
}
const textes = ['-3', '2.5', '2,5', '8abc', 'huit', ''];
assert.equal(textes.length, 6);
for (const sieges of textes) {
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges, origine: 'champ' }), 'NON_ENTIER', { saisie: sieges, remede: null });
}
const deux = accepter('changerPlacesTable', charge, { id: 1, sieges: '2', origine: 'champ' });
assert.equal(deux.charge.tables[0].sieges, 2);
assert.equal(deux.libelle, 'Table 1 : 2 places');
});
test("une surcharge au défaut est une surcharge ; réaligner la retire, et le libellé nomme le défaut", () => {
const charge = chargeDe({ tables: [{ id: 5, numero: 2 }], n: 2 });
const surchargee = accepter('changerPlacesTable', charge, { id: 5, sieges: 8, origine: 'champ' });
assert.equal(surchargee.charge.tables[0].sieges, 8);
assert.equal(surchargee.libelle, 'Table 2 : 8 places');
const realignee = accepter('realignerTable', surchargee.charge, { id: 5 });
assert.equal(realignee.charge.tables[0].sieges, null);
assert.equal(realignee.libelle, 'Table 2 : suit le défaut (8 places)');
refuse(() => appliquerCommande('realignerTable', realignee.charge, { id: 5 }), 'SANS_EFFET');
});
test("le champ dans le retenu, sièges attribués : les occupants des chaises 7 et 8 passent en réserve, la capacité du retenu suit", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18, attribuerSieges: true });
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), [18, 11, 12, 13, 14, 15, 16, 17]],
],
{ id: 1, retenu: true },
);
const chaises = etatPlaces(charge, { tour: 2 }).tables[2].chaises;
const personnes = [chaises[6].occupant, chaises[7].occupant];
assert.deepEqual(personnes, [16, 17]);
const { charge: apres, avertissements, libelle: texte } = accepter('changerPlacesTable', charge, { id: 3, sieges: 6, origine: 'champ' });
assert.equal(texte, 'Table 3 : 6 places');
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes } }]);
const etage = apres.retenu.placement[1];
assert.deepEqual(etage.sieges[2], [18, 11, 12, 13, 14, 15]);
assert.deepEqual(etage.reserve, [16, 17]);
assert.deepEqual([...etage.sieges.flat(), ...etage.reserve].sort((a, b) => a - b), suite(1, 18));
assert.deepEqual(apres.retenu.placement[0], charge.retenu.placement[0]);
assert.deepEqual(apres.retenu.capacites, [8, 8, 6]);
assert.deepEqual(fautes(apres.retenu), []);
assert.deepEqual(derive(apres.retenu, apres), []);
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(derive(apres.propositions[0], apres), [{ code: 'CAPACITE_CHANGEE', table: 3, avant: 8, maintenant: 6 }]);
});
test("le champ dans le retenu, sièges non attribués : la personne réservée au siège 2 reste, les deux suivantes partent", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18, reservations: [resa(18, 3, 2)] });
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 3, sieges: 6, origine: 'champ' });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [16, 17] } }]);
assert.deepEqual(apres.retenu.placement[1].sieges[2], [11, 12, 13, 14, 15, 18]);
assert.deepEqual(apres.retenu.placement[1].reserve, [16, 17]);
assert.deepEqual(derive(apres.retenu, apres), []);
// La personne réservée garde sa place au plan, au siège 2.
assert.equal(etatPlaces(apres, { tour: 2 }).tables[2].chaises[1].occupant, 18);
});
test('sièges attribués : une liste réduite ne finit jamais par une chaise vide ; fautes(retenu) reste vide', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, attribuerSieges: true });
placer(charge, [[suite(1, 6), [7, 8, 9, 10, 11, null, null, 12]]], { id: 1, retenu: true });
for (const sieges of [6, 7]) {
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 2, sieges, origine: 'champ' });
assert.deepEqual(apres.retenu.placement[0].sieges[1], [7, 8, 9, 10, 11]);
assert.deepEqual(apres.retenu.placement[0].reserve, [12]);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 1, personnes: [12] } }]);
assert.deepEqual(fautes(apres.retenu), []);
}
});
test('la poignée : 8 → 9 accepté, la capacité du retenu suit ; sous l\'occupation, SOUS_OCCUPES et rien ne change', () => {
const cinq = suite(1, 5);
const charge = chargeDe({ tables: [{ id: 1 }], n: 7, tours: 4 });
placer(charge, [cinq, cinq, suite(1, 7), cinq].map((liste) => [liste]), { id: 1, retenu: true });
const neuf = accepter('changerPlacesTable', charge, { id: 1, sieges: 9, origine: 'poignee' });
assert.deepEqual(neuf.charge.retenu.capacites, [9]);
assert.deepEqual(neuf.charge.retenu.placement, charge.retenu.placement);
assert.deepEqual(neuf.avertissements, []);
refuse(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: 6, origine: 'poignee' }), 'SOUS_OCCUPES', {
table: 1,
n: 7,
tour: 3,
remede: afficherTour(3),
});
});
test('réaligner une table surchargée à 10 qui en assied dix, sous un défaut de 8 : deux surnuméraires', () => {
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 16, tours: 1 });
placer(charge, [[suite(1, 10), suite(11, 16)]], { id: 1, retenu: true });
const { charge: apres, avertissements, libelle: texte } = accepter('realignerTable', charge, { id: 1 });
assert.equal(texte, 'Table 1 : suit le défaut (8 places)');
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [9, 10] } }]);
assert.deepEqual(apres.retenu.placement[0].reserve, [9, 10]);
assert.deepEqual(apres.retenu.capacites, [8, 8]);
assert.deepEqual(derive(apres.retenu, apres), []);
});
test("une hausse n'assied personne que le plan ne montrait pas : un retenu déclaré à 10 sur une table de 8", () => {
// Avec la personne 9 réservée à la table 1 sans siège : sièges attribués,
// son rang 9 ne se montre pas et la hausse ne l'assied pas ; sans
// attribution, l'affichage l'élit déjà, et la personne 8 n'est pas montrée.
// La dérive du retenu masquait la réservation non tenue de la personne 9 ;
// la capacité rejointe, invariantViole la nomme.
const nonTenue = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 9, table: 1, tour: 1 }] } };
const cas = [
{ attribuerSieges: true, reservations: [], liste: suite(1, 8), reserve: [9, 10], invariant: null },
{ attribuerSieges: false, reservations: [], liste: suite(1, 8), reserve: [9, 10], invariant: null },
{ attribuerSieges: true, reservations: [resa(9, 1, null)], liste: suite(1, 8), reserve: [9, 10], invariant: nonTenue },
{ attribuerSieges: false, reservations: [resa(9, 1, null)], liste: [1, 2, 3, 4, 5, 6, 7, 9], reserve: [8, 10], invariant: null },
];
for (const { attribuerSieges, reservations, liste, reserve, invariant } of cas) {
for (const origine of ['champ', 'poignee']) {
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations });
placer(charge, [[suite(1, 10), [11, 12]]], { id: 1, retenu: true });
charge.tables[0].sieges = null;
const montrees = etatPlaces(charge, { tour: 1 }).tables[0].chaises.map(({ occupant }) => occupant);
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 10, origine });
const nom = `attribuerSieges ${attribuerSieges}, ${reservations.length} réservation, ${origine}`;
assert.deepEqual(montrees, liste, nom);
assert.deepEqual(avertissements, [], nom);
assert.deepEqual(apres.retenu.placement[0].sieges[0], liste, nom);
assert.deepEqual(apres.retenu.placement[0].reserve, reserve, nom);
assert.deepEqual(apres.retenu.capacites, [10, 8], nom);
assert.deepEqual(derive(apres.retenu, apres), [], nom);
assert.equal(invariantViole(charge), null, nom);
assert.deepEqual(invariantViole(apres), invariant, nom);
}
}
});
test('un retenu de plus de tours que la soirée se réduit à chacun de ses tours, sans faute', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, attribuerSieges: true });
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
// Le tour 3 n'est plus de la soirée, mais le retenu le porte encore : ses
// surnuméraires se nomment comme ceux d'un tour montré.
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [11, 12] } }]);
assert.deepEqual(apres.retenu.placement[2].sieges[1], suite(5, 10));
assert.deepEqual(apres.retenu.placement[2].reserve, [11, 12]);
assert.deepEqual(fautes(apres.retenu), []);
});
test('sans attribution, un tour du retenu que la soirée n\'a plus élit aussi la personne que la table réserve', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, reservations: [resa(12, 2, null)] });
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [10, 11] } }]);
assert.deepEqual(apres.retenu.placement[2].sieges[1], [5, 6, 7, 8, 9, 12]);
assert.deepEqual(apres.retenu.placement[2].reserve, [10, 11]);
assert.deepEqual(fautes(apres.retenu), []);
});
test("une réduction acceptée garde chaque réservation que le retenu tenait : invariantViole reste null", () => {
// La personne 8, réservée à la table 1 sans siège, est assise à la chaise 8.
const gestes = [
['changerPlacesTable', { id: 1, sieges: 6, origine: 'champ' }],
['changerDefaut', { sieges: 6, realigner: false }],
];
let acceptees = 0;
for (const attribuerSieges of [true, false]) {
for (const [nom, args] of gestes) {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations: [resa(8, 1, null)] });
placer(charge, [[suite(1, 8), suite(9, 12)]], { id: 1, retenu: true });
assert.equal(invariantViole(charge), null);
const { charge: apres, avertissements } = accepter(nom, charge, args);
const cas = `${nom}, attribuerSieges ${attribuerSieges}`;
assert.equal(invariantViole(apres), null, cas);
// Un non-réservé cède sa chaise : la chaise 6, la plus haute, sous des
// sièges attribués ; sinon l'affichage élit d'abord la réservée.
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 8], cas);
assert.deepEqual(apres.retenu.placement[0].reserve, [6, 7], cas);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [6, 7] } }], cas);
assert.deepEqual(derive(apres.retenu, apres), [], cas);
acceptees += 1;
}
}
assert.equal(acceptees, 4);
// Sièges attribués, une chaise vide dans la capacité reçoit la réservée
// avant qu'un non-réservé ne cède la sienne.
const vide = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1, attribuerSieges: true, reservations: [resa(8, 1, null)] });
placer(vide, [[[1, 2, 3, null, 5, 6, 7, 8], suite(9, 12)]], { id: 1, retenu: true });
const { charge: apres, avertissements } = accepter('changerPlacesTable', vide, { id: 1, sieges: 6, origine: 'champ' });
assert.equal(invariantViole(apres), null);
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 2, 3, 8, 5, 6]);
assert.deepEqual(apres.retenu.placement[0].reserve, [4, 7]);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [7] } }]);
// Réaligner une table surchargée à 10, la personne 10 réservée sans siège.
const dix = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 14, tours: 1, attribuerSieges: true, reservations: [resa(10, 1, null)] });
placer(dix, [[suite(1, 10), suite(11, 14)]], { id: 1, retenu: true });
const realignee = accepter('realignerTable', dix, { id: 1 });
assert.equal(invariantViole(realignee.charge), null);
assert.deepEqual(realignee.charge.retenu.placement[0].sieges[0], [1, 2, 3, 4, 5, 6, 7, 10]);
assert.deepEqual(realignee.avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes: [8, 9] } }]);
});
test('sièges attribués, le retour des réservés : siège voulu, chaise libre ou d\'un absent, non-réservé du plus haut rang, ordre des rangs, tour couvert', () => {
// La table 1, de 8, réduite à 6 par le champ ; les exclus le deviennent
// après la retenue. Par tour : la liste qui reste, la réserve, et les
// personnes de SURNUMERAIRES.
const cas = [
{
nom: "une chaise tenue par un absent est libre : la personne 8 la prend, l'absent 2 passe en réserve",
reservations: [resa(8, 1, null)],
exclus: [2],
listes: [[suite(1, 8), suite(9, 12)]],
attendus: [[[1, 8, 3, 4, 5, 6], [2, 7], [7]]],
},
{
nom: 'le siège réservé, libre, passe avant la première chaise libre',
reservations: [resa(9, 1, 3), resa(9, 1, 4, 1)],
listes: [[[1, null, null, null, 5, 6, 7, 9], [2, 3, 4, 8, 10, 11, 12]]],
attendus: [[[1, null, 9, null, 5, 6], [7], [7]]],
},
{
nom: "le siège réservé est le premier siège non nul des réservations de la personne, dans l'ordre canonique",
reservations: [resa(9, 1, null), resa(9, 1, 4, 1)],
listes: [[[1, null, null, null, 5, 6, 7, 9], [2, 3, 4, 8, 10, 11, 12]]],
attendus: [[[1, null, null, 9, 5, 6], [7], [7]]],
},
{
nom: "un siège réservé tenu par une personne présente n'est pas libre : le non-réservé du plus haut rang cède le sien",
reservations: [resa(9, 1, 3)],
listes: [[[1, 2, 3, 4, 5, 6, 7, 9], [8, 10, 11, 12]]],
attendus: [[[1, 2, 3, 4, 5, 9], [6, 7], [6, 7]]],
},
{
nom: 'une personne réservée ne cède pas sa chaise : la personne 5 part, la 6 reste',
reservations: [resa(6, 1, null), resa(8, 1, null)],
listes: [[suite(1, 8), suite(9, 12)]],
attendus: [[[1, 2, 3, 4, 8, 6], [5, 7], [5, 7]]],
},
{
nom: "dans l'ordre des rangs : la personne 7 prend la chaise vide, la 8 celle du plus haut rang",
reservations: [resa(7, 1, null), resa(8, 1, null)],
listes: [[[1, null, 3, 4, 5, 6, 7, 8], [2, 9, 10, 11, 12]]],
attendus: [[[1, 7, 3, 4, 5, 8], [6], [6]]],
},
{
nom: 'une réservation « tour » ramène sa personne au tour 2 seulement',
reservations: [resa(8, 1, null, 2)],
listes: [
[suite(1, 8), suite(9, 12)],
[suite(1, 8), suite(9, 12)],
],
attendus: [
[suite(1, 6), [7, 8], [7, 8]],
[[1, 2, 3, 4, 5, 8], [6, 7], [6, 7]],
],
},
{
nom: 'la réservation suspendue de la personne 8, exclue, ne la ramène pas',
reservations: [resa(8, 1, null)],
exclus: [8],
listes: [[suite(1, 8), suite(9, 12)]],
attendus: [[suite(1, 6), [7, 8], [7]]],
},
];
for (const { nom, reservations, exclus = [], listes, attendus } of cas) {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: listes.length, attribuerSieges: true, reservations });
placer(charge, listes, { id: 1, retenu: true });
for (const id of exclus) charge.participants[id - 1].exclu = true;
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
attendus.forEach(([liste, reserve], rang) => {
assert.deepEqual(apres.retenu.placement[rang].sieges[0], liste, `${nom}, tour ${rang + 1}`);
assert.deepEqual(apres.retenu.placement[rang].reserve, reserve, `${nom}, tour ${rang + 1}`);
});
const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } }));
assert.deepEqual(avertissements, surnumeraires, nom);
assert.equal(invariantViole(apres), null, nom);
// La réduction n'ajoute aucune raison de dérive ; un exclu garde la sienne.
assert.deepEqual(derive(apres.retenu, apres), derive(charge.retenu, charge), nom);
assert.deepEqual(fautes(apres.retenu), [], nom);
}
});
test("sièges attribués, un tour du retenu que la soirée n'a plus ramène la personne qu'une réservation « tous » tient", () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 3, attribuerSieges: true, reservations: [resa(12, 2, null)] });
placer(charge, [[suite(1, 6), suite(7, 12)], [suite(1, 6), suite(7, 12)], [suite(1, 4), suite(5, 12)]], { id: 1, retenu: true });
const deux = accepter('changerTours', charge, { tours: 2 }).charge;
const { charge: apres, avertissements } = accepter('changerPlacesTable', deux, { id: 2, sieges: 6, origine: 'champ' });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 2, tour: 3, personnes: [10, 11] } }]);
assert.deepEqual(apres.retenu.placement[2].sieges[1], [5, 6, 7, 8, 9, 12]);
assert.deepEqual(apres.retenu.placement[2].reserve, [10, 11]);
assert.deepEqual(fautes(apres.retenu), []);
assert.equal(invariantViole(apres), null);
});
test('une baisse ne ramène pas la personne réservée d\'un rang au-delà de la capacité courante, que le plan ne montrait pas', () => {
// Le retenu déclare la table 1 à 10, qui n'en a plus que 8 : sièges
// attribués, la personne 9, réservée sans siège, est au rang 9.
// Sièges attribués, la réservation que le plan montrait non tenue le reste,
// et invariantViole la nomme une fois la dérive levée ; sans attribution,
// l'affichage élisait la personne 9, qui reste.
const nonTenue = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 9, table: 1, tour: 1 }] } };
const cas = [
{ attribuerSieges: true, liste: suite(1, 6), reserve: [7, 8, 9, 10], personnes: [7, 8], invariant: nonTenue },
{ attribuerSieges: false, liste: [1, 2, 3, 4, 5, 9], reserve: [6, 7, 8, 10], personnes: [6, 7], invariant: null },
];
for (const { attribuerSieges, liste, reserve, personnes, invariant } of cas) {
const charge = chargeDe({ tables: [{ id: 1, sieges: 10 }, { id: 2 }], n: 12, tours: 1, attribuerSieges, reservations: [resa(9, 1, null)] });
placer(charge, [[suite(1, 10), [11, 12]]], { id: 1, retenu: true });
charge.tables[0].sieges = null;
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
const nom = `attribuerSieges ${attribuerSieges}`;
assert.deepEqual(apres.retenu.placement[0].sieges[0], liste, nom);
assert.deepEqual(apres.retenu.placement[0].reserve, reserve, nom);
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 1, tour: 1, personnes } }], nom);
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve.includes(9), attribuerSieges, nom);
assert.deepEqual(invariantViole(apres), invariant, nom);
}
});
test('sans attribution, une liste qui dépasse encore perd ses absents, le plus petit identifiant d\'abord', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 12, tours: 1 });
placer(charge, [[suite(1, 8), suite(9, 12)]], { id: 1, retenu: true });
for (const id of [2, 3]) charge.participants[id - 1].exclu = true;
const { charge: apres, avertissements } = accepter('changerPlacesTable', charge, { id: 1, sieges: 6, origine: 'champ' });
// Six présents s'assoient ; les deux absents partent, le plus petit d'abord, jusqu'à six.
assert.deepEqual(avertissements, []);
assert.deepEqual(apres.retenu.placement[0].sieges[0], [1, 4, 5, 6, 7, 8]);
assert.deepEqual(apres.retenu.placement[0].reserve, [2, 3]);
const sept = accepter('changerPlacesTable', charge, { id: 1, sieges: 7, origine: 'champ' }).charge;
assert.deepEqual(sept.retenu.placement[0].sieges[0], [1, 3, 4, 5, 6, 7, 8]);
assert.deepEqual(sept.retenu.placement[0].reserve, [2]);
});
test('un compteur de tables saturé refuse COMPTEUR_SATURE ; une origine ou un réalignement de forme fausse sont des fautes du code', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
charge.prochainsIds.table = 2 ** 31 - 1;
refuse(() => COMMANDES.get('creerTable')(charge, { forme: 'ronde' }), 'COMPTEUR_SATURE', { compteur: 'table', remede: null });
assert.throws(() => COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: 6, origine: 'clavier' }), TypeError);
assert.throws(() => COMMANDES.get('changerDefaut')(charge, { sieges: 6 }), TypeError);
});
});
describe('mobilier : changer le défaut (§ 6.1, § 6.2)', () => {
function treizeTables(options = {}) {
const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]);
const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null }));
return chargeDe({ tables, n: 120, exclus: [40, 80], ...options });
}
const capacites = (charge) => charge.tables.map((table) => capacite(charge, table));
test('appliquer : les quatre surchargées gardent 7, 7, 6 et 12, les neuf autres valent 10, une entrée ; réaligner : les treize suivent 10', async () => {
const { seance, entrees } = await seanceSur(treizeTables());
const avant = entrees();
const rendu = await seance.executer('changerDefaut', { sieges: 10, realigner: false });
assert.equal(rendu.libelle, 'Sièges par défaut : 10');
assert.equal(entrees() - avant, 1);
assert.deepEqual(capacites(seance.charge), [10, 10, 7, 10, 10, 7, 10, 10, 6, 10, 10, 12, 10]);
assert.deepEqual(seance.charge.tables.filter(({ sieges }) => sieges !== null).map(({ id }) => id), [3, 6, 9, 12]);
const realigne = await seance.executer('changerDefaut', { sieges: 10, realigner: true });
assert.equal(realigne.libelle, 'Sièges par défaut : 10 ; 4 tables réalignées');
assert.equal(entrees() - avant, 2);
assert.ok(seance.charge.tables.every(({ sieges }) => sieges === null));
assert.deepEqual(capacites(seance.charge), Array(13).fill(10));
});
test('réaligner refusé en bloc : le titre du siège 12 refuse, table et remède compris, et rien ne s\'écrit ; sans réaligner, accepté', async () => {
const charge = treizeTables({ attribuerSieges: true, titres: [{ table: 12, siege: 12, libelle: 'animateur' }] });
const { seance, ecritures } = await seanceSur(charge);
const avant = ecritures();
const leve = await refuseAsync(seance.executer('changerDefaut', { sieges: 10, realigner: true }), 'SIEGES_RETIRES', {
table: 12,
sieges: [{ siege: 12, cause: 'SIEGE_TITRE', libelle: 'animateur' }],
remede: { commande: 'retirerTitre', arguments: { table: 12, siege: 12 } },
});
assert.deepEqual(leve.details.autres, []);
assert.equal(ecritures(), avant);
assert.deepEqual(capacites(seance.charge), capacites(charge));
await seance.executer('changerDefaut', { sieges: 10, realigner: false });
assert.equal(seance.charge.tables[11].sieges, 12);
});
test('baisse refusée en bloc : la table 2 porte 7 réservations ; le défaut 6 refuse, nommant la table 2, et les trois tables restent à 8', async () => {
const reservations = suite(1, 7).map((id) => resa(id, 2, null));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 20, reservations });
const { seance, ecritures } = await seanceSur(charge);
const avant = ecritures();
await refuseAsync(seance.executer('changerDefaut', { sieges: 6, realigner: false }), 'SOUS_RESERVATIONS', {
table: 2,
n: 7,
tour: 1,
remede: afficherTour(1),
});
assert.equal(ecritures(), avant);
assert.equal(seance.charge.evenement.siegesParDefaut, 8);
assert.deepEqual(capacites(seance.charge), [8, 8, 8]);
refuse(() => COMMANDES.get('changerDefaut')(charge, { sieges: '1', realigner: false }), 'MOINS_DE_DEUX', { remede: null });
refuse(() => COMMANDES.get('changerDefaut')(charge, { sieges: 'six', realigner: false }), 'NON_ENTIER');
});
test('une baisse sous un retenu occupé : accepté, SURNUMERAIRES, et ces deux personnes dans la réserve du tour 2', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
placer(
charge,
[
[suite(1, 6), suite(7, 12), suite(13, 18)],
[suite(1, 5), suite(6, 10), suite(11, 18)],
],
{ id: 1, retenu: true },
);
const { charge: apres, avertissements } = accepter('changerDefaut', charge, { sieges: 6, realigner: false });
assert.deepEqual(avertissements, [{ code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [17, 18] } }]);
assert.deepEqual(apres.retenu.placement[1].reserve, [17, 18]);
assert.deepEqual(apres.retenu.capacites, [6, 6, 6]);
assert.deepEqual(derive(apres.retenu, apres), []);
});
test('un défaut au-delà du seuil de lisibilité avertit une fois', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4 });
const { avertissements } = accepter('changerDefaut', charge, { sieges: 40, realigner: false });
assert.equal(avertissements.length, 1);
assert.equal(avertissements[0].code, 'AU_DELA_LISIBLE');
});
});
describe('mobilier : les tours (§ 2.1 étape 5, § 4.3)', () => {
test('R = 4 → 3 → 4 : les réservations reviennent telles quelles, et la portée « tous » ancre encore au tour 4', () => {
const debut = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4, tours: 4, reservations: [resa(1, 2, null)] });
const trois = accepter('changerTours', debut, { tours: 3 });
assert.equal(trois.libelle, 'Nombre de tours : 3');
const quatre = accepter('changerTours', trois.charge, { tours: '4' }).charge;
assert.deepEqual(quatre.reservations, debut.reservations);
const instance = normaliser(configurationDepuisCharge(quatre));
const p = instance.indexDe.get(1);
assert.equal(instance.statut[p], STATUT.ANCRE);
assert.equal(instance.fixe[p * instance.R + 3], instance.indexTableDe.get(2));
});
test("retirer le tour 4 détruit ses réservations « tour », garde les autres, et laisse propositions et retenu en dérive", () => {
const reservations = [resa(1, 1, null, 4), resa(2, 2, null, 4), resa(3, 1, null, 2), resa(4, 2, null)];
const charge = chargeDe({ tables: [{ id: 1, sieges: 4 }, { id: 2, sieges: 4 }], n: 8, tours: 4, reservations });
const listes = [[[1, 2, 3, 5], [4, 6, 7, 8]], [[1, 3, 5, 6], [2, 4, 7, 8]], [[1, 5, 6, 7], [2, 3, 4, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]]];
placer(charge, listes, { id: 1 });
placer(charge, listes, { id: 2, retenu: true });
const { charge: apres, libelle: texte } = accepter('changerTours', charge, { tours: 3 });
assert.equal(texte, 'Nombre de tours : 3 ; 2 réservations retirées');
assert.deepEqual(apres.reservations, [resa(3, 1, null, 2), resa(4, 2, null)]);
analyser(serialiser(apres, { revision: 2, produitVersion: 'essai' }));
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(apres.retenu, charge.retenu);
assert.deepEqual(derive(apres.retenu, apres), [{ code: 'TOURS_CHANGES', avant: 4, maintenant: 3 }]);
});
test('3 → 4 : trois réservations « tour » à une table rendent B partiellement fixé ; propositions et retenu gardés, TOURS_CHANGES', () => {
const b = 5;
const reservations = [1, 2, 3].map((tour) => resa(b, 1, null, tour));
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 8, tours: 3, reservations });
const listes = [[[1, 2, 3, 5], [4, 6, 7, 8]], [[4, 5, 6, 7], [1, 2, 3, 8]], [[1, 5, 7, 8], [2, 3, 4, 6]]];
placer(charge, listes, { id: 1, retenu: true });
const avant = normaliser(configurationDepuisCharge(charge));
assert.equal(avant.statut[avant.indexDe.get(b)], STATUT.ANCRE);
const { charge: apres } = accepter('changerTours', charge, { tours: 4 });
const instance = normaliser(configurationDepuisCharge(apres));
assert.equal(instance.statut[instance.indexDe.get(b)], STATUT.PARTIELLEMENT_FIXE);
assert.deepEqual(apres.propositions, charge.propositions);
assert.deepEqual(apres.retenu, charge.retenu);
for (const placement of [apres.propositions[0], apres.retenu]) {
assert.deepEqual(derive(placement, apres), [{ code: 'TOURS_CHANGES', avant: 3, maintenant: 4 }]);
}
});
test('« 0 » refuse MOINS_D_UN_TOUR {tours: 0}, « 2.5 » et « x » NON_ENTIER, sans écriture', async () => {
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
const avant = ecritures();
await refuseAsync(seance.executer('changerTours', { tours: '0' }), 'MOINS_D_UN_TOUR', { tours: 0, remede: null });
for (const tours of ['2.5', 'x']) {
await refuseAsync(seance.executer('changerTours', { tours }), 'NON_ENTIER', { saisie: tours, remede: null });
}
assert.equal(ecritures(), avant);
assert.equal(seance.charge.evenement.tours, 2);
});
});
describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {
test('« Séparer les appartenances : désactivé », une entrée', async () => {
const { seance, entrees } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2 }));
const avant = entrees();
const rendu = await seance.executer('modifierReglage', { cle: 'separerAppartenances', valeur: false });
assert.equal(rendu.libelle, 'Séparer les appartenances : désactivé');
assert.equal(entrees() - avant, 1);
assert.equal(seance.charge.reglages.separerAppartenances, false);
});
test('chaque réglage nommé se change ; une clé inconnue ou une valeur de forme fausse est une faute du code', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const attendus = [
['nouveauxVoisins', false, 'Nouveaux voisins à chaque tour : désactivé'],
['nouvelleTable', false, 'Nouvelle table à chaque tour : désactivé'],
['varierAppartenances', false, 'Varier les appartenances rencontrées : désactivé'],
['attribuerSieges', true, 'Attribuer les sièges : activé'],
['generation.nombre', 8, 'Propositions par génération : 8'],
['generation.arret', 300000, 'Mouvements par proposition : 300000'],
['generation.historique', 500, "Historique d'acceptation : 500"],
];
for (const [cle, valeur, texte] of attendus) {
const rendu = accepter('modifierReglage', charge, { cle, valeur });
assert.equal(rendu.libelle, texte);
const [tete, queue] = cle.split('.');
assert.equal(queue === undefined ? rendu.charge.reglages[tete] : rendu.charge.reglages[tete][queue], valeur);
}
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'couleur', valeur: true }), TypeError);
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'nouveauxVoisins', valeur: 'non' }), TypeError);
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'generation.nombre', valeur: 0 }), TypeError);
refuse(() => appliquerCommande('modifierReglage', charge, { cle: 'nouveauxVoisins', valeur: true }), 'SANS_EFFET');
});
});

View file

@ -399,6 +399,35 @@ const EXEMPLES = new Map([
['ENTREE_DEPLACER_DANS_RETENU', { personne: 'Iris Ombrelle', de: 4, vers: 7, siege: null, tour: 2 }],
['ENTREE_ECHANGER_DANS_RETENU', { a: 'Iris Ombrelle', deA: 4, b: 'Théo Pervenche', deB: null, tour: 2 }],
['ENTREE_RETIRER_DU_TOUR', { personne: 'Iris Ombrelle', numero: 4, tour: 2 }],
// Mobilier : saisies, capacité, sièges retirés, libellés figés
['NON_ENTIER', { saisie: '8abc', remede: null }],
['MOINS_DE_DEUX', { remede: null }],
['MOINS_D_UN_TOUR', { tours: 0, remede: null }],
['SOUS_RESERVATIONS', { table: 2, n: 7, tour: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } }],
['SOUS_OCCUPES', { table: 2, n: 7, tour: 3, remede: { geste: 'afficherTour', tour: 3, proposition: null } }],
[
'SIEGES_RETIRES',
{
table: 2,
sieges: [
{ siege: 8, cause: 'SIEGE_TITRE', libelle: 'animateur' },
{ siege: 7, cause: 'SIEGE_RESERVE', participant: 1, suspendue: true },
],
remede: { commande: 'retirerTitre', arguments: { table: 2, siege: 8 } },
},
],
['SIEGE_RESERVE', { siege: 7, participant: 1, suspendue: false }],
['SIEGE_TITRE', { siege: 8, libelle: 'animateur' }],
['SURNUMERAIRES', { table: 3, tour: 2, personnes: [16, 17] }],
['AU_DELA_LISIBLE', { seuil: 16 }],
['ENTREE_CREER_TABLE', { numero: 3 }],
['ENTREE_SUPPRIMER_TABLE', { numero: 2, reservations: 2, titres: 1 }],
['ENTREE_DEPLACER_TABLE', { numero: 3 }],
['ENTREE_CHANGER_PLACES_TABLE', { numero: 5, sieges: 10 }],
['ENTREE_REALIGNER_TABLE', { numero: 5, sieges: 8 }],
['ENTREE_CHANGER_DEFAUT', { sieges: 10, realignees: 4 }],
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
]);
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni

View file

@ -1,5 +1,114 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes du mobilier — tables, sièges, tours (§ 6, § 14.6).
export const TABLE = new Map();
// Les textes des commandes du mobilier — tables, sièges, tours, réglages
// (§ 4, § 6, § 7.1, § 8.2, § 14.6) : les refus d'une capacité et d'un nombre
// de tours, leurs avertissements, les causes d'un siège retiré et les
// libellés figés du journal. Les détails désignent tables et personnes par
// identifiant : le refus se pose sur la table qu'il concerne, et son texte ne
// les nomme pas. Un libellé figé reçoit le numéro de table que la commande
// lit dans la charge.
import { cite, compte, enumeration, nomDans } from './aides.js';
// Le nom de chaque réglage que modifierReglage change, tel que le journal le
// fige (§ 5.3, § 5.10).
const NOMS_DES_REGLAGES = new Map([
['separerAppartenances', 'Séparer les appartenances'],
['nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
['nouvelleTable', 'Nouvelle table à chaque tour'],
['varierAppartenances', 'Varier les appartenances rencontrées'],
['attribuerSieges', 'Attribuer les sièges'],
['generation.nombre', 'Propositions par génération'],
['generation.arret', 'Mouvements par proposition'],
['generation.historique', "Historique d'acceptation"],
]);
// La valeur d'un réglage dans un libellé : activé ou désactivé pour un
// interrupteur, le nombre sinon.
const valeurDeReglage = (valeur) => (typeof valeur === 'boolean' ? (valeur ? 'activé' : 'désactivé') : String(valeur));
// Ce qu'une saisie refusée est : vide, ou un texte qui n'est pas un entier.
const saisieCitee = (saisie) => (String(saisie).trim() === '' ? 'La saisie est vide' : `${cite(String(saisie))} n'est pas un nombre entier`);
export const TABLE = new Map([
// --- Les saisies (§ 6.2) ---------------------------------------------------
[
'NON_ENTIER',
({ saisie }) => `${saisieCitee(saisie)} : un nombre entier, sans signe ni décimale, est attendu.`,
],
[
'MOINS_DE_DEUX',
() => "Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.",
],
['MOINS_D_UN_TOUR', () => 'Une soirée compte au moins un tour.'],
// --- Les refus d'une capacité (§ 6.2, § 7.1) -------------------------------
[
'SOUS_RESERVATIONS',
({ n, tour }) =>
`La table porte ${compte(n, 'réservation', 'réservations')} au tour ${tour} : ` +
`elle ne descend pas sous ${compte(n, 'place', 'places')}.`,
],
[
'SOUS_OCCUPES',
({ n, tour }) =>
`Au tour ${tour}, la table occupe ${compte(n, 'place', 'places')} : la poignée ne descend pas en deçà.`,
],
[
'SIEGES_RETIRES',
// Sièges non attribués (titres présent), un titre se compte : son siège
// est une place d'affichage, que l'opérateur n'a pas choisie.
({ sieges, titres }, texte) => {
const listes = titres === undefined ? sieges : sieges.filter(({ cause }) => cause !== 'SIEGE_TITRE');
const termes = listes.map((siege) => texte(siege.cause, siege));
if (titres !== undefined && titres > 0) termes.push(compte(titres, 'siège porte un titre', 'sièges portent un titre'));
return `Réduire la table retire des sièges qui portent une saisie : ${enumeration(termes)}.`;
},
],
[
'SIEGE_RESERVE',
({ siege, suspendue }) => `le siège ${siege} est réservé${suspendue ? ', par une réservation suspendue' : ''}`,
],
['SIEGE_TITRE', ({ siege, libelle }) => `le siège ${siege} porte le titre ${cite(libelle)}`],
// --- Les avertissements d'une capacité (§ 6.2) -----------------------------
[
'SURNUMERAIRES',
({ tour, personnes }) =>
`Au tour ${tour}, ${compte(personnes.length, 'personne passe', 'personnes passent')} dans la réserve : ` +
`la table n'a plus de chaise pour ${personnes.length <= 1 ? 'elle' : 'elles'}.`,
],
[
'AU_DELA_LISIBLE',
({ seuil }) => `Au-delà de ${seuil} places, la liste des noms de la table cesse d'être lisible sur le plan.`,
],
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
['ENTREE_CREER_TABLE', ({ numero }) => `Ajouté la table ${numero}`],
[
'ENTREE_SUPPRIMER_TABLE',
({ numero, reservations, titres }) => {
const retires = [];
if (reservations > 0) retires.push(compte(reservations, 'réservation', 'réservations'));
if (titres > 0) retires.push(compte(titres, 'titre', 'titres'));
return `Supprimé la table ${numero}${retires.length > 0 ? ` ; retirés : ${enumeration(retires)}` : ''}`;
},
],
['ENTREE_DEPLACER_TABLE', ({ numero }) => `Déplacé la table ${numero}`],
['ENTREE_CHANGER_PLACES_TABLE', ({ numero, sieges }) => `Table ${numero} : ${compte(sieges, 'place', 'places')}`],
['ENTREE_REALIGNER_TABLE', ({ numero, sieges }) => `Table ${numero} : suit le défaut (${compte(sieges, 'place', 'places')})`],
[
'ENTREE_CHANGER_DEFAUT',
({ sieges, realignees }) =>
`Sièges par défaut : ${sieges}${realignees > 0 ? ` ; ${compte(realignees, 'table réalignée', 'tables réalignées')}` : ''}`,
],
[
'ENTREE_CHANGER_TOURS',
({ tours, retirees }) =>
`Nombre de tours : ${tours}${retirees > 0 ? ` ; ${compte(retirees, 'réservation retirée', 'réservations retirées')}` : ''}`,
],
[
'ENTREE_MODIFIER_REGLAGE',
({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`,
],
]);