[ADD] application: observable session, events facade, places and seats

The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.

--- FR ---

[ADD] application : séance observable, façade des événements, places

Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 04:14:24 -04:00
parent f76c5dede5
commit abae4fa9b8
25 changed files with 7297 additions and 158 deletions

View file

@ -0,0 +1,157 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les avertissements qu'un geste fait naître (§ 5.9, § 9, § 14.12), calculés
// de la charge d'avant à celle d'après, et les violations du retenu que la
// séance montre tant que la charge les porte. Un avertissement n'empêche
// rien : il dit ce que le geste a changé, avec le nombre.
//
// Aucun calcul ne lève sur un placement. Les raisons de dérive se lisent sur
// examiner (S2), qui les rend pour tout placement cohérent, dérivé compris ;
// le retenu ne se juge contre l'instance par verifierInvariants que quand
// examiner ne lui trouve aucune faute, et que la configuration se normalise.
// Ni indexerPlan ni derive n'entrent ici : le premier lève sur un plan qui ne
// décrit plus la configuration, le second sur un placement fautif.
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js';
const avertissement = (code, details) => ({ code, details });
// L'instance de la charge, ou l'ErreurConfiguration qui la refuse :
// { instance } ou { refus }. Une autre erreur remonte.
function instanceDe(charge) {
try {
return { instance: normaliser(configurationDepuisCharge(charge)), refus: null };
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
return { instance: null, refus: erreur };
}
}
// Deux objets plats égaux champ pour champ : mêmes clés, mêmes valeurs.
function memesChamps(a, b) {
const cles = Object.keys(a);
return cles.length === Object.keys(b).length && cles.every((cle) => Object.hasOwn(b, cle) && Object.is(a[cle], b[cle]));
}
// Les raisons de dérive de chaque placement d'un examen : les propositions
// par identifiant croissant, puis le retenu sous la clé 'retenu'.
function raisonsParPlacement(examen) {
const raisons = new Map(examen.derives.map(({ id, raisons: liste }) => [id, liste]));
raisons.set('retenu', examen.retenu.raisons);
return raisons;
}
// Les violations du retenu cohérent d'une charge, contre son instance, la
// réserve admise : des surnuméraires versés en réserve ne violent rien.
// [] sans retenu, pour un retenu fautif, ou sans instance.
function violationsDuRetenu(charge, examen, instance) {
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true });
}
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
// La raison de dérive qui nomme déjà la violation, ou undefined : la
// personne exclue, supprimée ou non placée ; la table supprimée, ajoutée, ou
// dont la capacité a changé ; le nombre de tours changé.
function raisonQuiLaNomme(violation, raisons) {
const { code, participant, table, detail } = violation;
if (code === 'EXCLU_PLACE') return raisons.find(memeRaison('PARTICIPANT_EXCLU', 'participant', participant));
if (code === 'INCONNU') return raisons.find(memeRaison('PARTICIPANT_SUPPRIME', 'participant', participant));
if (code === 'NON_ASSIS') return raisons.find(memeRaison('PARTICIPANT_NON_PLACE', 'participant', participant));
if (code === 'CAPACITE') return raisons.find(memeRaison('CAPACITE_CHANGEE', 'table', table));
if (code === 'TOURS') return raisons.find((raison) => raison.code === 'TOURS_CHANGES');
if (code === 'TABLE_INCONNUE' && detail === 'inconnue') return raisons.find(memeRaison('TABLE_SUPPRIMEE', 'table', table));
if (code === 'TABLE_INCONNUE' && detail === 'absente') return raisons.find(memeRaison('TABLE_AJOUTEE', 'table', table));
return undefined;
}
/**
* Les violations du retenu de la charge que la dérive ne nomme pas déjà
* (§ 14.12, § 14.15) : INVARIANT_VIOLE {proposition, violations}, la
* proposition d'origine du retenu et les violations de verifierInvariants
* dans leur ordre, réserve admise ; null quand il n'en reste aucune, sans
* retenu, pour un retenu fautif, ou sur une configuration qui ne se
* normalise pas. Une réservation que le retenu ne tient pas en est le cas
* ordinaire : la dérive ne compare pas les réservations.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {import('../stockage/placements.js').Examen} [examen] celui de la
* charge, examiner(charge) quand il manque
* @returns {{code: 'INVARIANT_VIOLE', details: {proposition: number, violations: object[]}}|null}
*/
export function invariantViole(charge, examen = examiner(charge)) {
const { instance } = instanceDe(charge);
const raisons = examen.retenu.raisons;
const violations = violationsDuRetenu(charge, examen, instance).filter(
(violation) => raisonQuiLaNomme(violation, raisons) === undefined,
);
if (violations.length === 0) return null;
return avertissement('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
}
/**
* Avertissements d'un geste, de la charge d'avant à celle d'après (§ 5.9,
* § 9), dans cet ordre. Aucun ne lève sur un placement : les raisons se
* lisent sur examiner(avant) et examiner(apres) (S2), et planDepuisFichier
* ne reçoit le retenu que quand examiner(…).retenu.fautes est vide.
*
* CONFIGURATION_INCOHERENTE {code, details} normaliser(configurationDepuisCharge(apres))
* lève, et celle d'avant se normalisait ; PLACES_MANQUANTES et
* RESERVATION_NON_HONOREE, qui lisent l'instance d'après, ne se calculent
* alors pas ;
* PLACES_MANQUANTES {placesManquantes} nombrePlacesManquantes de l'instance
* d'après, non nul et plus grand que celui d'avant (0 quand avant ne se
* normalisait pas) ;
* DERIVE_NOUVELLE {touchees: [{id|'retenu', raisons}]} par placement, les
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
* ne portait pas, comparées champ pour champ ; les propositions par
* identifiant, puis le retenu, sous id 'retenu' ;
* RESERVATION_NON_HONOREE {participant, table, tour} chaque violation
* RESERVATION de verifierInvariants(instance d'après,
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa
* configuration ne se normalisait pas.
*
* @param {import('../stockage/types.js').Charge} avant
* @param {import('../stockage/types.js').Charge} apres
* @returns {Array<{code: string, details: Object}>}
*/
export function avertissementsApres(avant, apres) {
const rendus = [];
const examenAvant = examiner(avant);
const examenApres = examiner(apres);
const instanceAvant = instanceDe(avant).instance;
const { instance, refus } = instanceDe(apres);
if (refus !== null && instanceAvant !== null) {
rendus.push(avertissement('CONFIGURATION_INCOHERENTE', { code: refus.code, details: refus.details }));
}
if (instance !== null) {
const manque = nombrePlacesManquantes(instance);
const manqueAvant = instanceAvant === null ? 0 : nombrePlacesManquantes(instanceAvant);
if (manque > 0 && manque > manqueAvant) rendus.push(avertissement('PLACES_MANQUANTES', { placesManquantes: manque }));
}
const anciennes = raisonsParPlacement(examenAvant);
const touchees = [];
for (const [id, raisons] of raisonsParPlacement(examenApres)) {
const connues = anciennes.get(id) ?? [];
const nouvelles = raisons.filter((raison) => !connues.some((connue) => memesChamps(connue, raison)));
if (nouvelles.length > 0) touchees.push({ id, raisons: nouvelles });
}
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
const reservations = (charge, examen, inst) =>
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
const tenues = reservations(avant, examenAvant, instanceAvant);
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour);
if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour }));
}
return rendus;
}

View file

@ -0,0 +1,323 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des avertissements d'un geste (§ 5.9, § 9, § 14.12), sur des
// charges construites à la main : le manque de places qui croît, la
// configuration qui ne se normalise plus, la dérive qu'un geste ajoute, la
// réservation que le retenu n'honore plus, et les violations du retenu qui
// subsistent hors de la dérive. La valeur attendue du manque vient du moteur
// lui-même, sur la configuration rendue. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { VERSION } from '../version.genere.js';
import { avertissementsApres, invariantViole } from './avertissements.js';
const personne = (id, nom, exclu = false) => ({
id,
nom,
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu,
});
const table = (id, sieges) => ({ id, numero: id, sieges, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } });
// Une copie modifiée : celle reçue ne change pas.
function avec(charge, modifier) {
const copie = structuredClone(charge);
modifier(copie);
return copie;
}
// Ajoute à la charge les convives de rang premier à dernier, inclus.
function ajouterConvives(charge, premier, dernier) {
for (let rang = premier; rang <= dernier; rang += 1) charge.participants.push(personne(rang, `Convive ${rang}`));
charge.prochainsIds.participant = dernier + 1;
}
// Deux tables de huit et seize convives : la salle est pleine.
function sallePleine() {
const charge = creerCharge({ id: 'evt-merles', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 2 });
charge.tables = [table(1, 8), table(2, 8)];
charge.prochainsIds = { participant: 1, table: 3, proposition: 1 };
ajouterConvives(charge, 1, 16);
return charge;
}
const manqueDu = (charge) => nombrePlacesManquantes(normaliser(configurationDepuisCharge(charge)));
// Quatre personnes, deux tables de deux, deux tours ; Iris Ombrelle réservée
// à la table 1 pour tous les tours. Le placement de la proposition 1 : Iris
// et Grisaille à la table 1 au premier tour, Iris et Pervenche au second.
const PLACEMENT = [
{ sieges: [[1, 2], [3, 4]], reserve: [] },
{ sieges: [[1, 3], [2, 4]], reserve: [] },
];
const plan = () => ({ tables: [1, 2], capacites: [2, 2], tours: 2, participants: [1, 2, 3, 4], placement: structuredClone(PLACEMENT) });
const proposition = (id) => ({
id,
graine: id,
arret: 40,
historique: 1,
produitVersion: VERSION.affichee,
siegesAttribues: false,
...plan(),
});
function petiteSoiree({ retenu = true } = {}) {
const charge = creerCharge({ id: 'evt-lucioles', nom: 'Soirée des Lucioles', siegesParDefaut: 2, tours: 2 });
charge.participants = [
personne(1, 'Ombrelle'),
personne(2, 'Grisaille'),
personne(3, 'Pervenche'),
personne(4, 'Lacasse'),
];
charge.tables = [table(1, null), table(2, null)];
charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
charge.propositions = [proposition(1), proposition(2)];
charge.retenu = retenu ? { proposition: 1, siegesAttribues: false, ...plan() } : null;
charge.prochainsIds = { participant: 5, table: 3, proposition: 3 };
charge.evenement.etat = retenu ? 'retenu' : 'propose';
return charge;
}
// Grisaille réservée à la table 2 au premier tour : le placement l'assoit à
// la table 1 à ce tour.
const reserverGrisaille = (charge) =>
charge.reservations.push({ participant: 2, table: 2, siege: null, portee: 'tour', tour: 1 });
// Le retenu se contredit : la personne 2 en réserve au premier tour, alors
// qu'elle y est déjà assise (DOUBLON).
const rendreFautif = (charge) => {
charge.retenu.placement[0].reserve = [2];
};
const codes = (avertissements) => avertissements.map(({ code }) => code);
describe('avertissements : le manque de places (§ 5.9)', () => {
test('la dix-septième personne : PLACES_MANQUANTES {placesManquantes: 1}, le nombre du moteur', () => {
const avant = sallePleine();
const apres = avec(avant, (charge) => ajouterConvives(charge, 17, 17));
assert.equal(manqueDu(avant), 0);
const rendu = avertissementsApres(avant, apres);
assert.deepEqual(rendu, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]);
assert.equal(rendu[0].details.placesManquantes, manqueDu(apres));
});
test('trois de plus : {placesManquantes: 4}, le compte de la configuration rendue', () => {
const avant = avec(sallePleine(), (charge) => ajouterConvives(charge, 17, 17));
const apres = avec(avant, (charge) => ajouterConvives(charge, 18, 20));
const rendu = avertissementsApres(avant, apres);
assert.deepEqual(rendu, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 4 } }]);
assert.equal(manqueDu(apres), 4);
});
test("un geste qui ne fait pas croître le manque n'en dit rien", () => {
const pleine = sallePleine();
const manque = avec(pleine, (charge) => ajouterConvives(charge, 17, 18));
const cas = [
[pleine, avec(pleine, (charge) => (charge.participants[0].nom = 'Ancolie'))],
[manque, avec(manque, (charge) => (charge.participants[0].nom = 'Ancolie'))],
[manque, avec(manque, (charge) => charge.participants.pop())],
[manque, avec(manque, (charge) => (charge.tables[0].sieges = 10))],
[manque, avec(manque, (charge) => (charge.participants[16].exclu = true))],
];
assert.equal(cas.length, 5);
for (const [avant, apres] of cas) assert.deepEqual(avertissementsApres(avant, apres), []);
});
test('ôter une table, ou baisser une capacité, fait croître le manque et le dit', () => {
const pleine = sallePleine();
assert.deepEqual(avertissementsApres(pleine, avec(pleine, (charge) => charge.tables.pop())), [
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 8 } },
]);
assert.deepEqual(avertissementsApres(pleine, avec(pleine, (charge) => (charge.tables[1].sieges = 6))), [
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } },
]);
});
});
describe('avertissements : une configuration qui ne se normalise plus (§ 5.9)', () => {
// Iris réservée aussi à la table 2 au premier tour : deux tables au même
// tour, RESERVATION_CONFLIT.
const enConflit = (charge) =>
charge.reservations.push({ participant: 1, table: 2, siege: null, portee: 'tour', tour: 1 });
test("la configuration rendue lève : CONFIGURATION_INCOHERENTE porte le code et les détails du moteur, et le manque ne se calcule pas", () => {
const avant = sallePleine();
avant.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
const apres = avec(avant, (charge) => {
enConflit(charge);
ajouterConvives(charge, 17, 19);
});
assert.deepEqual(avertissementsApres(avant, apres), [
{
code: 'CONFIGURATION_INCOHERENTE',
details: { code: 'RESERVATION_CONFLIT', details: { participant: 1, tour: 1, tables: [1, 2] } },
},
]);
});
test("déjà incohérente avant le geste : rien ne se répète ; redevenue cohérente, le manque se compte depuis zéro", () => {
const avant = sallePleine();
avant.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
enConflit(avant);
const encore = avec(avant, (charge) => ajouterConvives(charge, 17, 17));
assert.deepEqual(avertissementsApres(avant, encore), []);
const reparee = avec(encore, (charge) => charge.reservations.pop());
assert.deepEqual(avertissementsApres(encore, reparee), [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]);
});
test("une configuration hors de sa forme n'est pas une incohérence : la TypeError du moteur remonte", () => {
const avant = sallePleine();
const apres = avec(avant, (charge) => (charge.participants[0].id = 'un'));
assert.throws(() => avertissementsApres(avant, apres), TypeError);
});
});
describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () => {
test('exclure une personne : DERIVE_NOUVELLE nomme chaque proposition par identifiant, puis le retenu, et la raison', () => {
const avant = petiteSoiree();
const apres = avec(avant, (charge) => (charge.participants[2].exclu = true));
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
assert.deepEqual(avertissementsApres(avant, apres), [
{
code: 'DERIVE_NOUVELLE',
details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] },
},
]);
});
test("une raison que le placement portait déjà ne se redit pas ; une raison au champ changé est nouvelle", () => {
const une = avec(petiteSoiree(), (charge) => (charge.participants[2].exclu = true));
const deux = avec(une, (charge) => (charge.participants[3].exclu = true));
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4 }];
assert.deepEqual(avertissementsApres(une, deux), [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
]);
assert.deepEqual(avertissementsApres(deux, avec(deux, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
const trois = avec(petiteSoiree(), (charge) => (charge.tables[0].sieges = 3));
const quatre = avec(trois, (charge) => (charge.tables[0].sieges = 4));
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4 }];
assert.deepEqual(avertissementsApres(trois, quatre), [
{
code: 'DERIVE_NOUVELLE',
details: { touchees: [{ id: 1, raisons: changee }, { id: 2, raisons: changee }, { id: 'retenu', raisons: changee }] },
},
]);
});
test("un retenu fautif ne dit rien de lui : ni dérive, ni réservation non honorée, et rien ne lève", () => {
const avant = avec(petiteSoiree(), rendreFautif);
const apres = avec(avant, (charge) => {
charge.participants[2].exclu = true;
reserverGrisaille(charge);
});
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }] } },
]);
});
});
describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, § 14.12)", () => {
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => {
const avant = petiteSoiree();
const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
]);
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
});
test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => {
const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
const apres = avec(avant, (charge) => {
charge.retenu = { proposition: 1, siegesAttribues: false, ...plan() };
charge.evenement.etat = 'retenu';
});
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
]);
});
test("le retenu redevenu cohérent compte ses violations contre un avant fautif, qui n'en comptait aucune", () => {
const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
]);
});
test("l'ordre du contrat : le manque, puis la dérive, puis la réservation non honorée", () => {
const avant = petiteSoiree();
const apres = avec(avant, (charge) => {
charge.participants.push(personne(5, 'Ancolie'));
charge.prochainsIds.participant = 6;
reserverGrisaille(charge);
});
assert.deepEqual(codes(avertissementsApres(avant, apres)), ['PLACES_MANQUANTES', 'DERIVE_NOUVELLE', 'RESERVATION_NON_HONOREE']);
});
});
describe('avertissements : les violations du retenu hors de la dérive (§ 14.12, § 14.15)', () => {
test('une réservation non honorée : INVARIANT_VIOLE {proposition, violations}, sans les violations que la dérive nomme', () => {
const charge = avec(petiteSoiree(), (copie) => {
reserverGrisaille(copie);
copie.participants[3].exclu = true;
});
assert.deepEqual(invariantViole(charge), {
code: 'INVARIANT_VIOLE',
details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }] },
});
});
test("chaque violation que la dérive nomme se tait : exclus, supprimés, non placés, tables, capacités, tours", () => {
const cas = [
(charge) => (charge.participants[3].exclu = true),
(charge) => charge.participants.pop(),
(charge) => {
charge.participants.push(personne(5, 'Ancolie'));
charge.prochainsIds.participant = 6;
},
(charge) => {
charge.tables.push(table(3, null));
charge.prochainsIds.table = 4;
},
(charge) => {
charge.tables[1].sieges = 2;
charge.retenu.capacites = [2, 3];
for (const tour of charge.retenu.placement) tour.sieges = [[1], [2, 3, 4]];
},
(charge) => {
charge.tables[1].id = 3;
charge.prochainsIds.table = 4;
},
(charge) => (charge.evenement.tours = 3),
];
assert.equal(cas.length, 7);
for (const modifier of cas) assert.equal(invariantViole(avec(petiteSoiree(), modifier)), null, String(modifier));
});
test('un retenu aux surnuméraires versés en réserve est sans violation ; sans retenu, ou fautif, ou sur une configuration incohérente, rien', () => {
const reserve = avec(petiteSoiree(), (charge) => {
charge.participants.push(personne(5, 'Ancolie'));
charge.prochainsIds.participant = 6;
charge.retenu.participants = [1, 2, 3, 4, 5];
for (const tour of charge.retenu.placement) tour.reserve = [5];
});
assert.equal(invariantViole(reserve), null);
assert.equal(invariantViole(petiteSoiree({ retenu: false })), null);
assert.equal(invariantViole(avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif)), null);
const incoherente = avec(petiteSoiree(), (charge) => {
reserverGrisaille(charge);
charge.reservations.push({ participant: 2, table: 1, siege: null, portee: 'tour', tour: 1 });
});
assert.equal(invariantViole(incoherente), null);
});
});

View file

