diff --git a/src/application/bilans.js b/src/application/bilans.js new file mode 100644 index 0000000..361e6bc --- /dev/null +++ b/src/application/bilans.js @@ -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), + }; +} diff --git a/src/application/bilans.test.js b/src/application/bilans.test.js new file mode 100644 index 0000000..8c383b0 --- /dev/null +++ b/src/application/bilans.test.js @@ -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); + }); +}); diff --git a/src/application/capacite.js b/src/application/capacite.js new file mode 100644 index 0000000..980fec5 --- /dev/null +++ b/src/application/capacite.js @@ -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} changements identifiant de table → places + * @returns {null|Array<{table: number, rang: number, sieges: number, + * tours: Array<{tour: number, liste: Array, 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} 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} 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 })) }; +} diff --git a/src/application/capacite.test.js b/src/application/capacite.test.js new file mode 100644 index 0000000..2f95487 --- /dev/null +++ b/src/application/capacite.test.js @@ -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, []); + } + }); +}); diff --git a/src/application/commandes.test.js b/src/application/commandes.test.js index 9550910..7fc9ca5 100644 --- a/src/application/commandes.test.js +++ b/src/application/commandes.test.js @@ -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 }], ]); } diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js index bb6d9f3..e8648e4 100644 --- a/src/application/commandes_mobilier.js +++ b/src/application/commandes_mobilier.js @@ -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 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], +]); diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js new file mode 100644 index 0000000..7049fa5 --- /dev/null +++ b/src/application/commandes_mobilier.test.js @@ -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'); + }); +}); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index f88a9cd..c79cec0 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -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 diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js index f86c064..441284c 100644 --- a/src/application/libelles/mobilier.js +++ b/src/application/libelles/mobilier.js @@ -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)}`, + ], +]);