@ -35,6 +35,8 @@ import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { nomAffiche } from './personnes.js';
import { couvre, repartirSieges, reservationsActives } from './places.js';
import { controlerReintegration, leverRefus } from './pose.js';
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
// dans personnes.js : les importateurs de ce module les y trouvent encore.
@ -211,11 +213,18 @@ function exclureParticipant(charge, { id }) {
};
}
/** reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres reviennent, RESERVATIONS_RETABLIES. */
/**
* reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres
* reviennent, RESERVATIONS_RETABLIES. Ses réservations suspendues se
* contrôlent comme une pose (controlerReintegration, § 5.9) : le premier
* refus se lève, SURRESERVATION ou PLACE_RESERVEE, les suivants dans
* details.autres.
*/
function reintegrerParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
leverRefus(controlerReintegration(charge, id));
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
const bilan = bilanExclusion(charge, suivante, participant);
return {
@ -330,13 +339,45 @@ function debloquer(charge) {
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_DEBLOQUER', { etat }), avertissements: [] };
}
// Le plan du moteur rangé par siège quand les sièges sont attribués (§ 4,
// § 5.3, § 8.9) : chaque liste de table de chaque tour par repartirSieges —
// la personne que sa réservation active, à un siège, couvrant ce tour à
// cette table, y prend ce rang —, les null de fin retirés. Le moteur ignore
// les sièges : sans ce rangement, la liste garde l'ordre du moteur,
// croissant par identifiant. Les places sont celles de la configuration sur
// laquelle le moteur a calculé, qui n'assied jamais plus : une table réduite
// ou supprimée depuis ne change pas le rangement, et la proposition
// s'enregistre avec sa dérive.
function rangerParSiege(charge, plan, configuration) {
const places = new Map(configuration.tables.map(({ id, capacite: sieges }) => [id, sieges]));
const assises = reservationsActives(charge).filter(({ siege }) => siege !== null);
return {
...plan,
tours: plan.tours.map((listes, rang) =>
listes.map((liste, i) => {
const idTable = plan.tables[i];
const siegeDe = new Map();
for (const reservation of assises) {
if (reservation.table === idTable && couvre(reservation, rang + 1) && !siegeDe.has(reservation.participant)) {
siegeDe.set(reservation.participant, reservation.siege);
}
}
const rangee = repartirSieges(liste, siegeDe, places.get(idTable) ?? 0);
while (rangee.length > 0 && rangee.at(-1) === null) rangee.pop();
return rangee;
}),
),
};
}
/**
* enregistrerGeneration {propositions, configuration, produitVersion} (§ 5.7,
* § 8.9) : les propositions du moteur, mises en forme de fichier
* (versFichier) sous les identifiants qui suivent prochainsIds.proposition —
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
* dernière. Un brouillon passe à proposé.
* dernière. Sièges attribués, chaque liste se range d'abord par siège
* (rangerParSiege). Un brouillon passe à proposé.
*/
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
exigerModifiable(charge);
@ -350,7 +391,10 @@ function enregistrerGeneration(charge, { propositions, configuration, produitVer
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
let fichiers;
try {
fichiers = propositions.map((proposition) => versFichier(proposition, configuration, options));
fichiers = propositions.map((proposition) => {
const rangee = options.attribuerSieges ? { ...proposition, plan: rangerParSiege(charge, proposition.plan, configuration) } : proposition;
return versFichier(rangee, configuration, options);
});
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
throw refus('COMPTEUR_SATURE', erreur.details);

View file

@ -96,20 +96,32 @@ const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
describe('commandes : le registre (§ 8.2)', () => {
test('le registre porte les onze commandes du plan, chacune une fonction', () => {
assert.deepEqual([...COMMANDES.keys()].sort(), [
test("le registre porte les onze commandes d'A1, les dix des places et celles du mobilier, chacune une fonction", () => {
const attendues = [
'ajouterParticipant',
'changerEtat',
'convertirEnTous',
'debloquer',
'deplacerDansRetenu',
'deplacerReservation',
'designer',
'echangerDansRetenu',
'effacerPropositions',
'enregistrerGeneration',
'exclureParticipant',
'importerParticipants',
'modifierParticipant',
'poserTitre',
'reintegrerParticipant',
'reserver',
'retenirProposition',
'retirerDuTour',
'retirerReservation',
'retirerTitre',
'supprimerParticipant',
]);
];
assert.equal(attendues.length, 21);
assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort());
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
});
@ -565,9 +577,41 @@ describe("commandes : la charge d'un événement neuf (§ 8.1)", () => {
});
describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
// Arguments d'exemple de chaque commande sur la charge riche ; debloquer
// reçoit la même charge, bloquée.
// Au tour 1 du retenu de la charge riche : le voisin d'Iris à sa table, et
// une personne de l'autre table, avec cette table. Lus dans le retenu, qui
// vient du moteur.
function auTour1({ retenu }) {
const { sieges } = retenu.placement[0];
const rang = sieges.findIndex((liste) => liste.includes(1));
assert.equal(sieges.length, 2);
return {
voisin: sieges[rang].find((id) => id !== null && id !== 1),
autre: sieges[1 - rang].find((id) => id !== null),
tableAutre: retenu.tables[1 - rang],
};
}
// La charge que reçoit chaque commande : la charge riche, sauf debloquer,
// qui la reçoit bloquée ; convertirEnTous, qui y trouve Théo réservé à la
// table 2 à chaque tour, en portée « tour » ; deplacerDansRetenu, qui y
// trouve l'autre personne du tour 1 retirée dans la réserve.
function recues(charge) {
const { autre } = auTour1(charge);
const reservee = structuredClone(charge);
reservee.reservations.push(
{ participant: 3, table: 2, siege: null, portee: 'tour', tour: 1 },
{ participant: 3, table: 2, siege: null, portee: 'tour', tour: 2 },
);
return new Map([
['debloquer', { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } }],
['convertirEnTous', reservee],
['deplacerDansRetenu', executer('retirerDuTour', charge, { tour: 1, participant: autre }).charge],
]);
}
// Arguments d'exemple de chaque commande sur la charge qu'elle reçoit.
function exemples(charge) {
const { voisin, autre, tableAutre } = auTour1(charge);
return new Map([
['ajouterParticipant', { champs: { nom: 'Bruyère' } }],
['modifierParticipant', { id: 4, champs: { courriel: 'o@exemple.test' } }],
@ -580,15 +624,26 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['enregistrerGeneration', generer(charge, 1, 5)],
['retenirProposition', { id: 2 }],
['effacerPropositions', {}],
['reserver', { participant: 4, table: 2, siege: null, portee: 'tour', tour: 1 }],
['retirerReservation', { participant: 1, table: 1, portee: 'tous', tour: null }],
['deplacerReservation', { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: null } }],
['poserTitre', { table: 2, siege: 1, libelle: 'accueil' }],
['retirerTitre', { table: 1, siege: 1 }],
['designer', { participant: 3, table: 2, siege: 2, libelle: 'accueil' }],
['convertirEnTous', { participant: 3, table: 2 }],
['deplacerDansRetenu', { tour: 1, participant: autre, table: tableAutre, siege: null }],
['echangerDansRetenu', { tour: 1, a: voisin, b: autre }],
['retirerDuTour', { tour: 1, participant: autre }],
]);
}
test("aucune commande ne modifie la charge qu'elle reçoit", () => {
const charge = chargeRiche();
const args = exemples(charge);
const particulieres = recues(charge);
assert.deepEqual([...args.keys()].sort(), [...COMMANDES.keys()].sort());
for (const [nom, commande] of COMMANDES) {
const recue = nom === 'debloquer' ? { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } } : charge;
const recue = particulieres.get(nom) ?? charge;
const copie = structuredClone(recue);
const rendu = commande(figer(recue), args.get(nom));
assert.deepEqual(recue, copie, nom);

View file

@ -4,6 +4,714 @@
// Le registre des commandes des places — réservations, titres, pose (§ 5.8,
// § 5.9, § 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.
//
// 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 ; un argument de forme fausse, une table inconnue, un retenu absent
// lèvent TypeError, que l'interface n'offre jamais. Un plan bloqué refuse
// tout (PLAN_BLOQUE). Les poses se contrôlent par controlerPose (pose.js),
// qui rend tous les refus ; la commande lève le premier et porte les autres
// dans details.autres.
//
// Avant la retenue, un geste de personne pose ou déplace une réservation ;
// après, il modifie le retenu, au tour visé seulement, jamais la proposition
// d'où il vient (§ 5.8). Sièges attribués, le siège est le rang dans la
// liste, une chaise vide s'y écrit null et une liste ne finit jamais par
// null : un départ libère sa chaise, et tout autre siège reste le même
// (§ 8.9). Sans attribution, la liste reste triée par identifiant, et chaque
// geste verse d'abord en réserve les présents que le plan ne montre pas
// assis aux tables qu'il touche (reduireAuxAssises) : la liste vaut ce que
// l'affichage assied. Chaque geste lit les places comme le plan les montre
// (placeDe). Une retouche qui ajoute une violation de verifierInvariants au
// retenu reçu est refusée (INVARIANT_VIOLE) ; une violation déjà présente,
// celle d'une dérive (§ 9), ne bloque rien.
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
import { verifierInvariants } from '../moteur/verification.js';
import { capacite } from '../stockage/document.js';
import { fautes, planDepuisFichier } from '../stockage/placements.js';
import { refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { conversionsPossibles } from './listes_travail.js';
import { nomAffiche } from './personnes.js';
import { etatPlaces, instanceDe, presents } from './places.js';
import { controlerPose, leverRefus } from './pose.js';
const croissant = (a, b) => a - b;
// --- Les contrôles communs ---------------------------------------------------
// 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' });
}
// Le participant d'identifiant id, ou PARTICIPANT_INCONNU.
function personneDe(charge, id) {
if (!Number.isSafeInteger(id)) throw new TypeError(`participant : entier attendu, reçu ${JSON.stringify(id)}`);
const personne = charge.participants.find((candidat) => candidat.id === id);
if (personne === undefined) throw refus('PARTICIPANT_INCONNU', { id });
return personne;
}
// 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;
}
// Un siège donné : null, ou un entier ≥ 1.
function exigerSiege(siege) {
if (siege !== null && !(Number.isSafeInteger(siege) && siege >= 1)) {
throw new TypeError(`siege : null ou entier ≥ 1 attendu, reçu ${JSON.stringify(siege)}`);
}
}
// Une portée et son tour : « tous » sans tour, « tour » de 1 à R.
function exigerPortee(charge, portee, tour) {
if (portee === 'tous' && tour === null) return;
if (portee === 'tour' && Number.isSafeInteger(tour) && tour >= 1 && tour <= charge.evenement.tours) return;
throw new TypeError(`portée ${JSON.stringify(portee)}, tour ${JSON.stringify(tour)} : « tous » sans tour ou « tour » de 1 à R attendus`);
}
// Le libellé d'un titre saisi : en NFC, ses blancs de bord retirés ;
// LIBELLE_VIDE quand il ne reste rien.
function libelleSaisi(valeur) {
if (typeof valeur !== 'string') throw new TypeError(`libelle : chaîne attendue, reçu ${JSON.stringify(valeur)}`);
const texte = valeur.normalize('NFC').trim();
if (texte === '') throw refus('LIBELLE_VIDE');
return texte;
}
// Vrai quand la réservation r est celle que désigne d pour ce participant :
// sa table, sa portée, son tour.
const designee = (r, participant, d) =>
r.participant === participant && r.table === d.table && r.portee === d.portee && r.tour === (d.portee === 'tous' ? null : d.tour);
// Le premier titre de la place dans l'ordre canonique, ou undefined.
const titreDe = (charge, idTable, siege) =>
charge.titres
.filter((titre) => titre.table === idTable && titre.siege === siege)
.sort((a, b) => (a.libelle < b.libelle ? -1 : a.libelle > b.libelle ? 1 : 0))[0];
// Le remède d'une table qui manque de places : une de plus que places, par
// la saisie de son champ.
const changerPlaces = (idTable, places) => ({
commande: 'changerPlacesTable',
arguments: { id: idTable, sieges: places + 1, origine: 'champ' },
});
// Le plus petit siège de la table, de 1 à places, qu'aucun titre ne porte ;
// AUCUN_SIEGE_SANS_TITRE quand tous en portent un.
function siegeSansTitre(charge, idTable, places) {
const titres = new Set(charge.titres.filter(({ table }) => table === idTable).map(({ siege }) => siege));
for (let siege = 1; siege <= places; siege += 1) if (!titres.has(siege)) return siege;
throw refus('AUCUN_SIEGE_SANS_TITRE', { table: idTable, capacite: places }, changerPlaces(idTable, places));
}
// SIEGE_INEXISTANT pour un titre au-delà de la capacité : un titre garde
// toujours un siège de sa table.
function exigerSiegeDeTitre(idTable, siege, places) {
if (siege > places) {
throw refus('SIEGE_INEXISTANT', { table: idTable, siege, capacite: places }, {
commande: 'changerPlacesTable',
arguments: { id: idTable, sieges: siege, origine: 'champ' },
});
}
}
// Les avertissements des signaux d'une pose acceptée.
const avertissementsDe = (signaux) => signaux.map(({ code, table }) => ({ code, details: { table } }));
// --- Les réservations (§ 4, § 5.9) -------------------------------------------
/**
* reserver {participant, table, siege|null, portee: 'tous'|'tour', tour|null}
* (§ 4, § 5.9) : une réservation de plus, contrôlée comme une pose. Sans
* attribution des sièges, un siège au-delà de la capacité s'enregistre null :
* la place est la table. Une réservation déjà là : SANS_EFFET.
*/
function reserver(charge, { participant, table: idTable, siege = null, portee, tour = null } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
exigerSiege(siege);
exigerPortee(charge, portee, tour);
const places = capacite(charge, table);
const siegeEcrit = !charge.reglages.attribuerSieges && siege !== null && siege > places ? null : siege;
const nouvelle = { participant, table: idTable, siege: siegeEcrit, portee, tour };
if (charge.reservations.some((r) => ordreDesReservations(r, nouvelle) === 0)) {
throw refus('SANS_EFFET', { commande: 'reserver' });
}
const { refus: refusPose, signaux } = controlerPose(charge, nouvelle);
leverRefus(refusPose);
const suivante = structuredClone(charge);
suivante.reservations.push(nouvelle);
return {
charge: suivante,
libelle: libelle('ENTREE_RESERVER', { personne: nomAffiche(personne), numero: table.numero, siege: siegeEcrit, portee, tour }),
avertissements: avertissementsDe(signaux),
};
}
// Les réservations du participant que désigne { table, portee, tour }, ou
// RESERVATION_ABSENTE.
function reservationsDesignees(charge, participant, designation) {
exigerPortee(charge, designation.portee, designation.tour ?? null);
const trouvees = charge.reservations.filter((r) => designee(r, participant, designation));
if (trouvees.length === 0) {
const { table, portee, tour = null } = designation;
throw refus('RESERVATION_ABSENTE', { participant, table, portee, tour });
}
return trouvees;
}
/** retirerReservation {participant, table, portee, tour|null} : la réservation ainsi désignée part. */
function retirerReservation(charge, { participant, table: idTable, portee, tour = null } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
const designation = { table: idTable, portee, tour };
reservationsDesignees(charge, participant, designation);
const suivante = structuredClone(charge);
suivante.reservations = suivante.reservations.filter((r) => !designee(r, participant, designation));
return {
charge: suivante,
libelle: libelle('ENTREE_RETIRER_RESERVATION', { personne: nomAffiche(personne), numero: table.numero, portee, tour }),
avertissements: [],
};
}
/**
* deplacerReservation {participant, de: {table, portee, tour|null}, vers:
* {table, siege|null}} (§ 5.9) : la réservation désignée change de place et
* garde sa portée ; la pose se contrôle sans elle. Elle garde son rang dans
* la liste des réservations.
*/
function deplacerReservation(charge, { participant, de, vers } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const depart = tableDe(charge, de.table);
const arrivee = tableDe(charge, vers.table);
const siege = vers.siege ?? null;
exigerSiege(siege);
const [premiere] = reservationsDesignees(charge, participant, de).sort(ordreDesReservations);
const places = capacite(charge, arrivee);
const siegeEcrit = !charge.reglages.attribuerSieges && siege !== null && siege > places ? null : siege;
const deplacee = { participant, table: vers.table, siege: siegeEcrit, portee: premiere.portee, tour: premiere.tour };
const { refus: refusPose, signaux } = controlerPose(charge, { ...deplacee, depuis: de });
leverRefus(refusPose);
const suivante = structuredClone(charge);
let remplacee = false;
suivante.reservations = suivante.reservations.flatMap((r) => {
if (!designee(r, participant, de)) return [r];
if (remplacee) return [];
remplacee = true;
return [deplacee];
});
return {
charge: suivante,
libelle: libelle('ENTREE_DEPLACER_RESERVATION', {
personne: nomAffiche(personne),
de: depart.numero,
vers: arrivee.numero,
siege: siegeEcrit,
portee: premiere.portee,
tour: premiere.tour,
}),
avertissements: avertissementsDe(signaux),
};
}
/**
* convertirEnTous {participant, table} (§ 4.2) : les R réservations « tour »
* de la personne à cette table deviennent une seule « tous les tours », au
* siège qu'elles portaient toutes, sans siège sinon. CONVERSION_IMPOSSIBLE
* hors de conversionsPossibles.
*/
function convertirEnTous(charge, { participant, table: idTable } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
if (!conversionsPossibles(charge).some((c) => c.participant === participant && c.table === idTable)) {
throw refus('CONVERSION_IMPOSSIBLE', { participant, table: idTable });
}
const parTour = (r) => r.participant === participant && r.table === idTable && r.portee === 'tour';
const sieges = new Set(charge.reservations.filter(parTour).map(({ siege }) => siege));
const siege = sieges.size === 1 ? [...sieges][0] : null;
const suivante = structuredClone(charge);
suivante.reservations = suivante.reservations.filter((r) => !parTour(r));
suivante.reservations.push({ participant, table: idTable, siege, portee: 'tous', tour: null });
return {
charge: suivante,
libelle: libelle('ENTREE_CONVERTIR_EN_TOUS', { personne: nomAffiche(personne), numero: table.numero }),
avertissements: [],
};
}
// --- Les titres et la désignation (§ 4, § 4.1, § 2.1 étape 6) ---------------
/**
* poserTitre {table, siege|null, libelle} (§ 4.1) : un titre à une place.
* Sans siège, le plus petit qui n'en porte pas. Une place titrée refuse
* PLACE_TITREE, remède le retrait de son titre.
*/
function poserTitre(charge, { table: idTable, siege = null, libelle: saisi } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, idTable);
exigerSiege(siege);
const texte = libelleSaisi(saisi);
const places = capacite(charge, table);
const place = siege ?? siegeSansTitre(charge, idTable, places);
exigerSiegeDeTitre(idTable, place, places);
const titre = titreDe(charge, idTable, place);
if (titre !== undefined) {
throw refus('PLACE_TITREE', { table: idTable, siege: place, libelle: titre.libelle }, {
commande: 'retirerTitre',
arguments: { table: idTable, siege: place },
});
}
const suivante = structuredClone(charge);
suivante.titres.push({ table: idTable, siege: place, libelle: texte });
return {
charge: suivante,
libelle: libelle('ENTREE_POSER_TITRE', { libelle: texte, numero: table.numero, siege: place }),
avertissements: [],
};
}
/** retirerTitre {table, siege} : le premier titre de la place part ; TITRE_ABSENT sans lui. */
function retirerTitre(charge, { table: idTable, siege } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, idTable);
const titre = titreDe(charge, idTable, siege);
if (titre === undefined) throw refus('TITRE_ABSENT', { table: idTable, siege });
const suivante = structuredClone(charge);
const rang = charge.titres.indexOf(titre);
suivante.titres.splice(rang, 1);
return {
charge: suivante,
libelle: libelle('ENTREE_RETIRER_TITRE', { libelle: titre.libelle, numero: table.numero, siege }),
avertissements: [],
};
}
/**
* designer {participant, table, siege|null, libelle} (§ 2.1 étape 6, § 5.8) :
* une réservation « tous les tours » et un titre au même siège, en une
* entrée. Sans siège, la place du premier titre de la table que rien ne
* pourvoit pour la soirée, sinon le plus petit siège sans titre. Un titre
* déjà posé à cette place est pourvu, et aucun second ne s'ajoute.
*/
function designer(charge, { participant, table: idTable, siege = null, libelle: saisi } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
exigerSiege(siege);
const texte = libelleSaisi(saisi);
const places = capacite(charge, table);
let place = siege;
if (place === null) {
const pourvoyeurs = pourvoyeursDesTitres(charge);
const libres = charge.titres
.map((titre, rang) => ({ titre, rang }))
.filter(({ titre, rang }) => titre.table === idTable && pourvoyeurs[rang].soiree === null)
.sort((a, b) => a.titre.siege - b.titre.siege);
place = libres.length > 0 ? libres[0].titre.siege : siegeSansTitre(charge, idTable, places);
}
exigerSiegeDeTitre(idTable, place, places);
const reservation = { participant, table: idTable, siege: place, portee: 'tous', tour: null };
const titre = titreDe(charge, idTable, place);
const dejaReservee = charge.reservations.some((r) => ordreDesReservations(r, reservation) === 0);
if (dejaReservee && titre !== undefined) throw refus('SANS_EFFET', { commande: 'designer' });
const { refus: refusPose, signaux } = controlerPose(charge, reservation);
leverRefus(refusPose);
const suivante = structuredClone(charge);
if (!dejaReservee) suivante.reservations.push(reservation);
if (titre === undefined) suivante.titres.push({ table: idTable, siege: place, libelle: texte });
return {
charge: suivante,
libelle: libelle('ENTREE_DESIGNER', {
personne: nomAffiche(personne),
numero: table.numero,
siege: place,
libelle: titre?.libelle ?? texte,
}),
avertissements: avertissementsDe(signaux),
};
}
// --- Le retenu (§ 7.1, § 8.9, § 9) -------------------------------------------
// Le retenu de la charge à ce tour, lisible : sans retenu, ou à un tour qu'il
// ne porte pas, TypeError — la cible d'etatPlaces ne l'offre jamais ; fautif,
// RETENU_FAUTIF, remède la version précédente du fichier (§ 8.8).
function exigerRetenu(charge, tour) {
const { retenu } = charge;
if (retenu === null) throw new TypeError('retenu : aucun placement retenu');
if (fautes(retenu).length > 0) throw refus('RETENU_FAUTIF', {}, { geste: 'ouvrirSecours', source: 'precedent' });
if (!Number.isSafeInteger(tour) || tour < 1 || tour > retenu.tours) {
throw new TypeError(`tour : de 1 à ${retenu.tours} attendu, reçu ${JSON.stringify(tour)}`);
}
}
// Où le retenu place id à ce tour : { table: rang de la liste, rang } à une
// table, 'reserve', ou null quand il ne le déclare pas.
function lieuDans(retenu, tour, id) {
if (!retenu.participants.includes(id)) return null;
const { sieges } = retenu.placement[tour - 1];
for (let table = 0; table < sieges.length; table += 1) {
const rang = sieges[table].indexOf(id);
if (rang >= 0) return { table, rang };
}
return 'reserve';
}
// Retire les null de fin d'une liste, en place.
function sansNullDeFin(liste) {
while (liste.length > 0 && liste.at(-1) === null) liste.pop();
}
// Déclare id dans le retenu qui ne le place pas encore : parmi ses
// participants, et en réserve à chaque tour — l'opérateur seul décide où il
// s'assied (§ 9).
function declarer(retenu, id) {
retenu.participants = [...retenu.participants, id].sort(croissant);
for (const tour of retenu.placement) tour.reserve = [...tour.reserve, id].sort(croissant);
}
// Ôte id de sa place au tour : sa chaise devient vide sous des sièges
// attribués, la liste se resserre sinon ; ou il quitte la réserve.
function oter(retenu, tour, id) {
const lieu = lieuDans(retenu, tour, id);
const etage = retenu.placement[tour - 1];
if (lieu === 'reserve') {
etage.reserve = etage.reserve.filter((autre) => autre !== id);
} else if (lieu !== null) {
const liste = etage.sieges[lieu.table];
if (retenu.siegesAttribues) {
liste[lieu.rang] = null;
sansNullDeFin(liste);
} else {
liste.splice(lieu.rang, 1);
}
}
}
// Verse id dans la réserve du tour.
function enReserve(retenu, tour, id) {
const etage = retenu.placement[tour - 1];
etage.reserve = [...etage.reserve, id].sort(croissant);
}
// Le rang de la table idTable dans le retenu, la table ajoutée vide à chaque
// tour quand il ne la déclare pas encore — une table créée après la
// retenue (A4 l'y ajoute aussi).
function rangDeTable(retenu, idTable, places) {
const rang = retenu.tables.indexOf(idTable);
if (rang >= 0) return rang;
retenu.tables.push(idTable);
retenu.capacites.push(places);
for (const tour of retenu.placement) tour.sieges.push([]);
return retenu.tables.length - 1;
}
// Clé d'une violation : ses champs, dans un ordre fixe.
const cleDeViolation = ({ code, participant, table, tour, detail }) => JSON.stringify([code, participant, table, tour, detail]);
// Les violations de verifierInvariants que suivant ajoute au retenu
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la
// clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque
// personne une fois, et verifierInvariants n'y rend jamais deux violations
// de même clé. [] quand la configuration ne se normalise pas.
function violationsNouvelles(charge, precedent, suivant) {
const instance = instanceDe(charge);
if (instance === null) return [];
const juger = (retenu) => verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true });
const connues = new Set(juger(precedent).map(cleDeViolation));
return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation)));
}
// La charge suivante d'une retouche, ou INVARIANT_VIOLE quand elle ajoute
// une violation au retenu reçu. Le versement en réserve des personnes que le
// plan n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute
// aucune : l'affichage assied d'abord, à leur table, les personnes qu'elle
// réserve, et une capacité ne descend jamais sous ses réservations (§ 6.2).
function retouche(charge, suivante) {
const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu);
if (violations.length > 0) {
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
}
return suivante;
}
// Le numéro de la table où le retenu place id à ce tour, null en réserve,
// hors du retenu, ou à une table que la charge n'a plus.
function numeroDuLieu(charge, tour, id) {
const lieu = lieuDans(charge.retenu, tour, id);
if (lieu === null || lieu === 'reserve') return null;
return charge.tables.find(({ id: idTable }) => idTable === charge.retenu.tables[lieu.table])?.numero ?? null;
}
/**
* deplacerDansRetenu {tour, participant, table, siege|null} (§ 7.1, § 9) :
* la personne s'assied à cette table au tour donné, et sa place d'avant se
* libère. Sièges attribués, au siège donné — sans siège, la première chaise
* libre —, les rangs sautés à null ; la chaise d'un présent refuse
* SIEGE_OCCUPE, remède l'échange ; celle d'un absent — exclu, ou que la
* charge n'a plus — l'envoie en réserve. Sans attribution, la liste reste
* triée ; une table pleine de présents refuse TABLE_PLEINE, et, pleine
* d'une liste qui porte un absent, envoie le plus petit en réserve. Une
* personne que le retenu ne place pas y entre, en réserve aux autres tours.
* Le lieu de départ est celui que le plan du tour montre (placeDe) : une
* personne qu'il ne montre pas assise part de la réserve.
*/
function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege = null } = {}) {
exigerModifiable(charge);
exigerRetenu(charge, tour);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
exigerSiege(siege);
const places = capacite(charge, table);
if (siege !== null && siege > places) throw new TypeError(`siege : de 1 à ${places} attendu, reçu ${siege}`);
leverRefus(controlerPose(charge, { participant, table: idTable, siege, portee: 'tour', tour, retenu: true }).refus);
const ids = presents(charge);
const assisesDuTour = assisesAffichees(charge, tour);
const place = placeDe(charge, tour, participant, assisesDuTour);
const de = place === null ? null : numeroDuLieu(charge, tour, participant);
const memeTable = place !== null && place.table === idTable;
const suivante = structuredClone(charge);
const { retenu } = suivante;
const rangTable = rangDeTable(retenu, idTable, places);
if (lieuDans(retenu, tour, participant) === null) declarer(retenu, participant);
// La table d'arrivée n'a rien à verser : une liste qui porte plus de
// présents que de places refuse TABLE_PLEINE, et une autre les assied tous.
reduireAuxAssises(retenu, tour, rangsDesTables(retenu, tour, [participant]), assisesDuTour, ids);
versLaReserveAffichee(retenu, tour, participant, place);
const etage = retenu.placement[tour - 1];
let siegeLibelle = null;
if (retenu.siegesAttribues) {
const liste = etage.sieges[rangTable];
let cible = siege;
if (cible === null) {
cible = 1;
while (cible <= places && cible <= liste.length && liste[cible - 1] !== null && ids.has(liste[cible - 1])) cible += 1;
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
}
const occupant = liste[cible - 1] ?? null;
if (occupant === participant) throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
if (occupant !== null && ids.has(occupant)) {
throw refus('SIEGE_OCCUPE', { table: idTable, siege: cible, tour, occupant }, {
commande: 'echangerDansRetenu',
arguments: { tour, a: participant, b: occupant },
});
}
if (occupant !== null) {
liste[cible - 1] = null;
enReserve(retenu, tour, occupant);
}
oter(retenu, tour, participant);
const garnie = etage.sieges[rangTable];
while (garnie.length < cible) garnie.push(null);
garnie[cible - 1] = participant;
sansNullDeFin(garnie);
if (garnie.length > retenu.capacites[rangTable]) retenu.capacites[rangTable] = places;
if (memeTable) siegeLibelle = cible;
} else {
const liste = etage.sieges[rangTable];
if (liste.includes(participant)) throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
const assises = liste.filter((id) => ids.has(id)).length;
if (assises >= places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
if (liste.length >= places) {
const absent = liste.filter((id) => !ids.has(id)).sort(croissant)[0];
if (absent !== undefined) {
liste.splice(liste.indexOf(absent), 1);
enReserve(retenu, tour, absent);
}
}
oter(retenu, tour, participant);
const garnie = etage.sieges[rangTable];
garnie.push(participant);
garnie.sort(croissant);
if (garnie.length > retenu.capacites[rangTable]) retenu.capacites[rangTable] = places;
}
return {
charge: retouche(charge, suivante),
libelle: libelle('ENTREE_DEPLACER_DANS_RETENU', {
personne: nomAffiche(personne),
de,
vers: table.numero,
siege: siegeLibelle,
tour,
}),
avertissements: [],
};
}
// Les personnes que le plan du tour montre assises (etatPlaces, source le
// retenu). Une place du retenu à une table que la charge n'a plus, ou à un
// rang au-delà de sa capacité courante, n'y figure pas : elle se lit comme
// la réserve.
function assisesAffichees(charge, tour) {
const assises = new Set();
for (const { chaises } of etatPlaces(charge, { tour }).tables) {
for (const { occupant } of chaises) if (occupant !== null) assises.add(occupant);
}
return assises;
}
// La place qu'une personne occupe au tour telle que le plan la montre :
// { table, siege }, ou null quand le plan ne l'assied pas — en réserve, hors
// du retenu, ou absente d'assises (assisesAffichees) : à une table que la
// charge n'a plus, à un rang au-delà de la capacité courante, ou, sans
// attribution, parmi les présents qu'une liste porte au-delà des places. Le
// siège est le rang sous des sièges attribués ; sans attribution, celui de sa
// réservation à un siège à cette table, null sans elle. Les gestes du retenu
// y lisent le lieu de départ, et la pose de l'autre sur elle s'y contrôle.
function placeDe(charge, tour, id, assises) {
const lieu = lieuDans(charge.retenu, tour, id);
if (lieu === null || lieu === 'reserve' || !assises.has(id)) return null;
const idTable = charge.retenu.tables[lieu.table];
if (charge.retenu.siegesAttribues) return { table: idTable, siege: lieu.rang + 1 };
const tenue = charge.reservations.find(
(r) => r.participant === id && r.table === idTable && r.siege !== null && (r.portee === 'tous' || r.tour === tour),
);
return { table: idTable, siege: tenue?.siege ?? null };
}
// Verse dans la réserve du tour une personne que le retenu range à une table
// alors que le plan ne la montre pas assise (place null, de placeDe) : sa
// liste la perd, et le geste part de la réserve, comme l'affichage. Personne
// n'entre ainsi dans la liste d'une table que la charge n'a plus.
function versLaReserveAffichee(retenu, tour, id, place) {
const lieu = lieuDans(retenu, tour, id);
if (place === null && lieu !== null && lieu !== 'reserve') {
oter(retenu, tour, id);
enReserve(retenu, tour, id);
}
}
// Sans attribution, verse dans la réserve du tour chaque présent (ids) que
// le plan ne montre pas assis (assises) aux tables des rangs donnés : leur
// liste vaut alors ce que l'affichage y assied, et le tri qui suit le geste
// n'assied personne d'autre que ceux qu'il nomme. Sous des sièges attribués,
// le rang est le siège et ne glisse pas : rien ne change.
function reduireAuxAssises(retenu, tour, rangs, assises, ids) {
if (retenu.siegesAttribues) return;
const etage = retenu.placement[tour - 1];
for (const rang of rangs) {
for (const id of etage.sieges[rang].filter((autre) => ids.has(autre) && !assises.has(autre))) {
oter(retenu, tour, id);
enReserve(retenu, tour, id);
}
}
}
// Les rangs des tables où le retenu place ces personnes au tour.
function rangsDesTables(retenu, tour, personnes) {
return personnes.map((id) => lieuDans(retenu, tour, id)).filter((lieu) => lieu !== null && lieu !== 'reserve').map(({ table }) => table);
}
/**
* echangerDansRetenu {tour, a, b} (§ 7.1) : a prend la place de b et b celle
* de a, au tour donné ; une place peut être la réserve. Chaque arrivée se
* contrôle comme un dépôt : PLACE_RESERVEE, RESERVEE_AILLEURS. Une personne
* que le retenu ne place pas y entre, en réserve aux autres tours.
*/
function echangerDansRetenu(charge, { tour, a, b } = {}) {
exigerModifiable(charge);
exigerRetenu(charge, tour);
if (a === b) throw new TypeError('echangerDansRetenu : deux personnes distinctes attendues');
const personneA = personneDe(charge, a);
const personneB = personneDe(charge, b);
for (const personne of [personneA, personneB]) {
if (personne.exclu) {
throw refus('PERSONNE_EXCLUE', { participant: personne.id }, { commande: 'reintegrerParticipant', arguments: { id: personne.id } });
}
}
const assises = assisesAffichees(charge, tour);
const placeA = placeDe(charge, tour, a, assises);
const placeB = placeDe(charge, tour, b, assises);
if (placeA === null && placeB === null) throw refus('SANS_EFFET', { commande: 'echangerDansRetenu' });
const poses = [];
if (placeB !== null) poses.push(...controlerPose(charge, { participant: a, ...placeB, portee: 'tour', tour, retenu: true }).refus);
if (placeA !== null) poses.push(...controlerPose(charge, { participant: b, ...placeA, portee: 'tour', tour, retenu: true }).refus);
leverRefus(poses);
const deA = placeA === null ? null : numeroDuLieu(charge, tour, a);
const deB = placeB === null ? null : numeroDuLieu(charge, tour, b);
const suivante = structuredClone(charge);
const { retenu } = suivante;
for (const id of [a, b]) if (lieuDans(retenu, tour, id) === null) declarer(retenu, id);
reduireAuxAssises(retenu, tour, rangsDesTables(retenu, tour, [a, b]), assises, presents(charge));
// L'autre remplace dans la réserve une personne que le plan n'y montre pas assise.
versLaReserveAffichee(retenu, tour, a, placeA);
versLaReserveAffichee(retenu, tour, b, placeB);
const lieuA = lieuDans(retenu, tour, a);
const lieuB = lieuDans(retenu, tour, b);
const etage = retenu.placement[tour - 1];
const poser = (lieu, id) => {
if (lieu === 'reserve') enReserve(retenu, tour, id);
else etage.sieges[lieu.table][lieu.rang] = id;
};
if (lieuA === 'reserve') etage.reserve = etage.reserve.filter((id) => id !== a);
if (lieuB === 'reserve') etage.reserve = etage.reserve.filter((id) => id !== b);
poser(lieuB, a);
poser(lieuA, b);
if (!retenu.siegesAttribues) for (const liste of etage.sieges) liste.sort(croissant);
return {
charge: retouche(charge, suivante),
libelle: libelle('ENTREE_ECHANGER_DANS_RETENU', { a: nomAffiche(personneA), deA, b: nomAffiche(personneB), deB, tour }),
avertissements: [],
};
}
/**
* retirerDuTour {tour, participant} (§ 7.1) : la personne quitte sa table au
* tour donné et attend dans la réserve de ce tour ; sa chaise se libère.
* Une personne que le plan du tour ne montre pas assise (placeDe) — en
* réserve, hors du retenu, à une table que la charge n'a plus ou à un rang
* au-delà de sa capacité courante — refuse SANS_EFFET.
*/
function retirerDuTour(charge, { tour, participant } = {}) {
exigerModifiable(charge);
exigerRetenu(charge, tour);
const personne = personneDe(charge, participant);
if (personne.exclu) {
throw refus('PERSONNE_EXCLUE', { participant }, { commande: 'reintegrerParticipant', arguments: { id: participant } });
}
const assises = assisesAffichees(charge, tour);
if (placeDe(charge, tour, participant, assises) === null) {
throw refus('SANS_EFFET', { commande: 'retirerDuTour' });
}
const numero = numeroDuLieu(charge, tour, participant);
const suivante = structuredClone(charge);
reduireAuxAssises(suivante.retenu, tour, rangsDesTables(suivante.retenu, tour, [participant]), assises, presents(charge));
oter(suivante.retenu, tour, participant);
enReserve(suivante.retenu, tour, participant);
return {
charge: retouche(charge, suivante),
libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }),
avertissements: [],
};
}
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_PLACES = new Map();
export const COMMANDES_PLACES = new Map([
['reserver', reserver],
['retirerReservation', retirerReservation],
['deplacerReservation', deplacerReservation],
['poserTitre', poserTitre],
['retirerTitre', retirerTitre],
['designer', designer],
['convertirEnTous', convertirEnTous],
['deplacerDansRetenu', deplacerDansRetenu],
['echangerDansRetenu', echangerDansRetenu],
['retirerDuTour', retirerDuTour],
]);

View file

@ -0,0 +1,899 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des commandes des places (§ 4, § 4.1, § 5.8, § 5.9, § 7.1, § 8.2,
// § 8.9, § 9, § 14.12) : réservations, titres, désignation, conversion,
// retouches du retenu et leur vérificateur d'invariants, rangement par siège
// d'une génération. Les noms d'épreuve sont inventés ; la version vient du
// module engendré.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { FLUX, creerPcg32 } from '../demo/prng.js';
import { normaliser } from '../moteur/configuration.js';
import { rechercher } from '../moteur/recherche.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { derive, fautes } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { avertissementsApres } from './avertissements.js';
import { COMMANDES, appliquerCommande } from './commandes.js';
import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande } from './erreurs.js';
import { libelle } from './libelles.js';
import { TABLE as TEXTES_DES_PLACES } from './libelles/places.js';
import { conversionsPossibles } from './listes_travail.js';
import { etatPlaces } from './places.js';
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
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'],
['Aubépine', 'Colombe'],
['Lavande', 'Ysolde'],
['Zéphyrin', 'Lucioles'],
];
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, idTable, siege, tour = null) => ({
participant,
table: idTable,
siege,
portee: tour === null ? 'tous' : 'tour',
tour,
});
// Le refus d'une ErreurCommande levée par appel, 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;
}
// 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;
}
// Un plan de fichier ; la réserve de chaque tour porte les déclarés qu'aucune
// liste n'assied.
function planFichier({ tables, capacites, participants, tours, siegesAttribues }) {
return {
siegesAttribues,
tables,
capacites,
tours: tours.length,
participants,
placement: tours.map((listes) => {
const assis = new Set(listes.flat());
return { sieges: listes, reserve: participants.filter((id) => !assis.has(id)) };
}),
};
}
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
// Douze personnes, trois tours, les tables numérotées 4 et 7 d'identifiants
// 5 et 8, de huit places ; la proposition 1, retenue, que tours donne.
function chargeRetenue({ siegesAttribues = true, tours, capacite = 8, reservations = [] } = {}) {
const charge = creerCharge({ id: 'evt-retenu', nom: 'Soirée des Lucioles', siegesParDefaut: capacite, tours: 3 });
charge.reglages.attribuerSieges = siegesAttribues;
charge.participants = DOUZE.map((id) => personne(id));
charge.tables = [
{ id: 5, numero: 4, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 8, numero: 7, sieges: null, forme: 'carree', position: { x: 400, y: 0 } },
];
charge.reservations = reservations;
const plan = planFichier({
tables: [5, 8],
capacites: [capacite, capacite],
participants: DOUZE,
siegesAttribues,
tours: tours ?? [
[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
[[1, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
],
});
charge.propositions = [{ id: 1, graine: 48272, arret: 200000, historique: 1000, produitVersion: VERSION.affichee, ...plan }];
charge.retenu = { proposition: 1, ...structuredClone(plan) };
charge.evenement.etat = 'retenu';
charge.prochainsIds = { participant: 13, table: 9, proposition: 2 };
return charge;
}
const listes = (charge, tour) => charge.retenu.placement[tour - 1].sieges;
const reserveDe = (charge, tour) => charge.retenu.placement[tour - 1].reserve;
describe('commandes des places : le registre (§ 8.2)', () => {
test('COMMANDES_PLACES porte les dix commandes des places, réunies dans COMMANDES', () => {
assert.deepEqual([...COMMANDES_PLACES.keys()].sort(), [
'convertirEnTous',
'deplacerDansRetenu',
'deplacerReservation',
'designer',
'echangerDansRetenu',
'poserTitre',
'reserver',
'retirerDuTour',
'retirerReservation',
'retirerTitre',
]);
for (const [nom, commande] of COMMANDES_PLACES) assert.equal(COMMANDES.get(nom), commande);
});
test('un plan bloqué refuse chaque commande des places, remède débloquer', () => {
const charge = chargeRetenue();
charge.evenement.etat = 'bloque';
const essais = [
['reserver', { participant: 1, table: 5, siege: 1, portee: 'tous', tour: null }],
['retirerReservation', { participant: 1, table: 5, portee: 'tous', tour: null }],
['deplacerReservation', { participant: 1, de: { table: 5, portee: 'tous', tour: null }, vers: { table: 8, siege: 1 } }],
['poserTitre', { table: 5, siege: 1, libelle: 'animation' }],
['retirerTitre', { table: 5, siege: 1 }],
['designer', { participant: 1, table: 5, siege: 1, libelle: 'animation' }],
['convertirEnTous', { participant: 1, table: 5 }],
['deplacerDansRetenu', { tour: 1, participant: 1, table: 8, siege: 7 }],
['echangerDansRetenu', { tour: 1, a: 1, b: 7 }],
['retirerDuTour', { tour: 1, participant: 1 }],
];
assert.equal(essais.length, COMMANDES_PLACES.size);
for (const [nom, args] of essais) refuse(() => executer(nom, charge, args), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
});
});
describe('commandes des places : réservations et libellés figés (§ 5.9, § 8.2)', () => {
test('reserver : la réservation, et le libellé qui nomme la personne et le numéro de la table', () => {
const charge = chargeRetenue();
const { charge: apres, libelle, avertissements } = executer('reserver', charge, {
participant: 2,
table: 5,
siege: 2,
portee: 'tour',
tour: 2,
});
assert.equal(libelle, 'Réservé Théo Pervenche à la table 4, siège 2, tour 2');
assert.deepEqual(apres.reservations, [resa(2, 5, 2, 2)]);
assert.deepEqual(avertissements, []);
assert.equal(
executer('reserver', charge, { participant: 4, table: 8, siege: null, portee: 'tous', tour: null }).libelle,
'Réservé Grisaille à la table 7, tous les tours',
);
});
test('reserver une réservation qui existe déjà : SANS_EFFET ; une table gelée avertit', () => {
const charge = chargeRetenue({ capacite: 2, tours: [[[1, 2], [3, 4]], [[1, 3], [2, 4]], [[1, 4], [2, 3]]] });
const une = executer('reserver', charge, { participant: 1, table: 5, siege: null, portee: 'tous', tour: null }).charge;
refuse(() => executer('reserver', une, { participant: 1, table: 5, siege: null, portee: 'tous', tour: null }), 'SANS_EFFET', {
commande: 'reserver',
});
const { avertissements } = executer('reserver', une, { participant: 2, table: 5, siege: null, portee: 'tous', tour: null });
assert.deepEqual(avertissements, [{ code: 'TABLE_GELEE', details: { table: 5 } }]);
});
test('retirer, déplacer : la réservation désignée par sa table et sa portée ; une réservation absente est refusée', () => {
const charge = chargeRetenue({ reservations: [resa(1, 5, 1), resa(2, 8, 2, 2)] });
const retiree = executer('retirerReservation', charge, { participant: 2, table: 8, portee: 'tour', tour: 2 });
assert.deepEqual(retiree.charge.reservations, [resa(1, 5, 1)]);
assert.equal(retiree.libelle, 'Réservation retirée : Théo Pervenche, table 7, tour 2');
refuse(() => executer('retirerReservation', charge, { participant: 2, table: 8, portee: 'tous', tour: null }), 'RESERVATION_ABSENTE', {
participant: 2,
remede: null,
});
const deplacee = executer('deplacerReservation', charge, {
participant: 1,
de: { table: 5, portee: 'tous', tour: null },
vers: { table: 8, siege: 3 },
});
assert.deepEqual(deplacee.charge.reservations, [resa(1, 8, 3), resa(2, 8, 2, 2)]);
assert.equal(deplacee.libelle, 'Réservation déplacée : Iris Ombrelle, de la table 4 à la table 7, siège 3, tous les tours');
refuse(
() => executer('deplacerReservation', charge, { participant: 1, de: { table: 5, portee: 'tous', tour: null }, vers: { table: 8, siege: 2 } }),
'PLACE_RESERVEE',
{ titulaire: 2 },
);
});
});
describe('commandes des places : titres et désignation (§ 4, § 4.1, § 2.1 étape 6)', () => {
// Deux tables, l'une de 2 places, trois tours, sans retenu.
function chargeTitres(attribuerSieges = false) {
const charge = creerCharge({ id: 'evt-titres', nom: 'Soirée des Lucioles', siegesParDefaut: 6, tours: 3 });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = [1, 2, 3, 4].map((id) => personne(id));
charge.tables = [
{ id: 1, numero: 1, sieges: 2, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 400, y: 0 } },
];
charge.prochainsIds = { participant: 5, table: 4, proposition: 1 };
return charge;
}
test('désigner Iris à la table 3 : une réservation « tous » et un titre au même siège, une entrée', () => {
const { charge, libelle } = executer('designer', chargeTitres(true), { participant: 1, table: 3, siege: 2, libelle: 'animatrice' });
assert.deepEqual(charge.reservations, [resa(1, 3, 2)]);
assert.deepEqual(charge.titres, [{ table: 3, siege: 2, libelle: 'animatrice' }]);
assert.equal(libelle, 'Désigné Iris Ombrelle à la table 3, siège 2 : « animatrice »');
});
test('un titre non pourvu déjà posé à cette place est pourvu, aucun second', () => {
const titree = executer('poserTitre', chargeTitres(), { table: 3, siege: 4, libelle: 'hôte' }).charge;
const { charge } = executer('designer', titree, { participant: 2, table: 3, siege: null, libelle: 'animateur' });
assert.deepEqual(charge.titres, [{ table: 3, siege: 4, libelle: 'hôte' }]);
assert.deepEqual(charge.reservations, [resa(2, 3, 4)]);
const deuxieme = executer('designer', charge, { participant: 3, table: 3, siege: null, libelle: 'veille' }).charge;
assert.deepEqual(deuxieme.titres, [{ table: 3, siege: 4, libelle: 'hôte' }, { table: 3, siege: 1, libelle: 'veille' }]);
assert.deepEqual(deuxieme.reservations.at(-1), resa(3, 3, 1));
});
test('poserTitre : vide refusé LIBELLE_VIDE sans écriture ; NFC sans blancs de bord ; place titrée PLACE_TITREE', () => {
const charge = figer(chargeTitres(true));
for (const libelle of ['', ' ']) {
refuse(() => executer('poserTitre', charge, { table: 3, siege: 1, libelle }), 'LIBELLE_VIDE', { remede: null });
refuse(() => executer('designer', charge, { participant: 1, table: 3, siege: 1, libelle }), 'LIBELLE_VIDE', { remede: null });
}
const nfd = ' Ho\u{0302}tesse\u{00A0}';
const { charge: titree, libelle } = executer('poserTitre', charge, { table: 3, siege: 1, libelle: nfd });
assert.deepEqual(titree.titres, [{ table: 3, siege: 1, libelle: 'H\u{00F4}tesse' }]);
assert.equal(libelle, 'Posé le titre « H\u{00F4}tesse » à la table 3, siège 1');
refuse(() => executer('poserTitre', titree, { table: 3, siege: 1, libelle: 'hôte' }), 'PLACE_TITREE', {
table: 3,
siege: 1,
libelle: 'H\u{00F4}tesse',
remede: { commande: 'retirerTitre', arguments: { table: 3, siege: 1 } },
});
refuse(() => executer('poserTitre', titree, { table: 3, siege: 7, libelle: 'hôte' }), 'SIEGE_INEXISTANT', { siege: 7, capacite: 6 });
const retire = executer('retirerTitre', titree, { table: 3, siege: 1 });
assert.deepEqual(retire.charge.titres, []);
assert.equal(retire.libelle, 'Retiré le titre « H\u{00F4}tesse » de la table 3, siège 1');
refuse(() => executer('retirerTitre', retire.charge, { table: 3, siege: 1 }), 'TITRE_ABSENT', { remede: null });
});
test('mode table : un titre sans siège prend le plus petit sans titre ; une table de 2 aux deux sièges titrés refuse AUCUN_SIEGE_SANS_TITRE', () => {
let charge = chargeTitres();
charge = executer('poserTitre', charge, { table: 1, siege: 2, libelle: 'accueil' }).charge;
charge = executer('poserTitre', charge, { table: 1, siege: null, libelle: 'animation' }).charge;
assert.deepEqual(charge.titres.at(-1), { table: 1, siege: 1, libelle: 'animation' });
refuse(() => executer('poserTitre', charge, { table: 1, siege: null, libelle: 'veille' }), 'AUCUN_SIEGE_SANS_TITRE', {
table: 1,
capacite: 2,
remede: { commande: 'changerPlacesTable', arguments: { id: 1, sieges: 3, origine: 'champ' } },
});
});
test('convertirEnTous : refusé sans les R réservations « tour » à une table ; libellé', () => {
let charge = chargeTitres();
refuse(() => executer('convertirEnTous', charge, { participant: 1, table: 3 }), 'CONVERSION_IMPOSSIBLE', { remede: null });
for (const tour of [1, 2, 3]) charge = executer('reserver', charge, { participant: 1, table: 3, siege: 5, portee: 'tour', tour }).charge;
const { charge: convertie, libelle } = executer('convertirEnTous', charge, { participant: 1, table: 3 });
assert.deepEqual(convertie.reservations, [resa(1, 3, 5)]);
assert.equal(libelle, 'Converti en « tous les tours » : Iris Ombrelle, table 3');
});
});
describe('commandes des places : les invariants des réservations (§ 5.9, § 14.12)', () => {
// Σ (c_t − k_t) − n de l'instance, et ses ancrés par table.
const ecart = ({ capacite, ancresParTable, n }) =>
capacite.reduce((somme, c) => somme + c, 0) - ancresParTable.reduce((somme, k) => somme + k, 0) - n;
// Une petite configuration tirée : deux à quatre tables de 2 à 5 places,
// un à trois tours, trois à douze personnes dont parfois une exclue.
function configurationTiree(rng) {
const tours = 1 + rng.borne(3);
const charge = creerCharge({ id: 'evt-grille', nom: 'Soirée des Lucioles', siegesParDefaut: 2 + rng.borne(4), tours });
charge.reglages.attribuerSieges = rng.borne(2) === 1;
const nombre = 3 + rng.borne(10);
charge.participants = Array.from({ length: nombre }, (_, rang) => personne(rang + 1, { exclu: rng.borne(6) === 0 }));
const tables = 2 + rng.borne(3);
charge.tables = Array.from({ length: tables }, (_, rang) => ({
id: rang + 1,
numero: rang + 1,
sieges: rng.borne(2) === 0 ? null : 2 + rng.borne(4),
forme: 'ronde',
position: { x: 300 * rang, y: 0 },
}));
charge.prochainsIds = { participant: nombre + 1, table: tables + 1, proposition: 1 };
return charge;
}
// Une commande de réservation tirée sur la charge, et les tables qu'elle
// touche : arguments valides en forme, que la commande accepte ou refuse.
function commandeTiree(rng, charge) {
const R = charge.evenement.tours;
const participant = 1 + rng.borne(charge.participants.length);
const idTable = 1 + rng.borne(charge.tables.length);
const siege = rng.borne(3) === 0 ? null : 1 + rng.borne(5);
const portee = rng.borne(2) === 0 ? 'tous' : 'tour';
const tour = portee === 'tous' ? null : 1 + rng.borne(R);
const existante = charge.reservations.length > 0 ? charge.reservations[rng.borne(charge.reservations.length)] : null;
const choix = rng.borne(6);
if (choix <= 1 || existante === null) return ['reserver', { participant, table: idTable, siege, portee, tour }, [idTable]];
if (choix === 2) {
const { participant: p, table: t, portee: po, tour: to } = existante;
return ['retirerReservation', { participant: p, table: t, portee: po, tour: to }, [t]];
}
if (choix === 3) {
const { participant: p, table: t, portee: po, tour: to } = existante;
return ['deplacerReservation', { participant: p, de: { table: t, portee: po, tour: to }, vers: { table: idTable, siege } }, [t, idTable]];
}
if (choix === 4) {
const possibles = conversionsPossibles(charge);
const cible = possibles.length > 0 ? possibles[rng.borne(possibles.length)] : { participant, table: idTable };
return ['convertirEnTous', cible, [cible.table]];
}
return ['designer', { participant, table: idTable, siege, libelle: 'animation' }, [idTable]];
}
test('Σ (c_t − k_t) − n ne change pas ; la charge rendue se relit et se normalise ; les ancrés ne bougent qu’aux tables touchées', () => {
assert.ok(!Object.values(FLUX).includes(7), 'le flux 7 est hors de FLUX');
const rng = creerPcg32(48271, 7);
let acceptees = 0;
const parCommande = new Map();
for (let essai = 0; essai < 300; essai += 1) {
let charge = configurationTiree(rng);
for (let geste = 0; geste < 6; geste += 1) {
const avant = normaliser(configurationDepuisCharge(charge));
const [nom, args, touchees] = commandeTiree(rng, charge);
let rendu;
try {
rendu = appliquerCommande(nom, charge, args);
} catch (erreur) {
if (erreur instanceof ErreurCommande) continue;
throw erreur;
}
acceptees += 1;
parCommande.set(nom, (parCommande.get(nom) ?? 0) + 1);
const texte = serialiser(rendu.charge, { revision: 1, produitVersion: 'essai' });
assert.doesNotThrow(() => analyser(texte), `${nom} ${JSON.stringify(args)}`);
const apres = normaliser(configurationDepuisCharge(rendu.charge));
assert.equal(ecart(apres), ecart(avant), `${nom} ${JSON.stringify(args)}`);
avant.ancresParTable.forEach((k, t) => {
const delta = apres.ancresParTable[t] - k;
const idTable = avant.idsTables[t];
if (nom === 'convertirEnTous' || !touchees.includes(idTable)) assert.equal(delta, 0, `${nom} table ${idTable}`);
else assert.ok(Math.abs(delta) <= 1, `${nom} table ${idTable} : ${delta}`);
});
charge = rendu.charge;
}
}
assert.ok(acceptees >= 200, `${acceptees} gestes acceptés`);
for (const nom of ['reserver', 'retirerReservation', 'deplacerReservation', 'convertirEnTous', 'designer']) {
assert.ok((parCommande.get(nom) ?? 0) > 0, `${nom} jamais accepté`);
}
});
});
describe('commandes des places : le retenu (§ 7.1, § 8.2, § 8.9)', () => {
test('au tour 2, déplacer, échanger, retirer ne changent que le tour 2 du retenu, jamais la proposition', () => {
const charge = figer(chargeRetenue());
const deplace = executer('deplacerDansRetenu', charge, { tour: 2, participant: 1, table: 8, siege: 7 });
assert.equal(deplace.libelle, 'Déplacé Iris Ombrelle de la table 4 à la table 7, tour 2');
assert.deepEqual(listes(deplace.charge, 2), [[null, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6, 1]]);
const echange = executer('echangerDansRetenu', charge, { tour: 2, a: 1, b: 2 });
assert.deepEqual(listes(echange.charge, 2), [[2, 8, 9, 10, 11, 12], [7, 1, 3, 4, 5, 6]]);
assert.equal(echange.libelle, 'Échangé Iris Ombrelle (table 4) et Théo Pervenche (table 7), tour 2');
const retire = executer('retirerDuTour', charge, { tour: 2, participant: 9 });
assert.equal(retire.libelle, 'Retiré Hortense Quenouille de la table 4, tour 2');
assert.deepEqual(reserveDe(retire.charge, 2), [9]);
assert.deepEqual(etatPlaces(retire.charge, { tour: 2 }).reserve, [9]);
for (const tour of [1, 3]) assert.deepEqual(etatPlaces(retire.charge, { tour }).reserve, []);
for (const { charge: apres } of [deplace, echange, retire]) {
assert.deepEqual(apres.propositions, charge.propositions);
for (const tour of [1, 3]) assert.deepEqual(apres.retenu.placement[tour - 1], charge.retenu.placement[tour - 1]);
assert.deepEqual(fautes(apres.retenu), []);
}
});
test('sièges attribués : un départ laisse sa chaise vide, et tout autre siège reste le même', () => {
const charge = chargeRetenue();
const sans3 = executer('retirerDuTour', charge, { tour: 1, participant: 3 }).charge;
assert.deepEqual(listes(sans3, 1)[0], [1, 2, null, 4, 5, 6]);
const sans6 = executer('retirerDuTour', sans3, { tour: 1, participant: 6 }).charge;
assert.deepEqual(listes(sans6, 1)[0], [1, 2, null, 4, 5]);
assert.deepEqual(fautes(sans6.retenu), []);
const retour = executer('deplacerDansRetenu', sans6, { tour: 1, participant: 12, table: 5, siege: 3 }).charge;
assert.deepEqual(listes(retour, 1), [[1, 2, 12, 4, 5], [7, 8, 9, 10, 11]]);
// La personne qui quitte sa table pour une autre libère aussi sa chaise.
const quitte = executer('deplacerDansRetenu', charge, { tour: 1, participant: 9, table: 5, siege: 7 }).charge;
assert.deepEqual(listes(quitte, 1), [[1, 2, 3, 4, 5, 6, 9], [7, 8, null, 10, 11, 12]]);
});
test('sièges attribués : un dépôt sur la chaise 8 d’une table de 8 qui en tient cinq, les rangs sautés à null', () => {
const charge = chargeRetenue({
tours: [
[[1, 2, 3, 4, 5], [6, 7, 8, 9, 10, 11, 12]],
[[1, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
],
});
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 5, siege: 8 });
assert.deepEqual(listes(apres, 1), [[1, 2, 3, 4, 5, null, null, 6], [null, 7, 8, 9, 10, 11, 12]]);
assert.deepEqual(fautes(apres.retenu), []);
refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 5, siege: 2 }), 'SIEGE_OCCUPE', {
table: 5,
siege: 2,
tour: 1,
occupant: 2,
remede: { commande: 'echangerDansRetenu', arguments: { tour: 1, a: 6, b: 2 } },
});
});
test('sans attribution : la liste reste triée, une table pleine de présents refuse TABLE_PLEINE', () => {
const charge = figer(chargeRetenue({ siegesAttribues: false, capacite: 6 }));
// La table 4 tient six présents : refusée ; libérée d'un, elle reçoit.
refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null }), 'TABLE_PLEINE', {
table: 5,
tour: 1,
capacite: 6,
remede: { commande: 'changerPlacesTable', arguments: { id: 5, sieges: 7, origine: 'champ' } },
});
const libre = executer('retirerDuTour', charge, { tour: 1, participant: 2 }).charge;
assert.deepEqual(listes(libre, 1)[0], [1, 3, 4, 5, 6]);
const recue = executer('deplacerDansRetenu', libre, { tour: 1, participant: 12, table: 5, siege: null }).charge;
assert.deepEqual(listes(recue, 1), [[1, 3, 4, 5, 6, 12], [7, 8, 9, 10, 11]]);
});
test('une retouche qui assiérait cinq personnes à une table de 4 au tour 2 est refusée, sans écriture', () => {
const charge = figer(
chargeRetenue({
siegesAttribues: false,
capacite: 4,
tours: [
[[1, 2, 3, 4], [5, 6, 7, 8]],
[[1, 2, 3, 4], [5, 6, 7, 8]],
[[1, 2, 3, 4], [5, 6, 7, 8]],
],
}),
);
refuse(() => appliquerCommande('deplacerDansRetenu', charge, { tour: 2, participant: 9, table: 5, siege: null }), 'TABLE_PLEINE');
});
test('la proposition montrée n’est jamais la cible : sans retenu, ou à un tour qu’il ne porte pas, une retouche est une faute du code', () => {
const sans = chargeRetenue();
sans.retenu = null;
assert.throws(() => executer('retirerDuTour', sans, { tour: 1, participant: 1 }), TypeError);
assert.throws(() => executer('retirerDuTour', chargeRetenue(), { tour: 4, participant: 1 }), TypeError);
});
test('un retenu fautif refuse ses retouches RETENU_FAUTIF, remède la version précédente', () => {
const charge = chargeRetenue();
charge.retenu.placement[0].reserve = [1];
for (const [nom, args] of [
['deplacerDansRetenu', { tour: 2, participant: 1, table: 8, siege: 7 }],
['echangerDansRetenu', { tour: 2, a: 1, b: 2 }],
['retirerDuTour', { tour: 2, participant: 1 }],
]) {
refuse(() => executer(nom, charge, args), 'RETENU_FAUTIF', { remede: { geste: 'ouvrirSecours', source: 'precedent' } });
}
});
test('une place réservée à B : refusée PLACE_RESERVEE par dépôt, puis par échange', () => {
const charge = chargeRetenue({ reservations: [resa(2, 8, 8, 2), resa(7, 8, 1)] });
refuse(() => executer('deplacerDansRetenu', charge, { tour: 2, participant: 9, table: 8, siege: 8 }), 'PLACE_RESERVEE', {
titulaire: 2,
table: 8,
siege: 8,
});
refuse(() => executer('echangerDansRetenu', charge, { tour: 2, a: 9, b: 7 }), 'PLACE_RESERVEE', { titulaire: 7, siege: 1 });
// Contre sa propre réservation : RESERVEE_AILLEURS.
refuse(() => executer('deplacerDansRetenu', charge, { tour: 2, participant: 7, table: 5, siege: 7 }), 'RESERVEE_AILLEURS', {
participant: 7,
tour: 2,
});
});
});
describe('commandes des places : le retenu et la dérive (§ 9, § 4.4)', () => {
test('une personne ajoutée après la retenue attend en réserve ; déplacée au tour 2, elle y est assise et attend aux autres', () => {
const charge = chargeRetenue();
charge.participants.push(personne(13));
charge.prochainsIds.participant = 14;
for (const tour of [1, 2, 3]) assert.ok(etatPlaces(charge, { tour }).reserve.includes(13));
const { charge: apres, libelle } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 13, table: 8, siege: 8 });
assert.equal(libelle, 'Déplacé Lucioles Zéphyrin de la réserve à la table 7, tour 2');
assert.deepEqual(listes(apres, 2)[1], [7, 2, 3, 4, 5, 6, null, 13]);
assert.deepEqual(reserveDe(apres, 1), [13]);
assert.deepEqual(reserveDe(apres, 3), [13]);
assert.ok(apres.retenu.participants.includes(13));
assert.deepEqual(fautes(apres.retenu), []);
assert.ok(!derive(apres.retenu, apres).some(({ participant }) => participant === 13));
assert.equal(etatPlaces(apres, { tour: 2 }).tables[1].chaises[7].occupant, 13);
});
test('Iris, exclue après la retenue, au siège 3 du tour 2 : y déposer Théo l’envoie en réserve, sans renuméroter personne', () => {
const charge = chargeRetenue({
tours: [
[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
[[8, 9, 1, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
],
});
charge.participants[0].exclu = true;
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 2, table: 5, siege: 3 });
assert.deepEqual(listes(apres, 2), [[8, 9, 2, 10, 11, 12], [7, null, 3, 4, 5, 6]]);
assert.deepEqual(reserveDe(apres, 2), [1]);
refuse(() => executer('deplacerDansRetenu', charge, { tour: 2, participant: 1, table: 8, siege: 8 }), 'PERSONNE_EXCLUE', {
participant: 1,
});
});
test('sans attribution, une table pleine qui porte un exclu reçoit le dépôt ; l’exclu au plus petit identifiant va en réserve', () => {
const charge = chargeRetenue({ siegesAttribues: false, capacite: 6 });
charge.participants[1].exclu = true;
charge.participants[3].exclu = true;
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null });
assert.deepEqual(listes(apres, 1), [[1, 3, 4, 5, 6, 12], [7, 8, 9, 10, 11]]);
assert.deepEqual(reserveDe(apres, 1), [2]);
});
test('une table créée après la retenue, écrite comme creerTable la laissera, reçoit un dépôt', () => {
const charge = chargeRetenue();
charge.tables.push({ id: 9, numero: 9, sieges: null, forme: 'ronde', position: { x: 800, y: 0 } });
charge.prochainsIds.table = 10;
charge.retenu.tables.push(9);
charge.retenu.capacites.push(8);
for (const tour of charge.retenu.placement) tour.sieges.push([]);
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 4, table: 9, siege: 1 });
assert.deepEqual(listes(apres, 2), [[1, 8, 9, 10, 11, 12], [7, 2, 3, null, 5, 6], [4]]);
assert.deepEqual(fautes(apres.retenu), []);
});
test('une table supprimée après la retenue : ses occupants attendent en réserve, et le remède de SIEGE_OCCUPE s’exécute tel quel', () => {
const charge = chargeRetenue();
charge.tables = charge.tables.filter(({ id }) => id !== 8);
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [7, 8, 9, 10, 11, 12]);
const occupe = refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 7, table: 5, siege: 1 }), 'SIEGE_OCCUPE', {
occupant: 1,
});
const { commande, arguments: args } = occupe.details.remede;
assert.deepEqual([commande, args], ['echangerDansRetenu', { tour: 1, a: 7, b: 1 }]);
const { charge: apres } = executer(commande, charge, args);
// 7 prend la chaise 1 ; 1 attend en réserve, et personne n'entre dans la
// liste de la table que la charge n'a plus.
assert.deepEqual(listes(apres, 1), [[7, 2, 3, 4, 5, 6], [null, 8, 9, 10, 11, 12]]);
assert.deepEqual(reserveDe(apres, 1), [1]);
assert.deepEqual(listes(apres, 2), listes(charge, 2));
const etat = etatPlaces(apres, { tour: 1 });
assert.equal(etat.tables.length, 1);
assert.equal(etat.tables[0].chaises[0].occupant, 7);
assert.deepEqual(etat.reserve, [1, 8, 9, 10, 11, 12]);
});
test('une table réduite après la retenue : un rang au-delà de sa capacité se lit comme la réserve, dans l’échange aussi', () => {
const charge = chargeRetenue();
charge.tables[0].sieges = 4;
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [5, 6]);
const occupe = refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 5, table: 5, siege: 1 }), 'SIEGE_OCCUPE', {
occupant: 1,
});
const { charge: apres } = executer(occupe.details.remede.commande, charge, occupe.details.remede.arguments);
assert.deepEqual(listes(apres, 1)[0], [5, 2, 3, 4, null, 6]);
assert.deepEqual(reserveDe(apres, 1), [1]);
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [1, 6]);
});
test('sans attribution, une table réduite après la retenue : une personne que le plan montre en réserve s’y trouve pour déplacer et retirer', () => {
const charge = chargeRetenue({ siegesAttribues: false });
charge.tables[0].sieges = 4;
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [5, 6]);
// La table affichée assied quatre présents : pleine, et non SANS_EFFET.
const pleine = refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 5, siege: null }), 'TABLE_PLEINE', {
table: 5,
tour: 1,
capacite: 4,
});
assert.deepEqual(pleine.details.remede, { commande: 'changerPlacesTable', arguments: { id: 5, sieges: 5, origine: 'champ' } });
// Déjà en réserve à l'affichage : retirer du tour n'a pas d'effet.
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 6 }), 'SANS_EFFET');
// Vers une autre table, elle part de la réserve.
const { charge: apres, libelle } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 8, siege: null });
assert.equal(libelle, 'Déplacé Capucine Sarcelle de la réserve à la table 7, tour 1');
assert.deepEqual(listes(apres, 1), [[1, 2, 3, 4], [6, 7, 8, 9, 10, 11, 12]]);
assert.deepEqual(reserveDe(apres, 1), [5]);
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [5]);
});
test('sans attribution, une table réduite après la retenue : chaque geste verse d’abord en réserve ceux que le plan n’y montre pas, et n’assied que ceux qu’il nomme', () => {
const charge = chargeRetenue({ siegesAttribues: false });
charge.tables[0].sieges = 4;
figer(charge);
const occupants = (apres) => etatPlaces(apres, { tour: 1 }).tables.map(({ chaises }) => chaises.map(({ occupant }) => occupant));
assert.deepEqual(occupants(charge), [
[1, 2, 3, 4],
[7, 8, 9, 10, 11, 12, null, null],
]);
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [5, 6]);
// 6, en réserve à l'affichage, prend la chaise de 2 ; 5 reste en réserve.
const echange = executer('echangerDansRetenu', charge, { tour: 1, a: 6, b: 2 }).charge;
assert.deepEqual(occupants(echange), [
[1, 3, 4, 6],
[7, 8, 9, 10, 11, 12, null, null],
]);
assert.deepEqual(etatPlaces(echange, { tour: 1 }).reserve, [2, 5]);
assert.deepEqual(listes(echange, 1), [[1, 3, 4, 6], [7, 8, 9, 10, 11, 12]]);
assert.deepEqual(reserveDe(echange, 1), [2, 5]);
// 7, venu d'une autre table, prend la chaise de 2 et s'y voit assis.
const croise = executer('echangerDansRetenu', charge, { tour: 1, a: 7, b: 2 }).charge;
assert.deepEqual(occupants(croise), [
[1, 3, 4, 7],
[2, 8, 9, 10, 11, 12, null, null],
]);
assert.deepEqual(etatPlaces(croise, { tour: 1 }).reserve, [5, 6]);
// 2, de la table réduite, prend la chaise de 7 : la table de a se verse
// aussi, et 7 s'assied à la chaise de 2 sans que 5 la lui dispute.
const inverse = executer('echangerDansRetenu', charge, { tour: 1, a: 2, b: 7 }).charge;
assert.deepEqual(occupants(inverse), [
[1, 3, 4, 7],
[2, 8, 9, 10, 11, 12, null, null],
]);
assert.deepEqual(etatPlaces(inverse, { tour: 1 }).reserve, [5, 6]);
assert.deepEqual(listes(inverse, 1), [[1, 3, 4, 7], [2, 8, 9, 10, 11, 12]]);
// Le départ de 2 libère sa chaise : 5 ne s'y assied pas.
const retire = executer('retirerDuTour', charge, { tour: 1, participant: 2 }).charge;
assert.deepEqual(occupants(retire), [
[1, 3, 4, null],
[7, 8, 9, 10, 11, 12, null, null],
]);
assert.deepEqual(etatPlaces(retire, { tour: 1 }).reserve, [2, 5, 6]);
assert.deepEqual(listes(retire, 1), [[1, 3, 4], [7, 8, 9, 10, 11, 12]]);
const deplace = executer('deplacerDansRetenu', charge, { tour: 1, participant: 2, table: 8, siege: null }).charge;
assert.deepEqual(occupants(deplace), [
[1, 3, 4, null],
[2, 7, 8, 9, 10, 11, 12, null],
]);
assert.deepEqual(etatPlaces(deplace, { tour: 1 }).reserve, [5, 6]);
assert.deepEqual(listes(deplace, 1), [[1, 3, 4], [2, 7, 8, 9, 10, 11, 12]]);
for (const apres of [echange, croise, inverse, retire, deplace]) {
assert.deepEqual(fautes(apres.retenu), []);
for (const tour of [2, 3]) assert.deepEqual(apres.retenu.placement[tour - 1], charge.retenu.placement[tour - 1]);
}
});
test('sans attribution, une table réduite : la personne réservée à cette table y reste assise, et un autre départ ne la verse pas en réserve', () => {
// 6, réservé à la table 4 sans siège, est au-delà de ses quatre places
// dans la liste : l'affichage l'assied avant 4 et 5, sans réservation.
const charge = chargeRetenue({ siegesAttribues: false, reservations: [resa(6, 5, null)] });
charge.tables[0].sieges = 4;
figer(charge);
const occupantsTable4 = (etat) => etat.tables[0].chaises.map(({ occupant }) => occupant);
assert.deepEqual(occupantsTable4(etatPlaces(charge, { tour: 1 })), [1, 2, 3, 6]);
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [4, 5]);
const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 2 });
// Seule la chaise que 2 libère reste vide.
assert.deepEqual(occupantsTable4(etatPlaces(apres, { tour: 1 })), [1, 3, 6, null]);
assert.deepEqual(listes(apres, 1), [[1, 3, 6], [7, 8, 9, 10, 11, 12]]);
assert.deepEqual(reserveDe(apres, 1), [2, 4, 5]);
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [2, 4, 5]);
assert.deepEqual(
avertissementsApres(charge, apres).filter(({ code }) => code === 'RESERVATION_NON_HONOREE'),
[],
);
});
test('sièges attribués, une table réduite après la retenue : le rang au-delà de sa capacité part de la réserve vers une chaise libre', () => {
const charge = chargeRetenue({
tours: [
[[1, null, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
[[1, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
],
});
charge.tables[0].sieges = 4;
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [2, 5, 6]);
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 5 }), 'SANS_EFFET');
const { charge: apres, libelle } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 5, table: 5, siege: 2 });
assert.equal(libelle, 'Déplacé Anouk Bruyère de la réserve à la table 4, tour 1');
assert.deepEqual(listes(apres, 1)[0], [1, 5, 3, 4, null, 6]);
assert.deepEqual(reserveDe(apres, 1), [2]);
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [2, 6]);
assert.deepEqual(fautes(apres.retenu), []);
});
});
describe('commandes des places : le vérificateur d’invariants du retenu (§ 14.12, § 14.15)', () => {
test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, sans écriture', () => {
// Le retenu reçu porte déjà une violation : 7, réservé à la table 4 au
// tour 2, y est assis à la table 7 ; elle n'entre pas dans le refus.
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null, 1), resa(7, 5, null, 2)] }));
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
proposition: 1,
violations: [{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 }],
remede: null,
});
const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 5 });
assert.deepEqual(reserveDe(apres, 1), [5]);
});
test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => {
const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] });
charge.participants.push(personne(13));
charge.prochainsIds.participant = 14;
const { charge: apres } = executer('retirerDuTour', charge, { tour: 2, participant: 9 });
assert.deepEqual(reserveDe(apres, 2), [9]);
const { charge: ensuite } = executer('echangerDansRetenu', apres, { tour: 2, a: 9, b: 10 });
assert.deepEqual(reserveDe(ensuite, 2), [10]);
});
});
describe('enregistrerGeneration : les sièges réservés dans une génération (§ 4, § 5.3, § 8.9)', () => {
// Douze personnes, deux tables, Théo réservé « tous » au siège siege de la
// table 2, sièges attribués.
function chargeGeneration({ siege, capacite = 8, attribuerSieges = true }) {
const charge = creerCharge({ id: 'evt-generation', nom: 'Soirée des Lucioles', siegesParDefaut: capacite, tours: 3 });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = DOUZE.map((id) => personne(id));
charge.tables = [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 400, y: 0 } },
];
charge.reservations = [resa(2, 2, siege)];
charge.prochainsIds = { participant: 13, table: 3, proposition: 1 };
return charge;
}
test('Théo, réservé au siège 3 de la table 2, y prend le rang 3 à chaque tour de chaque proposition', () => {
const charge = chargeGeneration({ siege: 3 });
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 7, arret: 60, nombre: 2 });
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee });
assert.equal(apres.propositions.length, 2);
for (const proposition of apres.propositions) {
assert.deepEqual(fautes(proposition), []);
const rang = proposition.tables.indexOf(2);
assert.equal(proposition.placement.length, 3);
for (const { sieges } of proposition.placement) {
assert.equal(sieges[rang][2], 2);
assert.notEqual(sieges[rang].at(-1), null);
}
}
});
test('réservé au siège 8 d’une table de 8 qui en assied six : au rang 8, les rangs 6 et 7 à null', () => {
const charge = chargeGeneration({ siege: 8 });
const configuration = configurationDepuisCharge(charge);
const tour = { tables: [1, 2], tours: [[1, 3, 4, 5, 6, 7], [12, 2, 8, 11, 9, 10]] };
const proposition = {
id: 1,
graine: 48271,
arret: 60,
historique: 1000,
plan: { tables: tour.tables, tours: [tour.tours, tour.tours, tour.tours], reserves: [[], [], []] },
};
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions: [proposition], configuration, produitVersion: VERSION.affichee });
assert.equal(apres.propositions[0].placement.length, 3);
for (const { sieges } of apres.propositions[0].placement) {
assert.deepEqual(sieges, [[1, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, null, null, 2]]);
}
// Sans attribution, la liste reste triée par identifiant.
const libre = chargeGeneration({ siege: 8, attribuerSieges: false });
const { charge: triee } = executer('enregistrerGeneration', libre, {
propositions: [proposition],
configuration: configurationDepuisCharge(libre),
produitVersion: VERSION.affichee,
});
assert.deepEqual(triee.propositions[0].placement[0].sieges, [[1, 3, 4, 5, 6, 7], [2, 8, 9, 10, 11, 12]]);
});
test('une table supprimée et une capacité réduite pendant la génération : la proposition s’enregistre, rangée sur la configuration, en dérive', () => {
const charge = chargeGeneration({ siege: 3 });
charge.tables.push({ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 800, y: 0 } });
charge.prochainsIds.table = 4;
const configuration = configurationDepuisCharge(charge);
const tours = [[1, 3, 4], [5, 6, 2, 7, 8, 9, 10, 11], [12]];
const proposition = {
id: 1,
graine: 48271,
arret: 60,
historique: 1000,
plan: { tables: [1, 2, 3], tours: [tours, tours, tours], reserves: [[], [], []] },
};
// Entre la recherche et l'enregistrement : la table 3 disparaît, la
// table 2 passe à six places alors que la proposition y en assied huit.
charge.tables = charge.tables.filter(({ id }) => id !== 3);
charge.tables[1].sieges = 6;
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions: [proposition], configuration, produitVersion: VERSION.affichee });
assert.equal(apres.propositions.length, 1);
const [enregistree] = apres.propositions;
assert.deepEqual(fautes(enregistree), []);
assert.equal(enregistree.placement.length, 3);
for (const { sieges } of enregistree.placement) assert.deepEqual(sieges, [[1, 3, 4], [5, 6, 2, 7, 8, 9, 10, 11], [12]]);
assert.ok(derive(enregistree, apres).length > 0);
});
test('une table agrandie pendant la génération, puis réservée à un siège nouveau : le rangement suit la capacité de la configuration', () => {
const charge = chargeGeneration({ siege: null });
charge.reservations = [];
const configuration = configurationDepuisCharge(charge);
const tours = [[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]];
const proposition = {
id: 1,
graine: 48271,
arret: 60,
historique: 1000,
plan: { tables: [1, 2], tours: [tours, tours, tours], reserves: [[], [], []] },
};
// Entre la recherche et l'enregistrement : la table 2 passe à dix places,
// et 7 y est réservé au siège 9, au-delà des huit de la configuration.
charge.tables[1].sieges = 10;
charge.reservations = [resa(7, 2, 9)];
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions: [proposition], configuration, produitVersion: VERSION.affichee });
assert.equal(apres.propositions.length, 1);
const [enregistree] = apres.propositions;
assert.deepEqual(fautes(enregistree), []);
assert.equal(enregistree.placement.length, 3);
// Le siège 9 n'existe pas dans la configuration : 7 se range par identifiant.
for (const { sieges } of enregistree.placement) assert.deepEqual(sieges, tours);
});
});
describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
// Détails d'exemple de chaque code de libelles/places.js, hors libellés
// figés, que les commandes composent ci-dessus.
const EXEMPLES = new Map([
['SIEGE_INEXISTANT', [{ table: 2, siege: 9, capacite: 8 }, '9']],
['DEUX_PLACES_MEME_TOUR', [{ participant: 1, tour: 2, autre: { table: 1, siege: null, portee: 'tous', tour: null } }, 'tour 2']],
['PERSONNE_EXCLUE', [{ participant: 3 }, 'exclue']],
['PLACE_RESERVEE', [{ table: 2, siege: 3, tour: 2, titulaire: 2, portee: 'tour' }, 'au tour 2']],
['RESERVEE_AILLEURS', [{ participant: 1, tour: 2, reservation: { table: 1, siege: null, portee: 'tous', tour: null } }, 'tour 2']],
['TABLE_GELEE', [{ table: 1 }, 'réservées']],
['LIBELLE_VIDE', [{}, 'vide']],
['PLACE_TITREE', [{ table: 3, siege: 1, libelle: 'hôte' }, '« hôte »']],
['AUCUN_SIEGE_SANS_TITRE', [{ table: 1, capacite: 2 }, '2 sièges']],
['TITRE_ABSENT', [{ table: 3, siege: 1 }, 'siège 1']],
['RESERVATION_ABSENTE', [{ participant: 1, table: 3, portee: 'tour', tour: 2 }, 'réservation']],
['CONVERSION_IMPOSSIBLE', [{ participant: 1, table: 3 }, 'convertir']],
['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']],
['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']],
['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']],
]);
test('chaque code de la table des places a son texte, composé de ses détails', () => {
const codes = [...TEXTES_DES_PLACES.keys()].filter((cle) => /^[A-Z][A-Z0-9_]*$/.test(cle) && !cle.startsWith('ENTREE_'));
assert.ok(codes.length > 0);
assert.deepEqual(codes.sort(), [...EXEMPLES.keys()].sort());
for (const [code, [details, attendu]] of EXEMPLES) {
const texte = libelle(code, { ...details, remede: null });
assert.ok(texte.includes(attendu), `${code} : ${texte}`);
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
}
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
assert.equal(libelle('places.titreParDefaut'), 'animateur');
});
});

View file

@ -0,0 +1,491 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La façade des événements (§ 8.1, § 8.6, § 8.7, § 8.8, § 15) : la
// détermination du dossier de travail, une fois par séance, ses avis et ses
// questions ; la liste des événements ; les opérations de fichiers de la
// liste — créer, charger une démonstration, copier, renommer, supprimer,
// importer — et les dialogues de fichiers, relayés tels quels. Elle est, avec
// la séance, le seul écrivain que l'interface atteint.
//
// La façade ne connaît pas la séance : elle reçoit de quoi savoir quel
// événement est ouvert, l'ouvrir, le fermer, en créer un, lire et poser le
// dossier de travail, et appelle chacun au moment du geste. Une primitive du
// système de fichiers se lit sur fs à l'appel, jamais à la création : une
// épreuve la remplace après le montage.
//
// Lister ne fait que lire. Une opération qui écrit dérive ses refus lisibles
// sans écrire — nom vide, plan bloqué, format plus récent — avant toute
// écriture, sonde de verrou comprise. Renommer et supprimer ferment d'abord
// l'événement visé quand il est ouvert : la séance rend son verrou, et la
// façade sonde alors celui du disque sous son propre identifiant de séance,
// tiré une fois à sa création ; une séance vivante, ou dont la vie ne se
// prouve pas, qui tient l'événement le refuse. Un refus qui suit la fermeture
// rouvre l'événement, en lecture, tel qu'il était.
//
// Les libellés figés qu'elle écrit au journal — copie, renommage, chargement
// d'une démonstration, jalon de création, reprise — se composent par
// libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis
// et questions sont des codes et leurs détails.
import { deverrouiller, lister as listerDepot, ouvrir as ouvrirDepot, verrouiller } from '../stockage/depot.js';
import { determinerDossierTravail } from '../stockage/dossier_travail.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { decoderJournal, lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import {
chargerDemonstration as chargerSurDisque,
copier as copierSurDisque,
importer,
importerDepuis,
libelleDeLInstant,
listerCorbeille,
renommer as renommerSurDisque,
supprimer as supprimerSurDisque,
} from '../stockage/operations.js';
import { lireReglages } from '../stockage/reglages_locaux.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
/** Sièges par défaut et tours d'un événement neuf (§ 2.1, étapes 4 et 5). */
export const SIEGES_PAR_DEFAUT = 8;
export const TOURS_PAR_DEFAUT = 4;
// Les réponses qu'admet chaque question du dossier de travail (§ 8.6).
const REPONSES = new Map([
['PORTABLE_NON_INSCRIPTIBLE', ['reessayer', 'documents']],
['DOCUMENTS_NON_INSCRIPTIBLE', ['reessayer', 'continuer']],
]);
// Ce que rend la façade d'un verrou présent qui ne se lit pas.
const VERROU_ILLISIBLE = Object.freeze({ seance: null, depuis: null });
/**
* @typedef {import('../stockage/systeme_fichiers.js').Racine} Racine
* @typedef {import('../stockage/dossier_travail.js').DossierTravail} DossierTravail
* @typedef {{code: string, details: Object}} Avis
*/
// Les avis d'une détermination, dans cet ordre : la raison du repli en
// Documents, les avertissements de la règle, puis celui des réglages locaux
// illisibles, quand il y en a un.
function avisDe(dossier, avertissementReglages) {
const avis = [];
if (dossier.raison !== null) {
avis.push({ code: dossier.raison, details: dossier.raison === 'SONDE_ECHOUEE' ? { cause: dossier.cause } : {} });
}
for (const code of dossier.avertissements) avis.push({ code, details: {} });
if (avertissementReglages !== null) avis.push(avertissementReglages);
return avis;
}
// Un nom saisi : en NFC, ses blancs de bout retirés ; vide, NOM_REQUIS. Ce
// qui n'est pas une chaîne est une faute du code.
function nomSaisi(nom) {
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
const texte = nom.normalize('NFC').trim();
if (texte === '') throw refus('NOM_REQUIS');
return texte;
}
// Le chemin affichable, en entier, du chemin relatif d'une racine : ses
// segments joints au séparateur du système.
function cheminAffiche(racine, separateur, relatif) {
const tete = racine.chemin.endsWith(separateur) ? racine.chemin : racine.chemin + separateur;
return tete + relatif.split('/').join(separateur);
}
// Un refus de la couche qui le lève : une erreur dont l'interface lit le
// code et les détails. Toute autre erreur est une faute ou une panne.
const estRefus = (erreur) => erreur instanceof ErreurStockage || erreur instanceof ErreurCommande;
// Le refus d'un événement qu'aucun geste de la liste ne doit réécrire : un
// format plus récent, puis un plan bloqué, que « Débloquer » rend
// modifiable.
function exigerModifiable({ formatPlusRecent, charge }) {
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT', remede: null });
if (charge.evenement.etat === 'bloque') {
throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE', remede: { geste: 'debloquer' } });
}
}
// L'horodatage de la dernière entrée du journal de base, tel que l'horloge
// de l'application qui l'a écrite l'a noté (§ 19.4) ; null sans journal,
// sans entrée lisible, ou quand le fichier ne se lit pas. Un journal dont une
// ligne n'est pas de l'UTF-8 se lit jusqu'à elle, comme à l'ouverture.
async function derniereModification(fs, racine, base) {
if (base === null) return null;
let texte;
try {
texte = await fs.lireTexte(racine, base + SUFFIXES.journal);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
if (erreur.code !== 'LECTURE' || erreur.details.cause !== 'UTF8_INVALIDE') return null;
texte = decoderJournal(erreur.details.octets).texte;
}
if (texte === null) return null;
return lireJournal(texte).entrees.at(-1)?.horodatage ?? null;
}
// Pourquoi l'état d'un résumé illisible ne s'ouvre pas : les détails de
// l'ETAT_ILLISIBLE qu'ouvrir lève — raison, chemin, format quand il en nomme
// un, et les secours lisibles —, sans rien écrire. Un état redevenu lisible,
// ou disparu, depuis la liste du dépôt garde la raison du résumé, sans
// secours.
async function illisibleDe(fs, racine, { base, illisible }) {
if (illisible === null) return null;
try {
await ouvrirDepot(fs, racine, base);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
if (erreur.code === 'ETAT_ILLISIBLE') return { ...erreur.details };
}
return { raison: illisible, secours: null };
}
// Le .gtt.verrou de base, lu sans rien écrire : sa séance et l'instant de sa
// prise ; null quand il manque ; seance et depuis null quand il ne se lit
// pas. Seule une sonde, qui écrit, dit si sa séance vit.
async function verrouDe(fs, racine, base) {
let texte;
try {
texte = await fs.lireTexte(racine, base + SUFFIXES.verrou);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
return erreur.code === 'ABSENT' ? null : { ...VERROU_ILLISIBLE };
}
let contenu;
try {
contenu = JSON.parse(texte);
} catch {
contenu = null;
}
const lu = contenu !== null && typeof contenu === 'object' && typeof contenu.seance === 'string' && typeof contenu.depuis === 'string';
return lu ? { seance: contenu.seance, depuis: contenu.depuis } : { ...VERROU_ILLISIBLE };
}
/**
* La façade des événements, branchée par App sur la séance (I1).
*
* @param {Object} parametres
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
* @param {import('./horloge.js').Horloge} parametres.horloge
* @param {() => string} parametres.identifiant tiré une fois à la création,
* pour la séance sous laquelle la façade sonde les verrous, puis une
* fois par événement qu'elle copie ou charge
* @param {string} parametres.produitVersion
* @param {Array<{cle: string, nom: string, texte: string}>} parametres.demonstrations
* @param {() => string|null} parametres.ouvert base de l'événement ouvert
* @param {(base: string) => Promise<unknown>} parametres.ouvrir en lecture
* @param {() => Promise<unknown>} parametres.fermer
* @param {(champs: Object) => Promise<{base: string}>} parametres.creer
* celle de la séance, qui juge nom et date et ouvre l'événement créé
* @param {() => Racine|null} parametres.racine le dossier de travail
* @param {(racine: Racine) => Promise<unknown>} parametres.changerRacine
*/
export function creerFacade({
fs,
horloge,
identifiant,
produitVersion,
demonstrations,
ouvert,
ouvrir,
fermer,
creer,
racine,
changerRacine,
}) {
const seance = identifiant();
const libelleReprise = libelle('ENTREE_REPRISE');
const nomJalon = libelle('JALON_CREATION');
// resultat : la dernière détermination aboutie, {dossier, avis} ; enCours :
// la promesse de celle qui se fait, null sinon.
let resultat = null;
let enCours = null;
// La règle du § 8.6, puis les réglages locaux du dossier retenu, lus une
// fois ; la racine se pose quand aucune question n'attend. Un rejet ne
// retient rien.
async function determiner() {
const dossier = await determinerDossierTravail(fs);
const { avertissement } = await lireReglages(fs, dossier.racine);
const issue = { dossier, avis: avisDe(dossier, avertissement) };
if (dossier.question === null) await changerRacine(dossier.racine);
resultat = issue;
return issue;
}
function demarrer() {
if (resultat !== null) return Promise.resolve(resultat);
if (enCours === null) {
enCours = determiner().finally(() => {
enCours = null;
});
}
return enCours;
}
// Le dossier de travail d'une opération : la détermination en cours
// attendue ; QUESTION_EN_ATTENTE tant qu'une question attend sa réponse.
async function exigerDossier() {
if (enCours !== null) await enCours.catch(() => undefined);
const question = resultat?.dossier.question ?? null;
if (question !== null) throw refus('QUESTION_EN_ATTENTE', { question: question.code });
const courante = racine();
if (courante === null) throw new RangeError("façade : aucun dossier de travail, demarrer n'a pas abouti");
return courante;
}
async function repondre(choix) {
const question = resultat?.dossier.question ?? null;
if (question === null) throw new RangeError('repondre : aucune question du dossier de travail n’attend');
if (!REPONSES.get(question.code).includes(choix)) {
throw new RangeError(`repondre : ${JSON.stringify(choix)} ne répond pas à ${question.code}`);
}
if (choix === 'reessayer') {
resultat = null;
return demarrer();
}
const { dossier, avis } = resultat;
let importe = null;
let suite = { ...dossier, question: null };
if (choix === 'documents') {
// data/ illisible, compte null : rien à copier.
const bases = question.evenements === null ? [] : (await listerDepot(fs, question.racine)).map(({ base }) => base);
importe = await importerDepuis(fs, question.racine, dossier.racine, bases);
suite = { ...suite, autre: null };
}
await changerRacine(suite.racine);
resultat = { dossier: suite, avis };
return importe === null ? resultat : { ...resultat, importe };
}
async function lister() {
const courante = await exigerDossier();
const resumes = await listerDepot(fs, courante);
const { separateur } = await fs.emplacements();
const lignes = [];
for (const resume of resumes) {
const { base, id, doublon } = resume;
lignes.push({
base,
id,
nom: resume.nom,
date: resume.date,
etat: resume.etat,
participants: resume.participants,
derniereModification: await derniereModification(fs, courante, resume.journal),
chemin: cheminAffiche(courante, separateur, base + SUFFIXES.etat),
illisible: await illisibleDe(fs, courante, resume),
journalSeul: resume.illisible === 'ABSENT',
doublonAvec: doublon
? resumes.filter((autre) => autre !== resume && autre.doublon && autre.id === id).map((autre) => autre.base)
: [],
verrou: await verrouDe(fs, courante, base),
});
}
return lignes;
}
// Le verrou de l'événement cible pris sous la séance de la façade. Le sien,
// resté d'un rendu refusé, est déjà pris ; celui d'une séance morte
// (vivant faux) s'efface en son nom, puis se prend ; tout autre lève
// VERROU_PRIS, sans rien changer.
async function prendreVerrou(cible) {
let verrou = await verrouiller(cible, seance);
if (!verrou.pris && verrou.seance === seance) return;
if (!verrou.pris && verrou.vivant === false) {
await deverrouiller(cible, verrou.seance);
verrou = await verrouiller(cible, seance);
}
if (!verrou.pris) {
const { seance: tenue, depuis, vivant } = verrou;
throw new ErreurStockage('VERROU_PRIS', { seance: tenue, depuis, vivant, remede: null });
}
}
// Après le refus d'une opération qui a fermé l'événement base, le rouvre en
// lecture ; une panne, qui n'est pas un refus, laisse tout. Un échec de
// cette réouverture ne masque pas le refus.
async function rouvrirApresRefus(base, etaitOuvert, erreur) {
if (etaitOuvert && estRefus(erreur)) await ouvrir(base).catch(() => undefined);
}
async function creerEvenement({ nom, date = null }) {
await exigerDossier();
return creer({ nom, date, siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT });
}
async function chargerDemonstration(cle) {
const demonstration = demonstrations.find((candidate) => candidate.cle === cle);
if (demonstration === undefined) throw new RangeError(`aucune démonstration de clé ${JSON.stringify(cle)}`);
const courante = await exigerDossier();
const charge = await chargerSurDisque(fs, courante, demonstration.texte, {
id: identifiant(),
libelle: libelle('ENTREE_DEMONSTRATION', { nom: demonstration.nom }),
nomJalon,
horodatage: horloge.horodatage(),
produitVersion,
});
await ouvrir(charge.base);
return { base: charge.base };
}
async function copier(base, { nom }) {
const texte = nomSaisi(nom);
const courante = await exigerDossier();
const source = await ouvrirDepot(fs, courante, base);
// L'instant copié, par la règle même qui l'inscrit à la filiation.
const instant = libelleDeLInstant(source, libelleReprise);
const copie = await copierSurDisque(source, {
id: identifiant(),
nom: texte,
libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }),
nomJalon,
horodatage: horloge.horodatage(),
produitVersion,
libelleReprise,
});
await ouvrir(copie.base);
return { base: copie.base };
}
async function renommer(base, { nom }) {
const texte = nomSaisi(nom);
const courante = await exigerDossier();
exigerModifiable(await ouvrirDepot(fs, courante, base));
const etaitOuvert = ouvert() === base;
if (etaitOuvert) await fermer();
const cible = { fs, racine: courante, base };
let renomme;
try {
await prendreVerrou(cible);
try {
// Relu sous le verrou : une autre séance a pu l'écrire depuis.
const source = await ouvrirDepot(fs, courante, base);
exigerModifiable(source);
renomme = await renommerSurDisque(source, {
nom: texte,
libelle: libelle('ENTREE_RENOMMER', { ancien: source.charge.evenement.nom, nom: texte }),
horodatage: horloge.horodatage(),
produitVersion,
libelleReprise,
});
} catch (erreur) {
await deverrouiller(cible, seance).catch(() => undefined);
throw erreur;
}
} catch (erreur) {
await rouvrirApresRefus(base, etaitOuvert, erreur);
throw erreur;
}
// Le renommage fait, un verrou qui ne se rend pas le dit sans le défaire.
const avertissements = [];
try {
await deverrouiller(cible, seance);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
avertissements.push({ code: 'VERROU_NON_RENDU', details: erreur.details });
}
if (etaitOuvert) await ouvrir(renomme.base);
return { base: renomme.base, avertissements };
}
async function supprimer(base) {
const courante = await exigerDossier();
const etaitOuvert = ouvert() === base;
if (etaitOuvert) await fermer();
try {
return await supprimerSurDisque(fs, courante, base, { horodatage: horloge.horodatage(), seance });
} catch (erreur) {
await rouvrirApresRefus(base, etaitOuvert, erreur);
if (erreur instanceof ErreurStockage && erreur.code === 'VERROU_PRIS') {
throw new ErreurStockage('VERROU_PRIS', { ...erreur.details, remede: null });
}
throw erreur;
}
}
async function corbeille() {
const courante = await exigerDossier();
const { separateur } = await fs.emplacements();
return (await listerCorbeille(fs, courante)).map(({ dossier, fichiers }) => ({
dossier,
chemin: cheminAffiche(courante, separateur, dossier),
fichiers,
}));
}
async function importerFichier() {
const courante = await exigerDossier();
const choisi = await fs.choisirFichierAImporter();
if (choisi === null) return null;
return importer(fs, courante, choisi.octets);
}
async function importerDepuisAutre() {
const vers = await exigerDossier();
const autre = resultat.dossier.autre;
if (autre === null) throw new RangeError("importerDepuisAutre : l'autre emplacement ne propose rien");
const bases = (await listerDepot(fs, autre.racine)).map(({ base }) => base);
const importe = await importerDepuis(fs, autre.racine, vers, bases);
resultat = { ...resultat, dossier: { ...resultat.dossier, autre: null } };
return importe;
}
function explorateur() {
return fs.nature === 'web'
? { disponible: false, raison: 'EXPLORATEUR_ABSENT' }
: { disponible: true, raison: null };
}
async function ouvrirExplorateur() {
if (fs.nature === 'web') throw refus('EXPLORATEUR_ABSENT');
return fs.ouvrirDansExplorateur(await exigerDossier());
}
return {
/**
* La détermination du dossier de travail, une fois par séance (§ 8.6) :
* { dossier, avis }, le même tant qu'aucune réponse ne la refait. Une
* détermination qui rejette ne retient rien, et l'appel suivant la refait.
* @returns {Promise<{dossier: DossierTravail, avis: Avis[]}>}
*/
demarrer,
/**
* La réponse à la question du dossier de travail : comme demarrer ;
* 'documents' rend aussi importe, ce que la copie de data/ a importé et
* refusé (importerDepuis). RangeError sans question, ou pour un choix
* qu'elle n'admet pas.
* @param {'reessayer'|'documents'|'continuer'} choix
*/
repondre,
/** Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien. */
lister,
/** Crée et ouvre en lecture l'événement { nom, date } (§ 2.1, étape 1). */
creer: creerEvenement,
/** Charge la démonstration de clé donnée sous « ‹nom› N », ouverte en lecture (§ 15). */
chargerDemonstration,
/** Copie l'événement base sous le nom donné, et ouvre la copie en lecture (§ 8.7). */
copier,
/** Renomme l'événement base ; ouvert, il se rouvre en lecture sous sa nouvelle base (§ 8.7). */
renommer,
/** Déplace les fichiers de l'événement base dans la corbeille datée (§ 8.7). */
supprimer,
/** Le contenu de la corbeille : [{ dossier, chemin, fichiers }] (§ 8.7). */
corbeille,
/** Importe le fichier d'événement que l'opérateur choisit → { base }, ou null (§ 8.7). */
importerFichier,
/** Copie dans le dossier de travail les événements de l'autre emplacement (§ 8.6). */
importerDepuisAutre,
/** Le dialogue de choix d'un fichier, relayé : { nom, octets } ou null. */
choisirCsv: () => fs.choisirFichierAImporter(),
/** Le dialogue d'enregistrement, relayé : le chemin choisi, ou null. */
enregistrerSous: (nomPropose, octets) => fs.enregistrerSous(nomPropose, octets),
/** { disponible, raison } : raison EXPLORATEUR_ABSENT sous web (§ 8.6). */
explorateur,
/** Montre le dossier de travail dans l'explorateur du système. */
ouvrirExplorateur,
};
}

File diff suppressed because it is too large Load diff

View file

@ -308,6 +308,32 @@ const EXEMPLES = new Map([
['PLAN_NON_BLOQUE', { remede: null }],
['JALON_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
['SANS_EFFET', { commande: 'modifierParticipant', remede: null }],
['RETOUR_AUTOMATIQUE', {}],
[
'DERIVE_NOUVELLE',
{
touchees: [
{ id: 'retenu', raisons: [{ code: 'TABLE_SUPPRIMEE', table: 2 }] },
{ id: 3, raisons: [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 8, maintenant: 6 }] },
],
},
],
['RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1 }],
[
'INVARIANT_VIOLE',
{
proposition: 1,
violations: [
{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 },
{ code: 'CAPACITE', table: 5, tour: 2 },
],
remede: null,
},
],
['CONFIGURATION_INCOHERENTE', { code: 'SURRESERVATION', details: { table: 2, tour: 1, reservees: 9, capacite: 8 } }],
// Moteur
['PLACES_MANQUANTES', { placesManquantes: 3 }],
['SURRESERVATION', { table: 2, tour: 1, reservees: 9, capacite: 8, remede: null }],
// Refus et avertissements des commandes
['NOM_REQUIS', { remede: null }],
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
@ -321,6 +347,9 @@ const EXEMPLES = new Map([
['RESERVATIONS_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
['IMPORT_EN_RETENU', {}],
// Façade des événements
['QUESTION_EN_ATTENTE', { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }],
['EXPLORATEUR_ABSENT', { remede: null }],
// Libellés figés du journal
['ENTREE_CREATION', { nom: "Soirée d'essai" }],
['JALON_CREATION', {}],
@ -338,9 +367,38 @@ const EXEMPLES = new Map([
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
['ENTREE_DEBLOQUER', { etat: 'retenu' }],
['ENTREE_COPIE', { source: 'Soirée des Lucioles', instant: "Création de l'événement « Soirée des Lucioles »" }],
['ENTREE_RENOMMER', { ancien: 'Soirée des Lucioles', nom: 'Soirée des Cerfs' }],
['ENTREE_DEMONSTRATION', { nom: 'Petite démonstration' }],
['ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }],
['ENTREE_RETENIR_PROPOSITION', { id: 3 }],
['ENTREE_EFFACER_PROPOSITIONS', { n: 4, gardee: 2 }],
// Places : refus d'une pose, titres, réservations, retenu
['SIEGE_INEXISTANT', { table: 2, siege: 9, capacite: 8, remede: null }],
['DEUX_PLACES_MEME_TOUR', { participant: 1, tour: 2, autre: { table: 1, siege: null, portee: 'tous', tour: null }, remede: null }],
['PERSONNE_EXCLUE', { participant: 3, remede: null }],
['PLACE_RESERVEE', { table: 2, siege: 3, tour: 2, titulaire: 2, portee: 'tour', remede: null }],
['RESERVEE_AILLEURS', { participant: 1, tour: 2, reservation: { table: 1, siege: null, portee: 'tous', tour: null }, remede: null }],
['TABLE_GELEE', { table: 1 }],
['LIBELLE_VIDE', { remede: null }],
['PLACE_TITREE', { table: 3, siege: 1, libelle: 'accueil', remede: null }],
['AUCUN_SIEGE_SANS_TITRE', { table: 1, capacite: 2, remede: null }],
['TITRE_ABSENT', { table: 3, siege: 1, remede: null }],
['RESERVATION_ABSENTE', { participant: 1, table: 3, portee: 'tour', tour: 2, remede: null }],
['CONVERSION_IMPOSSIBLE', { participant: 1, table: 3, remede: null }],
['PROPOSITION_MONTREE', { proposition: 4, remede: null }],
['SIEGE_OCCUPE', { table: 5, siege: 2, tour: 1, occupant: 2, remede: null }],
['TABLE_PLEINE', { table: 5, tour: 1, capacite: 6, remede: null }],
['ENTREE_RESERVER', { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }],
['ENTREE_RETIRER_RESERVATION', { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }],
['ENTREE_DEPLACER_RESERVATION', { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }],
['ENTREE_POSER_TITRE', { libelle: 'accueil', numero: 3, siege: 1 }],
['ENTREE_RETIRER_TITRE', { libelle: 'accueil', numero: 3, siege: 1 }],
['ENTREE_DESIGNER', { personne: 'Iris Ombrelle', numero: 3, siege: 1, libelle: 'accueil' }],
['ENTREE_CONVERTIR_EN_TOUS', { personne: 'Iris Ombrelle', numero: 3 }],
['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 }],
]);
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni

View file

@ -2,8 +2,9 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes de la création d'un événement, de ses états et du déblocage
// (§ 8.1, § 9, § 14.6) : refus et libellés figés. Un état inconnu lève
// RangeError, au lieu de se taire.
// (§ 8.1, § 9, § 14.6), et ceux de la façade des événements (§ 8.6, § 8.7,
// § 15) : refus et libellés figés. Un état inconnu lève RangeError, au lieu
// de se taire.
import { cite, nomEtat } from './aides.js';
export const TABLE = new Map([
@ -15,8 +16,25 @@ export const TABLE = new Map([
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
// --- La façade des événements (§ 8.6) --------------------------------------
// question nomme le code de la question du dossier de travail qui attend.
[
'QUESTION_EN_ATTENTE',
() => "Le dossier de travail attend une réponse : la liste des événements s'ouvre une fois la question répondue.",
],
[
'EXPLORATEUR_ABSENT',
() => "Sous la plateforme web, aucun explorateur de fichiers ne montre le dossier de travail : il vit dans le navigateur.",
],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
// source : le nom de l'événement copié ; instant : le libellé de l'instant
// copié (§ 8.7).
['ENTREE_COPIE', ({ source, instant }) => `Créée par copie de ${cite(source)}, à l'instant ${cite(instant)}`],
['ENTREE_RENOMMER', ({ ancien, nom }) => `Renommé de ${cite(ancien)} en ${cite(nom)}`],
// nom : celui de la démonstration au catalogue, sans rang (§ 15).
['ENTREE_DEMONSTRATION', ({ nom }) => `Chargement de la démonstration ${cite(nom)}`],
]);

View file

@ -3,4 +3,21 @@
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
// libellés de ses critères (§ 5, § 14.6).
export const TABLE = new Map();
import { compte } from './aides.js';
export const TABLE = new Map([
// Une salle trop petite : avertissement d'un geste qui fait croître le
// manque (A2), refus de la génération (A6) ; un seul texte pour les deux.
[
'PLACES_MANQUANTES',
({ placesManquantes }) =>
`Il manque ${compte(placesManquantes, 'place', 'places')} à chaque tour pour asseoir toutes les personnes : ` +
'ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.',
],
[
'SURRESERVATION',
({ table, tour, reservees, capacite }) =>
`La table d'identifiant ${table} reçoit ${compte(reservees, 'personne réservée', 'personnes réservées')} ` +
`au tour ${tour}, pour ${compte(capacite, 'place', 'places')}.`,
],
]);

View file

@ -2,5 +2,131 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes des places — réservations, titres, pose (§ 5.8,
// § 5.9, § 14.6).
export const TABLE = new Map();
// § 5.9, § 8.2, § 14.6) : leurs refus, leurs signaux, leurs libellés figés,
// et le libellé de titre proposé à la désignation. Les détails désignent
// tables et personnes par identifiant : un refus se pose sur la place qu'il
// concerne (§ 5.9), et son texte ne les nomme pas. Un libellé figé reçoit le
// nom affiché et le numéro de table que la commande lit dans la charge.
// SURRESERVATION garde le texte du moteur (libelles/moteur.js), et
// INVARIANT_VIOLE celui de la séance (libelles/seance.js).
import { cite } from './aides.js';
// La portée d'une réservation dans un libellé figé.
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'tous les tours' : `tour ${tour}`);
// Une place nommée par un détail de libellé : la table d'un numéro, ou la
// réserve quand le numéro est null.
const lieu = (numero) => (numero === null ? 'réserve' : `table ${numero}`);
export const TABLE = new Map([
// --- Les refus d'une pose (§ 5.9) -----------------------------------------
[
'SIEGE_INEXISTANT',
({ siege, capacite }) => `La table n'a que ${capacite} sièges : le siège ${siege} n'existe pas.`,
],
[
'DEUX_PLACES_MEME_TOUR',
({ tour }) => `Cette personne a déjà une place au tour ${tour} : elle n'en occupe qu'une par tour.`,
],
['PERSONNE_EXCLUE', () => "Cette personne est exclue : elle n'est placée nulle part tant qu'elle n'est pas réintégrée."],
[
'PLACE_RESERVEE',
({ siege, tour, portee: genre }) =>
`Le siège ${siege} est réservé à une autre personne ${genre === 'tous' ? 'à tous les tours' : `au tour ${tour}`} : ` +
'déplacer sa réservation libère la place.',
],
[
'RESERVEE_AILLEURS',
({ tour }) => `Cette personne est réservée à une autre place au tour ${tour} : sa réservation se déplace d'abord.`,
],
[
'TABLE_GELEE',
() => "Toutes les places de cette table sont réservées pour la soirée : la génération n'y placera personne.",
],
// --- Les titres (§ 4, § 4.1) -----------------------------------------------
['LIBELLE_VIDE', () => 'Le libellé du titre est vide.'],
[
'PLACE_TITREE',
({ siege, libelle }) => `Le siège ${siege} porte déjà le titre ${cite(libelle)} : une place porte un seul titre.`,
],
[
'AUCUN_SIEGE_SANS_TITRE',
({ capacite }) => `Les ${capacite} sièges de la table portent déjà un titre.`,
],
['TITRE_ABSENT', ({ siege }) => `Le siège ${siege} ne porte aucun titre.`],
// --- Les réservations (§ 4, § 4.2) -----------------------------------------
['RESERVATION_ABSENTE', () => "Cette réservation n'existe pas."],
[
'CONVERSION_IMPOSSIBLE',
() => "Ces réservations ne couvrent pas chaque tour à cette table : il n'y a rien à convertir.",
],
// --- Le retenu (§ 5.8, § 7.1) ----------------------------------------------
[
'PROPOSITION_MONTREE',
({ proposition }) =>
`La proposition n° ${proposition} se montre en vue seule : seul le placement retenu se modifie.`,
],
[
'SIEGE_OCCUPE',
({ siege, tour }) => `Le siège ${siege} est occupé au tour ${tour} : déposer la personne sur l'autre les échange.`,
],
['TABLE_PLEINE', ({ capacite, tour }) => `La table est pleine au tour ${tour} : ses ${capacite} places sont occupées.`],
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
[
'ENTREE_RESERVER',
({ personne, numero, siege, ...reservation }) =>
`Réservé ${personne} à la table ${numero}${siege === null ? '' : `, siège ${siege}`}, ${portee(reservation)}`,
],
[
'ENTREE_RETIRER_RESERVATION',
({ personne, numero, ...reservation }) => `Réservation retirée : ${personne}, table ${numero}, ${portee(reservation)}`,
],
[
'ENTREE_DEPLACER_RESERVATION',
({ personne, de, vers, siege, ...reservation }) =>
`Réservation déplacée : ${personne}, de la table ${de} à la table ${vers}` +
`${siege === null ? '' : `, siège ${siege}`}, ${portee(reservation)}`,
],
[
'ENTREE_POSER_TITRE',
({ libelle, numero, siege }) => `Posé le titre ${cite(libelle)} à la table ${numero}, siège ${siege}`,
],
[
'ENTREE_RETIRER_TITRE',
({ libelle, numero, siege }) => `Retiré le titre ${cite(libelle)} de la table ${numero}, siège ${siege}`,
],
[
'ENTREE_DESIGNER',
({ personne, numero, siege, libelle }) => `Désigné ${personne} à la table ${numero}, siège ${siege} : ${cite(libelle)}`,
],
[
'ENTREE_CONVERTIR_EN_TOUS',
({ personne, numero }) => `Converti en ${cite('tous les tours')} : ${personne}, table ${numero}`,
],
[
'ENTREE_DEPLACER_DANS_RETENU',
// siege n'est donné que pour un déplacement dans la même table.
({ personne, de, vers, siege, tour }) =>
siege !== null
? `Déplacé ${personne} au siège ${siege} de la table ${vers}, tour ${tour}`
: `Déplacé ${personne} de la ${lieu(de)} à la table ${vers}, tour ${tour}`,
],
[
'ENTREE_ECHANGER_DANS_RETENU',
({ a, deA, b, deB, tour }) => `Échangé ${a} (${lieu(deA)}) et ${b} (${lieu(deB)}), tour ${tour}`,
],
[
'ENTREE_RETIRER_DU_TOUR',
({ personne, numero, tour }) =>
numero === null ? `Retiré ${personne} du tour ${tour}` : `Retiré ${personne} de la table ${numero}, tour ${tour}`,
],
// --- Les libellés de l'application ----------------------------------------
// Le libellé de titre proposé à la désignation quand rien d'autre ne le
// suggère : il devient une donnée du fichier, en français (§ 8.2).
['places.titreParDefaut', 'animateur'],
]);

View file

@ -5,7 +5,31 @@
// figés que la séance écrit au journal : jalons, reprise, retours. Un libellé
// d'entrée se compose une fois, à l'écriture du geste, et le journal en garde
// le texte, jamais le code (§ 8.2).
import { causeEntreParentheses, cite, depuisQuand, nomEtat, ouEcrire } from './aides.js';
import {
capitale,
causeEntreParentheses,
cite,
compte,
depuisQuand,
enumeration,
nomEtat,
ouEcrire,
phraseDeRaison,
} from './aides.js';
// Le placement qu'une dérive touche : le retenu, ou une proposition par son
// numéro.
const nomDuPlacement = (id) => (id === 'retenu' ? 'le placement retenu' : `la proposition n° ${id}`);
// Les raisons d'un placement, chacune par son texte.
const phrasesDe = (raisons, texte) => raisons.map((raison) => phraseDeRaison(raison.code, raison, texte)).join(' ');
// Une violation du retenu : la réservation qu'il ne tient pas par le texte
// de RESERVATION_NON_HONOREE, toute autre par le texte de son code.
const phraseDeViolation = (violation, texte) =>
violation.code === 'RESERVATION'
? texte('RESERVATION_NON_HONOREE', violation)
: phraseDeRaison(violation.code, violation, texte);
export const TABLE = new Map([
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
@ -70,6 +94,45 @@ export const TABLE = new Map([
'VERROU_REPRIS',
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
],
[
'RETOUR_AUTOMATIQUE',
() =>
"Faute d'activité, l'événement est revenu seul en lecture ; chaque geste achevé était déjà enregistré. " +
'« Modifier » permet de reprendre.',
],
// --- Ce qu'un geste fait naître (§ 5.9, § 9, § 14.12) ----------------------
[
'DERIVE_NOUVELLE',
({ touchees }, texte) => {
const noms = touchees.map(({ id }) => nomDuPlacement(id));
const accord = touchees.length > 1 ? 'ne décrivent plus' : 'ne décrit plus';
const parPlacement = touchees.map(({ id, raisons }) => `${capitale(nomDuPlacement(id))} : ${phrasesDe(raisons, texte)}`);
return (
`Après ce geste, ${enumeration(noms)} ${accord} la liste ou les tables actuelles, et rien n'est effacé. ` +
parPlacement.join(' ')
);
},
],
[
'RESERVATION_NON_HONOREE',
({ participant, table, tour }) =>
`Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` +
`à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`,
],
[
'INVARIANT_VIOLE',
({ proposition, violations }, texte) =>
`Le placement retenu, issu de la proposition n° ${proposition}, enfreint ` +
`${compte(violations.length, 'règle', 'règles')} que la dérive ne dit pas. ` +
violations.map((violation) => phraseDeViolation(violation, texte)).join(' '),
],
[
'CONFIGURATION_INCOHERENTE',
({ code, details }, texte) =>
`Après ce geste, la configuration ne se tient plus : ${phraseDeRaison(code, details, texte)} ` +
'Le diagnostic et la génération la refusent tant que rien ne la corrige.',
],
[
'VERROU_NON_RENDU',
({ cause }, texte) =>

View file

@ -0,0 +1,101 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les listes de travail (§ 5.8, § 4.1, § 4.2, § 2.1 étape 6) : le travail qui
// reste, montré plutôt que fait. Les titres de place que rien ne pourvoit
// pour la soirée, les personnes au titre pressenti que rien ne réserve, les
// conversions que le logiciel propose sans les faire, et les comptes de
// titres d'une table. Un titre pourvu se lit selon la seule règle,
// pourvoyeursDesTitres : pour la soirée, par une réservation « tous les
// tours » ; une réservation d'un tour désigné ne le pourvoit qu'à ce tour,
// et le titre reste dans la liste. Aucune fonction ne modifie la charge.
import { pourvoyeursDesTitres } from '../csv/import.js';
import { presents } from './places.js';
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
/**
* Les titres que rien ne pourvoit pour la soirée (§ 4.1), chacun avec les
* tours où une réservation d'un tour désigné le pourvoit, croissants ; par
* numéro de table, puis par siège et par libellé, l'identifiant de table
* départageant deux numéros égaux.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {Array<{table: number, numero: number, siege: number, libelle: string, pourvuAuxTours: number[]}>}
*/
export function titresNonPourvus(charge) {
const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
const pourvoyeurs = pourvoyeursDesTitres(charge);
return charge.titres
.map((titre, rang) => ({ titre, ...pourvoyeurs[rang] }))
.filter(({ soiree }) => soiree === null)
.map(({ titre: { table, siege, libelle }, tours }) => ({
table,
numero: numeroDe.get(table),
siege,
libelle,
pourvuAuxTours: tours.flatMap((reservation, rang) => (reservation === null ? [] : [rang + 1])),
}))
.sort((a, b) => a.numero - b.numero || a.table - b.table || a.siege - b.siege || comparerTextes(a.libelle, b.libelle));
}
/**
* Les personnes non exclues qui portent un titre pressenti et qu'aucune
* réservation ne place encore (§ 5.8), par identifiant croissant.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {Array<{participant: number, titrePressenti: string}>}
*/
export function pressentisNonReserves(charge) {
const reservees = new Set(charge.reservations.map(({ participant }) => participant));
return charge.participants
.filter(({ id, exclu, titrePressenti }) => !exclu && titrePressenti !== null && !reservees.has(id))
.map(({ id, titrePressenti }) => ({ participant: id, titrePressenti }))
.sort((a, b) => a.participant - b.participant);
}
/**
* Les conversions proposées (§ 4.2) : une personne non exclue dont les
* réservations « tour désigné » à une même table couvrent chacun des R tours,
* et qui n'y tient aucune réservation « tous » — R réservations qu'une seule
* de portée « tous les tours » remplacerait. Par personne, puis par table.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {Array<{participant: number, table: number}>}
*/
export function conversionsPossibles(charge) {
const ids = presents(charge);
const R = charge.evenement.tours;
const toursDe = new Map();
const tous = new Set();
for (const { participant, table, portee, tour } of charge.reservations) {
if (!ids.has(participant)) continue;
const cle = `${participant}:${table}`;
if (portee === 'tous') {
tous.add(cle);
continue;
}
if (!toursDe.has(cle)) toursDe.set(cle, { participant, table, tours: new Set() });
toursDe.get(cle).tours.add(tour);
}
return [...toursDe.entries()]
.filter(([cle, { tours }]) => !tous.has(cle) && tours.size === R)
.map(([, { participant, table }]) => ({ participant, table }))
.sort((a, b) => a.participant - b.participant || a.table - b.table);
}
/**
* Les titres d'une table et ceux qu'une réservation « tous les tours »
* pourvoit pour la soirée (§ 2.1 étape 6) ; deux titres sur une même place
* comptent deux.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} idTable
* @returns {{titres: number, pourvus: number}}
*/
export function comptesTitres(charge, idTable) {
const pourvoyeurs = pourvoyeursDesTitres(charge);
const rangs = charge.titres.flatMap(({ table }, rang) => (table === idTable ? [rang] : []));
return { titres: rangs.length, pourvus: rangs.filter((rang) => pourvoyeurs[rang].soiree !== null).length };
}

View file

@ -0,0 +1,163 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des listes de travail (§ 4, § 4.1, § 4.2, § 5.8, § 2.1 étape 6) :
// les titres que rien ne pourvoit, les pressentis non réservés, les
// conversions proposées et les comptes de titres d'une table. Les noms
// d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { apercevoir } from '../csv/apercu.js';
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { creerCharge } from '../stockage/document.js';
import { COMMANDES } from './commandes.js';
import { comptesTitres, conversionsPossibles, pressentisNonReserves, titresNonPourvus } from './listes_travail.js';
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
const personne = (id, nom, prenom = null, autres = {}) => ({
id,
nom,
prenom,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const table = (id, numero = id) => ({ id, numero, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } });
const resa = (participant, idTable, siege, tour = null) => ({
participant,
table: idTable,
siege,
portee: tour === null ? 'tous' : 'tour',
tour,
});
// Trois tours, quatre tables de 6 numérotées comme leurs identifiants, cinq
// personnes ; le mode table sauf demande contraire.
function chargeDe({ attribuerSieges = false, reservations = [], titres = [] } = {}) {
const charge = creerCharge({ id: 'evt-listes', nom: 'Soirée des Lucioles', siegesParDefaut: 6, tours: 3 });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = [
personne(1, 'Ombrelle', 'Iris'),
personne(2, 'Pervenche', 'Théo', { titrePressenti: 'animateur' }),
personne(3, 'Lacasse', 'Ondine', { titrePressenti: 'hôtesse' }),
personne(4, 'Grisaille'),
personne(5, 'Bruyère', 'Anouk', { titrePressenti: 'accueil', exclu: true }),
];
charge.tables = [table(1), table(2), table(3), table(4)];
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: 6, table: 5, proposition: 1 };
return charge;
}
describe('titresNonPourvus et comptesTitres (§ 4.1, § 2.1 étape 6)', () => {
test('mode table : trois titres à la table 2 et une désignation → trois titres, un pourvu, deux à pourvoir', () => {
let charge = chargeDe();
for (const libelle of ['animation', 'accueil', 'veille']) {
charge = executer('poserTitre', charge, { table: 2, siege: null, libelle }).charge;
}
assert.deepEqual(
charge.titres.map(({ siege }) => siege),
[1, 2, 3],
);
charge = executer('designer', charge, { participant: 1, table: 2, siege: null, libelle: 'animation' }).charge;
assert.deepEqual(comptesTitres(charge, 2), { titres: 3, pourvus: 1 });
assert.deepEqual(comptesTitres(charge, 1), { titres: 0, pourvus: 0 });
const deLaTable = titresNonPourvus(charge).filter(({ table: t }) => t === 2);
assert.deepEqual(deLaTable, [
{ table: 2, numero: 2, siege: 2, libelle: 'accueil', pourvuAuxTours: [] },
{ table: 2, numero: 2, siege: 3, libelle: 'veille', pourvuAuxTours: [] },
]);
});
test('une réservation d’un tour désigné pourvoit le titre à ce tour seulement : il reste dans la liste', () => {
const charge = chargeDe({ reservations: [resa(4, 3, 1, 2)], titres: [{ table: 3, siege: 1, libelle: 'animation' }] });
assert.deepEqual(titresNonPourvus(charge), [{ table: 3, numero: 3, siege: 1, libelle: 'animation', pourvuAuxTours: [2] }]);
assert.deepEqual(comptesTitres(charge, 3), { titres: 1, pourvus: 0 });
});
test('les titres se rangent par numéro de table, puis par siège', () => {
const charge = chargeDe({
titres: [
{ table: 1, siege: 2, libelle: 'accueil' },
{ table: 4, siege: 1, libelle: 'animation' },
{ table: 1, siege: 1, libelle: 'animation' },
],
});
charge.tables[3].numero = 1;
charge.tables[0].numero = 4;
assert.deepEqual(
titresNonPourvus(charge).map(({ table: t, siege }) => [t, siege]),
[[4, 1], [1, 1], [1, 2]],
);
});
test('un titre pourvu, une règle : en mode table, une réservation au siège 5 ne pourvoit pas le titre du siège 1', () => {
const charge = chargeDe({ reservations: [resa(1, 2, 5)], titres: [{ table: 2, siege: 1, libelle: 'animation' }] });
assert.equal(bilanRemplacement(charge).titresPourvus, 0);
assert.deepEqual(
titresNonPourvus(charge).map(({ table: t, siege }) => [t, siege]),
[[2, 1]],
);
});
test('le titre reste : retirer, déplacer, supprimer ou exclure la personne désignée laisse le titre, non pourvu', () => {
const designee = executer('designer', chargeDe(), { participant: 1, table: 3, siege: null, libelle: 'animation' }).charge;
assert.deepEqual(titresNonPourvus(designee), []);
const variantes = [
['retirerReservation', { participant: 1, table: 3, portee: 'tous', tour: null }],
['deplacerReservation', { participant: 1, de: { table: 3, portee: 'tous', tour: null }, vers: { table: 4, siege: null } }],
['supprimerParticipant', { id: 1 }],
['exclureParticipant', { id: 1 }],
];
assert.equal(variantes.length, 4);
for (const [nom, args] of variantes) {
const { charge } = executer(nom, designee, args);
assert.deepEqual(charge.titres, designee.titres, nom);
assert.deepEqual(
titresNonPourvus(charge).map(({ table: t }) => t),
[3],
nom,
);
}
});
});
describe('pressentisNonReserves et conversionsPossibles (§ 4, § 4.2, § 5.8)', () => {
test('les pressentis que rien ne réserve, exclus omis', () => {
const charge = chargeDe({ reservations: [resa(3, 1, null, 2)] });
assert.deepEqual(pressentisNonReserves(charge), [{ participant: 2, titrePressenti: 'animateur' }]);
});
test('titre pressenti importé : ni réservation ni titre, la liste des titres inchangée, la personne dans les pressentis', () => {
const charge = chargeDe({ titres: [{ table: 1, siege: 1, libelle: 'animation' }] });
charge.participants = charge.participants.filter(({ titrePressenti }) => titrePressenti === null);
const texte = 'nom;prenom;titre_pressenti\nSarcelle;Capucine;animatrice\n';
const { charge: apres } = appliquerImport(charge, apercevoir(texte, { participants: charge.participants }), 'ajouter');
assert.deepEqual(apres.reservations, charge.reservations);
assert.deepEqual(apres.titres, charge.titres);
assert.deepEqual(titresNonPourvus(apres), titresNonPourvus(charge));
assert.deepEqual(pressentisNonReserves(apres), [{ participant: 6, titrePressenti: 'animatrice' }]);
});
test('la R-ième réservation « tour » à une même table propose la conversion, sans rien changer ; convertirEnTous en fait une seule « tous »', () => {
const charge = chargeDe({ reservations: [resa(2, 1, null, 1), resa(2, 1, null, 2), resa(3, 1, null, 1), resa(3, 2, null, 2)] });
assert.deepEqual(conversionsPossibles(charge), []);
const { charge: troisieme } = executer('reserver', charge, { participant: 2, table: 1, siege: null, portee: 'tour', tour: 3 });
const avant = structuredClone(troisieme);
assert.deepEqual(conversionsPossibles(troisieme), [{ participant: 2, table: 1 }]);
assert.deepEqual(troisieme, avant);
const { charge: convertie } = executer('convertirEnTous', troisieme, { participant: 2, table: 1 });
assert.deepEqual(
convertie.reservations.filter(({ participant }) => participant === 2),
[resa(2, 1, null)],
);
assert.deepEqual(conversionsPossibles(convertie), []);
});
});

354
src/application/places.js Normal file
View file

@ -0,0 +1,354 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'état des places d'un tour (§ 5.8, § 7.5, § 7.6, § 8.9, § 9) : ce que le
// plan dessine et ce qu'un geste de personne modifie, lu dans la charge sans
// rien recalculer du moteur que ses conflits. Aussi la seule répartition par
// siège (repartirSieges), le libellé proposé à la désignation et les statuts
// calculés (§ 4.2). Aucune fonction ne modifie ce qu'elle reçoit.
//
// Une réservation est active quand sa personne est dans la charge et n'est
// pas exclue : celle d'une exclue est suspendue (§ 4.4) et ne tient aucune
// place. Un titre se lit pourvu selon la règle de pourvoyeursDesTitres, la
// seule (src/csv/import.js).
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
import { conflitsParTable } from '../moteur/conflits.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
import { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
import { libelle } from './libelles.js';
const croissant = (a, b) => a - b;
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
/** Vrai quand la réservation couvre le tour r : « tous », ou son tour. */
export const couvre = ({ portee, tour }, r) => portee === 'tous' || tour === r;
/** Les identifiants des personnes non exclues de la charge. */
export const presents = (charge) => new Set(charge.participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
/** Les réservations actives — personne présente et non exclue —, dans l'ordre canonique. */
export function reservationsActives(charge) {
const ids = presents(charge);
return charge.reservations.filter(({ participant }) => ids.has(participant)).sort(ordreDesReservations);
}
/**
* L'instance du moteur de la charge, ou null quand la configuration ne se
* normalise pas (ErreurConfiguration) ; toute autre erreur passe.
*/
export function instanceDe(charge) {
try {
return normaliser(configurationDepuisCharge(charge));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;
}
}
/**
* La seule répartition par siège : la personne réservée au siège s ≤ places
* (siegeDe : Map id → s ; deux au même siège, le plus petit identifiant) au
* rang s, les autres aux rangs libres par identifiant croissant, null aux
* rangs restés libres. Quand les autres dépassent les rangs libres, les
* prioritaires y entrent d'abord, puis les plus petits identifiants : les
* plus grands identifiants non prioritaires restent dehors. etatPlaces la
* sollicite avec places = capacité, et pour prioritaires les personnes dont
* une réservation couvrante tient cette table ; une génération à sièges
* attribués avec places = capacité, puis retire les null de fin : ceux du
* milieu sont les chaises vides du fichier.
*
* @param {number[]} personnes
* @param {Map<number, number|null>} siegeDe
* @param {number} places
* @param {Set<number>} [prioritaires] vide par défaut
* @returns {Array<number|null>} places rangs
*/
export function repartirSieges(personnes, siegeDe, places, prioritaires = new Set()) {
const rangs = Array.from({ length: places }, () => null);
const placees = new Set();
const siegeValide = (id) => {
const siege = siegeDe.get(id);
return Number.isInteger(siege) && siege >= 1 && siege <= places;
};
for (const id of personnes.filter(siegeValide).sort(croissant)) {
const rang = siegeDe.get(id) - 1;
if (rangs[rang] === null) {
rangs[rang] = id;
placees.add(id);
}
}
const libres = rangs.filter((occupant) => occupant === null).length;
const autres = personnes.filter((id) => !placees.has(id)).sort(croissant);
const elues = [...autres.filter((id) => prioritaires.has(id)), ...autres.filter((id) => !prioritaires.has(id))]
.slice(0, libres)
.sort(croissant);
let suivante = 0;
for (let rang = 0; rang < places && suivante < elues.length; rang += 1) {
if (rangs[rang] === null) {
rangs[rang] = elues[suivante];
suivante += 1;
}
}
return rangs;
}
// La source d'un tour (contrat de données, « Source ») : la proposition
// demandée quand elle existe, se lit (fautes vide) et porte ce tour ; sinon
// le retenu, s'il se lit et porte ce tour ; sinon les seules réservations.
// cible et refus disent ce qu'un geste de personne y modifie.
function sourceDuTour(charge, tour, proposition) {
const { retenu } = charge;
const retenuFautif = retenu !== null && fautes(retenu).length > 0;
const porte = (placement) => fautes(placement).length === 0 && placement.tours >= tour;
const demandee = proposition === null ? undefined : charge.propositions.find(({ id }) => id === proposition);
if (demandee !== undefined && porte(demandee)) {
if (retenu === null) return { source: 'proposition', placement: demandee, cible: 'reservations', refus: null };
const remede = { geste: 'afficherTour', tour, proposition: null };
return {
source: 'proposition',
placement: demandee,
cible: null,
refus: { code: 'PROPOSITION_MONTREE', details: { proposition: demandee.id, remede } },
};
}
if (retenu !== null && !retenuFautif && porte(retenu)) return { source: 'retenu', placement: retenu, cible: 'retenu', refus: null };
if (retenuFautif) {
const remede = { geste: 'ouvrirSecours', source: 'precedent' };
return { source: 'reservations', placement: null, cible: null, refus: { code: 'RETENU_FAUTIF', details: { remede } } };
}
return { source: 'reservations', placement: null, cible: 'reservations', refus: null };
}
// Le plan indexé dont se lisent les conflits du tour, ou null quand ils ne
// s'évaluent pas : la configuration ne se normalise pas, ou la source est en
// dérive. Sans source, la rangée des réservations de l'instance
// (instance.fixe) se lit comme le plan qui n'assied que les réservés.
function planEvalue(charge, instance, placement) {
if (instance === null) return null;
if (placement === null) return instance.fixe;
if (derive(placement, charge).length > 0) return null;
try {
return indexerPlan(instance, planDepuisFichier(placement));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;
}
}
// Les places que les réservations actives couvrantes tiennent à une table :
// tenu, Map siège → réservation, la première dans l'ordre canonique de
// chaque siège dans la capacité, une personne n'en tenant qu'un ; sansSiege,
// Map personne → réservation, pour chaque autre personne réservée à la
// table — sans siège, ou dont le siège est pris : elle se lit comme une
// réservation sans siège.
function placesTenues(couvrantes, places) {
const tenu = new Map();
const siegeDe = new Map();
const sansSiege = new Map();
for (const reservation of couvrantes) {
const { siege, participant } = reservation;
if (siege !== null && siege <= places && !tenu.has(siege) && !siegeDe.has(participant)) {
tenu.set(siege, reservation);
siegeDe.set(participant, siege);
}
}
for (const reservation of couvrantes) {
if (!siegeDe.has(reservation.participant) && !sansSiege.has(reservation.participant)) {
sansSiege.set(reservation.participant, reservation);
}
}
return { tenu, siegeDe, sansSiege };
}
// Le remplissage et les marques d'une chaise (§ 7.6) : plein et cadenas
// fermé quand une réservation tient la place, l'anneau en plus pour la portée
// « tous les tours » ; sinon cadenas ouvert pour un titre, hachure quand rien
// ne le pourvoit à ce tour.
function aspect(reservation, titre) {
if (reservation !== null) {
return { remplissage: 'plein', marques: reservation.portee === 'tous' ? ['cadenasFerme', 'anneau'] : ['cadenasFerme'] };
}
if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] };
return { remplissage: 'normal', marques: [] };
}
/**
* L'état des places d'un tour (contrat de données, EtatPlaces) : la source
* — la proposition demandée, le retenu, ou les seules réservations —, ce
* qu'un geste de personne y modifie, chaque table de la charge dans son
* ordre avec ses chaises, et la réserve. Sièges attribués, la chaise s porte
* le rang s de la liste de la source ; sinon, et sous les seules
* réservations, repartirSieges répartit les occupants de la table, chacun à
* son siège réservé, une personne réservée sans siège qui pourvoit un titre
* au siège de ce titre ; quand la liste dépasse la capacité, les personnes
* réservées à la table passent avant les autres. Un exclu, une personne que la charge n'a plus,
* n'occupe rien et n'attend pas ; une personne présente qu'aucune chaise ne
* reçoit attend en réserve. Les conflits sont ceux de conflitsParTable au
* tour, null quand la source est en dérive ou que la configuration ne se
* normalise pas.
*
* Ne lève sur aucune charge que l'analyse admet, pour tout tour de 1 à R.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {{tour: number, proposition?: number|null}} demande
* @returns {Object} EtatPlaces
* @throws {TypeError} un tour hors de 1 à R
*/
export function etatPlaces(charge, { tour, proposition = null }) {
if (!Number.isInteger(tour) || tour < 1 || tour > charge.evenement.tours) {
throw new TypeError(`etatPlaces : tour de 1 à ${charge.evenement.tours} attendu, reçu ${JSON.stringify(tour)}`);
}
const { source, placement, cible, refus } = sourceDuTour(charge, tour, proposition);
const siegesAttribues = placement === null ? charge.reglages.attribuerSieges : placement.siegesAttribues;
const ids = presents(charge);
const instance = instanceDe(charge);
const tableDe = planEvalue(charge, instance, placement);
const conflitsDe = new Map(
tableDe === null ? [] : conflitsParTable(instance, tableDe)[tour - 1].map(({ table, nombre, regles }) => [table, { nombre, regles }]),
);
const { titres } = charge;
const pourvoyeurs = pourvoyeursDesTitres(charge);
const ordreDesTitres = titres
.map((_, rang) => rang)
.sort((a, b) => titres[a].table - titres[b].table || titres[a].siege - titres[b].siege || comparerTextes(titres[a].libelle, titres[b].libelle));
const titreDePlace = new Map();
for (const rang of ordreDesTitres) {
const cle = `${titres[rang].table}:${titres[rang].siege}`;
if (!titreDePlace.has(cle)) titreDePlace.set(cle, rang);
}
const pourvuAuTour = (rang) => pourvoyeurs[rang].tours[tour - 1] !== null;
const couvrantes = reservationsActives(charge).filter((reservation) => couvre(reservation, tour));
const assis = new Set();
const tables = charge.tables.map((enregistrement) => {
const { id, numero, forme, position } = enregistrement;
const places = capacite(charge, enregistrement);
const { tenu, siegeDe, sansSiege } = placesTenues(
couvrantes.filter(({ table }) => table === id),
places,
);
const titresDeLaTable = ordreDesTitres.filter((rang) => titres[rang].table === id);
// Une personne réservée sans siège qui pourvoit un titre s'assied au
// siège de ce titre, quand nul ne le tient.
const pris = new Set(siegeDe.values());
const siegesVoulus = new Map(siegeDe);
for (const rang of titresDeLaTable) {
const pourvoyeur = pourvoyeurs[rang].tours[tour - 1];
const { siege } = titres[rang];
if (pourvoyeur === null || !sansSiege.has(pourvoyeur.participant) || siegesVoulus.has(pourvoyeur.participant)) continue;
if (pris.has(siege) || siege > places) continue;
siegesVoulus.set(pourvoyeur.participant, siege);
pris.add(siege);
}
let occupants;
if (placement === null) {
const reserves = [...siegeDe.keys(), ...sansSiege.keys()].filter((personne) => !assis.has(personne));
occupants = repartirSieges(reserves, siegesVoulus, places);
} else {
const rang = placement.tables.indexOf(id);
const liste = rang < 0 ? [] : placement.placement[tour - 1].sieges[rang];
const lisible = (personne) => personne !== null && ids.has(personne) && !assis.has(personne);
if (placement.siegesAttribues) {
occupants = Array.from({ length: places }, (_, i) => (i < liste.length && lisible(liste[i]) ? liste[i] : null));
} else {
// Les personnes que réserve cette table s'assoient avant les autres :
// une capacité réduite, jamais sous ses réservations (§ 6.2), n'en
// verse aucune en réserve.
const reservees = new Set([...siegeDe.keys(), ...sansSiege.keys()]);
occupants = repartirSieges(liste.filter(lisible), siegesVoulus, places, reservees);
}
}
for (const personne of occupants) if (personne !== null) assis.add(personne);
const chaises = occupants.map((occupant, rang) => {
const siege = rang + 1;
const tenue = tenu.get(siege) ?? (occupant !== null && sansSiege.has(occupant) ? sansSiege.get(occupant) : null);
const reservation = tenue === null ? null : { participant: tenue.participant, portee: tenue.portee, tour: tenue.tour };
const rangTitre = titreDePlace.get(`${id}:${siege}`);
const titre = rangTitre === undefined ? null : { libelle: titres[rangTitre].libelle, pourvu: pourvuAuTour(rangTitre) };
return { siege, occupant, reservation, titre, ...aspect(reservation, titre) };
});
return {
id,
numero,
forme,
position: { x: position.x, y: position.y },
capacite: places,
surchargee: enregistrement.sieges !== null,
gelee: instance !== null && instance.ancresParTable[instance.indexTableDe.get(id)] === places,
titres: { titres: titresDeLaTable.length, pourvus: titresDeLaTable.filter(pourvuAuTour).length },
conflits: tableDe === null ? null : conflitsDe.get(id),
chaises,
};
});
return {
tour,
source,
proposition: source === 'proposition' ? placement.id : null,
siegesAttribues,
tables,
reserve: [...ids].filter((personne) => !assis.has(personne)).sort(croissant),
evalue: tableDe !== null,
cible,
refus,
};
}
/**
* Le libellé proposé à la désignation de participant (§ 4, § 2.1 étape 6) :
* son titre pressenti ; à défaut, le libellé le plus fréquent des titres de
* la charge, le plus petit par unités UTF-16 à égalité ; à défaut, le
* français de la clé places.titreParDefaut. Il devient une donnée du
* fichier : il se lit par libelle, jamais par la traduction de l'écran.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} participant identifiant
* @returns {string}
*/
export function libellePropose(charge, participant) {
const personne = charge.participants.find(({ id }) => id === participant);
if (personne !== undefined && personne.titrePressenti !== null) return personne.titrePressenti;
const comptes = new Map();
for (const { libelle: texte } of charge.titres) comptes.set(texte, (comptes.get(texte) ?? 0) + 1);
let meilleur = null;
for (const [texte, nombre] of comptes) {
const ecart = meilleur === null ? 1 : nombre - comptes.get(meilleur);
if (ecart > 0 || (ecart === 0 && comparerTextes(texte, meilleur) < 0)) meilleur = texte;
}
return meilleur ?? libelle('places.titreParDefaut');
}
// Le nom de chaque statut du moteur.
const NOMS_DES_STATUTS = new Map([
[STATUT.ANCRE, 'ancre'],
[STATUT.PARTIELLEMENT_FIXE, 'partiel'],
[STATUT.MOBILE, 'mobile'],
]);
/**
* Le statut calculé de chaque participant non exclu (§ 4.2), lu dans
* l'instance du moteur : ancré, partiellement fixé ou mobile ; un exclu n'a
* pas d'entrée. Une configuration qui ne se normalise pas rend son code dans
* erreur, et aucun statut, au lieu de lever.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {{parId: Map<number, 'ancre'|'partiel'|'mobile'>, erreur: string|null}}
*/
export function statuts(charge) {
let instance;
try {
instance = normaliser(configurationDepuisCharge(charge));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return { parId: new Map(), erreur: erreur.code };
throw erreur;
}
const parId = new Map(instance.ids.map((id, p) => [id, NOMS_DES_STATUTS.get(instance.statut[p])]));
return { parId, erreur: null };
}

View file

@ -0,0 +1,522 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de l'état des places d'un tour (§ 5.8, § 7.5, § 7.6, § 8.9, § 9),
// de la répartition par siège, du libellé proposé à la désignation et des
// statuts calculés (§ 4.2). Les noms d'épreuve sont inventés ; la petite
// démonstration livrée se lit telle quelle, et la version vient du module
// engendré.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
import { conflitsParTable } from '../moteur/conflits.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { COMMANDES } from './commandes.js';
import { titresNonPourvus } from './listes_travail.js';
import { etatPlaces, libellePropose, repartirSieges, statuts } from './places.js';
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
const personne = (id, nom, prenom = null, autres = {}) => ({
id,
nom,
prenom,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...autres,
});
const table = (id, sieges = null, numero = id) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } });
const resa = (participant, idTable, siege, tour = null) => ({
participant,
table: idTable,
siege,
portee: tour === null ? 'tous' : 'tour',
tour,
});
// Un plan de fichier de ces tables et capacités, dont chaque tour est donné
// par ses listes ; la réserve de chaque tour porte les déclarés qu'aucune
// liste n'assied.
function planFichier({ tables, capacites, participants, tours, siegesAttribues = false }) {
return {
siegesAttribues,
tables,
capacites,
tours: tours.length,
participants,
placement: tours.map((listes) => {
const assis = new Set(listes.flat());
return { sieges: listes, reserve: participants.filter((id) => !assis.has(id)) };
}),
};
}
const propositionDe = (id, plan) => ({
id,
graine: 48271 + id,
arret: 200000,
historique: 1000,
produitVersion: VERSION.affichee,
...plan,
});
const retenuDe = (proposition, plan) => ({ proposition, ...plan });
// --- La petite démonstration et un plan parfait ---------------------------
// La petite démonstration livrée : douze personnes, quatre tables de 3,
// quatre tours, aucune réservation.
const petite = () => analyser(readFileSync(new URL('../demo/livrees/petite.gtt.json', import.meta.url), 'utf8')).charge;
// Un plan qui sépare les trois appartenances, ne réunit jamais deux fois les
// mêmes voisins et n'assied personne deux fois à la même table.
const PARFAIT = [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[5, 9, 10], [1, 7, 11], [2, 6, 12], [3, 4, 8]],
[[6, 8, 11], [3, 9, 12], [1, 4, 10], [2, 5, 7]],
[[4, 7, 12], [2, 8, 10], [3, 5, 11], [1, 6, 9]],
];
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
const planParfait = (siegesAttribues = false) =>
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT, siegesAttribues });
// La petite démonstration, son plan parfait proposé sous l'identifiant 1, et
// retenu quand retenu est vrai.
function petiteRetenue({ retenu = true } = {}) {
const charge = petite();
charge.propositions = [propositionDe(1, planParfait())];
charge.prochainsIds.proposition = 2;
charge.retenu = retenu ? retenuDe(1, planParfait()) : null;
charge.evenement.etat = retenu ? 'retenu' : 'propose';
return charge;
}
const occupants = (etat, rang) => etat.tables[rang].chaises.map(({ occupant }) => occupant);
// Les conflits que le moteur lit sur ce placement, tour par tour.
function conflitsDuMoteur(charge, placement) {
const instance = normaliser(configurationDepuisCharge(charge));
return conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(placement)));
}
describe('repartirSieges : la seule répartition par siège', () => {
test('la personne réservée au siège s prend le rang s, les autres les rangs libres par identifiant croissant', () => {
assert.deepEqual(repartirSieges([4, 9, 2], new Map([[9, 1]]), 4), [9, 2, 4, null]);
assert.deepEqual(repartirSieges([], new Map(), 3), [null, null, null]);
});
test('deux au même siège : le plus petit identifiant ; un siège au-delà des places ne compte pas ; trop de monde : les plus grands restent dehors', () => {
assert.deepEqual(repartirSieges([7, 3, 5], new Map([[7, 2], [3, 2]]), 3), [5, 3, 7]);
assert.deepEqual(repartirSieges([1, 2], new Map([[2, 9]]), 3), [1, 2, null]);
assert.deepEqual(repartirSieges([6, 1, 4, 3], new Map([[6, 3]]), 3), [1, 3, 6]);
});
test('les prioritaires prennent les rangs libres avant les autres, puis tous se rangent par identifiant croissant', () => {
assert.deepEqual(repartirSieges([1, 2, 3, 4, 5, 6], new Map(), 4, new Set([6])), [1, 2, 3, 6]);
assert.deepEqual(repartirSieges([5, 2, 8, 1], new Map([[8, 1]]), 3, new Set([5])), [8, 1, 5]);
assert.deepEqual(repartirSieges([9, 4, 7], new Map(), 2, new Set([9, 7])), [7, 9]);
assert.deepEqual(repartirSieges([3, 1], new Map(), 4, new Set([3])), [1, 3, null, null]);
});
});
describe('etatPlaces : la source et la cible (§ 5.8, § 8.9)', () => {
test('plan parfait en retenu : occupants 1, 2, 3 aux sièges 1 à 3 de la table 1 ; cible retenu ; réserve vide ; zéro conflit', () => {
const charge = petiteRetenue();
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(etat.tour, 1);
assert.equal(etat.source, 'retenu');
assert.equal(etat.proposition, null);
assert.equal(etat.siegesAttribues, false);
assert.equal(etat.cible, 'retenu');
assert.equal(etat.refus, null);
assert.equal(etat.evalue, true);
assert.deepEqual(etat.reserve, []);
assert.equal(etat.tables.length, 4);
assert.deepEqual(
etat.tables[0].chaises.map(({ siege, occupant }) => [siege, occupant]),
[[1, 1], [2, 2], [3, 3]],
);
for (const { conflits } of etat.tables) assert.equal(conflits.nombre, 0);
assert.deepEqual(
etat.tables.map(({ id, numero, capacite, surchargee, gelee }) => [id, numero, capacite, surchargee, gelee]),
[[1, 1, 3, false, false], [2, 2, 3, false, false], [3, 3, 3, false, false], [4, 4, 3, false, false]],
);
});
test('sans retenu ni proposition : les réservations seules ; la réserve porte les douze, moins une réservée à ce tour', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(etat.source, 'reservations');
assert.equal(etat.cible, 'reservations');
assert.equal(etat.siegesAttribues, false);
assert.deepEqual(etat.reserve, DOUZE);
charge.reservations = [resa(3, 2, null, 1)];
const tour1 = etatPlaces(charge, { tour: 1 });
assert.deepEqual(tour1.reserve, DOUZE.filter((id) => id !== 3));
assert.deepEqual(occupants(tour1, 1), [3, null, null]);
assert.deepEqual(tour1.tables[1].chaises[0].reservation, { participant: 3, portee: 'tour', tour: 1 });
assert.deepEqual(etatPlaces(charge, { tour: 2 }).reserve, DOUZE);
});
test('une proposition demandée se montre, avec ou sans retenu ; après la retenue, en vue seule', () => {
const avant = petiteRetenue({ retenu: false });
const montree = etatPlaces(avant, { tour: 2, proposition: 1 });
assert.equal(montree.source, 'proposition');
assert.equal(montree.proposition, 1);
assert.equal(montree.cible, 'reservations');
assert.equal(montree.refus, null);
assert.deepEqual(occupants(montree, 0), [5, 9, 10]);
assert.equal(etatPlaces(avant, { tour: 2 }).source, 'reservations');
const apres = etatPlaces(petiteRetenue(), { tour: 2, proposition: 1 });
assert.equal(apres.source, 'proposition');
assert.equal(apres.cible, null);
assert.deepEqual(apres.refus, {
code: 'PROPOSITION_MONTREE',
details: { proposition: 1, remede: { geste: 'afficherTour', tour: 2, proposition: null } },
});
});
test('les conflits de chaque table sont ceux de conflitsParTable sur le même plan, à chaque tour', () => {
const charge = petiteRetenue();
// Un plan imparfait : la table 1 réunit deux fois les mêmes voisins.
const tours = PARFAIT.map((listes) => listes.map((liste) => [...liste]));
tours[1] = [[1, 2, 10], [5, 7, 11], [6, 9, 12], [3, 4, 8]];
charge.retenu = retenuDe(1, planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours }));
const attendus = conflitsDuMoteur(charge, charge.retenu);
let compte = 0;
for (let tour = 1; tour <= 4; tour += 1) {
const etat = etatPlaces(charge, { tour });
etat.tables.forEach(({ id, conflits }, rang) => {
assert.deepEqual(conflits, { nombre: attendus[tour - 1][rang].nombre, regles: attendus[tour - 1][rang].regles }, `tour ${tour}, table ${id}`);
compte += conflits.nombre;
});
}
assert.ok(compte > 0, 'le plan imparfait porte des conflits');
});
test('sans source, les conflits sont ceux des seules réservations', () => {
const charge = petite();
// 1 et 5 sont de la même appartenance : réservés à la même table.
charge.reservations = [resa(1, 1, null), resa(5, 1, null, 2)];
const etat = etatPlaces(charge, { tour: 2 });
assert.equal(etat.evalue, true);
const instance = normaliser(configurationDepuisCharge(charge));
assert.deepEqual(etat.tables[0].conflits, (({ nombre, regles }) => ({ nombre, regles }))(conflitsParTable(instance, instance.fixe)[1][0]));
assert.ok(etat.tables[0].conflits.nombre > 0);
});
test('une source en dérive : evalue faux, conflits null ; un exclu assis ne paraît ni sur sa chaise ni en réserve', () => {
const charge = petiteRetenue();
charge.participants[4].exclu = true;
for (let tour = 1; tour <= 4; tour += 1) {
const etat = etatPlaces(charge, { tour });
assert.equal(etat.evalue, false);
assert.equal(etat.tables.length, 4);
for (const { conflits } of etat.tables) assert.equal(conflits, null);
assert.ok(!etat.reserve.includes(5));
assert.ok(etat.tables.every(({ chaises }) => chaises.every(({ occupant }) => occupant !== 5)));
}
assert.deepEqual(occupants(etatPlaces(charge, { tour: 1 }), 1), [4, 6, null]);
});
test('sièges non attribués : une personne réservée à un siège s’y affiche, sous le retenu comme sous une proposition ; les autres prennent les chaises libres par identifiant croissant', () => {
for (const [retenu, proposition, source] of [
[true, null, 'retenu'],
[false, 1, 'proposition'],
]) {
const charge = petiteRetenue({ retenu });
// 2, assis à la table 1 au tour 1, y est réservé « tous » au siège 3.
charge.reservations = [resa(2, 1, 3)];
const etat = etatPlaces(charge, { tour: 1, proposition });
assert.equal(etat.source, source);
assert.equal(etat.siegesAttribues, false);
assert.deepEqual(occupants(etat, 0), [1, 3, 2]);
const chaise = etat.tables[0].chaises[2];
assert.equal(chaise.remplissage, 'plein');
assert.deepEqual(chaise.marques, ['cadenasFerme', 'anneau']);
assert.equal(etat.tables[0].chaises[0].remplissage, 'normal');
}
});
test("sièges attribués : l'ordre d'une liste du retenu fait l'ordre des chaises, chaises vides comprises", () => {
const charge = petiteRetenue();
const tours = PARFAIT.map((listes) => listes.map((liste) => [...liste]));
tours[0][0] = [3, 1, 2];
tours[0][1] = [null, 6, 4];
tours[0][2] = [9, null, 5];
tours[0][3] = [7, 8];
const plan = planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours, siegesAttribues: true });
plan.placement[0].reserve = [10, 11, 12];
charge.retenu = retenuDe(1, plan);
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(etat.siegesAttribues, true);
assert.deepEqual(occupants(etat, 0), [3, 1, 2]);
assert.deepEqual(occupants(etat, 1), [null, 6, 4]);
assert.deepEqual(occupants(etat, 2), [9, null, 5]);
assert.deepEqual(occupants(etat, 3), [7, 8, null]);
assert.deepEqual(etat.reserve, [10, 11, 12]);
});
});
describe('etatPlaces : les sources qui ne décrivent plus la configuration (§ 8.9, § 9)', () => {
test('un retenu de trois tours sous R = 4 : au tour 4, source et cible réservations', () => {
const charge = petiteRetenue();
charge.retenu = retenuDe(1, planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT.slice(0, 3) }));
const etat = etatPlaces(charge, { tour: 4 });
assert.equal(etat.source, 'reservations');
assert.equal(etat.cible, 'reservations');
assert.equal(etatPlaces(charge, { tour: 3 }).source, 'retenu');
});
test('un retenu qui déclare une table supprimée : ses occupants en réserve, evalue faux', () => {
const charge = petiteRetenue();
charge.tables = charge.tables.filter(({ id }) => id !== 4);
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(etat.source, 'retenu');
assert.equal(etat.evalue, false);
assert.equal(etat.tables.length, 3);
assert.deepEqual(etat.reserve, [10, 11, 12]);
});
test('une proposition qui assied huit personnes à une table ramenée à 6 : deux dans la réserve', () => {
const charge = creerCharge({ id: 'evt-huit', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 1 });
charge.participants = Array.from({ length: 10 }, (_, rang) => personne(rang + 1, `Merle ${rang + 1}`));
charge.tables = [table(1, 6), table(2)];
charge.prochainsIds = { participant: 11, table: 3, proposition: 2 };
charge.propositions = [
propositionDe(1, planFichier({ tables: [1, 2], capacites: [8, 8], participants: charge.participants.map(({ id }) => id), tours: [[[1, 2, 3, 4, 5, 6, 7, 8], [9, 10]]] })),
];
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
assert.equal(etat.source, 'proposition');
assert.deepEqual(occupants(etat, 0), [1, 2, 3, 4, 5, 6]);
assert.deepEqual(etat.reserve, [7, 8]);
assert.equal(etat.evalue, false);
});
test('sans attribution, une table ramenée à 6 qui en porte huit : la personne réservée à cette table s’assied avant les autres', () => {
const charge = creerCharge({ id: 'evt-huit', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 1 });
charge.participants = Array.from({ length: 10 }, (_, rang) => personne(rang + 1, `Merle ${rang + 1}`));
charge.tables = [table(1, 6), table(2)];
charge.reservations = [resa(8, 1, null)];
charge.prochainsIds = { participant: 11, table: 3, proposition: 2 };
const plan = planFichier({ tables: [1, 2], capacites: [8, 8], participants: charge.participants.map(({ id }) => id), tours: [[[1, 2, 3, 4, 5, 6, 7, 8], [9, 10]]] });
charge.propositions = [propositionDe(1, plan)];
for (const etat of [etatPlaces(charge, { tour: 1, proposition: 1 }), etatPlaces({ ...charge, retenu: retenuDe(1, plan), evenement: { ...charge.evenement, etat: 'retenu' } }, { tour: 1 })]) {
assert.deepEqual(occupants(etat, 0), [1, 2, 3, 4, 5, 8]);
assert.deepEqual(etat.reserve, [6, 7]);
assert.equal(etat.tables[0].chaises[5].remplissage, 'plein');
assert.deepEqual(etat.tables[0].chaises[5].marques, ['cadenasFerme', 'anneau']);
}
});
test('un retenu fautif : source réservations, cible null, refus RETENU_FAUTIF, et etatPlaces ne lève à aucun tour', () => {
const charge = petiteRetenue();
charge.retenu.placement[0].sieges[0] = [1, 2, 3, 4];
for (let tour = 1; tour <= 4; tour += 1) {
const etat = etatPlaces(charge, { tour });
assert.equal(etat.source, 'reservations');
assert.equal(etat.cible, null);
assert.deepEqual(etat.refus, { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } });
}
});
test('une proposition demandée qui n’existe pas ou se contredit ne se montre pas', () => {
const charge = petiteRetenue({ retenu: false });
assert.equal(etatPlaces(charge, { tour: 1, proposition: 9 }).source, 'reservations');
charge.propositions[0].placement[0].reserve = [1];
assert.equal(etatPlaces(charge, { tour: 1, proposition: 1 }).source, 'reservations');
});
});
describe('etatPlaces : titres, réservations et marques (§ 4.1, § 4.4, § 7.6)', () => {
// Cinq tables de 4, mode table ; Grisaille (4) et les autres sans réservation.
function chargeTitres({ attribuerSieges = false } = {}) {
const charge = creerCharge({ id: 'evt-titres', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = [
personne(1, 'Ombrelle', 'Iris'),
personne(2, 'Pervenche', 'Théo'),
personne(3, 'Lacasse', 'Ondine'),
personne(4, 'Grisaille'),
personne(5, 'Bruyère', 'Anouk'),
];
charge.tables = [1, 2, 3, 4, 5].map((id) => table(id));
charge.prochainsIds = { participant: 6, table: 6, proposition: 1 };
return charge;
}
test('pourvu par une réservation seulement : le retenu qui assied Grisaille à la table 5 ne pourvoit pas son titre', () => {
const charge = chargeTitres();
charge.titres = [{ table: 5, siege: 1, libelle: 'animation' }];
const plan = planFichier({ tables: [1, 2, 3, 4, 5], capacites: [4, 4, 4, 4, 4], participants: [1, 2, 3, 4, 5], tours: [[[1], [2], [3], [5], [4]], [[2], [3], [5], [1], [4]]] });
charge.propositions = [propositionDe(1, plan)];
charge.retenu = retenuDe(1, plan);
charge.prochainsIds.proposition = 2;
const chaise = etatPlaces(charge, { tour: 1 }).tables[4].chaises[0];
assert.equal(chaise.occupant, 4);
assert.deepEqual(chaise.titre, { libelle: 'animation', pourvu: false });
assert.equal(chaise.reservation, null);
assert.equal(chaise.remplissage, 'hachure');
assert.deepEqual(chaise.marques, ['cadenasOuvert']);
assert.deepEqual(
titresNonPourvus(charge).map(({ table: t }) => t),
[5],
);
});
test('exclusion : Iris désignée puis exclue laisse ses réservations, son titre redevient non pourvu ; réintégrée, il l’est de nouveau', () => {
const designee = executer('designer', chargeTitres(), { participant: 1, table: 3, siege: null, libelle: 'animatrice' }).charge;
const exclue = executer('exclureParticipant', designee, { id: 1 }).charge;
assert.deepEqual(exclue.reservations, designee.reservations);
const sansIris = etatPlaces(exclue, { tour: 1 }).tables[2];
assert.deepEqual(sansIris.titres, { titres: 1, pourvus: 0 });
assert.deepEqual(
[sansIris.chaises[0].remplissage, sansIris.chaises[0].marques, sansIris.chaises[0].titre.pourvu],
['hachure', ['cadenasOuvert'], false],
);
const reintegree = executer('reintegrerParticipant', exclue, { id: 1 }).charge;
const avecIris = etatPlaces(reintegree, { tour: 1 }).tables[2];
assert.deepEqual(avecIris.titres, { titres: 1, pourvus: 1 });
assert.deepEqual(
[avecIris.chaises[0].occupant, avecIris.chaises[0].remplissage, avecIris.chaises[0].marques, avecIris.chaises[0].titre.pourvu],
[1, 'plein', ['cadenasFerme', 'anneau'], true],
);
assert.deepEqual(avecIris.chaises[0].reservation, { participant: 1, portee: 'tous', tour: null });
});
test('deux titres sur une place : la chaise montre le premier dans l’ordre canonique, la table en compte deux', () => {
const charge = chargeTitres();
charge.titres = [
{ table: 2, siege: 2, libelle: 'hôte' },
{ table: 2, siege: 2, libelle: 'animation' },
];
const deux = etatPlaces(charge, { tour: 1 }).tables[1];
assert.equal(deux.chaises[1].titre.libelle, 'animation');
assert.equal(deux.titres.titres, 2);
});
test('deux réservations de personnes différentes au même siège et au même tour : la chaise porte la première, la seconde au premier siège libre', () => {
const charge = chargeTitres({ attribuerSieges: true });
charge.reservations = [resa(3, 1, 2, 1), resa(2, 1, 2)];
const premiere = etatPlaces(charge, { tour: 1 }).tables[0];
assert.deepEqual(
premiere.chaises.map(({ occupant }) => occupant),
[3, 2, null, null],
);
assert.deepEqual(premiere.chaises[1].reservation, { participant: 2, portee: 'tous', tour: null });
assert.deepEqual(premiere.chaises[0].reservation, { participant: 3, portee: 'tour', tour: 1 });
assert.deepEqual(premiere.chaises[0].marques, ['cadenasFerme']);
// Au tour 2, seule la réservation « tous » tient le siège 2.
assert.deepEqual(
etatPlaces(charge, { tour: 2 }).tables[0].chaises.map(({ occupant }) => occupant),
[null, 2, null, null],
);
});
test('mode table : la personne réservée sans siège qui pourvoit un titre s’affiche sur sa chaise', () => {
const charge = chargeTitres();
charge.titres = [{ table: 1, siege: 3, libelle: 'animation' }];
charge.reservations = [resa(2, 1, null)];
const premiere = etatPlaces(charge, { tour: 1 }).tables[0];
assert.deepEqual(
premiere.chaises.map(({ occupant }) => occupant),
[null, null, 2, null],
);
assert.deepEqual([premiere.chaises[2].remplissage, premiere.chaises[2].titre.pourvu], ['plein', true]);
assert.deepEqual(premiere.titres, { titres: 1, pourvus: 1 });
});
test('une table gelée : k_t = c_t', () => {
const charge = chargeTitres();
charge.reservations = [1, 2, 3, 4].map((id) => resa(id, 2, null));
const etat = etatPlaces(charge, { tour: 1 });
assert.deepEqual(
etat.tables.map(({ gelee }) => gelee),
[false, true, false, false, false],
);
});
test("le marqueur de chaise vide : la liste [12, null, 7] du retenu assied 12 au siège 1 et 7 au siège 3", () => {
const charge = petiteRetenue();
charge.tables = [table(1, 4), table(2, 4), table(3, 4), table(4, 4)];
const plan = planFichier({
tables: [1, 2, 3, 4],
capacites: [4, 4, 4, 4],
participants: DOUZE,
tours: [[[12, null, 7], [1, 2, 3], [4, 5, 6], [8, 9, 10, 11]], ...PARFAIT.slice(1)],
siegesAttribues: true,
});
charge.retenu = retenuDe(1, plan);
assert.deepEqual(occupants(etatPlaces(charge, { tour: 1 }), 0), [12, null, 7, null]);
});
});
describe('libellePropose et statuts (§ 4, § 4.2)', () => {
test('le titre pressenti, puis le libellé le plus fréquent, le plus petit à égalité, puis « animateur », jamais marqué', () => {
const charge = creerCharge({ id: 'evt-libelles', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 });
charge.participants = [personne(1, 'Ombrelle', 'Iris', { titrePressenti: 'hôtesse' }), personne(2, 'Pervenche', 'Théo')];
charge.tables = [table(1), table(2)];
charge.prochainsIds = { participant: 3, table: 3, proposition: 1 };
assert.equal(libellePropose(charge, 2), 'animateur');
assert.equal(libellePropose(charge, 1), 'hôtesse');
charge.titres = [
{ table: 1, siege: 1, libelle: 'veilleur' },
{ table: 1, siege: 2, libelle: 'accueil' },
{ table: 2, siege: 1, libelle: 'veilleur' },
{ table: 2, siege: 2, libelle: 'accueil' },
{ table: 2, siege: 3, libelle: 'Zélie' },
];
assert.equal(libellePropose(charge, 2), 'accueil');
charge.titres.push({ table: 1, siege: 3, libelle: 'veilleur' });
assert.equal(libellePropose(charge, 2), 'veilleur');
assert.equal(libellePropose(charge, 1), 'hôtesse');
});
test('ancré, ancré, partiellement fixé, mobile ; un exclu sans entrée ; chacun égal au statut du moteur', () => {
const charge = creerCharge({ id: 'evt-statuts', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 3 });
charge.participants = [
personne(1, 'Ombrelle', 'Iris'),
personne(2, 'Pervenche', 'Théo'),
personne(3, 'Lacasse', 'Ondine'),
personne(4, 'Grisaille'),
personne(5, 'Bruyère', 'Anouk', { exclu: true }),
];
charge.tables = [table(1), table(2)];
charge.prochainsIds = { participant: 6, table: 3, proposition: 1 };
charge.reservations = [
resa(1, 1, null),
resa(2, 1, null, 1), resa(2, 1, null, 2), resa(2, 1, null, 3),
resa(3, 1, null, 1), resa(3, 2, null, 2),
resa(5, 2, null),
];
const { parId, erreur } = statuts(charge);
assert.equal(erreur, null);
assert.deepEqual([...parId.entries()], [[1, 'ancre'], [2, 'ancre'], [3, 'partiel'], [4, 'mobile']]);
const instance = normaliser(configurationDepuisCharge(charge));
const NOMS = new Map([[STATUT.ANCRE, 'ancre'], [STATUT.PARTIELLEMENT_FIXE, 'partiel'], [STATUT.MOBILE, 'mobile']]);
assert.ok(parId.size > 0);
for (const [id, statut] of parId) assert.equal(statut, NOMS.get(instance.statut[instance.indexDe.get(id)]));
});
test('une configuration qui ne se normalise pas rend son code au lieu de lever', () => {
const charge = creerCharge({ id: 'evt-statuts', nom: 'Soirée des Lucioles', siegesParDefaut: 2, tours: 1 });
charge.participants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo'), personne(3, 'Grisaille')];
charge.tables = [table(1)];
charge.prochainsIds = { participant: 4, table: 2, proposition: 1 };
charge.reservations = [1, 2, 3].map((id) => resa(id, 1, null));
const { parId, erreur } = statuts(charge);
assert.equal(erreur, 'SURRESERVATION');
assert.equal(parId.size, 0);
assert.doesNotThrow(() => etatPlaces(charge, { tour: 1 }));
assert.equal(etatPlaces(charge, { tour: 1 }).evalue, false);
});
});

258
src/application/pose.js Normal file
View file

@ -0,0 +1,258 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le contrôle de pose (§ 5.9) : ce que le logiciel refuse au moment du geste,
// avant toute écriture, et le seul — les commandes l'appellent, aucun
// composant ne le refait (§ 13.2). Le moteur ignore sièges et titres et
// s'arrête au premier refus par une exception ; controlerPose rend tous les
// refus d'une pose, chacun avec le remède qui le lève, écrit en entier : une
// commande et ses arguments, exécutables tels quels, ou un geste.
//
// Un refus a la forme { code, details }, le remède dans details.remede.
// Plusieurs se rangent dans l'ordre SURRESERVATION, SIEGE_INEXISTANT,
// DEUX_PLACES_MEME_TOUR, PERSONNE_EXCLUE, PLACE_RESERVEE, RESERVEE_AILLEURS ;
// une commande lève le premier et porte les suivants dans details.autres
// (leverRefus). Une réservation suspendue — celle d'une personne exclue —
// ne compte nulle part : une pose peut prendre sa place (§ 4.4), et sa
// réintégration se contrôle comme une pose (controlerReintegration). Aucune
// fonction ne modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { capacite } from '../stockage/document.js';
import { ErreurCommande } from './erreurs.js';
import { couvre, instanceDe, reservationsActives } from './places.js';
// Un refus : son code, ses détails, et le remède parmi eux.
const refusDe = (code, details, remede) => ({ code, details: { ...details, remede } });
// Le remède d'une capacité trop petite : la table portée à sieges places,
// par la saisie de son champ.
const changerPlaces = (table, sieges) => ({ commande: 'changerPlacesTable', arguments: { id: table, sieges, origine: 'champ' } });
// Ce qui désigne une réservation dans une commande : sa table, sa portée, son
// tour, null pour la portée « tous ».
const designation = ({ table, portee, tour }) => ({ table, portee, tour: portee === 'tous' ? null : tour });
// Vrai quand la réservation r est celle que désigne d, pour ce participant.
const designee = (r, participant, d) =>
r.participant === participant && r.table === d.table && r.portee === d.portee && r.tour === (d.portee === 'tous' ? null : d.tour);
// Les tours qu'une réservation couvre, croissants.
function toursCouverts(charge, { portee, tour }) {
if (portee === 'tous') return Array.from({ length: charge.evenement.tours }, (_, rang) => rang + 1);
return [tour];
}
// 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;
}
// Les personnes distinctes dont une réservation de la liste couvre le tour
// à cette table.
const reserveesAuTour = (actives, idTable, tour) =>
new Set(actives.filter((r) => r.table === idTable && couvre(r, tour)).map(({ participant }) => participant));
/**
* [r − 1] : les personnes distinctes non exclues dont une réservation couvre
* le tour r à cette table — le compte de SURRESERVATION du moteur, sans
* lever.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} idTable
* @returns {number[]} R comptes
*/
export function reservationsParTour(charge, idTable) {
const actives = reservationsActives(charge);
return Array.from({ length: charge.evenement.tours }, (_, rang) => reserveesAuTour(actives, idTable, rang + 1).size);
}
/**
* Contrôle d'une pose (§ 5.9), sans rien écrire ; pose : { participant,
* table, siege, portee, tour, depuis?, retenu? } — depuis : la réservation
* que la pose déplace, { table, portee, tour }, qui ne compte plus ;
* retenu : vrai pour un dépôt dans le placement retenu au tour donné, qui ne
* pose aucune réservation. Chaque remède s'écrit en entier. Rend tous les
* refus, dans l'ordre du contrat :
* - SURRESERVATION {table, tour, reservees, capacite} : le code et les
* détails de l'ErreurConfiguration du moteur, reservees compté après la
* pose, au premier tour couvert qui déborde ; remède la table portée à
* reservees places ;
* - SIEGE_INEXISTANT {table, siege, capacite} : sièges attribués seulement ;
* remède la table portée à siege places ;
* - DEUX_PLACES_MEME_TOUR {participant, tour, autre} : une autre réservation
* active de la personne couvre un tour de la pose, à une autre table, ou à
* un autre siège de la même ; remède le déplacement de cette réservation
* vers la place de la pose ;
* - PERSONNE_EXCLUE {participant} : remède la réintégration ;
* - PLACE_RESERVEE {table, siege, tour, titulaire, portee} : la réservation
* active d'une autre personne tient ce siège à un tour de la pose, dans les
* deux modes ; remède le choix d'une place pour la réservation de la
* titulaire ;
* - RESERVEE_AILLEURS {participant, tour, reservation} : dans le retenu, la
* personne est réservée à ce tour à une autre table — ou, sièges du retenu
* attribués, à un autre siège ; remède le déplacement de sa réservation
* vers le dépôt.
* Les trois premiers ne valent que pour une réservation. signaux porte
* TABLE_GELEE {table} quand, la pose faite, les ancrés de la table en
* occupent toutes les places (k_t = c_t) : accepté, mais rarement voulu.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {Object} pose
* @returns {{refus: Array<{code: string, details: Object}>, signaux: Array<{code: 'TABLE_GELEE', table: number}>}}
*/
export function controlerPose(charge, pose) {
const { participant, table: idTable, siege = null, portee, tour = null, depuis = null, retenu = false } = pose;
const places = capacite(charge, tableDe(charge, idTable));
const exclu = charge.participants.some(({ id, exclu: estExclu }) => id === participant && estExclu);
const couverts = retenu ? [tour] : toursCouverts(charge, { portee, tour });
const actives = reservationsActives(charge).filter((r) => depuis === null || !designee(r, participant, depuis));
const premierCommun = (r) => couverts.find((t) => couvre(r, t));
const refus = [];
if (!retenu) {
for (const t of couverts) {
const reservees = reserveesAuTour(actives, idTable, t);
if (!exclu) reservees.add(participant);
if (reservees.size > places) {
const details = { table: idTable, tour: t, reservees: reservees.size, capacite: places };
refus.push(refusDe('SURRESERVATION', details, changerPlaces(idTable, reservees.size)));
break;
}
}
if (charge.reglages.attribuerSieges && siege !== null && siege > places) {
refus.push(refusDe('SIEGE_INEXISTANT', { table: idTable, siege, capacite: places }, changerPlaces(idTable, siege)));
}
const autre = actives.find(
(r) =>
r.participant === participant &&
premierCommun(r) !== undefined &&
(r.table !== idTable || (r.siege !== null && siege !== null && r.siege !== siege)),
);
if (autre !== undefined) {
const remede = {
commande: 'deplacerReservation',
arguments: { participant, de: designation(autre), vers: { table: idTable, siege } },
};
const details = {
participant,
tour: premierCommun(autre),
autre: { table: autre.table, siege: autre.siege, portee: autre.portee, tour: autre.tour },
};
refus.push(refusDe('DEUX_PLACES_MEME_TOUR', details, remede));
}
}
if (exclu) {
refus.push(refusDe('PERSONNE_EXCLUE', { participant }, { commande: 'reintegrerParticipant', arguments: { id: participant } }));
}
if (siege !== null) {
const titulaire = actives.find(
(r) => r.participant !== participant && r.table === idTable && r.siege === siege && premierCommun(r) !== undefined,
);
if (titulaire !== undefined) refus.push(placeReservee(titulaire, premierCommun(titulaire)));
}
if (retenu && !exclu) {
const attribues = charge.retenu?.siegesAttribues === true;
const propre = actives.find(
(r) =>
r.participant === participant &&
couvre(r, tour) &&
(r.table !== idTable || (attribues && r.siege !== null && siege !== null && r.siege !== siege)),
);
if (propre !== undefined) {
const remede = {
commande: 'deplacerReservation',
arguments: { participant, de: designation(propre), vers: { table: idTable, siege } },
};
const reservation = { table: propre.table, siege: propre.siege, portee: propre.portee, tour: propre.tour };
refus.push(refusDe('RESERVEE_AILLEURS', { participant, tour, reservation }, remede));
}
}
return { refus, signaux: retenu ? [] : signauxDe(charge, pose, places) };
}
// PLACE_RESERVEE de la réservation r d'une titulaire, au tour donné : le
// remède fait choisir une place sur le plan, l'écrit dans
// puis.arguments.vers, puis déplace la réservation de la titulaire.
function placeReservee(r, tour) {
const remede = {
geste: 'choisirPlace',
puis: { commande: 'deplacerReservation', arguments: { participant: r.participant, de: designation(r) } },
};
return refusDe('PLACE_RESERVEE', { table: r.table, siege: r.siege, tour, titulaire: r.participant, portee: r.portee }, remede);
}
// TABLE_GELEE quand la charge où la pose serait faite se normalise et que
// les ancrés de la table en occupent toutes les places.
function signauxDe(charge, { participant, table: idTable, siege = null, portee, tour = null, depuis = null }, places) {
const gardees = charge.reservations.filter((r) => depuis === null || !designee(r, participant, depuis));
const posee = { participant, table: idTable, siege, portee, tour: portee === 'tous' ? null : tour };
const instance = instanceDe({ ...charge, reservations: [...gardees, posee] });
if (instance === null || instance.ancresParTable[instance.indexTableDe.get(idTable)] !== places) return [];
return [{ code: 'TABLE_GELEE', table: idTable }];
}
/**
* Les refus de la réintégration de id (§ 4.4, § 5.9) : ses réservations
* suspendues, posées contre les actives. SURRESERVATION quand une table ne
* peut plus les recevoir, au premier tour qui déborde, une fois par table,
* dans l'ordre de ses réservations ; puis PLACE_RESERVEE, une par
* réservation à un siège qu'une autre personne a pris depuis à un tour
* qu'elle couvre. [] quand rien ne s'y oppose, et pour une personne qui
* n'est pas exclue.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} id
* @returns {Array<{code: string, details: Object}>}
*/
export function controlerReintegration(charge, id) {
if (!charge.participants.some((personne) => personne.id === id && personne.exclu)) return [];
const apres = {
...charge,
participants: charge.participants.map((personne) => (personne.id === id ? { ...personne, exclu: false } : personne)),
};
const siennes = charge.reservations.filter(({ participant }) => participant === id).sort(ordreDesReservations);
const refus = [];
const vues = new Set();
for (const { table: idTable } of siennes) {
if (vues.has(idTable)) continue;
vues.add(idTable);
const places = capacite(apres, tableDe(apres, idTable));
const comptes = reservationsParTour(apres, idTable);
const rang = comptes.findIndex((n) => n > places);
if (rang >= 0) {
const details = { table: idTable, tour: rang + 1, reservees: comptes[rang], capacite: places };
refus.push(refusDe('SURRESERVATION', details, changerPlaces(idTable, comptes[rang])));
}
}
const actives = reservationsActives(charge);
for (const sienne of siennes) {
if (sienne.siege === null) continue;
const couverts = toursCouverts(charge, sienne);
const autre = actives.find(
(r) => r.table === sienne.table && r.siege === sienne.siege && couverts.some((t) => couvre(r, t)),
);
if (autre !== undefined) refus.push(placeReservee(autre, couverts.find((t) => couvre(autre, t))));
}
return refus;
}
/**
* Lève le premier refus d'une liste, en ErreurCommande de son code et de ses
* détails, remède compris, les suivants dans details.autres ; ne fait rien
* d'une liste vide.
*
* @param {Array<{code: string, details: Object}>} refus
* @throws {ErreurCommande}
*/
export function leverRefus(refus) {
if (refus.length === 0) return;
const [premier, ...autres] = refus;
throw new ErreurCommande(premier.code, { ...premier.details, autres });
}

View file

@ -0,0 +1,351 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du contrôle de pose (§ 5.9, § 4.4) : chaque refus d'une pose, son
// remède écrit en entier, le cumul de plusieurs refus, le signal d'une table
// gelée, le compte des réservations par tour et le contrôle d'une
// réintégration. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { COMMANDES } from './commandes.js';
import { ErreurCommande } from './erreurs.js';
import { controlerPose, controlerReintegration, reservationsParTour } from './pose.js';
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 table = (id, sieges = null) => ({ id, numero: id, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } });
// Réservation « tous les tours » quand tour est null, du tour donné sinon.
const resa = (participant, idTable, siege, tour = null) => ({
participant,
table: idTable,
siege,
portee: tour === null ? 'tous' : 'tour',
tour,
});
// Une charge de R tours, siegesParDefaut places par défaut, qui porte ce
// qu'on lui donne ; les compteurs dépassent chaque identifiant donné.
function chargeDe({
participants = 10,
exclus = [],
tables = [table(1), table(2, 8), table(3)],
reservations = [],
titres = [],
tours = 4,
sieges = 4,
attribuerSieges = false,
} = {}) {
const charge = creerCharge({ id: 'evt-pose', nom: 'Soirée des Lucioles', siegesParDefaut: sieges, tours });
charge.reglages.attribuerSieges = attribuerSieges;
charge.participants = Array.from({ length: participants }, (_, rang) =>
personne(rang + 1, { exclu: exclus.includes(rang + 1) }),
);
charge.tables = tables;
charge.reservations = reservations;
charge.titres = titres;
charge.prochainsIds = { participant: participants + 1, table: 10, proposition: 1 };
return charge;
}
// Le refus d'une ErreurCommande levée par appel, de ce code ; ses détails
// contiennent ceux donnés.
function refuse(appel, code, attendus = {}) {
assert.throws(appel, (erreur) => {
assert.ok(erreur instanceof ErreurCommande, `ErreurCommande attendue, reçu ${erreur}`);
assert.equal(erreur.code, code);
for (const [cle, valeur] of Object.entries(attendus)) assert.deepEqual(erreur.details[cle], valeur, cle);
return true;
});
}
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
// La charge où la pose serait faite : la réservation ajoutée telle quelle.
const avecPose = (charge, { participant, table: idTable, siege, portee, tour }) => ({
...charge,
reservations: [...charge.reservations, { participant, table: idTable, siege, portee, tour }],
});
describe('controlerPose : les refus de pose (§ 5.9)', () => {
test('quatre réservations « tous » à une table de 4 : la cinquième SURRESERVATION aux détails du moteur, la quatrième gelée', () => {
const quatre = chargeDe({ reservations: [1, 2, 3, 4].map((id) => resa(id, 1, null)) });
const pose = { participant: 5, table: 1, siege: null, portee: 'tous', tour: null };
const { refus, signaux } = controlerPose(quatre, pose);
assert.equal(refus.length, 1);
assert.equal(refus[0].code, 'SURRESERVATION');
const { remede, ...details } = refus[0].details;
assert.deepEqual(details, { table: 1, tour: 1, reservees: 5, capacite: 4 });
assert.deepEqual(remede, { commande: 'changerPlacesTable', arguments: { id: 1, sieges: 5, origine: 'champ' } });
let duMoteur = null;
try {
normaliser(configurationDepuisCharge(avecPose(quatre, pose)));
} catch (erreur) {
duMoteur = erreur;
}
assert.ok(duMoteur instanceof ErreurConfiguration);
assert.equal(duMoteur.code, 'SURRESERVATION');
assert.deepEqual(duMoteur.details, details);
assert.deepEqual(signaux, []);
const trois = chargeDe({ reservations: [1, 2, 3].map((id) => resa(id, 1, null)) });
const quatrieme = controlerPose(trois, { participant: 4, table: 1, siege: null, portee: 'tous', tour: null });
assert.deepEqual(quatrieme, { refus: [], signaux: [{ code: 'TABLE_GELEE', table: 1 }] });
assert.deepEqual(reservationsParTour(quatre, 1), [4, 4, 4, 4]);
assert.deepEqual(reservationsParTour(quatre, 2), [0, 0, 0, 0]);
});
test("reservationsParTour compte les personnes distinctes non exclues dont une réservation couvre le tour", () => {
const charge = chargeDe({
exclus: [4],
reservations: [resa(1, 1, null), resa(1, 1, 2, 3), resa(2, 1, null, 2), resa(3, 1, null, 2), resa(4, 1, null)],
});
assert.deepEqual(reservationsParTour(charge, 1), [1, 3, 1, 1]);
});
test('sièges attribués, le siège 9 d’une table de 8 : SIEGE_INEXISTANT ; sans attribution, aucun', () => {
const pose = { participant: 1, table: 2, siege: 9, portee: 'tous', tour: null };
const { refus } = controlerPose(chargeDe({ attribuerSieges: true }), pose);
assert.deepEqual(refus, [
{
code: 'SIEGE_INEXISTANT',
details: {
table: 2,
siege: 9,
capacite: 8,
remede: { commande: 'changerPlacesTable', arguments: { id: 2, sieges: 9, origine: 'champ' } },
},
},
]);
assert.deepEqual(controlerPose(chargeDe(), pose).refus, []);
const { charge } = executer('reserver', chargeDe(), { participant: 1, table: 2, siege: 9, portee: 'tous', tour: null });
assert.deepEqual(charge.reservations, [resa(1, 2, null)]);
});
test('« tous » à la table 1 puis le tour 2 à la table 2 : DEUX_PLACES_MEME_TOUR, et son remède déplace la première', () => {
const charge = chargeDe({ reservations: [resa(1, 1, null)] });
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 4, portee: 'tour', tour: 2 });
assert.deepEqual(refus, [
{
code: 'DEUX_PLACES_MEME_TOUR',
details: {
participant: 1,
tour: 2,
autre: { table: 1, siege: null, portee: 'tous', tour: null },
remede: {
commande: 'deplacerReservation',
arguments: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 4 } },
},
},
},
]);
});
test("deux sièges d'une même table au même tour : DEUX_PLACES_MEME_TOUR, que normaliser accepterait", () => {
const charge = chargeDe({ attribuerSieges: true, reservations: [resa(1, 1, 1, 3)] });
const pose = { participant: 1, table: 1, siege: 2, portee: 'tour', tour: 3 };
assert.doesNotThrow(() => normaliser(configurationDepuisCharge(avecPose(charge, pose))));
const { refus } = controlerPose(charge, pose);
assert.deepEqual(
refus.map(({ code, details }) => [code, details.tour, details.autre]),
[['DEUX_PLACES_MEME_TOUR', 3, { table: 1, siege: 1, portee: 'tour', tour: 3 }]],
);
});
test('déplacer sa propre réservation ne la compte pas contre la pose (depuis)', () => {
const charge = chargeDe({ reservations: [1, 2, 3, 4].map((id) => resa(id, 1, null)) });
const pose = { participant: 4, table: 2, siege: null, portee: 'tous', tour: null, depuis: { table: 1, portee: 'tous', tour: null } };
assert.deepEqual(controlerPose(charge, pose), { refus: [], signaux: [] });
});
test('une personne exclue : PERSONNE_EXCLUE, remède la réintégration', () => {
const { refus } = controlerPose(chargeDe({ exclus: [3] }), { participant: 3, table: 1, siege: null, portee: 'tour', tour: 1 });
assert.deepEqual(refus, [
{
code: 'PERSONNE_EXCLUE',
details: { participant: 3, remede: { commande: 'reintegrerParticipant', arguments: { id: 3 } } },
},
]);
});
test("une place réservée à une autre : PLACE_RESERVEE, remède choisir une place pour la réservation de la titulaire", () => {
const charge = chargeDe({ attribuerSieges: true, reservations: [resa(2, 2, 3, 2)] });
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 3, portee: 'tous', tour: null });
assert.deepEqual(refus, [
{
code: 'PLACE_RESERVEE',
details: {
table: 2,
siege: 3,
tour: 2,
titulaire: 2,
portee: 'tour',
remede: {
geste: 'choisirPlace',
puis: { commande: 'deplacerReservation', arguments: { participant: 2, de: { table: 2, portee: 'tour', tour: 2 } } },
},
},
},
]);
// Sans attribution des sièges, la place réservée reste refusée.
const sansAttribution = { ...charge, reglages: { ...charge.reglages, attribuerSieges: false } };
assert.deepEqual(
controlerPose(sansAttribution, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus.map(({ code }) => code),
['PLACE_RESERVEE'],
);
// Une titulaire « tous » : le remède désigne sa réservation sans tour.
const tous = chargeDe({ attribuerSieges: true, reservations: [resa(2, 2, 3)] });
const [place] = controlerPose(tous, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 4 }).refus;
assert.deepEqual(place.details.remede.puis.arguments.de, { table: 2, portee: 'tous', tour: null });
assert.equal(place.details.portee, 'tous');
});
test('une réservation suspendue ne compte pas : sa place et son siège sont libres pour une pose', () => {
const charge = chargeDe({ attribuerSieges: true, exclus: [2], reservations: [resa(2, 1, 1), resa(3, 1, 2), resa(4, 1, 3), resa(5, 1, 4)] });
assert.deepEqual(controlerPose(charge, { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }).refus, []);
assert.deepEqual(reservationsParTour(charge, 1), [3, 3, 3, 3]);
});
test('dans le retenu, une personne réservée ailleurs : RESERVEE_AILLEURS, remède le déplacement de sa réservation', () => {
const charge = chargeDe({ reservations: [resa(1, 1, null)] });
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 5, portee: 'tour', tour: 2, retenu: true });
assert.deepEqual(refus, [
{
code: 'RESERVEE_AILLEURS',
details: {
participant: 1,
tour: 2,
reservation: { table: 1, siege: null, portee: 'tous', tour: null },
remede: {
commande: 'deplacerReservation',
arguments: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 5 } },
},
},
},
]);
// Le retenu ne pose aucune réservation : ni surréservation ni double place.
const pleine = chargeDe({ reservations: [2, 3, 4, 5].map((id) => resa(id, 1, null)) });
assert.deepEqual(controlerPose(pleine, { participant: 1, table: 1, siege: null, portee: 'tour', tour: 1, retenu: true }), {
refus: [],
signaux: [],
});
});
test('une pose qui cumule des refus les rend tous, dans l’ordre du contrat, chacun avec un remède non nul', () => {
const charge = chargeDe({
attribuerSieges: true,
exclus: [1],
reservations: [resa(1, 3, 1, 2), resa(2, 2, 3, 2), ...[3, 4, 5, 6, 7, 8, 9].map((id) => resa(id, 2, null))],
});
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 9, portee: 'tour', tour: 2 });
assert.deepEqual(
refus.map(({ code }) => code),
['SIEGE_INEXISTANT', 'PERSONNE_EXCLUE'],
);
const cumul = chargeDe({ attribuerSieges: true, exclus: [1], reservations: [resa(2, 2, 3, 2)] });
const deux = controlerPose(cumul, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus;
assert.deepEqual(
deux.map(({ code }) => code),
['PERSONNE_EXCLUE', 'PLACE_RESERVEE'],
);
const actif = chargeDe({ attribuerSieges: true, reservations: [resa(1, 1, null), resa(2, 2, 3, 2), ...[3, 4, 5, 6, 7, 8, 9].map((id) => resa(id, 2, null))] });
const tous = controlerPose(actif, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus;
assert.deepEqual(
tous.map(({ code }) => code),
['SURRESERVATION', 'DEUX_PLACES_MEME_TOUR', 'PLACE_RESERVEE'],
);
for (const { details } of [...refus, ...deux, ...tous]) {
assert.ok(details.remede !== null && typeof details.remede === 'object', 'remède non nul');
}
});
test('une commande lève le premier refus et porte les suivants dans details.autres', () => {
const charge = chargeDe({ attribuerSieges: true, exclus: [1], reservations: [resa(2, 2, 3, 2)] });
refuse(() => executer('reserver', charge, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }), 'PERSONNE_EXCLUE', {
participant: 1,
autres: controlerPose(charge, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus.slice(1),
});
});
});
describe('controlerReintegration : la place d’une suspendue (§ 4.4, § 5.9)', () => {
// Sièges attribués : Iris désignée au siège 1 de la table 3, puis exclue.
function irisDesigneeExclue() {
const charge = chargeDe({ attribuerSieges: true });
const designee = executer('designer', charge, { participant: 1, table: 3, siege: 1, libelle: 'animatrice' }).charge;
return executer('exclureParticipant', designee, { id: 1 }).charge;
}
test('désigner Théo à la place d’Iris exclue passe ; réintégrer Iris est refusé PLACE_RESERVEE, sans rien écrire', () => {
const exclue = irisDesigneeExclue();
const avecTheo = executer('designer', exclue, { participant: 2, table: 3, siege: 1, libelle: 'animatrice' }).charge;
assert.deepEqual(
avecTheo.reservations.filter(({ table: t }) => t === 3),
[resa(1, 3, 1), resa(2, 3, 1)],
);
const avant = structuredClone(avecTheo);
assert.deepEqual(
controlerReintegration(avecTheo, 1).map(({ code, details }) => [code, details.table, details.siege, details.titulaire]),
[['PLACE_RESERVEE', 3, 1, 2]],
);
refuse(() => executer('reintegrerParticipant', avecTheo, { id: 1 }), 'PLACE_RESERVEE', {
table: 3,
siege: 1,
titulaire: 2,
autres: [],
});
assert.deepEqual(avecTheo, avant);
});
test('une table de 4 dont une réservation est suspendue reçoit une cinquième ; réintégrer est alors refusé SURRESERVATION', () => {
const charge = chargeDe({ exclus: [1], reservations: [1, 2, 3, 4].map((id) => resa(id, 1, null)) });
const cinq = executer('reserver', charge, { participant: 5, table: 1, siege: null, portee: 'tous', tour: null }).charge;
assert.deepEqual(
controlerReintegration(cinq, 1).map(({ code, details: { remede, ...details } }) => [code, details]),
[['SURRESERVATION', { table: 1, tour: 1, reservees: 5, capacite: 4 }]],
);
refuse(() => executer('reintegrerParticipant', cinq, { id: 1 }), 'SURRESERVATION', { table: 1, reservees: 5 });
});
test('sans conflit, la réintégration passe et rend les réservations', () => {
const exclue = irisDesigneeExclue();
assert.deepEqual(controlerReintegration(exclue, 1), []);
const { charge, avertissements } = executer('reintegrerParticipant', exclue, { id: 1 });
assert.equal(charge.participants[0].exclu, false);
assert.deepEqual(charge.reservations, exclue.reservations);
assert.deepEqual(
avertissements.map(({ code }) => code),
['RESERVATIONS_RETABLIES'],
);
});
test('une personne présente n’a rien à contrôler', () => {
assert.deepEqual(controlerReintegration(chargeDe({ reservations: [resa(1, 1, null)] }), 1), []);
});
});

View file

@ -52,7 +52,22 @@
//
// Les avertissements de la séance sont ceux de l'Ouvert courant — ceux de
// l'ouverture, puis ceux que le dépôt recalcule après chaque écriture —,
// suivis de ceux du dernier geste.
// puis INVARIANT_VIOLE, tant que le retenu de la charge enfreint une règle
// que la dérive ne nomme pas, puis ceux du dernier geste — ceux d'un passage
// en écriture, ceux de la commande, puis ceux que le geste fait naître
// (avertissementsApres) —, puis RETOUR_AUTOMATIQUE après un retour en
// lecture au terme de l'inactivité, jusqu'au passage en écriture suivant.
//
// Chaque refus porte son remède, details.remede : le geste qui le lève, ou
// null. Le refus qu'un geste modifiant recevrait maintenant se lit dans
// l'état observable, calculé par la même fonction que celle qui refuse
// (exigerEcriture) : aucun composant ne refait ce contrôle.
//
// La séance est observable : etat() rend un instantané figé en profondeur,
// neuf à chaque changement et le même entre deux, et subscribe suit le
// contrat des magasins de Svelte. Le cadrage de l'événement ouvert se range
// hors du fichier, dans reglages_locaux.json, par un écrivain différé
// (§ 8.5) : regarder n'écrit jamais l'événement.
//
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
@ -70,17 +85,24 @@ import {
ouvrirSecours as ouvrirSecoursSurDisque,
verrouiller,
} from '../stockage/depot.js';
import { FORMAT, analyser } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { fil, reconstruire } from '../stockage/journal.js';
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
import { avertissementsApres, invariantViole } from './avertissements.js';
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
import { libelle } from './libelles.js';
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
export const INACTIVITE_MS = 10 * 60 * 1000;
/** Délai de l'écriture différée du cadrage dans reglages_locaux.json (§ 8.5). */
export const DELAI_REGLAGES_MS = 1000;
/**
* Le refus d'un geste par la séance : son code et ses détails. VERROU_PRIS
* et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
* Le refus d'un geste par la séance : son code et ses détails, dont le
* remède, details.remede, null quand les détails n'en donnent pas.
* VERROU_PRIS et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
* ErreurStockage. Le texte affiché vient de la table des libellés (§ 14.6).
*/
export class ErreurSeance extends Error {
@ -89,9 +111,10 @@ export class ErreurSeance extends Error {
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
const complets = Object.hasOwn(details, 'remede') ? details : { ...details, remede: null };
super(`${code} ${JSON.stringify(complets)}`);
this.code = code;
this.details = details;
this.details = complets;
}
}
ErreurSeance.prototype.name = 'ErreurSeance';
@ -101,6 +124,30 @@ const avertissement = (code, details) => ({ code, details });
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
// Les refus de lecture seule de la séance, et leur remède : aucun pour un
// format plus récent ; débloquer un plan bloqué ; ouvrir le .precedent d'un
// état aux octets hors UTF-8, que son premier geste met de côté (§ 8.8).
const refusFormat = () => new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT', remede: null });
const refusBloque = () => new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE', remede: { geste: 'debloquer' } });
const refusEncodage = () =>
new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } });
// Deux racines désignent le même dossier.
const memeRacine = (a, b) => a?.id === b?.id && a?.chemin === b?.chemin;
// Fige un objet et tout ce qu'il atteint. Un objet déjà figé est laissé
// tel quel : l'état observable ne reçoit que des copies neuves
// (structuredClone) ou des objets déjà passés par cette fonction.
function figer(valeur) {
if (valeur === null || typeof valeur !== 'object' || Object.isFrozen(valeur)) return valeur;
for (const cle of Object.keys(valeur)) figer(valeur[cle]);
return Object.freeze(valeur);
}
// Le format qu'annonce l'en-tête de l'état sur le disque, null sans texte :
// texteEtat d'un Ouvert est toujours lisible quand il n'est pas null.
const formatDe = (texteEtat) => (texteEtat === null ? null : analyser(texteEtat).entete.format);
// La révision de la dernière entrée du journal de l'Ouvert, null sans
// journal.
const derniereEntree = ({ journal }) => journal?.entrees.at(-1)?.revision ?? null;
@ -121,8 +168,10 @@ function libelleDuRetour(sens, instant) {
*
* @param {Object} parametres
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
* @param {import('../stockage/systeme_fichiers.js').Racine} parametres.racine
* le dossier de travail que la règle du § 8.6 a déterminé
* @param {import('../stockage/systeme_fichiers.js').Racine|null} [parametres.racine]
* le dossier de travail que la règle du § 8.6 a déterminé ; null
* jusqu'à changerRacine, la séance ne le lisant qu'à ouvrir, créer et
* écrire ailleurs
* @param {{horodatage: () => string}} parametres.horloge
* horodatage rend AAAA-MM-JJTHH:MM:SS±HH:MM
* @param {() => string} parametres.identifiant tiré une fois pour le verrou
@ -132,16 +181,23 @@ function libelleDuRetour(sens, instant) {
* arme une minuterie ; rend ce qu'annuler reçoit
* @param {(minuterie: unknown) => void} parametres.annuler
* @param {number} [parametres.inactiviteMs]
* @param {number} [parametres.formatCourant] le format au-dessous duquel un
* fichier ouvert s'annonce plus ancien ; il ne règle que l'annonce : le
* stockage n'écrit que FORMAT
* @param {number} [parametres.delaiReglagesMs] délai de l'écriture différée
* du cadrage
*/
export function creerSeance({
fs,
racine,
racine = null,
horloge,
identifiant,
produitVersion,
planifier,
annuler,
inactiviteMs = INACTIVITE_MS,
formatCourant = FORMAT,
delaiReglagesMs = DELAI_REGLAGES_MS,
}) {
const id = identifiant();
const libelleReprise = libelle('ENTREE_REPRISE');
@ -154,7 +210,12 @@ export function creerSeance({
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
// passage en écriture ; gestes, calculs : ceux qui sont ouverts, qui
// suspendent le compte d'inactivité ; minuterie : celle qu'il a armée.
// suspendent le compte d'inactivité ; minuterie : celle qu'il a armée ;
// ailleurs : la racine documents à proposer quand le support rompu en est
// une autre ; fautives : les propositions que l'ouverture a écartées ;
// formatLu : le format de l'état relu, null dès qu'un geste l'a écrit ;
// retourAutomatique : le dernier retour en lecture est celui de
// l'inactivité ; cadrage : celui de l'événement ouvert, lu ou demandé.
const etat = {
racine,
ouvert: null,
@ -163,18 +224,166 @@ export function creerSeance({
verrouTenu: false,
supportRompu: false,
refusSupport: null,
ailleurs: null,
verrouAutre: null,
avertissementsGeste: [],
gestes: 0,
calculs: 0,
minuterie: null,
fautives: [],
formatLu: null,
retourAutomatique: false,
cadrage: null,
};
// --- L'observation (A2) -----------------------------------------------------
// Les abonnés de subscribe, ceux de surAnnulation, et l'instantané rendu
// depuis le dernier changement, null quand il est à refaire.
const abonnes = new Set();
const annulations = new Set();
let instantane = null;
// Par Ouvert, sa charge et son examen figés, et INVARIANT_VIOLE : un
// Ouvert ne change jamais, ses copies se font une fois.
const parOuvert = new WeakMap();
function figesDe(ouvert) {
let figes = parOuvert.get(ouvert);
if (figes === undefined) {
const copie = structuredClone({ charge: ouvert.charge, examen: ouvert.examen });
figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) };
parOuvert.set(ouvert, figes);
}
return figes;
}
// Le refus qu'un geste modifiant recevrait maintenant : celui
// qu'exigerEcriture lève, {code, details} ; null sans événement, ou quand
// le geste passerait.
function refusCourant() {
if (etat.ouvert === null) return null;
try {
exigerEcriture();
return null;
} catch (erreur) {
return { code: erreur.code, details: erreur.details };
}
}
// L'événement de l'état observable : sa base, son identifiant, son nom, son
// état, sa révision, et l'annonce du format.
function evenementDe(ouvert) {
const { id: idEvenement, nom, etat: etatDuPlan } = ouvert.charge.evenement;
let annonceFormat = null;
if (ouvert.formatPlusRecent) annonceFormat = 'plusRecent';
else if (etat.formatLu !== null && etat.formatLu < formatCourant) annonceFormat = 'plusAncien';
return { base: ouvert.base, id: idEvenement, nom, etat: etatDuPlan, revision: ouvert.revision, annonceFormat };
}
// L'état observable, construit à neuf (contrat EtatSeance).
function construire() {
const { ouvert } = etat;
const figes = ouvert === null ? null : figesDe(ouvert);
const avertissements = [
...(ouvert?.avertissements ?? []),
...(figes?.invariant ? [figes.invariant] : []),
...etat.avertissementsGeste,
...(etat.retourAutomatique ? [avertissement('RETOUR_AUTOMATIQUE', {})] : []),
];
const petit = structuredClone({
racine: etat.racine,
mode: etat.mode,
evenement: ouvert === null ? null : evenementDe(ouvert),
fautives: etat.fautives,
avertissements,
refus: refusCourant(),
retours: retoursDe(ouvert),
supportRompu: etat.supportRompu ? { ...etat.refusSupport, ailleurs: etat.ailleurs } : null,
calculEnCours: etat.calculs > 0,
verrouAutre: etat.verrouAutre,
});
return figer({
racine: petit.racine,
mode: petit.mode,
evenement: petit.evenement,
charge: figes?.charge ?? null,
examen: figes?.examen ?? null,
fautives: petit.fautives,
avertissements: petit.avertissements,
refus: petit.refus,
retours: petit.retours,
supportRompu: petit.supportRompu,
calculEnCours: petit.calculEnCours,
verrouAutre: petit.verrouAutre,
});
}
function lireEtat() {
if (instantane === null) instantane = construire();
return instantane;
}
// Un changement a pu avoir lieu : l'instantané se refait, et chaque abonné
// reçoit le nouveau.
function publier() {
instantane = null;
if (abonnes.size === 0) return;
const courant = lireEtat();
for (const abonne of [...abonnes]) abonne(courant);
}
// Le passage en lecture, explicite ou automatique, et la fermeture :
// chaque fonction de surAnnulation est appelée, dans l'ordre des
// abonnements.
function annoncerAnnulation() {
for (const fonction of [...annulations]) fonction();
}
// --- Le cadrage (§ 8.5) -----------------------------------------------------
// Écrit le cadrage demandé dans les réglages de sa racine, relus au moment
// d'écrire : un cadrage qu'une autre séance y a posé entre-temps reste.
// Des réglages que la lecture ne prend pas (REGLAGES_ILLISIBLES : lecture
// refusée, JSON ou forme fautifs) ne s'écrasent pas des défauts, qui
// perdraient le papier et les autres cadrages : le refus lève, et
// l'écrivain garde le cadrage en attente pour la demande suivante.
async function ecrireCadrage({ racine: dossier, evenement, cadrage }) {
const { reglages, avertissement } = await lireReglages(fs, dossier);
if (avertissement !== null) throw new ErreurSeance(avertissement.code, avertissement.details);
await ecrireReglages(fs, dossier, avecCadrage(reglages, evenement, cadrage));
}
// Le refus d'une écriture lancée par la minuterie se tait : le cadrage
// n'est qu'une commodité, et l'écrivain le réessaie à la demande suivante.
const ecrivain = creerEcrivainDiffere({ ecrire: ecrireCadrage, planifier, annuler, delaiMs: delaiReglagesMs });
// Le cadrage mémorisé de l'événement ouvert, relu dans les réglages de la
// racine ; null quand ils n'en portent pas, et pour des réglages que la
// lecture ne prend pas — la façade annonce REGLAGES_ILLISIBLES (A3).
async function cadrageMemorise(ouvert) {
try {
const { reglages } = await lireReglages(fs, ouvert.racine);
return cadrageDe(reglages, ouvert.charge.evenement.id);
} catch {
return null;
}
}
// Écrit aussitôt le cadrage en attente ; un refus se tait.
async function viderCadrage() {
try {
await ecrivain.vider();
} catch {
// Le cadrage refusé reste en attente de l'écrivain.
}
}
// La file des appels qui lisent ou écrivent l'événement : chacun part
// quand le précédent a fini, réussi ou non.
// quand le précédent a fini, réussi ou non, et publie l'état qu'il laisse
// avant que son appelant reprenne la main.
let file = Promise.resolve();
function enFile(travail) {
const suite = file.then(travail);
const suite = file.then(travail).finally(publier);
file = suite.then(
() => undefined,
() => undefined,
@ -201,23 +410,28 @@ export function creerSeance({
// Le terme d'une minuterie, à son tour dans la file : celle qui est
// toujours armée ramène la séance en lecture, tant que rien n'est ouvert.
// Le retour automatique se dit par RETOUR_AUTOMATIQUE, que le passage en
// écriture suivant retire.
async function expirer(minuterie) {
if (etat.minuterie !== minuterie) return;
etat.minuterie = null;
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
await quitterEcriture();
etat.retourAutomatique = true;
}
// --- Le verrou (§ 8.8) -----------------------------------------------------
// Prend le verrou de l'événement de l'Ouvert. Le verrou de cette séance,
// resté d'un rendu refusé, est le sien. Celui d'une séance morte de ce
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS.
// Celui d'une séance vivante, d'un autre poste ou illisible ne se reprend
// pas : VERROU_PRIS, que verrouAutre garde. Un système sans verrou le rend
// toujours pris. Un support qui refuse d'écrire le verrou, ou d'effacer
// celui du mort, rompt le support sous l'échec VERROU_NON_ECRIT, et son
// ECRITURE remonte (§ 8.6). Rend les avertissements.
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS
// {seance, depuis}. Celui d'une séance vivante, d'un autre poste (vivant
// null) ou illisible ne se reprend pas : VERROU_PRIS {seance, depuis,
// vivant}, remède null, que verrouAutre garde. Un système sans verrou le
// rend toujours pris. Un support qui refuse d'écrire le verrou, ou
// d'effacer celui du mort, rompt le support sous l'échec
// VERROU_NON_ECRIT, et son ECRITURE remonte (§ 8.6). Rend les
// avertissements.
async function prendreVerrou(ouvert) {
let verrou;
const avertissements = [];
@ -233,13 +447,13 @@ export function creerSeance({
}
}
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert, 'VERROU_NON_ECRIT');
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') await rompre(erreur, ouvert, 'VERROU_NON_ECRIT');
throw erreur;
}
if (!verrou.pris) {
const { seance, depuis, vivant } = verrou;
etat.verrouAutre = { seance, depuis, vivant };
throw new ErreurStockage('VERROU_PRIS', { seance, depuis, vivant });
throw new ErreurStockage('VERROU_PRIS', { seance, depuis, vivant, remede: null });
}
etat.verrouAutre = null;
etat.verrouTenu = true;
@ -282,9 +496,7 @@ export function creerSeance({
return { frais: await ouvrirSurDisque(systeme, dossier, base), source: null };
} catch (erreur) {
const illisible = erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE';
if (illisible && erreur.details.raison === 'ENCODAGE') {
throw new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE' });
}
if (illisible && erreur.details.raison === 'ENCODAGE') throw refusEncodage();
throw erreur;
}
}
@ -322,18 +534,20 @@ export function creerSeance({
const { frais, source } = relu;
etat.ouvert = frais;
etat.source = source;
etat.formatLu = formatDe(frais.texteEtat);
const change =
frais.revision !== ouvert.revision || serialiserCharge(frais.charge) !== serialiserCharge(ouvert.charge);
return change ? [avertissement('EVENEMENT_CHANGE', { revision: frais.revision })] : [];
}
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
// retour explicite annule en plus le geste ouvert. Les avertissements du
// rendu s'ajoutent à ceux du dernier geste.
// Le retour en lecture : le compte s'arrête, le verrou se rend, et
// surAnnulation l'annonce ; le retour explicite annule en plus le geste
// ouvert. Les avertissements du rendu s'ajoutent à ceux du dernier geste.
async function quitterEcriture({ explicite = false } = {}) {
suspendre();
if (explicite) etat.gestes = 0;
etat.mode = 'lecture';
annoncerAnnulation();
const avertissements = await rendreVerrou(etat.ouvert);
etat.avertissementsGeste = [...etat.avertissementsGeste, ...avertissements];
return avertissements;
@ -346,26 +560,32 @@ export function creerSeance({
return etat.ouvert;
}
// Le remède d'un support rompu : écrire ailleurs, dans la racine documents
// quand le support rompu en est une autre ; null, l'opérateur désigne le
// dossier.
const versAilleurs = () => ({ geste: 'ecrireAilleurs', racine: etat.ailleurs });
function exigerSupport() {
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport, remede: versAilleurs() });
}
// Quitter l'événement — ouvrir, créer, fermer — quand le support est
// rompu perdrait la seule copie qu'écrire ailleurs peut sauver : refusé,
// sauf sur abandon explicite.
// Quitter l'événement — ouvrir, créer, fermer, changer de racine — quand
// le support est rompu perdrait la seule copie qu'écrire ailleurs peut
// sauver : refusé, sauf sur abandon explicite.
function exigerSauve({ abandonner = false } = {}) {
if (etat.supportRompu && abandonner !== true) {
throw new ErreurSeance('SUPPORT_ROMPU_NON_SAUVE', { ...etat.refusSupport });
throw new ErreurSeance('SUPPORT_ROMPU_NON_SAUVE', { ...etat.refusSupport, remede: versAilleurs() });
}
}
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
// l'ordre de l'en-tête du module.
// l'ordre de l'en-tête du module, son remède compris. Le refus courant de
// l'état observable vient d'ici (refusCourant).
function exigerEcriture() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE');
if (ouvert.formatPlusRecent) throw refusFormat();
if (estBloque(ouvert)) throw refusBloque();
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE', { remede: { geste: 'modifier' } });
exigerSupport();
return ouvert;
}
@ -377,24 +597,37 @@ export function creerSeance({
// que le support n'a pas reçu : par défaut le code de l'erreur, la ligne
// ou l'état d'un geste ; JALON_NON_ECRIT, le jalon d'un geste enregistré ;
// VERROU_NON_ECRIT, le verrou d'un passage en écriture.
function rompre(erreur, ouvert, echec = erreur.code) {
// La racine documents, que le remède propose, se demande ensuite au
// système : null quand le support rompu est elle, ou que le système ne la
// dit pas.
async function rompre(erreur, ouvert, echec = erreur.code) {
const { chemin, dossier, cause } = erreur.details;
etat.ouvert = ouvert;
etat.supportRompu = true;
etat.refusSupport = { echec, chemin, dossier, cause };
etat.ailleurs = null;
try {
const { documents } = await fs.racines();
etat.ailleurs = memeRacine(documents, ouvert.racine) ? null : documents;
} catch {
etat.ailleurs = null;
}
}
// Une écriture du dépôt depuis l'Ouvert reçu ; ce qu'elle rend devient
// l'Ouvert de la séance. ECRITURE rompt le support en gardant l'Ouvert
// reçu, que rien n'a changé ; ETAT_NON_ECRIT, en gardant l'Ouvert avancé de
// ses détails, son geste au journal. Le refus remonte.
// Une écriture réussie porte l'état au format FORMAT : l'annonce d'un
// format plus ancien tombe.
async function ecrire(ouvert, ecriture) {
try {
etat.ouvert = await ecriture(ouvert);
etat.formatLu = null;
return etat.ouvert;
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') await rompre(erreur, ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') await rompre(erreur, erreur.details.ouvert);
throw erreur;
}
}
@ -423,7 +656,7 @@ export function creerSeance({
etat.ouvert = await nommerSurDisque(ouvert, { nom, ...commun });
} catch (erreur) {
if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur;
rompre(erreur, ouvert, 'JALON_NON_ECRIT');
await rompre(erreur, ouvert, 'JALON_NON_ECRIT');
const { chemin, dossier, cause } = erreur.details;
suite.push(avertissement('JALON_NON_ECRIT', { chemin, dossier, cause }));
}
@ -461,20 +694,28 @@ export function creerSeance({
avertissements.push(...(await relireSousVerrou(ouvert)));
depart = etat.ouvert;
try {
if (depart.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (depart.formatPlusRecent) throw refusFormat();
resultat = appliquerCommande('debloquer', depart.charge, args);
} catch (erreur) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))];
throw erreur;
}
}
etat.mode = 'ecriture';
entrerEnEcriture();
return ecrireGeste(depart, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
}
// La séance passe en écriture : le retour automatique précédent ne se dit
// plus.
function entrerEnEcriture() {
etat.mode = 'ecriture';
etat.retourAutomatique = false;
}
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
// commande ajoute. Un rendu au libellé null n'écrit rien : la séance rend
// son résumé, le libellé null.
// commande ajoute, puis ce que le geste fait naître, de la charge d'avant à
// celle qu'il écrit (avertissementsApres). Un rendu au libellé null
// n'écrit rien : la séance rend son résumé, le libellé null.
async function geste(nom, args) {
const ouvert = exigerOuvert();
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
@ -487,7 +728,8 @@ export function creerSeance({
return { libelle: null, resume: resultat.resume };
}
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
const avertissements = [...resultat.avertissements, ...avertissementsApres(ouvert.charge, resultat.charge)];
const texte = await ecrireGeste(ouvert, { ...resultat, avertissements }, { jalonAvant });
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
}
@ -505,6 +747,14 @@ export function creerSeance({
// --- Les retours (§ 8.3) -----------------------------------------------------
// {defaire, refaire} : les retours qui existent sur le fil de l'Ouvert ;
// faux sans événement.
function retoursDe(ouvert) {
if (ouvert === null) return { defaire: false, refaire: false };
const { cibleDefaire, cibleRefaire } = filDe(ouvert);
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
}
// Le fil de l'Ouvert : les cibles de défaire et de refaire, null quand
// elles n'existent pas, et le libellé de l'instant courant. Quand le
// journal finit sur une autre révision que la charge ouverte, ou manque, la
@ -580,11 +830,14 @@ export function creerSeance({
// --- Ouvrir, créer, fermer (§ 8.1, § 8.4) ----------------------------------------
// Ferme l'événement ouvert : le compte s'arrête, le verrou se rend, et la
// séance oublie l'événement. Rend les avertissements du rendu.
// Ferme l'événement ouvert : le compte s'arrête, le cadrage en attente
// s'écrit, le verrou se rend, surAnnulation l'annonce, et la séance oublie
// l'événement. Rend les avertissements du rendu.
async function fermerEvenement() {
suspendre();
await viderCadrage();
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
if (etat.ouvert !== null) annoncerAnnulation();
Object.assign(etat, {
ouvert: null,
source: null,
@ -592,22 +845,32 @@ export function creerSeance({
verrouTenu: false,
supportRompu: false,
refusSupport: null,
ailleurs: null,
verrouAutre: null,
avertissementsGeste: [],
gestes: 0,
calculs: 0,
fautives: [],
formatLu: null,
retourAutomatique: false,
cadrage: null,
});
return avertissements;
}
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
// fermé le précédent ; source dit d'où il se relit (relireSousVerrou).
// L'ouverture garde les propositions qu'elle écarte, le format de l'état
// lu et le cadrage mémorisé de l'événement.
async function ouvrirAvec(lire, source, quitter) {
exigerSauve(quitter);
await fermerEvenement();
const ouvert = await lire();
etat.ouvert = ouvert;
etat.source = source;
etat.fautives = ouvert.examen.fautives;
etat.formatLu = formatDe(ouvert.texteEtat);
etat.cadrage = await cadrageMemorise(ouvert);
return { base: ouvert.base, avertissements: ouvert.avertissements };
}
@ -631,7 +894,7 @@ export function creerSeance({
try {
etat.ouvert = await creerSurDisque(fs, etat.racine, charge, options);
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') await rompre(erreur, erreur.details.ouvert);
throw erreur;
}
return { base: etat.ouvert.base };
@ -644,21 +907,21 @@ export function creerSeance({
// et la séance reste en lecture sur ce qu'elle a relu, le refus levé.
async function passerEnEcriture() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (ouvert.formatPlusRecent) throw refusFormat();
if (estBloque(ouvert)) throw refusBloque();
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
const avertissements = await prendreVerrou(ouvert);
avertissements.push(...(await relireSousVerrou(ouvert)));
const relu = etat.ouvert;
let refus = null;
if (relu.formatPlusRecent) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
else if (estBloque(relu)) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (relu.formatPlusRecent) refus = refusFormat();
else if (estBloque(relu)) refus = refusBloque();
if (refus !== null) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
throw refus;
}
etat.mode = 'ecriture';
entrerEnEcriture();
etat.avertissementsGeste = avertissements;
armer();
return { avertissements };
@ -723,10 +986,15 @@ export function creerSeance({
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
// annonce la garantie affaiblie d'un support amovible, comme la règle du
// dossier de travail au démarrage (§ 8.8).
async function ecrireAilleurs(nouvelle) {
//
// Sans racine, le dossier se demande à l'opérateur par fs.choisirDossier ;
// son annulation ne change rien, et rend null.
async function ecrireAilleurs(designee) {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (ouvert.formatPlusRecent) throw refusFormat();
if (!etat.supportRompu) throw new ErreurSeance('SUPPORT_INTACT');
const nouvelle = designee ?? (await fs.choisirDossier());
if (nouvelle === null) return null;
const present = await dejaPresent(nouvelle, ouvert.charge.evenement.id);
if (present !== null) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id: present.id, base: present.base });
const options = { existantes: [], libelleReprise, horodatage: horloge.horodatage() };
@ -738,13 +1006,21 @@ export function creerSeance({
suspendre();
etat.mode = 'lecture';
etat.racine = nouvelle;
rompre(erreur, erreur.details.ouvert);
await rompre(erreur, erreur.details.ouvert);
etat.avertissementsGeste = await rendreVerrou(ouvert);
}
throw erreur;
}
const avertissements = await rendreVerrou(ouvert);
Object.assign(etat, { ouvert: ailleurs, source: null, racine: nouvelle, supportRompu: false, refusSupport: null });
Object.assign(etat, {
ouvert: ailleurs,
source: null,
racine: nouvelle,
supportRompu: false,
refusSupport: null,
ailleurs: null,
formatLu: null,
});
if (etat.mode === 'ecriture') {
try {
avertissements.push(...(await prendreVerrou(ailleurs)));
@ -806,15 +1082,13 @@ export function creerSeance({
get verrouAutre() {
return etat.verrouAutre;
},
/** Ceux de l'Ouvert courant, recalculés à chaque écriture, puis ceux du dernier geste. */
/** Ceux de l'état observable, dans son ordre (etat().avertissements). */
get avertissements() {
return [...(etat.ouvert?.avertissements ?? []), ...etat.avertissementsGeste];
return [...lireEtat().avertissements];
},
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
get retours() {
if (etat.ouvert === null) return { defaire: false, refaire: false };
const { cibleDefaire, cibleRefaire } = filDe(etat.ouvert);
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
return retoursDe(etat.ouvert);
},
get gesteEnCours() {
return etat.gestes > 0;
@ -823,6 +1097,55 @@ export function creerSeance({
return etat.calculs > 0;
},
/**
* L'état observable (EtatSeance), figé en profondeur : le même objet
* entre deux changements, un neuf après chacun.
*/
etat: () => lireEtat(),
/**
* Le contrat des magasins de Svelte : fn(etat) tout de suite, puis à
* chaque changement ; rend la fonction qui désabonne.
*/
subscribe(fn) {
abonnes.add(fn);
fn(lireEtat());
return () => {
abonnes.delete(fn);
};
},
/**
* fn() au passage en lecture, explicite ou automatique, et à la
* fermeture de l'événement : le geste en cours s'annule. Rend la
* fonction qui désabonne.
*/
surAnnulation(fn) {
annulations.add(fn);
return () => {
annulations.delete(fn);
};
},
/** {k, tx, ty} : le cadrage de l'événement ouvert, mémorisé ou demandé ; null sinon. */
cadrage: () => (etat.cadrage === null ? null : { ...etat.cadrage }),
/**
* Demande l'écriture différée du cadrage de l'événement ouvert dans
* reglages_locaux.json (§ 8.5), vivante dans tout mode : l'état et le
* journal ne s'écrivent pas. Une vue hors de sa règle — k nul, négatif
* ou non fini, translation non finie — n'est pas demandée ; sans
* événement, rien.
*/
demanderCadrage(vue) {
if (etat.ouvert === null) return;
const evenement = etat.ouvert.charge.evenement.id;
const cadrage = { k: vue?.k, tx: vue?.tx, ty: vue?.ty };
try {
avecCadrage({ cadrages: [] }, evenement, cadrage);
} catch {
return;
}
etat.cadrage = cadrage;
ecrivain.demander({ racine: etat.ouvert.racine, evenement, cadrage });
},
// quitter.abandonner : vrai pour quitter l'événement d'un support rompu
// (exigerSauve).
ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
@ -834,6 +1157,18 @@ export function creerSeance({
exigerSauve(quitter);
return { avertissements: await fermerEvenement() };
}),
/**
* Ferme l'événement ouvert, comme fermer, puis fait de racine le dossier
* de travail, où la séance ouvre et crée désormais ; rien ne s'écrit
* sans événement ouvert.
*/
changerRacine: (nouvelle, quitter) =>
enFile(async () => {
exigerSauve(quitter);
const avertissements = await fermerEvenement();
etat.racine = nouvelle;
return { avertissements };
}),
passerEnEcriture: () => enFile(passerEnEcriture),
passerEnLecture: () => enFile(passerEnLecture),
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
@ -862,10 +1197,12 @@ export function creerSeance({
debuterCalcul() {
etat.calculs += 1;
suspendre();
publier();
},
finirCalcul() {
etat.calculs = Math.max(0, etat.calculs - 1);
armer();
publier();
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,7 @@ import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
/**
* Modes de l'import (§ 10.1), que l'aperçu et l'import reçoivent tous deux.
* Modes de l'import, que l'aperçu et l'import reçoivent tous deux (§ 10.1).
*
* @type {readonly string[]}
*/

View file

@ -67,43 +67,122 @@ const ENTETE_MOTIF = 'motif';
const parLigne = (a, b) => a.ligne - b.ligne;
// Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation
// d'une personne non exclue, sur sa place, à un tour au moins : la
// réservation d'une personne exclue est suspendue. La place se lit selon le
// réglage attribuerSieges (§ 2.1, étape 6). Vrai, c'est un siège : une
// réservation sans siège n'y pourvoit aucun titre. Faux, c'est la table, dont
// les titres ne se distinguent pas : à un tour donné, une table de k titres
// en a autant de pourvus que de personnes non exclues qui y sont réservées à
// ce tour, k au plus, et ses titres pourvus sont ceux du tour qui en pourvoit
// le plus.
function titresPourvus({ evenement, reglages, participants, reservations, titres }) {
const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
const actives = reservations.filter(({ participant }) => presents.has(participant));
if (reglages.attribuerSieges) {
// Un titre porte toujours un siège : la place d'une réservation sans
// siège, [table, null], n'est celle d'aucun titre.
const place = (table, siege) => JSON.stringify([table, siege]);
const occupees = new Set(actives.map(({ table, siege }) => place(table, siege)));
return titres.filter(({ table, siege }) => occupees.has(place(table, siege))).length;
// L'ordre canonique des réservations, celui du schéma du fichier d'état :
// participant, table, la portée « tous » avant « tour », puis le tour et le
// siège, null en tête. Il départage, partout où la règle des titres et
// l'état des places choisissent entre deux réservations.
const rangDePortee = (portee) => (portee === 'tous' ? 0 : 1);
const nulEnTete = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a - b);
export const ordreDesReservations = (a, b) =>
a.participant - b.participant ||
a.table - b.table ||
rangDePortee(a.portee) - rangDePortee(b.portee) ||
nulEnTete(a.tour, b.tour) ||
nulEnTete(a.siege, b.siege);
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Les titres d'une table, par rang dans charge.titres, rangés par siège puis
// par libellé : l'ordre où les réservations sans siège les pourvoient.
function titresParTable(titres) {
const parTable = new Map();
titres.forEach((titre, rang) => {
if (!parTable.has(titre.table)) parTable.set(titre.table, []);
parTable.get(titre.table).push(rang);
});
for (const rangs of parTable.values()) {
rangs.sort((a, b) => titres[a].siege - titres[b].siege || comparerTextes(titres[a].libelle, titres[b].libelle) || a - b);
}
const titresParTable = new Map();
for (const { table } of titres) titresParTable.set(table, (titresParTable.get(table) ?? 0) + 1);
let pourvus = 0;
for (const [table, nombre] of titresParTable) {
let auMieux = 0;
for (let tour = 1; tour <= evenement.tours; tour += 1) {
const reserves = new Set(
actives
.filter((r) => r.table === table && (r.portee === 'tous' || r.tour === tour))
.map(({ participant }) => participant),
);
auMieux = Math.max(auMieux, Math.min(nombre, reserves.size));
}
pourvus += auMieux;
}
return pourvus;
return parTable;
}
// Répartit entre les titres d'une table — rangs, dans leur ordre — les
// réservations couvrantes, déjà rangées dans l'ordre canonique, et rend
// Map rang → réservation qui le pourvoit. D'abord chaque réservation à un
// siège pourvoit les titres de ce siège encore libres. Puis, en mode table
// seulement, chaque personne qu'aucune réservation à un siège de cette table
// ne place pourvoit le premier titre resté libre : celles qui tiennent une
// réservation « tous » d'abord, par identifiant croissant, puis les autres ;
// une personne en pourvoit un au plus. Sous des sièges attribués, une
// réservation sans siège ne pourvoit rien : un titre porte toujours un siège.
function repartirTitres(rangs, titres, couvrantes, attribuerSieges) {
const pris = new Map();
const assises = new Set();
for (const reservation of couvrantes) {
if (reservation.siege === null) continue;
assises.add(reservation.participant);
for (const rang of rangs) {
if (!pris.has(rang) && titres[rang].siege === reservation.siege) pris.set(rang, reservation);
}
}
if (attribuerSieges) return pris;
const sansSiege = new Map();
for (const reservation of couvrantes) {
if (reservation.siege !== null || assises.has(reservation.participant)) continue;
const deja = sansSiege.get(reservation.participant);
if (deja === undefined || (deja.portee !== 'tous' && reservation.portee === 'tous')) {
sansSiege.set(reservation.participant, reservation);
}
}
const ordre = [...sansSiege.values()].sort(
(a, b) => rangDePortee(a.portee) - rangDePortee(b.portee) || a.participant - b.participant,
);
const libres = rangs.filter((rang) => !pris.has(rang));
ordre.forEach((reservation, i) => {
if (i < libres.length) pris.set(libres[i], reservation);
});
return pris;
}
/**
* La règle d'un titre pourvu (§ 4.1, § 4.4, § 2.1 étape 6), seule
* implémentation : bilanRemplacement en compte les titres, l'application en
* tire ses listes de travail et l'état de ses places. Un titre n'est pourvu
* que par la réservation d'une personne non exclue — la réservation d'une
* exclue est suspendue — qui tient sa place : même table, et même siège
* quand la réservation en porte un. Sous des sièges attribués, une
* réservation sans siège ne pourvoit aucun titre. En mode table, une
* réservation sans siège pourvoit au plus un titre de sa table, dans l'ordre
* des sièges des titres, après ceux que les réservations à un siège
* pourvoient (repartirTitres).
*
* Rend, rang à rang de charge.titres, { soiree, tours } : soiree, la
* réservation « tous les tours » qui le pourvoit pour la soirée, null sans
* elle — la répartition des seules réservations « tous », la même à chaque
* tour ; tours[r − 1], la réservation qui le pourvoit au tour r, parmi
* toutes celles qui couvrent ce tour, null sans elle. Une réservation d'un
* tour désigné pourvoit le titre à ce tour seulement. La charge n'est pas
* modifiée.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {Array<{soiree: Object|null, tours: Array<Object|null>}>}
*/
export function pourvoyeursDesTitres({ evenement, reglages, participants, reservations, titres }) {
const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
const actives = reservations.filter(({ participant }) => presents.has(participant)).sort(ordreDesReservations);
const rendus = titres.map(() => ({ soiree: null, tours: Array.from({ length: evenement.tours }, () => null) }));
for (const [idTable, rangs] of titresParTable(titres)) {
const aLaTable = actives.filter(({ table }) => table === idTable);
const tous = aLaTable.filter(({ portee }) => portee === 'tous');
for (const [rang, reservation] of repartirTitres(rangs, titres, tous, reglages.attribuerSieges)) {
rendus[rang].soiree = reservation;
}
for (let tour = 1; tour <= evenement.tours; tour += 1) {
const couvrantes = aLaTable.filter((r) => r.portee === 'tous' || r.tour === tour);
for (const [rang, reservation] of repartirTitres(rangs, titres, couvrantes, reglages.attribuerSieges)) {
rendus[rang].tours[tour - 1] = reservation;
}
}
}
return rendus;
}
// Titres pourvus à un tour au moins (§ 4.1, § 4.4) : ceux qu'un
// remplacement laisserait non pourvus.
const titresPourvus = (charge) =>
pourvoyeursDesTitres(charge).filter(({ soiree, tours }) => soiree !== null || tours.some((r) => r !== null)).length;
/**
* Ce que remplacer détruirait, à annoncer avant d'agir (§ 10.1) : « 260
* personnes seront supprimées » laisse croire qu'on ne perd que des noms.
@ -114,10 +193,9 @@ function titresPourvus({ evenement, reglages, participants, reservations, titres
* - reservations : leurs réservations, suspendues comprises, soit toutes :
* chaque réservation d'une charge que l'analyse admet est celle d'un
* participant ;
* - titresPourvus : les titres qu'une réservation pourvoit, et que le
* remplacement laisse non pourvus — par la réservation d'une personne non
* exclue, sur son siège quand les sièges sont attribués, sur sa table
* sinon, à un tour au moins ;
* - titresPourvus : les titres qu'une réservation pourvoit à un tour au
* moins, et que le remplacement laisse non pourvus, selon la règle de
* pourvoyeursDesTitres ;
* - propositions : les propositions qui placent au moins une personne
* retirée. Le retenu n'y entre pas : l'import en état retenu porte
* l'avertissement de dérive.

View file

@ -14,7 +14,7 @@ import { VERSION } from '../version.genere.js';
import { apercevoir } from './apercu.js';
import { decoder } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { appliquerImport, bilanRemplacement, exporterRefus } from './import.js';
import { appliquerImport, bilanRemplacement, exporterRefus, pourvoyeursDesTitres } from './import.js';
import { decouper } from './lecture.js';
const MARQUE = '\u{FEFF}';
@ -553,6 +553,36 @@ describe('appliquerImport : remplacer, et ce que remplacer annonce (§ 10.1)', (
assert.equal(bilanRemplacement(chargeGarnie()).titresPourvus, 3);
});
test('lecture par table : une réservation qui porte un siège ne pourvoit que le titre de ce siège', () => {
const charge = chargeDe({
participants: [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')],
tables: [table(1)],
reservations: [reservation(1, 1, 5)],
titres: [titre(1, 1)],
});
charge.evenement.siegesParDefaut = 6;
assert.equal(bilanRemplacement(charge).titresPourvus, 0);
charge.reservations.push(reservation(2, 1, null));
assert.equal(bilanRemplacement(charge).titresPourvus, 1);
});
test('lecture par table, à un tour : les réservations à un siège pourvoient leur titre, puis chaque personne sans siège un des titres restants', () => {
const charge = chargeDe({
participants: [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo'), personne(3, 'Lacasse', 'Ondine')],
tables: [table(1)],
reservations: [reservation(1, 1, null), reservation(2, 1, 1, 2)],
titres: [titre(1, 1), titre(1, 2), titre(1, 3)],
});
// Tour 1 : Iris pourvoit le premier titre ; tour 2 : Théo le titre de
// son siège, Iris le suivant. Deux titres pourvus à un tour au moins.
assert.deepEqual(pourvoyeursDesTitres(charge).map(({ soiree, tours }) => [soiree?.participant ?? null, tours.map((r) => r?.participant ?? null)]), [
[1, [1, 2]],
[null, [null, 1]],
[null, [null, null]],
]);
assert.equal(bilanRemplacement(charge).titresPourvus, 2);
});
test("une proposition qui ne place aucune des personnes de la liste n'est pas touchée", () => {
const charge = chargeDe({
participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')],

View file

@ -113,11 +113,20 @@ function exigerSeance(seance) {
}
}
// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
// révision au journal ; sans elle — un état sans journal, ou que le journal
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
// l'entrée de reprise de cet état. TypeError quand il le faut et qu'il manque.
function libelleDeLInstant({ journal, revision }, libelleReprise) {
/**
* Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
* révision au journal ; sans elle — un état sans journal, ou que le journal
* ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
* l'entrée de reprise de cet état. La filiation qu'écrit copier le porte, et
* l'appelant qui compose le libellé de l'entrée de copie le lit ici, avant
* l'appel.
*
* @param {{journal: Object|null, revision: number}} ouvert
* @param {string} [libelleReprise]
* @returns {string}
* @throws {TypeError} quand il le faut et qu'il manque
*/
export function libelleDeLInstant({ journal, revision }, libelleReprise) {
const libelle = journal?.entrees.find((entree) => entree.revision === revision)?.libelle ?? libelleReprise;
if (typeof libelle !== 'string' || libelle === '') {
throw new TypeError(`copier : la révision ${revision} n'a pas d'entrée au journal, libelleReprise requis`);