diff --git a/src/application/avertissements.js b/src/application/avertissements.js new file mode 100644 index 0000000..f4385c3 --- /dev/null +++ b/src/application/avertissements.js @@ -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; +} diff --git a/src/application/avertissements.test.js b/src/application/avertissements.test.js new file mode 100644 index 0000000..e87d02b --- /dev/null +++ b/src/application/avertissements.test.js @@ -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); + }); +}); diff --git a/src/application/commandes.js b/src/application/commandes.js index 136e6cf..a86b120 100644 --- a/src/application/commandes.js +++ b/src/application/commandes.js @@ -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); diff --git a/src/application/commandes.test.js b/src/application/commandes.test.js index 4c5e16a..9550910 100644 --- a/src/application/commandes.test.js +++ b/src/application/commandes.test.js @@ -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); diff --git a/src/application/commandes_places.js b/src/application/commandes_places.js index a8a6be1..4ea5ad9 100644 --- a/src/application/commandes_places.js +++ b/src/application/commandes_places.js @@ -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 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], +]); diff --git a/src/application/commandes_places.test.js b/src/application/commandes_places.test.js new file mode 100644 index 0000000..307231e --- /dev/null +++ b/src/application/commandes_places.test.js @@ -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'); + }); +}); diff --git a/src/application/evenements.js b/src/application/evenements.js new file mode 100644 index 0000000..460039f --- /dev/null +++ b/src/application/evenements.js @@ -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} parametres.ouvrir en lecture + * @param {() => Promise} 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} 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, + }; +} diff --git a/src/application/evenements.test.js b/src/application/evenements.test.js new file mode 100644 index 0000000..17a0d40 --- /dev/null +++ b/src/application/evenements.test.js @@ -0,0 +1,1115 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la façade des événements (§ 8.1, § 8.6, § 8.7, § 8.8, § 15, +// § 19.4), toutes sur le système de fichiers d'épreuve, qui compte ses +// lectures et ses écritures, avec l'horloge pilotée. La façade se branche sur +// une séance réelle, que le montage crée sur la racine que reçoit +// changerRacine, comme App la branche ; chaque appel que la façade fait à la +// séance s'inscrit au relevé du montage. Les noms d'épreuve sont inventés ; la +// version vient du module engendré. +import assert from 'node:assert/strict'; +import { creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { describe, test } from '../../test/lanceur.js'; +import { CATALOGUE } from '../demo/catalogue.js'; +import { LIVREES, texteLivre } from '../demo/livrees.js'; +import { serialiser } from '../stockage/canonique.js'; +import { creer as creerDansLeDepot } from '../stockage/depot.js'; +import { analyser, creerCharge } from '../stockage/document.js'; +import { ErreurStockage } from '../stockage/erreurs.js'; +import { lireJournal } from '../stockage/journal.js'; +import { SUFFIXES } from '../stockage/noms.js'; +import { VERSION } from '../version.genere.js'; +import { creerFacade } from './evenements.js'; +import { creerHorlogePilotee } from './horloge.js'; +import { libelle } from './libelles.js'; +import { creerSeance } from './seance.js'; + +const V1 = VERSION.affichee; + +const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; +const DOC = { id: 'documents', chemin: DOCUMENTS }; +const PUBLIE = 'E:\\soirees'; +const PORTABLE = { id: 'portable', chemin: 'E:\\soirees\\data' }; +const SOUS_APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programmes\\Tablee'; +const PORTABLE_APPDATA = { id: 'portable', chemin: `${SOUS_APPDATA}\\data` }; + +// L'instant de départ de l'horloge pilotée, et le dossier de corbeille qu'il +// date. +const DEPART = '2026-10-06T19:00:00-04:00'; +const CORBEILLE_DU_DEPART = 'corbeille/2026-10-06_19-00-00'; + +const LUCIOLES = 'Soirée des Lucioles'; +const CERFS = 'Soirée des Cerfs'; + +const DEMONSTRATIONS = LIVREES.map(({ cle, texte }) => ({ + cle, + nom: CATALOGUE.find((entree) => entree.cle === cle).nom, + texte, +})); + +const UTF8 = new TextEncoder(); + +// --- Le montage ------------------------------------------------------------- + +// Identifiants d'épreuve, numérotés dans l'ordre des tirages. +function creerIdentifiants(prefixe) { + let n = 0; + return () => { + n += 1; + return `${prefixe}-${n}`; + }; +} + +// La façade sur le système fs, branchée comme App la branche : changerRacine +// crée la séance sur la racine reçue. appels relève, dans l'ordre, ce que la +// façade demande à la séance, et ce que l'épreuve y ajoute. +function monter({ fs = creerFichiersSimules(), horloge = creerHorlogePilotee({ depart: DEPART }), demonstrations = DEMONSTRATIONS } = {}) { + const identifiant = creerIdentifiants('id'); + const appels = []; + let seance = null; + let racine = null; + const facade = creerFacade({ + fs, + horloge, + identifiant, + produitVersion: V1, + demonstrations, + ouvert: () => seance?.ouvert?.base ?? null, + ouvrir: async (base) => { + appels.push(['ouvrir', base]); + return seance.ouvrir(base); + }, + fermer: async () => { + appels.push(['fermer']); + return seance.fermer(); + }, + creer: async (champs) => { + appels.push(['creer', champs]); + return seance.creer(champs); + }, + racine: () => racine, + changerRacine: async (nouvelle) => { + appels.push(['changerRacine', nouvelle]); + racine = nouvelle; + seance = creerSeance({ + fs, + racine: nouvelle, + horloge, + identifiant, + produitVersion: V1, + planifier: horloge.planifier, + annuler: horloge.annuler, + }); + }, + }); + return { + fs, + horloge, + facade, + appels, + get seance() { + return seance; + }, + }; +} + +// Un événement posé par le dépôt, hors de la façade : sa paire, journal +// d'une entrée et son jalon, puis l'état. +async function deposerEvenement(fs, racine, { id, nom, etat = null }) { + const charge = creerCharge({ id, nom, siegesParDefaut: 8, tours: 2 }); + if (etat !== null) charge.evenement.etat = etat; + const ouvert = await creerDansLeDepot(fs, racine, charge, { + libelle: libelle('ENTREE_CREATION', { nom }), + nomJalon: libelle('JALON_CREATION'), + horodatage: '2031-05-17T08:00:00-04:00', + produitVersion: V1, + }); + return ouvert.base; +} + +// Le texte d'état d'un événement neuf, que l'épreuve dépose tel quel. +const texteEtat = ({ id, nom }) => + serialiser(creerCharge({ id, nom, siegesParDefaut: 8, tours: 2 }), { revision: 1, produitVersion: V1 }); + +// Le refus qu'une promesse rejette. +async function refusDe(promesse) { + try { + await promesse; + } catch (erreur) { + return erreur; + } + assert.fail('la promesse devait rejeter'); +} + +// Les textes de chaque fichier d'une racine, par nom, sous le dossier donné. +async function photographier(fs, racine, dossier = '') { + const entrees = await fs.lister(racine, dossier); + const photo = {}; + for (const { nom, type } of entrees) { + if (type === 'fichier') photo[nom] = fs.contenu(racine.id, dossier === '' ? nom : `${dossier}/${nom}`); + } + return photo; +} + +const ecritures = (fs) => fs.compteurs.ecritures; + +// Le nom en NFD que copier et renommer reçoivent, et sa forme NFC, distinctes. +const NFD = 'Soire\u{0065}\u{0301}e des Hiboux'.normalize('NFD'); +const NFC = NFD.normalize('NFC'); + +// Des Documents sous un chemin de 250 caractères : aucun nom long n'y tient +// sous la borne d'un chemin, que lève deriverBase. +const PROFOND = `C:\\${'p'.repeat(247)}`; + +// --- La détermination du dossier de travail (§ 8.6) -------------------------- + +describe('demarrer : une détermination par séance (§ 8.6)', () => { + test('le second demarrer ne lit ni n’écrit rien, et rend le même résultat', async () => { + const { fs, facade, appels } = monter(); + const premier = await facade.demarrer(); + const { ecritures: e, lectures: l } = fs.compteurs; + const second = await facade.demarrer(); + assert.equal(second, premier); + assert.equal(fs.compteurs.ecritures, e); + assert.equal(fs.compteurs.lectures, l); + assert.deepEqual(premier.dossier.racine, DOC); + assert.deepEqual(appels, [['changerRacine', DOC]]); + }); + + test('les cinq issues de S8 rendent leurs avis, dans l’ordre d’Avis', async () => { + const cas = [ + [{}, [{ code: 'NON_PUBLIE', details: {} }]], + [{ executable: SOUS_APPDATA }, [{ code: 'DONNEES_APPLICATIVES', details: {} }]], + [{ executable: PUBLIE, sonde: { portable: 'EACCES' } }, [{ code: 'SONDE_ECHOUEE', details: { cause: 'EACCES' } }]], + [{ nature: 'web' }, [{ code: 'PLATEFORME_WEB', details: {} }]], + [{ executable: PUBLIE, support: { portable: 'amovible' } }, [{ code: 'SUPPORT_AMOVIBLE', details: {} }]], + [ + { nature: 'web', support: { documents: 'amovible' } }, + [ + { code: 'PLATEFORME_WEB', details: {} }, + { code: 'SUPPORT_AMOVIBLE', details: {} }, + ], + ], + [{ executable: PUBLIE }, []], + ]; + assert.equal(cas.length, 7); + for (const [systeme, attendus] of cas) { + const { facade } = monter({ fs: creerFichiersSimules(systeme) }); + const { avis } = await facade.demarrer(); + assert.deepEqual(avis, attendus, JSON.stringify(systeme)); + } + }); + + test('changerRacine reçoit la racine retenue : data/ en mode portable', async () => { + const { facade, appels } = monter({ fs: creerFichiersSimules({ executable: PUBLIE }) }); + const { dossier } = await facade.demarrer(); + assert.equal(dossier.mode, 'portable'); + assert.deepEqual(appels, [['changerRacine', PORTABLE]]); + assert.deepEqual(facade.explorateur(), { disponible: true, raison: null }); + }); + + test('des réglages locaux corrompus ajoutent REGLAGES_ILLISIBLES après les avis de S8', async () => { + const fs = creerFichiersSimules(); + fs.deposer('documents', 'reglages_locaux.json', '{ pas du JSON'); + const { facade } = monter({ fs }); + const { avis } = await facade.demarrer(); + assert.deepEqual(avis, [ + { code: 'NON_PUBLIE', details: {} }, + { code: 'REGLAGES_ILLISIBLES', details: { chemin: 'reglages_locaux.json', raison: 'JSON' } }, + ]); + }); + + test('une lecture refusée rejette demarrer sans rien retenir, et le suivant refait la détermination', async () => { + const fs = creerFichiersSimules(); + const lister = fs.lister; + let pannes = 1; + fs.lister = async (...parametres) => { + if (pannes > 0) { + pannes -= 1; + throw new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EIO' }); + } + return lister(...parametres); + }; + const { facade, appels } = monter({ fs }); + const refus = await refusDe(facade.demarrer()); + assert.equal(refus.code, 'LECTURE'); + assert.deepEqual(appels, []); + const { dossier } = await facade.demarrer(); + assert.deepEqual(dossier.racine, DOC); + assert.deepEqual(appels, [['changerRacine', DOC]]); + }); +}); + +// --- Les questions (§ 8.6) --------------------------------------------------- + +describe('les questions du dossier de travail (§ 8.6)', () => { + // data/ porte deux événements, posés par un premier processus ; le second, + // monté sur le même disque, voit data/ refuser l'écriture. + async function dataQuiRefuse() { + const disque = new Map(); + const avant = creerFichiersSimules({ disque, executable: PUBLIE }); + avant.deposerDossier('portable'); + await deposerEvenement(avant, PORTABLE, { id: 'evt-lucioles', nom: LUCIOLES }); + await deposerEvenement(avant, PORTABLE, { id: 'evt-cerfs', nom: CERFS }); + const fs = creerFichiersSimules({ disque, executable: PUBLIE, sonde: { portable: 'EACCES' } }); + const sondes = []; + const sonder = fs.sonder; + fs.sonder = async (racine) => { + sondes.push(racine.id); + return sonder(racine); + }; + return { fs, sondes }; + } + + test('PORTABLE_NON_INSCRIPTIBLE : question au compte de 2, liste refusée tant qu’elle attend', async () => { + const { fs } = await dataQuiRefuse(); + const { facade, appels } = monter({ fs }); + const { dossier, avis } = await facade.demarrer(); + assert.deepEqual(dossier.question, { + code: 'PORTABLE_NON_INSCRIPTIBLE', + racine: PORTABLE, + evenements: 2, + cause: 'EACCES', + }); + assert.deepEqual(avis, [{ code: 'SONDE_ECHOUEE', details: { cause: 'EACCES' } }]); + assert.deepEqual(appels, []); + const refus = await refusDe(facade.lister()); + assert.equal(refus.code, 'QUESTION_EN_ATTENTE'); + assert.deepEqual(refus.details, { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }); + assert.equal(typeof libelle(refus.code, refus.details), 'string'); + }); + + test('« reessayer » refait la détermination : deux sondes de plus, la même question', async () => { + const { fs, sondes } = await dataQuiRefuse(); + const { facade, appels } = monter({ fs }); + await facade.demarrer(); + assert.deepEqual(sondes, ['portable', 'documents']); + const avant = ecritures(fs); + const { dossier } = await facade.repondre('reessayer'); + assert.deepEqual(sondes, ['portable', 'documents', 'portable', 'documents']); + // Les deux sondes, et rien d'autre. + assert.equal(ecritures(fs), avant + 2); + assert.equal(dossier.question.code, 'PORTABLE_NON_INSCRIPTIBLE'); + assert.deepEqual(appels, []); + assert.equal((await refusDe(facade.lister())).code, 'QUESTION_EN_ATTENTE'); + }); + + test('« documents » copie les deux paires dans les Documents, originaux intacts', async () => { + const { fs } = await dataQuiRefuse(); + const { facade, appels } = monter({ fs }); + await facade.demarrer(); + const originaux = await photographier(fs, PORTABLE); + assert.equal(Object.keys(originaux).length, 4); + const avant = ecritures(fs); + const { dossier } = await facade.repondre('documents'); + // Chaque paire : son journal, puis son état. + assert.equal(ecritures(fs), avant + 4); + assert.equal(dossier.question, null); + assert.equal(dossier.autre, null); + assert.deepEqual(dossier.racine, DOC); + assert.deepEqual(appels, [['changerRacine', DOC]]); + assert.deepEqual(await photographier(fs, PORTABLE), originaux); + const lignes = await facade.lister(); + assert.equal(lignes.length, 2); + assert.deepEqual( + lignes.map(({ id, nom }) => [id, nom]), + [ + ['evt-cerfs', CERFS], + ['evt-lucioles', LUCIOLES], + ], + ); + for (const base of [LUCIOLES, CERFS]) { + assert.equal(fs.contenu('documents', base + SUFFIXES.etat), originaux[base + SUFFIXES.etat]); + assert.equal(fs.contenu('documents', base + SUFFIXES.journal), originaux[base + SUFFIXES.journal]); + } + assert.equal((await facade.demarrer()).dossier, dossier); + }); + + test('un data/ qui ne se lit pas, compte null : « documents » n’a rien à copier, ni à relire', async () => { + const { fs } = await dataQuiRefuse(); + const lister = fs.lister; + const listes = []; + fs.lister = async (racine, dossier) => { + listes.push(racine.id); + if (racine.id === 'portable') throw new ErreurStockage('LECTURE', { chemin: dossier, dossier: PORTABLE.chemin, cause: 'EIO' }); + return lister(racine, dossier); + }; + const { facade, appels } = monter({ fs }); + const { dossier } = await facade.demarrer(); + assert.deepEqual(dossier.question, { + code: 'PORTABLE_NON_INSCRIPTIBLE', + racine: PORTABLE, + evenements: null, + cause: 'EACCES', + }); + const lus = listes.length; + const avant = ecritures(fs); + const reponse = await facade.repondre('documents'); + assert.deepEqual(reponse.importe, { importes: [], refuses: [] }); + assert.deepEqual(reponse.dossier.racine, DOC); + assert.deepEqual(appels, [['changerRacine', DOC]]); + assert.deepEqual(listes.slice(lus), []); + assert.equal(ecritures(fs), avant); + }); + + test('« continuer » ne répond pas à PORTABLE_NON_INSCRIPTIBLE : RangeError', async () => { + const { fs } = await dataQuiRefuse(); + const { facade, appels } = monter({ fs }); + await facade.demarrer(); + await assert.rejects(facade.repondre('continuer'), RangeError); + await assert.rejects(facade.repondre('ignorer'), RangeError); + assert.deepEqual(appels, []); + }); + + test('DOCUMENTS_NON_INSCRIPTIBLE nomme le dossier, la cause et le compte ; « continuer » ouvre la liste sans écrire', async () => { + const disque = new Map(); + const avant = creerFichiersSimules({ disque }); + avant.deposerDossier('documents'); + await deposerEvenement(avant, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const fs = creerFichiersSimules({ disque, sonde: { documents: 'EACCES' } }); + const { facade, appels } = monter({ fs }); + const { dossier } = await facade.demarrer(); + assert.deepEqual(dossier.question, { + code: 'DOCUMENTS_NON_INSCRIPTIBLE', + racine: DOC, + evenements: 1, + cause: 'EACCES', + }); + assert.equal((await refusDe(facade.lister())).code, 'QUESTION_EN_ATTENTE'); + await assert.rejects(facade.repondre('documents'), RangeError); + assert.deepEqual(appels, []); + const avant2 = ecritures(fs); + const reponse = await facade.repondre('continuer'); + assert.equal(reponse.dossier.question, null); + assert.deepEqual(appels, [['changerRacine', DOC]]); + const lignes = await facade.lister(); + assert.deepEqual( + lignes.map(({ base }) => base), + [LUCIOLES], + ); + assert.equal(ecritures(fs), avant2); + }); +}); + +// --- La liste (§ 8.1, § 19.4) ----------------------------------------------- + +describe('lister (§ 8.1, § 19.4)', () => { + // Publié en E:\soirees, data/ inscriptible : la petite démonstration + // déposée sans journal, et un événement renommé à la main. + async function deuxEvenements() { + const fs = creerFichiersSimules({ executable: PUBLIE }); + const montage = monter({ fs }); + await montage.facade.demarrer(); + fs.deposer('portable', `petite${SUFFIXES.etat}`, texteLivre('petite')); + const base = await deposerEvenement(fs, PORTABLE, { id: 'evt-gala', nom: 'Gala du printemps' }); + await fs.deplacer(PORTABLE, base + SUFFIXES.etat, `soiree-a${SUFFIXES.etat}`); + await fs.deplacer(PORTABLE, base + SUFFIXES.journal, `soiree-a${SUFFIXES.journal}`); + return montage; + } + + test('six informations par ligne, le nom et le compte lus dans le fichier, rien d’écrit', async () => { + const { fs, facade } = await deuxEvenements(); + const avant = ecritures(fs); + const lignes = await facade.lister(); + assert.equal(ecritures(fs), avant); + const petite = analyser(texteLivre('petite')).charge; + assert.deepEqual(lignes, [ + { + base: 'soiree-a', + id: 'evt-gala', + nom: 'Gala du printemps', + date: null, + etat: 'brouillon', + participants: 0, + derniereModification: '2031-05-17T08:00:00-04:00', + chemin: `E:\\soirees\\data\\soiree-a${SUFFIXES.etat}`, + illisible: null, + journalSeul: false, + doublonAvec: [], + verrou: null, + }, + { + base: 'petite', + id: petite.evenement.id, + nom: petite.evenement.nom, + date: petite.evenement.date, + etat: petite.evenement.etat, + participants: petite.participants.length, + derniereModification: null, + chemin: `E:\\soirees\\data\\petite${SUFFIXES.etat}`, + illisible: null, + journalSeul: false, + doublonAvec: [], + verrou: null, + }, + ]); + assert.ok(petite.participants.length > 0); + }); + + test('deux exécutions rendent la même liste ; la date des fichiers n’y change rien', async () => { + const { fs, facade } = await deuxEvenements(); + const avant = ecritures(fs); + const premiere = await facade.lister(); + assert.equal(premiere.length, 2); + assert.deepEqual(await facade.lister(), premiere); + fs.deposer('portable', `petite${SUFFIXES.etat}`, texteLivre('petite'), 1); + fs.deposer('portable', `soiree-a${SUFFIXES.journal}`, fs.contenu('portable', `soiree-a${SUFFIXES.journal}`), 2); + assert.deepEqual(await facade.lister(), premiere); + assert.equal(ecritures(fs), avant); + }); + + test('la dernière modification est l’horodatage de la dernière entrée du journal', async () => { + const { fs, horloge, facade } = monter(); + await facade.demarrer(); + await facade.creer({ nom: LUCIOLES }); + horloge.avancer(3_600_000); + await facade.renommer(LUCIOLES, { nom: CERFS }); + const journal = lireJournal(fs.contenu('documents', CERFS + SUFFIXES.journal)); + assert.deepEqual( + journal.entrees.map(({ horodatage }) => horodatage), + [DEPART, '2026-10-06T20:00:00-04:00'], + ); + const avant = ecritures(fs); + const [ligne] = await facade.lister(); + assert.equal(ligne.derniereModification, '2026-10-06T20:00:00-04:00'); + assert.equal(ecritures(fs), avant); + }); + + test('un .gtt.verrou présent se lit sans écrire ; illisible, sa séance et son instant sont null', async () => { + const { fs, facade } = await deuxEvenements(); + const contenu = { seance: 'seance-voisine', pid: 7, hote: 'poste-voisin', depuis: '2031-05-17T09:30:00-04:00' }; + fs.deposer('portable', `soiree-a${SUFFIXES.verrou}`, `${JSON.stringify(contenu)}\n`); + fs.deposer('portable', `petite${SUFFIXES.verrou}`, 'pas un verrou'); + const avant = ecritures(fs); + const lignes = await facade.lister(); + assert.equal(ecritures(fs), avant); + assert.deepEqual( + lignes.map(({ base, verrou }) => [base, verrou]), + [ + ['soiree-a', { seance: 'seance-voisine', depuis: '2031-05-17T09:30:00-04:00' }], + ['petite', { seance: null, depuis: null }], + ], + ); + }); + + test('signalements : état vide, journal seul, doublons ; rien pour les autres fichiers', async () => { + const fs = creerFichiersSimules(); + const { facade } = monter({ fs }); + await facade.demarrer(); + fs.deposer('documents', `Soirée vide${SUFFIXES.etat}`, ''); + const hiboux = await deposerEvenement(fs, DOC, { id: 'evt-hiboux', nom: 'Soirée des Hiboux' }); + await fs.supprimer(DOC, hiboux + SUFFIXES.etat); + const merles = texteEtat({ id: 'evt-merles', nom: 'Club des Merles' }); + fs.deposer('documents', `Merles A${SUFFIXES.etat}`, merles); + fs.deposer('documents', `Merles B${SUFFIXES.etat}`, merles); + fs.deposer('documents', 'reglages_locaux.json', '{}'); + fs.deposer('documents', `corbeille/2031-01-01_00-00-00/Ancienne${SUFFIXES.etat}`, merles); + fs.deposer('documents', 'planche.pdf', '%PDF'); + const avant = ecritures(fs); + const lignes = await facade.lister(); + assert.equal(ecritures(fs), avant); + assert.equal(lignes.length, 4); + const signalees = lignes.filter((l) => l.illisible !== null || l.journalSeul || l.doublonAvec.length > 0); + assert.equal(signalees.length, 4); + const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); + assert.deepEqual(Object.keys(par).sort(), ['Merles A', 'Merles B', 'Soirée des Hiboux', 'Soirée vide']); + + assert.equal(par['Soirée vide'].illisible.raison, 'VIDE'); + assert.deepEqual(par['Soirée vide'].illisible.secours, { precedent: null, journal: null }); + assert.equal(par['Soirée vide'].journalSeul, false); + assert.equal(typeof libelle('ETAT_ILLISIBLE', par['Soirée vide'].illisible), 'string'); + + assert.equal(par['Soirée des Hiboux'].journalSeul, true); + assert.equal(par['Soirée des Hiboux'].id, 'evt-hiboux'); + assert.deepEqual(par['Soirée des Hiboux'].illisible.secours, { precedent: null, journal: 1 }); + assert.equal(par['Soirée des Hiboux'].derniereModification, '2031-05-17T08:00:00-04:00'); + + assert.deepEqual(par['Merles A'].doublonAvec, ['Merles B']); + assert.deepEqual(par['Merles B'].doublonAvec, ['Merles A']); + assert.equal(par['Merles A'].illisible, null); + assert.equal(par['Soirée vide'].doublonAvec.length, 0); + }); +}); + +// --- Créer (§ 2.1, § 8.3) ---------------------------------------------------- + +describe('creer (§ 2.1, § 8.3)', () => { + test('« Soirée d’essai » : creer de la séance, une entrée, le jalon « Création », ouvert en lecture', async () => { + const montage = monter(); + const { fs, facade, appels } = montage; + await facade.demarrer(); + const avant = ecritures(fs); + const { base } = await facade.creer({ nom: "Soirée d'essai", date: null }); + assert.equal(ecritures(fs), avant + 2); + assert.deepEqual(appels.at(-1), ['creer', { nom: "Soirée d'essai", date: null, siegesParDefaut: 8, tours: 4 }]); + const journal = lireJournal(fs.contenu('documents', base + SUFFIXES.journal)); + assert.equal(journal.entrees.length, 1); + assert.deepEqual( + journal.jalons.map(({ nom }) => nom), + ['Création'], + ); + assert.equal(montage.seance.ouvert.base, base); + assert.equal(montage.seance.mode, 'lecture'); + const { charge } = analyser(fs.contenu('documents', base + SUFFIXES.etat)); + assert.equal(charge.evenement.siegesParDefaut, 8); + assert.equal(charge.evenement.tours, 4); + }); + + test('une coupure après la première écriture laisse le journal sans l’état : le journal d’abord', async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque }); + const { facade } = monter({ fs }); + await facade.demarrer(); + fs.pannes.couperApres(1); + await assert.rejects(facade.creer({ nom: "Soirée d'essai" })); + const relu = creerFichiersSimules({ disque }); + assert.notEqual(relu.contenu('documents', `Soirée d'essai${SUFFIXES.journal}`), null); + assert.equal(relu.contenu('documents', `Soirée d'essai${SUFFIXES.etat}`), null); + }); + + test('un nom en NFD est inscrit en NFC', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const nfd = 'Soire\u{0065}\u{0301}e des Lucioles'.normalize('NFD'); + assert.notEqual(nfd, nfd.normalize('NFC')); + const { base } = await facade.creer({ nom: nfd }); + assert.equal(analyser(fs.contenu('documents', base + SUFFIXES.etat)).charge.evenement.nom, nfd.normalize('NFC')); + }); + + test('nom blanc, date hors du calendrier ou mal écrite : refus, rien d’écrit', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const avant = ecritures(fs); + assert.equal((await refusDe(facade.creer({ nom: ' ' }))).code, 'NOM_REQUIS'); + for (const date of ['2026-02-30', '2026-2-3']) { + const refus = await refusDe(facade.creer({ nom: LUCIOLES, date })); + assert.equal(refus.code, 'DATE_INVALIDE'); + assert.equal(refus.details.date, date); + } + assert.equal(ecritures(fs), avant); + }); + + test('des Documents sous un chemin de 250 caractères : CHEMIN_TROP_LONG, rien d’écrit', async () => { + assert.equal(PROFOND.length, 250); + const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); + await facade.demarrer(); + const avant = ecritures(fs); + assert.equal((await refusDe(facade.creer({ nom: LUCIOLES }))).code, 'CHEMIN_TROP_LONG'); + assert.equal(ecritures(fs), avant); + }); +}); + +// --- Charger une démonstration (§ 15) --------------------------------------- + +describe('chargerDemonstration (§ 15)', () => { + test('deux chargements : « ‹nom› 1 » puis « ‹nom› 2 », identités distinctes, ouverts en lecture', async () => { + const montage = monter(); + const { fs, facade } = montage; + await facade.demarrer(); + const { base: courant } = await facade.creer({ nom: LUCIOLES }); + const courantAvant = await photographier(fs, DOC); + const courants = [courant + SUFFIXES.etat, courant + SUFFIXES.journal]; + assert.deepEqual(Object.keys(courantAvant).sort(), [...courants].sort()); + const livre = texteLivre('petite'); + const nom = CATALOGUE.find((entree) => entree.cle === 'petite').nom; + + const avant = ecritures(fs); + const premier = await facade.chargerDemonstration('petite'); + // Le journal, puis l'état. + assert.equal(ecritures(fs), avant + 2); + assert.equal(montage.seance.ouvert.base, premier.base); + assert.equal(montage.seance.mode, 'lecture'); + const second = await facade.chargerDemonstration('petite'); + assert.equal(ecritures(fs), avant + 4); + assert.equal(montage.seance.ouvert.base, second.base); + assert.equal(montage.seance.mode, 'lecture'); + + const lire = (base) => analyser(fs.contenu('documents', base + SUFFIXES.etat)).charge.evenement; + assert.equal(lire(premier.base).nom, `${nom} 1`); + assert.equal(lire(second.base).nom, `${nom} 2`); + assert.notEqual(lire(premier.base).id, lire(second.base).id); + const journal = lireJournal(fs.contenu('documents', premier.base + SUFFIXES.journal)); + assert.deepEqual( + journal.entrees.map(({ libelle: texte }) => texte), + [`Chargement de la démonstration « ${nom} »`], + ); + assert.equal(texteLivre('petite'), livre); + const photo = await photographier(fs, DOC); + for (const fichier of courants) { + assert.equal(typeof courantAvant[fichier], 'string'); + assert.equal(photo[fichier], courantAvant[fichier]); + } + }); + + test('une clé inconnue lève RangeError, sans rien écrire', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const avant = ecritures(fs); + await assert.rejects(facade.chargerDemonstration('inconnue'), RangeError); + assert.equal(ecritures(fs), avant); + }); +}); + +// --- Copier (§ 8.7) ---------------------------------------------------------- + +describe('copier (§ 8.7)', () => { + test('depuis un plan bloqué : nouvel identifiant, une entrée, non bloquée, original intact', async () => { + const montage = monter(); + const { fs, facade } = montage; + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES, etat: 'bloque' }); + const original = await photographier(fs, DOC); + assert.equal(Object.keys(original).length, 2); + const avant = ecritures(fs); + const { base } = await facade.copier(LUCIOLES, { nom: ' Variante des Lucioles ' }); + // Le journal de la copie, puis son état. + assert.equal(ecritures(fs), avant + 2); + assert.equal(base, 'Variante des Lucioles'); + assert.equal(montage.seance.ouvert.base, base); + assert.equal(montage.seance.mode, 'lecture'); + const copie = analyser(fs.contenu('documents', base + SUFFIXES.etat)).charge.evenement; + assert.notEqual(copie.id, 'evt-lucioles'); + assert.notEqual(copie.etat, 'bloque'); + assert.equal(copie.nom, 'Variante des Lucioles'); + const creation = libelle('ENTREE_CREATION', { nom: LUCIOLES }); + const journal = lireJournal(fs.contenu('documents', base + SUFFIXES.journal)); + assert.deepEqual( + journal.entrees.map(({ libelle: texte }) => texte), + [`Créée par copie de « ${LUCIOLES} », à l'instant « ${creation} »`], + ); + // L'entrée nomme l'instant que la filiation inscrit. + assert.equal(copie.filiation.instant.libelle, creation); + const apres = await photographier(fs, DOC); + for (const [fichier, texte] of Object.entries(original)) assert.equal(apres[fichier], texte); + }); + + test('un nom vide est refusé, sans écrire, et aucun « (copie) » ne paraît', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const avant = ecritures(fs); + for (const nom of ['', ' \t ']) { + const refus = await refusDe(facade.copier(LUCIOLES, { nom })); + assert.equal(refus.code, 'NOM_REQUIS'); + assert.equal(refus.details.remede, null); + } + assert.equal(ecritures(fs), avant); + const lignes = await facade.lister(); + assert.equal(lignes.length, 1); + assert.ok(lignes.every(({ nom }) => !nom.includes('(copie)'))); + }); + + test('un nom en NFD : la copie porte le nom et la base en NFC', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + assert.notEqual(NFD, NFC); + const { base } = await facade.copier(LUCIOLES, { nom: NFD }); + assert.equal(base, NFC); + assert.equal(analyser(fs.contenu('documents', NFC + SUFFIXES.etat)).charge.evenement.nom, NFC); + assert.equal(fs.contenu('documents', NFD + SUFFIXES.etat), null); + }); + + test('des Documents trop profonds pour le nom : CHEMIN_TROP_LONG, rien d’écrit', async () => { + const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); + await facade.demarrer(); + fs.deposer('documents', `Court${SUFFIXES.etat}`, texteEtat({ id: 'evt-court', nom: 'Court' })); + const avant = ecritures(fs); + const refus = await refusDe(facade.copier('Court', { nom: 'x'.repeat(120) })); + assert.equal(refus.code, 'CHEMIN_TROP_LONG'); + assert.equal(ecritures(fs), avant); + assert.deepEqual(Object.keys(await photographier(fs, { id: 'documents', chemin: PROFOND })), [`Court${SUFFIXES.etat}`]); + }); +}); + +// --- Renommer (§ 8.7, § 8.8) ------------------------------------------------- + +describe('renommer (§ 8.7, § 8.8)', () => { + test('nouveau nom, nouveau chemin, une entrée du renommage, aucun verrou restant', async () => { + const { fs, facade, appels } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const avant = ecritures(fs); + const { base } = await facade.renommer(LUCIOLES, { nom: CERFS }); + // La sonde qui prend le verrou ; la nouvelle paire — .precedent, journal, + // état — ; l'effacement de l'ancienne — état, .precedent, journal — ; le + // verrou rendu. + assert.equal(ecritures(fs), avant + 1 + 3 + 3 + 1); + assert.equal(base, CERFS); + const lignes = await facade.lister(); + assert.deepEqual( + lignes.map(({ base: b, nom, chemin }) => [b, nom, chemin]), + [[CERFS, CERFS, `${DOCUMENTS}\\${CERFS}${SUFFIXES.etat}`]], + ); + assert.equal(fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null); + assert.equal(fs.contenu('documents', CERFS + SUFFIXES.verrou), null); + const journal = lireJournal(fs.contenu('documents', CERFS + SUFFIXES.journal)); + assert.equal(journal.entrees.at(-1).libelle, `Renommé de « ${LUCIOLES} » en « ${CERFS} »`); + assert.deepEqual( + appels.filter(([nom]) => nom !== 'changerRacine'), + [], + ); + }); + + // Coupe le processus dès que l'écriture atomique a posé ce fichier. + function couperApresEcriture(fs, chemin) { + const ecrire = fs.ecrireAtomique; + fs.ecrireAtomique = async (racine, cible, texte) => { + await ecrire(racine, cible, texte); + if (cible === chemin) fs.pannes.couperApres(0); + }; + } + + test('une coupure après le nouvel état laisse deux paires, signalées doublons l’une de l’autre', async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque }); + const { facade } = monter({ fs }); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + couperApresEcriture(fs, CERFS + SUFFIXES.etat); + await assert.rejects(facade.renommer(LUCIOLES, { nom: CERFS })); + const relu = monter({ fs: creerFichiersSimules({ disque }) }); + await relu.facade.demarrer(); + const lignes = await relu.facade.lister(); + assert.deepEqual( + lignes.map(({ base, doublonAvec }) => [base, doublonAvec]), + [ + [CERFS, [LUCIOLES]], + [LUCIOLES, [CERFS]], + ], + ); + }); + + test('une coupure après le seul nouveau journal laisse une ligne, sous l’ancienne base', async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque }); + const { facade } = monter({ fs }); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + couperApresEcriture(fs, CERFS + SUFFIXES.journal); + await assert.rejects(facade.renommer(LUCIOLES, { nom: CERFS })); + const relu = monter({ fs: creerFichiersSimules({ disque }) }); + await relu.facade.demarrer(); + const lignes = await relu.facade.lister(); + assert.deepEqual( + lignes.map(({ base, nom }) => [base, nom]), + [[LUCIOLES, LUCIOLES]], + ); + }); + + test('plan bloqué : LECTURE_SEULE {raison: BLOQUE}, remède « débloquer », rien d’écrit', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES, etat: 'bloque' }); + const avant = ecritures(fs); + const refus = await refusDe(facade.renommer(LUCIOLES, { nom: CERFS })); + assert.equal(refus.code, 'LECTURE_SEULE'); + assert.deepEqual(refus.details, { raison: 'BLOQUE', remede: { geste: 'debloquer' } }); + assert.equal(ecritures(fs), avant); + }); + + test('un nom vide : NOM_REQUIS avant toute sonde, rien d’écrit', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const avant = ecritures(fs); + assert.equal((await refusDe(facade.renommer(LUCIOLES, { nom: ' ' }))).code, 'NOM_REQUIS'); + assert.equal(ecritures(fs), avant); + }); + + test('un nom en NFD : le nom et la base renommés sont en NFC', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + assert.notEqual(NFD, NFC); + const { base } = await facade.renommer(LUCIOLES, { nom: NFD }); + assert.equal(base, NFC); + assert.equal(analyser(fs.contenu('documents', NFC + SUFFIXES.etat)).charge.evenement.nom, NFC); + assert.equal(fs.contenu('documents', NFD + SUFFIXES.etat), null); + }); + + test('CHEMIN_TROP_LONG, levé sous le verrou : le verrou se rend, le disque reste tel qu’il était', async () => { + const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); + await facade.demarrer(); + const profond = { id: 'documents', chemin: PROFOND }; + fs.deposer('documents', `Court${SUFFIXES.etat}`, texteEtat({ id: 'evt-court', nom: 'Court' })); + const photo = await photographier(fs, profond); + assert.deepEqual(Object.keys(photo), [`Court${SUFFIXES.etat}`]); + const avant = ecritures(fs); + const refus = await refusDe(facade.renommer('Court', { nom: 'x'.repeat(120) })); + assert.equal(refus.code, 'CHEMIN_TROP_LONG'); + // La sonde qui prend le verrou, puis le verrou rendu. + assert.equal(ecritures(fs), avant + 2); + assert.equal(fs.contenu('documents', `Court${SUFFIXES.verrou}`), null); + assert.deepEqual(await photographier(fs, profond), photo); + }); + + test('l’événement ouvert en écriture : fermé d’abord, aucun VERROU_PRIS, rouvert sous la nouvelle base', async () => { + const montage = monter(); + const { facade, appels } = montage; + await facade.demarrer(); + await facade.creer({ nom: LUCIOLES }); + await montage.seance.passerEnEcriture(); + assert.equal(montage.seance.mode, 'ecriture'); + const debut = appels.length; + const { base } = await facade.renommer(LUCIOLES, { nom: CERFS }); + assert.deepEqual(appels.slice(debut), [['fermer'], ['ouvrir', CERFS]]); + assert.equal(base, CERFS); + assert.equal(montage.seance.ouvert.base, CERFS); + assert.equal(montage.seance.mode, 'lecture'); + assert.equal(montage.fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null); + }); + + test('le verrou d’une séance vivante : VERROU_PRIS, rien ne bouge ; celui d’une séance morte s’efface', async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque }); + const { facade } = monter({ fs }); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const voisin = creerFichiersSimules({ disque }); + await voisin.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-voisine'); + const avant = await photographier(fs, DOC); + assert.equal(Object.keys(avant).length, 3); + const ecrituresAvant = ecritures(fs); + const refus = await refusDe(facade.renommer(LUCIOLES, { nom: CERFS })); + assert.equal(refus.code, 'VERROU_PRIS'); + assert.equal(refus.details.seance, 'seance-voisine'); + assert.equal(refus.details.vivant, true); + // La seule sonde, refusée. + assert.equal(ecritures(fs), ecrituresAvant + 1); + assert.deepEqual(await photographier(fs, DOC), avant); + + voisin.pannes.couperApres(0); + const avantReprise = ecritures(fs); + const { base } = await facade.renommer(LUCIOLES, { nom: CERFS }); + // La sonde refusée, le verrou mort effacé, la sonde qui le prend ; puis + // le renommage : nouvelle paire, ancienne effacée, verrou rendu. + assert.equal(ecritures(fs), avantReprise + 3 + 3 + 3 + 1); + assert.equal(base, CERFS); + assert.equal(fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null); + }); +}); + +// --- Supprimer (§ 8.7, § 8.8) ------------------------------------------------ + +describe('supprimer (§ 8.7, § 8.8)', () => { + test('les fichiers partent dans la corbeille datée par l’horloge, l’ouvert fermé d’abord', async () => { + const { fs, facade, appels } = monter(); + await facade.demarrer(); + await facade.creer({ nom: LUCIOLES }); + const deplacer = fs.deplacer; + fs.deplacer = async (...parametres) => { + appels.push(['deplacer', parametres[1]]); + return deplacer(...parametres); + }; + const avant = ecritures(fs); + const { dossier } = await facade.supprimer(LUCIOLES); + // La sonde qui prend le verrou, le dossier daté, puis trois déplacements : + // le verrou, le journal, l'état. + assert.equal(ecritures(fs), avant + 1 + 1 + 3); + assert.equal(dossier, CORBEILLE_DU_DEPART); + const fermer = appels.findIndex(([nom]) => nom === 'fermer'); + const premierDeplacement = appels.findIndex(([nom]) => nom === 'deplacer'); + assert.ok(fermer >= 0 && premierDeplacement > fermer); + assert.deepEqual(await facade.lister(), []); + assert.deepEqual(await facade.corbeille(), [ + { + dossier: CORBEILLE_DU_DEPART, + chemin: `${DOCUMENTS}\\corbeille\\2026-10-06_19-00-00`, + fichiers: [LUCIOLES + SUFFIXES.journal, LUCIOLES + SUFFIXES.etat, LUCIOLES + SUFFIXES.verrou], + }, + ]); + }); + + test('le verrou d’une séance morte ne retient rien : il part avec l’événement', async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque }); + const { facade } = monter({ fs }); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const mort = creerFichiersSimules({ disque }); + await mort.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-morte'); + mort.pannes.couperApres(0); + const avant = ecritures(fs); + await facade.supprimer(LUCIOLES); + // La sonde, que le verrou mort n'arrête pas, le dossier daté, trois + // déplacements. + assert.equal(ecritures(fs), avant + 1 + 1 + 3); + const [{ fichiers }] = await facade.corbeille(); + assert.ok(fichiers.includes(LUCIOLES + SUFFIXES.verrou)); + assert.deepEqual(await facade.lister(), []); + }); + + test('un plan bloqué se supprime : rien ne se détruit, ses fichiers passent dans la corbeille', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES, etat: 'bloque' }); + const original = await photographier(fs, DOC); + assert.equal(Object.keys(original).length, 2); + const avant = ecritures(fs); + const { dossier } = await facade.supprimer(LUCIOLES); + assert.equal(ecritures(fs), avant + 1 + 1 + 3); + assert.equal(dossier, CORBEILLE_DU_DEPART); + assert.deepEqual(await facade.lister(), []); + const [{ fichiers }] = await facade.corbeille(); + assert.deepEqual(fichiers, [LUCIOLES + SUFFIXES.journal, LUCIOLES + SUFFIXES.etat, LUCIOLES + SUFFIXES.verrou]); + for (const [fichier, texte] of Object.entries(original)) { + assert.equal(fs.contenu('documents', `${CORBEILLE_DU_DEPART}/${fichier}`), texte); + } + }); + + test('le verrou d’une séance vivante, puis d’un autre hôte : VERROU_PRIS, fichiers en place', async () => { + const cas = [ + [{}, true], + [{ hote: 'poste-voisin' }, null], + ]; + assert.equal(cas.length, 2); + for (const [reglages, vivant] of cas) { + const disque = new Map(); + const fs = creerFichiersSimules({ disque }); + const { facade } = monter({ fs }); + await facade.demarrer(); + await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); + const autre = creerFichiersSimules({ disque, ...reglages }); + await autre.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-autre'); + const avant = await photographier(fs, DOC); + assert.equal(Object.keys(avant).length, 3); + const ecrituresAvant = ecritures(fs); + const refus = await refusDe(facade.supprimer(LUCIOLES)); + assert.equal(refus.code, 'VERROU_PRIS'); + assert.equal(refus.details.vivant, vivant); + // La seule sonde, refusée. + assert.equal(ecritures(fs), ecrituresAvant + 1); + assert.deepEqual(await photographier(fs, DOC), avant); + assert.deepEqual(await facade.corbeille(), []); + } + }); +}); + +// --- Importer (§ 8.6, § 8.7) ------------------------------------------------- + +describe('importer (§ 8.6, § 8.7)', () => { + const MERLES = { id: 'evt-merles', nom: 'Club des Merles' }; + + test('un fichier choisi : ses octets sous une base dérivée de son nom ; une seconde fois, IDENTIFIANT_PRESENT', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const octets = UTF8.encode(texteEtat(MERLES)); + fs.choisirFichierAImporter = async () => ({ nom: 'recu-par-courriel.gtt.json', octets }); + const avant = ecritures(fs); + const { base } = await facade.importerFichier(); + // L'état seul, aucun verrou. + assert.equal(ecritures(fs), avant + 1); + assert.equal(base, MERLES.nom); + assert.deepEqual(fs.octets('documents', base + SUFFIXES.etat), octets); + const photo = await photographier(fs, DOC); + assert.equal(Object.keys(photo).length, 1); + const avantRefus = ecritures(fs); + const refus = await refusDe(facade.importerFichier()); + assert.equal(refus.code, 'IDENTIFIANT_PRESENT'); + assert.deepEqual(refus.details, { id: MERLES.id, base: MERLES.nom }); + assert.equal(ecritures(fs), avantRefus); + assert.deepEqual(await photographier(fs, DOC), photo); + }); + + test('un fichier d’un format plus récent s’importe tel quel', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const texte = texteEtat(MERLES).replace('"format":1', '"format":2'); + assert.ok(texte.includes('"format":2')); + const octets = UTF8.encode(texte); + fs.choisirFichierAImporter = async () => ({ nom: 'futur.gtt.json', octets }); + const avant = ecritures(fs); + const { base } = await facade.importerFichier(); + assert.equal(ecritures(fs), avant + 1); + assert.deepEqual(fs.octets('documents', base + SUFFIXES.etat), octets); + }); + + test('le dialogue annulé rend null, sans rien écrire', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const avant = ecritures(fs); + assert.equal(await facade.importerFichier(), null); + assert.equal(ecritures(fs), avant); + }); + + test('l’autre emplacement : Documents neufs, data/ en porte deux → copiés, originaux intacts', async () => { + const disque = new Map(); + const avant = creerFichiersSimules({ disque, executable: SOUS_APPDATA }); + avant.deposerDossier('portable'); + await deposerEvenement(avant, PORTABLE_APPDATA, { id: 'evt-lucioles', nom: LUCIOLES }); + await deposerEvenement(avant, PORTABLE_APPDATA, { id: 'evt-cerfs', nom: CERFS }); + const fs = creerFichiersSimules({ disque, executable: SOUS_APPDATA }); + const { facade } = monter({ fs }); + const { dossier } = await facade.demarrer(); + assert.deepEqual(dossier.autre, { racine: PORTABLE_APPDATA, evenements: 2 }); + const originaux = await photographier(fs, PORTABLE_APPDATA); + assert.equal(Object.keys(originaux).length, 4); + const ecrituresAvant = ecritures(fs); + const { importes, refuses } = await facade.importerDepuisAutre(); + // Chaque paire : son journal, puis son état. + assert.equal(ecritures(fs), ecrituresAvant + 4); + assert.equal(importes.length, 2); + assert.deepEqual(refuses, []); + assert.deepEqual(await photographier(fs, PORTABLE_APPDATA), originaux); + assert.deepEqual( + (await facade.lister()).map(({ id }) => id), + ['evt-cerfs', 'evt-lucioles'], + ); + assert.equal((await facade.demarrer()).dossier.autre, null); + }); +}); + +// --- Les dialogues de fichiers, relayés (contrat de données) ----------------- + +describe('les dialogues de fichiers, lus sur fs à l’appel', () => { + test('choisirCsv rend tels quels {nom, octets} et null', async () => { + const { fs, facade } = monter(); + assert.equal(await facade.choisirCsv(), null); + const choisi = { nom: 'liste.csv', octets: UTF8.encode('nom\nOmbrelle\n') }; + fs.choisirFichierAImporter = async () => choisi; + assert.equal(await facade.choisirCsv(), choisi); + }); + + test('enregistrerSous remet nom et octets tels quels, et rend le chemin ou null', async () => { + const { fs, facade } = monter(); + assert.equal(await facade.enregistrerSous('a.csv', new Uint8Array([1])), null); + const recus = []; + fs.enregistrerSous = async (nom, octets) => { + recus.push([nom, octets]); + return 'C:\\Users\\Exemple\\Documents\\liste-refus.csv'; + }; + const octets = UTF8.encode('motif\n'); + assert.equal(await facade.enregistrerSous('liste-refus.csv', octets), 'C:\\Users\\Exemple\\Documents\\liste-refus.csv'); + assert.equal(recus.length, 1); + assert.equal(recus[0][0], 'liste-refus.csv'); + assert.equal(recus[0][1], octets); + }); +}); + +// --- L'explorateur (§ 8.6) --------------------------------------------------- + +describe('explorateur (§ 8.6)', () => { + test('sous web : indisponible, EXPLORATEUR_ABSENT, aucun appel', async () => { + const { fs, facade } = monter({ fs: creerFichiersSimules({ nature: 'web' }) }); + await facade.demarrer(); + const recus = []; + fs.ouvrirDansExplorateur = async (racine) => recus.push(racine); + assert.deepEqual(facade.explorateur(), { disponible: false, raison: 'EXPLORATEUR_ABSENT' }); + assert.equal(typeof libelle('EXPLORATEUR_ABSENT'), 'string'); + const refus = await refusDe(facade.ouvrirExplorateur()); + assert.equal(refus.code, 'EXPLORATEUR_ABSENT'); + assert.deepEqual(recus, []); + }); + + test('ailleurs : ouvrirDansExplorateur reçoit la racine', async () => { + const { fs, facade } = monter(); + await facade.demarrer(); + const recus = []; + fs.ouvrirDansExplorateur = async (racine) => recus.push(racine); + assert.deepEqual(facade.explorateur(), { disponible: true, raison: null }); + await facade.ouvrirExplorateur(); + assert.deepEqual(recus, [DOC]); + }); +}); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index a95582c..f88a9cd 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -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 diff --git a/src/application/libelles/evenements.js b/src/application/libelles/evenements.js index 04cb5a1..5aab933 100644 --- a/src/application/libelles/evenements.js +++ b/src/application/libelles/evenements.js @@ -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)}`], ]); diff --git a/src/application/libelles/moteur.js b/src/application/libelles/moteur.js index d9359f2..892f42d 100644 --- a/src/application/libelles/moteur.js +++ b/src/application/libelles/moteur.js @@ -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')}.`, + ], +]); diff --git a/src/application/libelles/places.js b/src/application/libelles/places.js index 2eac819..2e24bfe 100644 --- a/src/application/libelles/places.js +++ b/src/application/libelles/places.js @@ -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'], +]); diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js index 847fa60..0ffa19c 100644 --- a/src/application/libelles/seance.js +++ b/src/application/libelles/seance.js @@ -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) => diff --git a/src/application/listes_travail.js b/src/application/listes_travail.js new file mode 100644 index 0000000..778aad6 --- /dev/null +++ b/src/application/listes_travail.js @@ -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 }; +} diff --git a/src/application/listes_travail.test.js b/src/application/listes_travail.test.js new file mode 100644 index 0000000..31f4e96 --- /dev/null +++ b/src/application/listes_travail.test.js @@ -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), []); + }); +}); diff --git a/src/application/places.js b/src/application/places.js new file mode 100644 index 0000000..91dbcec --- /dev/null +++ b/src/application/places.js @@ -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} siegeDe + * @param {number} places + * @param {Set} [prioritaires] vide par défaut + * @returns {Array} 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, 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 }; +} diff --git a/src/application/places.test.js b/src/application/places.test.js new file mode 100644 index 0000000..9948a76 --- /dev/null +++ b/src/application/places.test.js @@ -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); + }); +}); diff --git a/src/application/pose.js b/src/application/pose.js new file mode 100644 index 0000000..cfb82e7 --- /dev/null +++ b/src/application/pose.js @@ -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 }); +} diff --git a/src/application/pose.test.js b/src/application/pose.test.js new file mode 100644 index 0000000..f6e19c2 --- /dev/null +++ b/src/application/pose.test.js @@ -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), []); + }); +}); diff --git a/src/application/seance.js b/src/application/seance.js index 642848f..256597b 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -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(); }, }; } diff --git a/src/application/seance.test.js b/src/application/seance.test.js index cabebc0..89661f9 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -14,16 +14,18 @@ import { readFileSync } from 'node:fs'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { apercevoir } from '../csv/apercu.js'; +import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { rechercher } from '../moteur/recherche.js'; import { serialiser } from '../stockage/canonique.js'; import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../stockage/depot.js'; -import { analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; +import { FORMAT, analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { ligneEntree, ligneOuverture, lireJournal, reconstruire } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; +import { FICHIER_REGLAGES } from '../stockage/reglages_locaux.js'; import { VERSION } from '../version.genere.js'; import { libelle } from './libelles.js'; -import { INACTIVITE_MS, creerSeance } from './seance.js'; +import { DELAI_REGLAGES_MS, INACTIVITE_MS, creerSeance } from './seance.js'; const V1 = VERSION.affichee; @@ -102,8 +104,11 @@ function creerPlanificateur() { // Une séance sur le système d'épreuve, sa racine créée : documents par // défaut. -function monter({ fs = creerFichiersSimules(), racine = DOC, prefixe = 'seance', inactiviteMs } = {}) { - fs.deposerDossier(racine.id); +// options : les paramètres de creerSeance qu'une épreuve règle en plus — +// formatCourant, delaiReglagesMs. Une racine null crée la séance sans +// dossier de travail. +function monter({ fs = creerFichiersSimules(), racine = DOC, prefixe = 'seance', inactiviteMs, ...options } = {}) { + if (racine !== null) fs.deposerDossier(racine.id); const planificateur = creerPlanificateur(); const identifiant = creerIdentifiants(prefixe); const seance = creerSeance({ @@ -115,6 +120,7 @@ function monter({ fs = creerFichiersSimules(), racine = DOC, prefixe = 'seance', planifier: planificateur.planifier, annuler: planificateur.annuler, ...(inactiviteMs === undefined ? {} : { inactiviteMs }), + ...options, }); return { fs, seance, planificateur, identifiant }; } @@ -204,6 +210,13 @@ async function refus(promesse, code, details) { return erreur; } +// Les refus de lecture seule de la séance, remède compris (A2). +const FORMAT_SEUL = { raison: 'FORMAT', remede: null }; +const BLOQUE_SEUL = { raison: 'BLOQUE', remede: { geste: 'debloquer' } }; +const ENCODAGE_SEUL = { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } }; +// Le remède d'un support rompu : écrire ailleurs, dans la racine proposée. +const versRacine = (racine) => ({ geste: 'ecrireAilleurs', racine }); + // --- Les épreuves ----------------------------------------------------------- describe('séance : créer et ouvrir (§ 2.1, § 8.1, § 8.4)', () => { @@ -267,7 +280,7 @@ describe('séance : créer et ouvrir (§ 2.1, § 8.1, § 8.4)', () => { assert.deepEqual([base, avertissements], [BASE, []]); assert.equal(fs.compteurs.ecritures, avant, 'ouvrir écrit'); assert.equal(seance.mode, 'lecture'); - await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE', {}); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE', { remede: { geste: 'modifier' } }); await refus(seance.defaire(), 'MODE_LECTURE'); await refus(seance.revenir(1), 'MODE_LECTURE'); await refus(seance.nommer('Avant le dessert'), 'MODE_LECTURE'); @@ -351,7 +364,7 @@ describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => { const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(second); const details = { seance: 'premiere-1', depuis, vivant: true }; - await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', details); + await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { ...details, remede: null }); assert.equal(second.seance.mode, 'lecture'); assert.deepEqual(second.seance.verrouAutre, details); assert.equal(verrouDe(second.fs).seance, 'premiere-1'); @@ -381,11 +394,11 @@ describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => { const { depuis } = verrouDe(premier.fs); const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' }); await ouvrirSansEcrire(lointain); - await refus(lointain.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: null }); + await refus(lointain.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: null, remede: null }); premier.fs.deposer('documents', VERROU, 'pas un verrou\n'); const voisin = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'voisine' }); await ouvrirSansEcrire(voisin); - await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null }); + await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null }); assert.equal(premier.fs.contenu('documents', VERROU), 'pas un verrou\n'); }); @@ -502,7 +515,9 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)', await enEcriture(montage); const avant = fs.compteurs.ecritures; const rendu = await seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } }); - assert.deepEqual(rendu, { libelle: 'Ajouté Iris Ombrelle', avertissements: [] }); + // L'événement neuf n'a aucune table : la première personne manque d'une place. + const manque = [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]; + assert.deepEqual(rendu, { libelle: 'Ajouté Iris Ombrelle', avertissements: manque }); assert.equal(fs.compteurs.ecritures - avant, 3, "la ligne, le .precedent, l'état"); await seance.executer('modifierParticipant', { id: 1, champs: { nom: 'Parasol' } }); const journal = journalDe(fs); @@ -548,7 +563,7 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)', await refus(montage.seance.nommer(' '), 'NOM_REQUIS'); }); - test("un import est une seule entrée ; il n'avertit pas en brouillon, et porte l'avertissement de dérive en retenu (§ 10.1)", async () => { + test("un import est une seule entrée ; en brouillon, il ne dit que le manque de places, et en retenu l'import et la dérive (§ 10.1, § 5.9)", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); @@ -559,7 +574,7 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)', const entrees = journalDe(fs).entrees.length; const rendu = await seance.executer('importerParticipants', { apercu: apercevoir(csv, { participants: seance.charge.participants }), mode: 'ajouter' }); assert.equal(rendu.libelle, 'Importé 3 participants'); - assert.deepEqual(rendu.avertissements, []); + assert.deepEqual(rendu.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 3 } }]); assert.equal(rendu.resume.ajoutes, 3); assert.equal(journalDe(fs).entrees.length, entrees + 1); assert.equal(seance.charge.participants.length, 7); @@ -571,7 +586,15 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)', await seance.executer('retenirProposition', { id: 1 }); const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants }); const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' }); - assert.deepEqual(enRetenu.avertissements, [{ code: 'IMPORT_EN_RETENU', details: {} }]); + const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8 }]; + assert.deepEqual(enRetenu.avertissements, [ + { code: 'IMPORT_EN_RETENU', details: {} }, + { code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }, + { + code: 'DERIVE_NOUVELLE', + details: { touchees: [{ id: 1, raisons: nonPlace }, { id: 2, raisons: nonPlace }, { id: 'retenu', raisons: nonPlace }] }, + }, + ]); // La séance montre aussi la dérive que l'Ouvert écrit porte désormais. assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...enRetenu.avertissements]); assert.deepEqual(seance.ouvert.avertissements.map(({ code }) => code), ['DERIVE']); @@ -584,7 +607,7 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)', await seance.executer('changerEtat', { etat: 'bloque' }); const avant = fs.compteurs.ecritures; const apercu = apercevoir('nom\nAncolie\n', { participants: [] }); - const erreur = await refus(seance.executer('importerParticipants', { apercu, mode: 'ajouter' }), 'LECTURE_SEULE', { raison: 'BLOQUE' }); + const erreur = await refus(seance.executer('importerParticipants', { apercu, mode: 'ajouter' }), 'LECTURE_SEULE', BLOQUE_SEUL); assert.match(libelle(erreur.code, erreur.details), /« Débloquer »/); assert.equal(fs.compteurs.ecritures, avant); }); @@ -658,9 +681,9 @@ describe("séance : ce qu'un import dit, et ce que la séance montre (§ 4.4, § await seance.executer('retenirProposition', { id: 1 }); assert.deepEqual(seance.avertissements, []); const exclusion = await seance.executer('exclureParticipant', { id: 2 }); - assert.deepEqual(exclusion.avertissements, []); - assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE']); - assert.deepEqual(seance.avertissements, seance.ouvert.avertissements); + assert.deepEqual(codesDe(exclusion.avertissements), ['DERIVE_NOUVELLE']); + assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE', 'DERIVE_NOUVELLE']); + assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...exclusion.avertissements]); assert.equal(seance.avertissements[0].details.retenu, true); await seance.executer('reintegrerParticipant', { id: 2 }); assert.deepEqual(seance.avertissements, []); @@ -681,7 +704,8 @@ describe("séance : ce qu'un import dit, et ce que la séance montre (§ 4.4, § await seance.passerEnEcriture(); assert.deepEqual(seance.avertissements, incomplet); await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }); - assert.deepEqual(seance.avertissements, []); + // Cinq personnes déjà pour quatre places : seul le manque, qui croît, se dit. + assert.deepEqual(seance.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } }]); }); }); @@ -795,7 +819,7 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('changerEtat', { etat: 'bloque' }); - await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'BLOQUE' }); + await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', BLOQUE_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'bloque']); assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }]); }); @@ -1008,9 +1032,9 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () const { seconde, fs } = await deuxSeances(); const entrees = journalDe(fs).entrees.length; plusRecent[fichier](fs); - await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' }); + await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', FORMAT_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); - await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', { raison: 'FORMAT' }); + await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', FORMAT_SEUL); if (fichier === 'etat') assert.equal(journalDe(fs).entrees.length, entrees); }); @@ -1022,7 +1046,7 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () } else { plusRecent.journal(fs); } - await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' }); + await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', FORMAT_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); }); @@ -1033,7 +1057,7 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () await seconde.ouvrir(BASE); const texte = fs.contenu('documents', JOURNAL); plusRecent[fichier](fs); - await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'FORMAT' }); + await refus(seconde.debloquer(), 'LECTURE_SEULE', FORMAT_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); if (fichier === 'etat') assert.equal(fs.contenu('documents', JOURNAL), texte); }); @@ -1057,7 +1081,7 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () const { seconde, fs } = await deuxSeances(); const octets = etatMalEncode(fs); const avant = await empreinte(fs); - const erreur = await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'ENCODAGE' }); + const erreur = await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', ENCODAGE_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]); assert.deepEqual(await empreinte(fs), avant); assert.deepEqual(fs.octets('documents', ETAT), octets); @@ -1073,7 +1097,7 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () await seconde.ouvrir(BASE); const octets = etatMalEncode(fs); const avant = await empreinte(fs); - await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'ENCODAGE' }); + await refus(seconde.debloquer(), 'LECTURE_SEULE', ENCODAGE_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]); assert.deepEqual(await empreinte(fs), avant); assert.deepEqual(fs.octets('documents', ETAT), octets); @@ -1255,8 +1279,8 @@ describe('séance : revenir, défaire, refaire (§ 8.3)', () => { await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); const avant = fs.compteurs.ecritures; - await refus(seance.revenir(3), 'INSTANT_NON_RESTITUABLE', { revision: 3 }); - await refus(seance.revenir(11), 'INSTANT_NON_RESTITUABLE', { revision: 11 }); + await refus(seance.revenir(3), 'INSTANT_NON_RESTITUABLE', { revision: 3, remede: null }); + await refus(seance.revenir(11), 'INSTANT_NON_RESTITUABLE', { revision: 11, remede: null }); await refus(seance.defaire(), 'RIEN_A_DEFAIRE'); assert.equal(fs.compteurs.ecritures, avant); }); @@ -1330,7 +1354,7 @@ describe('séance : bloquer et débloquer (§ 8.5, § 9)', () => { await ouvrirSansEcrire(second); for (const { seance, fs } of [premier, second]) { const avant = fs.compteurs.ecritures; - const bloque = { raison: 'BLOQUE' }; + const bloque = BLOQUE_SEUL; await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', bloque); await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', bloque); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'LECTURE_SEULE', bloque); @@ -1370,7 +1394,7 @@ describe('séance : bloquer et débloquer (§ 8.5, § 9)', () => { fs.deposer('documents', VERROU, `${JSON.stringify({ seance: 'lointaine-1', pid: 99, hote: 'autre-poste', depuis })}\n`); const journal = fs.contenu('documents', JOURNAL); const avant = fs.compteurs.ecritures; - await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis, vivant: null }); + await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis, vivant: null, remede: null }); assert.equal(fs.compteurs.ecritures - avant, 1, 'seule la prise du verrou se tente'); assert.equal(fs.contenu('documents', JOURNAL), journal); assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']); @@ -1399,7 +1423,7 @@ describe("séance : un fichier d'un format plus récent (§ 8.8)", () => { const avant = fs.compteurs.ecritures; await seance.ouvrir(BASE); assert.equal(seance.ouvert.formatPlusRecent, true); - const format = { raison: 'FORMAT' }; + const format = FORMAT_SEUL; await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', format); await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', format); await refus(seance.debloquer(), 'LECTURE_SEULE', format); @@ -1428,7 +1452,7 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => { assert.deepEqual(seance.refusSupport, refusSupport); assert.deepEqual(noms(seance.charge), ['Ancolie']); const avant = fs.compteurs.ecritures; - await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'SUPPORT_ROMPU', refusSupport); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'SUPPORT_ROMPU', { ...refusSupport, remede: versRacine(DOC) }); await refus(seance.defaire(), 'SUPPORT_ROMPU'); assert.equal(fs.compteurs.ecritures, avant); assert.match(libelle('SUPPORT_ROMPU', refusSupport), /Écrire ailleurs/); @@ -1523,7 +1547,7 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => { assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, refusSupport]); assert.deepEqual(noms(seance.charge), ['Ancolie']); const avant = fs.compteurs.ecritures; - await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', refusSupport); + await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', { ...refusSupport, remede: versRacine(DOC) }); assert.equal(fs.compteurs.ecritures, avant); const { base } = await seance.ecrireAilleurs(DOC); assert.equal(base, BASE); @@ -1567,7 +1591,8 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => { const refusSupport = { echec: 'VERROU_NON_ECRIT', ...details }; assert.deepEqual([seance.racine, seance.mode, seance.supportRompu, seance.refusSupport], [DOC, 'lecture', true, refusSupport]); assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie']); - await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', refusSupport); + // Le support rompu est celui des Documents : aucune racine à proposer. + await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', { ...refusSupport, remede: versRacine(null) }); }); test("écrire ailleurs refuse un dossier qui porte déjà l'événement, le sien compris : jamais deux copies d'un identifiant dans un dossier", async () => { @@ -1714,11 +1739,12 @@ describe('séance : la perte du support en cours de séance (§ 8.6)', () => { fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }; + const nonSauve = { ...refusSupport, remede: versRacine(DOC) }; const avant = fs.compteurs.ecritures; - await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); - await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); - await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); - await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); + await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual([seance.supportRompu, seance.refusSupport, noms(seance.charge)], [true, refusSupport, ['Ancolie']]); assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', refusSupport), /« Écrire ailleurs »/); @@ -1782,7 +1808,7 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => { const code = (texte) => texte.replace(JETONS, (jeton) => jeton.replace(/[^\n]/g, ' ')); test("ni la séance, ni les commandes, ni la table des libellés ne lisent l'horloge, un aléa ou une minuterie", () => { - for (const fichier of ['seance.js', 'commandes.js', 'libelles.js']) { + for (const fichier of ['seance.js', 'avertissements.js', 'commandes.js', 'libelles.js']) { const texte = code(readFileSync(new URL(`./${fichier}`, import.meta.url), 'utf8')); assert.ok(texte.trim().length > 0, `${fichier} vide`); for (const motif of [/\bDate\b/, /\bsetTimeout\b/, /\bsetInterval\b/, /\bclearTimeout\b/, /\bperformance\b/, /\bMath\s*\.\s*random\b/, /\bcrypto\b/]) { @@ -1791,3 +1817,839 @@ describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => { } }); }); + +// --- La séance observable (A2) ----------------------------------------------- + +// Laisse passer les tâches en attente : l'écrivain différé des réglages +// écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse. +const laisser = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// Compte les objets atteints depuis valeur, chacun une fois, en affirmant +// que chacun est figé. +function compterFiges(valeur, vus = new Set()) { + if (valeur === null || typeof valeur !== 'object' || vus.has(valeur)) return 0; + vus.add(valeur); + assert.ok(Object.isFrozen(valeur), `objet non figé : ${JSON.stringify(valeur).slice(0, 80)}`); + return 1 + Object.keys(valeur).reduce((n, cle) => n + compterFiges(valeur[cle], vus), 0); +} + +const codesDe = (avertissements) => avertissements.map(({ code }) => code); + +// La charge d'essai, d'un plan bloqué. +const chargeBloquee = () => { + const charge = chargeEssai(); + charge.evenement.etat = 'bloque'; + return charge; +}; + +// Le retenu de la proposition 1 posé sur la charge d'essai : Iris et +// Grisaille à la table 1 au premier tour, Iris et Théo au second. +function avecRetenu(charge) { + charge.retenu = { + proposition: 1, + siegesAttribues: false, + tables: [1, 2], + capacites: [2, 2], + tours: 2, + participants: [1, 2, 3, 4], + placement: [ + { sieges: [[1, 2], [3, 4]], reserve: [] }, + { sieges: [[1, 3], [2, 4]], reserve: [] }, + ], + }; + charge.prochainsIds.proposition = 2; + charge.evenement.etat = 'retenu'; + return charge; +} + +// Grisaille réservée à la table 2 au premier tour, où le retenu l'assoit à +// la table 1. +const reserverGrisaille = (charge) => { + charge.reservations.push({ participant: 2, table: 2, siege: null, portee: 'tour', tour: 1 }); + return charge; +}; + +// Un montage dont l'événement d'essai, de cette charge, est ouvert. +async function ouvertSur(charge = chargeEssai(), options = {}) { + const fs = options.fs ?? creerFichiersSimules(); + await deposerEssai(fs, charge); + const montage = monter({ ...options, fs }); + await montage.seance.ouvrir(BASE); + return montage; +} + +// Un fichier d'état de la charge d'essai, au format donné. +function deposerAuFormat(fs, format, charge = chargeEssai()) { + fs.deposerDossier('documents'); + const texte = serialiser(charge, { revision: 1, produitVersion: V1 }); + fs.deposer('documents', ETAT, texte.replace(`"format":${FORMAT}`, `"format":${format}`)); +} + +const MODIFIER = { geste: 'modifier' }; +const DEBLOQUER = { geste: 'debloquer' }; + +describe('séance observable : etat() et subscribe (§ 8.4, A2)', () => { + test('subscribe appelle aussitôt, puis après ouvrir, passer en écriture, un geste et passer en lecture ; désabonné, plus rien', async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs); + const { seance } = monter({ fs }); + const recus = []; + const desabonner = seance.subscribe((etat) => recus.push(etat)); + assert.equal(recus.length, 1); + assert.equal(recus[0], seance.etat()); + assert.equal(recus[0].evenement, null); + await seance.ouvrir(BASE); + assert.deepEqual([recus.at(-1).evenement.base, recus.at(-1).mode], [BASE, 'lecture']); + await seance.passerEnEcriture(); + assert.equal(recus.at(-1).mode, 'ecriture'); + await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + assert.deepEqual(noms(recus.at(-1).charge), ['Ombrelle', 'Grisaille', 'Pervenche', 'Lacasse', 'Ancolie']); + await seance.passerEnLecture(); + assert.equal(recus.at(-1).mode, 'lecture'); + assert.ok(recus.length >= 5, `${recus.length} appels`); + assert.equal(new Set(recus.slice(1)).size, recus.length - 1, 'un état rendu deux fois'); + const n = recus.length; + desabonner(); + await seance.passerEnEcriture(); + seance.debuterCalcul(); + assert.equal(recus.length, n); + }); + + test("l'état rendu n'est jamais muté : figé en profondeur, et une copie d'avant compare égale après les gestes suivants", async () => { + const { seance } = await ouvertSur(); + await seance.passerEnEcriture(); + const etat = seance.etat(); + const copie = structuredClone(etat); + assert.ok(compterFiges(etat) > 20); + assert.throws(() => etat.charge.participants.push(personne(9, 'Ancolie')), TypeError); + await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + await seance.executer('exclureParticipant', { id: 3 }); + await seance.defaire(); + assert.deepEqual(etat, copie); + assert.notEqual(seance.etat(), etat); + assert.equal(seance.etat(), seance.etat(), "deux lectures sans changement rendent le même état"); + compterFiges(seance.etat()); + }); + + test("l'état porte l'événement, la charge, l'examen, les retours, le calcul en cours ; sans événement, rien", async () => { + const vide = monter().seance.etat(); + assert.deepEqual( + [vide.evenement, vide.charge, vide.examen, vide.fautives, vide.avertissements, vide.refus, vide.supportRompu, vide.verrouAutre], + [null, null, null, [], [], null, null, null], + ); + assert.deepEqual([vide.mode, vide.retours, vide.calculEnCours, vide.racine], ['lecture', { defaire: false, refaire: false }, false, DOC]); + const { seance } = await ouvertSur(); + await seance.passerEnEcriture(); + await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + const etat = seance.etat(); + assert.deepEqual(etat.evenement, { base: BASE, id: 'evt-lucioles', nom: BASE, etat: 'brouillon', revision: 2, annonceFormat: null }); + assert.deepEqual(etat.charge, seance.charge); + assert.deepEqual(etat.examen, seance.ouvert.examen); + assert.deepEqual(etat.retours, { defaire: true, refaire: false }); + const recus = []; + seance.subscribe((suivant) => recus.push(suivant)); + seance.debuterCalcul(); + assert.deepEqual([seance.etat().calculEnCours, recus.at(-1).calculEnCours], [true, true]); + seance.finirCalcul(); + assert.deepEqual([seance.etat().calculEnCours, recus.at(-1).calculEnCours], [false, false]); + }); +}); + +describe('séance observable : la racine (§ 8.6, A2)', () => { + test("une séance créée sans racine n'en a pas ; changerRacine la pose sans rien écrire", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + const { seance } = monter({ fs, racine: null }); + assert.equal(seance.etat().racine, null); + const avant = fs.compteurs.ecritures; + const recus = []; + seance.subscribe((etat) => recus.push(etat)); + await seance.changerRacine(DOC); + assert.deepEqual([seance.etat().racine, recus.at(-1).racine], [DOC, DOC]); + assert.equal(fs.compteurs.ecritures, avant); + await seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }); + assert.equal(seance.etat().evenement.base, BASE); + }); + + test("changerRacine ferme l'événement ouvert en écriture : son verrou quitte le disque, et la séance ouvre désormais là", async () => { + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + fs.deposerDossier('portable'); + const montage = monter({ fs }); + await enEcriture(montage); + assert.equal(verrouDe(fs).seance, montage.seance.id); + await montage.seance.changerRacine(PORTABLE); + assert.equal(verrouDe(fs), null); + assert.deepEqual([montage.seance.etat().evenement, montage.seance.etat().racine, montage.seance.etat().mode], [null, PORTABLE, 'lecture']); + await refus(montage.seance.ouvrir(BASE), 'ABSENT'); + }); +}); + +describe('séance observable : la version de format (§ 8.8, § 18.6, A2)', () => { + test("un fichier d'un format plus récent s'annonce 'plusRecent' ; passer en écriture et un geste sont refusés LECTURE_SEULE FORMAT, remède null, jamais MODE_LECTURE", async () => { + const fs = creerFichiersSimules(); + deposerAuFormat(fs, FORMAT + 1); + const { seance } = monter({ fs }); + const avant = fs.compteurs.ecritures; + await seance.ouvrir(BASE); + assert.equal(seance.etat().evenement.annonceFormat, 'plusRecent'); + const format = { raison: 'FORMAT', remede: null }; + await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', format); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'LECTURE_SEULE', format); + assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: format }); + assert.equal(fs.compteurs.ecritures, avant); + }); + + test("au format courant, aucune annonce ; formatCourant au-delà, 'plusAncien' sans rien écrire, puis le premier geste écrit au format FORMAT et l'annonce tombe", async () => { + const courant = await ouvertSur(); + assert.equal(courant.seance.etat().evenement.annonceFormat, null); + + const fs = creerFichiersSimules(); + await deposerEssai(fs); + const { seance } = monter({ fs, formatCourant: FORMAT + 1 }); + const avant = fs.compteurs.ecritures; + await seance.ouvrir(BASE); + assert.equal(seance.etat().evenement.annonceFormat, 'plusAncien'); + assert.equal(fs.compteurs.ecritures, avant); + await seance.passerEnEcriture(); + assert.equal(seance.etat().evenement.annonceFormat, 'plusAncien', 'le passage en écriture ne convertit rien'); + const entrees = journalDe(fs).entrees.length; + await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + assert.equal(journalDe(fs).entrees.length, entrees + 1); + assert.equal(etatDe(fs).entete.format, FORMAT); + assert.equal(seance.etat().evenement.annonceFormat, null); + }); +}); + +describe('séance observable : bloquer et débloquer (§ 8.5, § 9, A2)', () => { + test("sur un plan bloqué, passer en écriture, changer l'état et défaire sont refusés BLOQUE, remède debloquer, jamais MODE_LECTURE, sans écriture", async () => { + const montage = await ouvertSur(chargeBloquee()); + const { fs, seance } = montage; + const avant = fs.compteurs.ecritures; + const bloque = { raison: 'BLOQUE', remede: DEBLOQUER }; + await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', bloque); + await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', bloque); + await refus(seance.defaire(), 'LECTURE_SEULE', bloque); + assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: bloque }); + assert.equal(fs.compteurs.ecritures, avant); + }); + + test("débloquer prend le verrou, écrit une entrée et un jalon, rend l'état que le contenu impose, et laisse en écriture", async () => { + const { fs, seance } = await ouvertSur(chargeBloquee()); + const entrees = journalDe(fs).entrees.length; + await seance.debloquer(); + assert.equal(seance.etat().mode, 'ecriture'); + assert.equal(verrouDe(fs).seance, seance.id); + const journal = journalDe(fs); + assert.equal(journal.entrees.length, entrees + 1); + assert.equal(journal.jalons.at(-1).revision, journal.entrees.at(-1).revision); + assert.equal(seance.etat().evenement.etat, etatDeduit(seance.charge)); + assert.equal(seance.etat().refus, null); + }); + + test('sur un plan non bloqué ouvert en lecture, un geste est refusé MODE_LECTURE, remède modifier', async () => { + const { fs, seance } = await ouvertSur(); + const avant = fs.compteurs.ecritures; + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'MODE_LECTURE', { remede: MODIFIER }); + await refus(seance.nommer('Avant le dessert'), 'MODE_LECTURE', { remede: MODIFIER }); + assert.equal(fs.compteurs.ecritures, avant); + }); + + test('bloquer écrit une entrée et un jalon, revient en lecture, rend le verrou ; une seconde séance du même disque ouvre un plan bloqué', async () => { + const disque = new Map(); + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); + await enEcriture(premier); + const entrees = journalDe(premier.fs).entrees.length; + await premier.seance.executer('changerEtat', { etat: 'bloque' }); + assert.equal(journalDe(premier.fs).entrees.length, entrees + 1); + assert.equal(journalDe(premier.fs).jalons.at(-1).nom, 'État « Bloqué »'); + assert.deepEqual([premier.seance.etat().mode, verrouDe(premier.fs)], ['lecture', null]); + const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); + await ouvrirSansEcrire(second); + assert.equal(second.seance.etat().evenement.etat, 'bloque'); + assert.deepEqual(second.seance.etat().refus, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: DEBLOQUER } }); + }); +}); + +describe("séance observable : l'annulation et le retour automatique (§ 8.4, A2)", () => { + test('surAnnulation est appelé au passage en lecture explicite pendant un geste, au retour automatique et à la fermeture ; désabonné, plus rien', async () => { + const montage = monter(); + const { seance, planificateur } = montage; + await enEcriture(montage); + let appels = 0; + const desabonner = seance.surAnnulation(() => { + appels += 1; + }); + seance.debuterGeste(); + await seance.passerEnLecture(); + assert.equal(appels, 1); + await seance.passerEnEcriture(); + await planificateur.avancer(INACTIVITE_MS); + assert.deepEqual([seance.mode, appels], ['lecture', 2]); + await seance.fermer(); + assert.equal(appels, 3); + desabonner(); + await seance.ouvrir(BASE); + await seance.passerEnEcriture(); + await seance.passerEnLecture(); + await seance.fermer(); + assert.equal(appels, 3); + }); + + test("le retour automatique ne tombe jamais pendant un geste ouvert ; il pose RETOUR_AUTOMATIQUE à la suite des avertissements, que le passage en écriture suivant retire", async () => { + const montage = monter(); + const { seance, planificateur } = montage; + await enEcriture(montage); + const ajout = await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + assert.deepEqual(codesDe(ajout.avertissements), ['PLACES_MANQUANTES']); + let appels = 0; + seance.surAnnulation(() => { + appels += 1; + }); + seance.debuterGeste(); + await planificateur.avancer(3 * INACTIVITE_MS); + assert.deepEqual([seance.mode, appels], ['ecriture', 0]); + assert.ok(!codesDe(seance.etat().avertissements).includes('RETOUR_AUTOMATIQUE')); + seance.finirGeste(); + await planificateur.avancer(INACTIVITE_MS); + assert.deepEqual([seance.mode, appels], ['lecture', 1]); + const retour = { code: 'RETOUR_AUTOMATIQUE', details: {} }; + assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, ...ajout.avertissements, retour]); + await seance.passerEnEcriture(); + assert.deepEqual(codesDe(seance.etat().avertissements), []); + await seance.passerEnLecture(); + assert.ok(!codesDe(seance.etat().avertissements).includes('RETOUR_AUTOMATIQUE'), 'le retour explicite se tait'); + }); +}); + +describe('séance observable : le cadrage (§ 8.5, A2)', () => { + const REGLAGES = `{"cadrages":[{"evenement":"evt-lucioles","k":2,"tx":10,"ty":-5}],"format":1,"papier":null}\n`; + const lireCadrages = (fs) => JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).cadrages; + + for (const [cas, charge, ecriture] of [ + ['en lecture', chargeEssai, false], + ['sur un plan bloqué', chargeBloquee, false], + ['en écriture', chargeEssai, true], + ]) { + test(`${cas} : ouvrir relit le cadrage ; vingt demandes en 500 ms font une seule écriture des réglages, ${DELAI_REGLAGES_MS} ms après la première, le dernier cadrage, sans toucher l'état ni le journal`, async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs, charge()); + fs.deposer('documents', FICHIER_REGLAGES, REGLAGES); + const montage = monter({ fs }); + const { seance, planificateur } = montage; + await seance.ouvrir(BASE); + if (ecriture) await seance.passerEnEcriture(); + assert.deepEqual(seance.cadrage(), { k: 2, tx: 10, ty: -5 }); + const avant = fs.compteurs.ecritures; + const revision = seance.etat().evenement.revision; + const textes = [fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)]; + for (let rang = 0; rang < 20; rang += 1) { + seance.demanderCadrage({ k: 1 + rang / 10, tx: rang, ty: -rang }); + await planificateur.avancer(25); + } + assert.deepEqual(seance.cadrage(), { k: 2.9, tx: 19, ty: -19 }); + await planificateur.avancer(DELAI_REGLAGES_MS - 500 - 1); + await laisser(); + assert.equal(fs.compteurs.ecritures, avant); + await planificateur.avancer(1); + await laisser(); + assert.equal(fs.compteurs.ecritures, avant + 1); + assert.deepEqual(lireCadrages(fs), [{ evenement: 'evt-lucioles', k: 2.9, tx: 19, ty: -19 }]); + assert.deepEqual([fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)], textes); + assert.equal(seance.etat().evenement.revision, revision); + }); + } + + test("une vue de k nul ou non fini n'est pas demandée ; une écriture refusée se tait", async () => { + const { fs, seance, planificateur } = await ouvertSur(); + const avant = fs.compteurs.ecritures; + for (const vue of [{ k: 0, tx: 0, ty: 0 }, { k: Number.NaN, tx: 0, ty: 0 }, { k: Infinity, tx: 0, ty: 0 }, { k: 1, tx: Number.NaN, ty: 0 }]) { + seance.demanderCadrage(vue); + } + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + assert.equal(fs.compteurs.ecritures, avant); + assert.equal(seance.cadrage(), null); + fs.pannes.ecriture('documents'); + seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), null); + assert.deepEqual(seance.cadrage(), { k: 1.5, tx: 3, ty: 4 }); + await seance.fermer(); + }); + + test("fermer écrit aussitôt une demande en attente ; rouvrir la rend ; défaire ne la change pas", async () => { + const montage = monter(); + const { fs, seance, planificateur } = montage; + await enEcriture(montage); + await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + seance.demanderCadrage({ k: 0.5, tx: -40, ty: 12 }); + await seance.fermer(); + assert.equal(planificateur.actives, 0); + const { id } = etatDe(fs).charge.evenement; + assert.deepEqual(lireCadrages(fs), [{ evenement: id, k: 0.5, tx: -40, ty: 12 }]); + await seance.ouvrir(BASE); + assert.deepEqual(seance.cadrage(), { k: 0.5, tx: -40, ty: 12 }); + await seance.passerEnEcriture(); + await seance.defaire(); + assert.deepEqual(seance.cadrage(), { k: 0.5, tx: -40, ty: 12 }); + const ailleurs = monter({ fs: creerFichiersSimules({ disque: new Map() }) }); + assert.equal(ailleurs.seance.cadrage(), null, 'sans événement, aucun cadrage'); + }); + + test("une lecture des réglages qui lève ne retient pas l'ouverture : cadrage() null", async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs); + const lireTexte = fs.lireTexte; + fs.lireTexte = async (racine, chemin) => { + if (chemin === FICHIER_REGLAGES) throw new TypeError('réponse illisible du système'); + return lireTexte(racine, chemin); + }; + const { seance } = monter({ fs }); + await seance.ouvrir(BASE); + assert.deepEqual([seance.etat().evenement.base, seance.cadrage()], [BASE, null]); + }); + + test("des réglages que la lecture ne prend pas ne s'écrasent pas : le cadrage attend, le papier et les autres cadrages restent", async () => { + const AUTRES = `{"cadrages":[{"evenement":"evt-voisin","k":3,"tx":1,"ty":2}],"format":1,"papier":"A4"}\n`; + const fs = creerFichiersSimules(); + await deposerEssai(fs); + fs.deposer('documents', FICHIER_REGLAGES, AUTRES); + let occupe = false; + const lireTexte = fs.lireTexte; + fs.lireTexte = async (racine, chemin) => { + if (occupe && chemin === FICHIER_REGLAGES) throw new ErreurStockage('LECTURE', { chemin, cause: 'EBUSY' }); + return lireTexte(racine, chemin); + }; + const montage = monter({ fs }); + const { seance, planificateur } = montage; + await seance.ouvrir(BASE); + occupe = true; + seance.demanderCadrage({ k: 1, tx: 0, ty: 0 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), AUTRES, 'une lecture refusée laisse le fichier'); + assert.deepEqual(seance.cadrage(), { k: 1, tx: 0, ty: 0 }); + occupe = false; + await seance.fermer(); + const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); + assert.equal(relus.papier, 'A4'); + assert.deepEqual(relus.cadrages, [ + { evenement: 'evt-lucioles', k: 1, tx: 0, ty: 0 }, + { evenement: 'evt-voisin', k: 3, tx: 1, ty: 2 }, + ]); + for (const corrompu of ['pas du json\n', '{"cadrages":[],"format":0,"papier":"A4"}\n']) { + fs.deposer('documents', FICHIER_REGLAGES, corrompu); + await seance.ouvrir(BASE); + seance.demanderCadrage({ k: 2, tx: 5, ty: 5 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + await seance.fermer(); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), corrompu, corrompu); + } + }); + + test("des réglages corrompus : cadrage() null, et aucun avertissement de la séance ne le dit", async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs); + fs.deposer('documents', FICHIER_REGLAGES, 'pas du json\n'); + const { seance } = monter({ fs }); + await seance.ouvrir(BASE); + assert.equal(seance.cadrage(), null); + assert.ok(!codesDe(seance.etat().avertissements).includes('REGLAGES_ILLISIBLES')); + }); +}); + +describe("séance observable : les avertissements d'un geste (§ 5.9, § 9, A2)", () => { + // Deux tables de huit et seize convives : la salle est pleine. + function sallePleine() { + const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 8, tours: 2 }); + charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } })); + charge.participants = Array.from({ length: 16 }, (_, rang) => personne(rang + 1, `Convive ${rang + 1}`)); + charge.prochainsIds = { participant: 17, table: 3, proposition: 1 }; + return charge; + } + const manqueDe = (charge) => nombrePlacesManquantes(normaliser(configurationDepuisCharge(charge))); + + test("la dix-septième personne avertit PLACES_MANQUANTES {1}, trois de plus {4}, le nombre du moteur ; un geste qui ne fait pas croître le manque n'en dit rien", async () => { + const { seance } = await ouvertSur(sallePleine()); + await seance.passerEnEcriture(); + const ajout = await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + assert.deepEqual(ajout.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]); + assert.equal(manqueDe(seance.charge), 1); + const apercu = apercevoir('nom\nBruyère\nCerfeuil\nDahlia\n', { participants: seance.charge.participants }); + const import3 = await seance.executer('importerParticipants', { apercu, mode: 'ajouter' }); + assert.deepEqual(import3.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 4 } }]); + assert.equal(manqueDe(seance.charge), 4); + assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, ...import3.avertissements]); + const renomme = await seance.executer('modifierParticipant', { id: 1, champs: { nom: 'Parasol' } }); + assert.deepEqual(renomme.avertissements, []); + const retire = await seance.executer('supprimerParticipant', { id: 2 }); + assert.deepEqual(retire.avertissements, []); + }); + + test("après la retenue, exclure une personne : DERIVE_NOUVELLE nomme le retenu et PARTICIPANT_EXCLU, sans lever ; le geste suivant passe sans redire cette raison", async () => { + const { seance } = await ouvertSur(); + await seance.passerEnEcriture(); + await seance.executer('enregistrerGeneration', generer(seance.charge)); + await seance.executer('retenirProposition', { id: 1 }); + const exclusion = await seance.executer('exclureParticipant', { id: 3 }); + const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }]; + assert.deepEqual(exclusion.avertissements, [ + { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } }, + ]); + const suivant = await seance.executer('modifierParticipant', { id: 4, champs: { nom: 'Lacasse-Ombrelle' } }); + assert.deepEqual(suivant.avertissements, []); + const autre = await seance.executer('exclureParticipant', { id: 2 }); + const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2 }]; + assert.deepEqual(autre.avertissements, [ + { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } }, + ]); + }); + + test("un fichier au retenu fautif : le geste passe, et ni DERIVE_NOUVELLE ni RESERVATION_NON_HONOREE ne vient du retenu", async () => { + const fs = creerFichiersSimules(); + const charge = avecRetenu(chargeEssai()); + charge.retenu.placement[0].reserve = [2]; + await deposerEssai(fs, reserverGrisaille(charge)); + const montage = monter({ fs }); + await ouvrirSansEcrire(montage); + const { seance } = montage; + assert.equal(seance.etat().avertissements[0].code, 'RETENU_FAUTIF'); + await seance.passerEnEcriture(); + const exclusion = await seance.executer('exclureParticipant', { id: 3 }); + assert.deepEqual(exclusion.avertissements, []); + assert.ok(!codesDe(seance.etat().avertissements).includes('INVARIANT_VIOLE')); + }); +}); + +describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () => { + test('la seconde séance ouvre en lecture sans refus ; son passage en écriture est refusé VERROU_PRIS, remède null, et verrouAutre porte la séance et depuis', async () => { + const disque = new Map(); + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); + await enEcriture(premier); + const { depuis } = verrouDe(premier.fs); + const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); + await ouvrirSansEcrire(second); + assert.equal(second.seance.etat().verrouAutre, null); + await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: true, remede: null }); + assert.deepEqual(second.seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: true }); + assert.equal(verrouDe(second.fs).seance, 'premiere-1'); + }); + + test("le verrou d'une séance morte se reprend seul, et VERROU_REPRIS le dit", async () => { + const disque = new Map(); + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); + await enEcriture(premier); + const { depuis } = verrouDe(premier.fs); + premier.fs.pannes.couperApres(0); + const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); + await ouvrirSansEcrire(second); + const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'premiere-1', depuis } }]; + assert.deepEqual(await second.seance.passerEnEcriture(), { avertissements: repris }); + assert.deepEqual(second.seance.etat().avertissements, repris); + }); + + test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste de la séance ne le reprend", async () => { + const disque = new Map(); + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); + await enEcriture(premier); + const { depuis } = verrouDe(premier.fs); + const texte = premier.fs.contenu('documents', VERROU); + const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' }); + await ouvrirSansEcrire(lointain); + const { seance } = lointain; + const pris = { seance: 'premiere-1', depuis, vivant: null, remede: null }; + await refus(seance.passerEnEcriture(), 'VERROU_PRIS', pris); + await refus(seance.passerEnEcriture(), 'VERROU_PRIS', pris); + assert.equal(premier.fs.contenu('documents', VERROU), texte); + assert.deepEqual(seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null }); + assert.equal(seance.etat().mode, 'lecture'); + assert.equal(seance.reprendreVerrou, undefined); + }); + + test("un verrou illisible n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé", async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs); + fs.deposer('documents', VERROU, 'pas un verrou\n'); + const montage = monter({ fs }); + await ouvrirSansEcrire(montage); + const illisible = { seance: null, depuis: null, vivant: null, remede: null }; + await refus(montage.seance.passerEnEcriture(), 'VERROU_PRIS', illisible); + assert.equal(fs.contenu('documents', VERROU), 'pas un verrou\n'); + }); + + test("sur un plan bloqué, débloquer devant le verrou d'un autre hôte est refusé VERROU_PRIS, remède null, sans entrée, le verrou inchangé", async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs, chargeBloquee()); + const depuis = '2031-05-17T09:00:00-04:00'; + const texte = `${JSON.stringify({ seance: 'lointaine-1', pid: 99, hote: 'autre-poste', depuis })}\n`; + fs.deposer('documents', VERROU, texte); + const montage = monter({ fs }); + await ouvrirSansEcrire(montage); + const { seance } = montage; + const entrees = journalDe(fs).entrees.length; + await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis, vivant: null, remede: null }); + assert.equal(journalDe(fs).entrees.length, entrees); + assert.deepEqual([seance.etat().mode, seance.etat().evenement.etat], ['lecture', 'bloque']); + assert.equal(fs.contenu('documents', VERROU), texte); + }); +}); + +describe('séance observable : le support rompu (§ 8.6, § 8.2, A2)', () => { + // Un événement du disque portable, en écriture, après un premier geste. + async function surPortable() { + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + const montage = monter({ fs, racine: PORTABLE }); + await enEcriture(montage); + await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + return montage; + } + const rompu = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }; + const versDocuments = { geste: 'ecrireAilleurs', racine: DOC }; + + test("une écriture refusée remonte ECRITURE ; supportRompu en garde les champs et la racine documents ; le refus courant et le geste suivant sont SUPPORT_ROMPU, remède écrire ailleurs ; écrire ailleurs rétablit", async () => { + const { fs, seance } = await surPortable(); + fs.pannes.ecriture('portable'); + const erreur = await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); + assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }); + assert.deepEqual(seance.etat().supportRompu, { ...rompu, ailleurs: DOC }); + const attendu = { code: 'SUPPORT_ROMPU', details: { ...rompu, remede: versDocuments } }; + assert.deepEqual(seance.etat().refus, attendu); + const avant = fs.compteurs.ecritures; + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'SUPPORT_ROMPU', attendu.details); + assert.equal(fs.compteurs.ecritures, avant); + await seance.ecrireAilleurs(DOC); + assert.deepEqual([seance.etat().supportRompu, seance.etat().racine, seance.etat().refus], [null, DOC, null]); + assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie']); + assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie']); + }); + + test("un état qui ne s'écrit pas : ETAT_NON_ECRIT, le geste au journal, l'état du disque intact ; écrire ailleurs écrit là-bas l'état de ce geste", async () => { + const { fs, seance } = await surPortable(); + fs.pannes.renommage('portable'); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ETAT_NON_ECRIT'); + assert.equal(seance.etat().supportRompu.echec, 'ETAT_NON_ECRIT'); + assert.deepEqual(libellesDe(journalDe(fs, 'portable')), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']); + assert.deepEqual(noms(etatDe(fs, 'portable').charge), ['Ancolie']); + await seance.ecrireAilleurs(DOC); + assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']); + }); + + test('sous un support rompu, ouvrir, créer, fermer et changerRacine sont refusés SUPPORT_ROMPU_NON_SAUVE, remède écrire ailleurs, sans écriture ; fermer({abandonner: true}) ferme', async () => { + const { fs, seance } = await surPortable(); + fs.pannes.ecriture('portable'); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); + const nonSauve = { ...rompu, remede: versDocuments }; + const avant = fs.compteurs.ecritures; + await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + await refus(seance.changerRacine(DOC), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); + assert.equal(fs.compteurs.ecritures, avant); + assert.deepEqual(seance.etat().racine, PORTABLE); + await seance.fermer({ abandonner: true }); + assert.deepEqual([seance.etat().evenement, seance.etat().supportRompu], [null, null]); + }); + + test("écrire ailleurs sans racine demande le dossier par fs.choisirDossier : son annulation ne change rien, son choix y écrit", async () => { + const { fs, seance } = await surPortable(); + fs.pannes.ecriture('portable'); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); + let appels = 0; + fs.choisirDossier = async () => { + appels += 1; + return null; + }; + const avant = fs.compteurs.ecritures; + assert.equal(await seance.ecrireAilleurs(null), null); + assert.equal(appels, 1); + assert.equal(fs.compteurs.ecritures, avant); + assert.deepEqual([seance.etat().supportRompu, seance.etat().racine], [{ ...rompu, ailleurs: DOC }, PORTABLE]); + fs.deposerDossier('documents'); + fs.choisirDossier = async () => DOC; + const { base } = await seance.ecrireAilleurs(null); + assert.equal(base, BASE); + assert.deepEqual([seance.etat().supportRompu, seance.etat().racine], [null, DOC]); + }); + + test("un système qui ne dit pas ses racines : aucun ailleurs n'est proposé", async () => { + const { fs, seance } = await surPortable(); + fs.pannes.ecriture('portable'); + fs.racines = async () => { + throw new TypeError('réponse illisible du système'); + }; + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); + assert.deepEqual(seance.etat().supportRompu, { ...rompu, ailleurs: null }); + }); + + test("le support rompu de la racine documents n'a pas d'ailleurs : le remède laisse l'opérateur désigner le dossier", async () => { + const montage = monter(); + const { fs, seance } = montage; + await enEcriture(montage); + fs.pannes.ecriture('documents'); + await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); + assert.equal(seance.etat().supportRompu.ailleurs, null); + assert.deepEqual(seance.etat().refus.details.remede, { geste: 'ecrireAilleurs', racine: null }); + }); +}); + +describe('séance observable : le refus courant (§ 2.1, garantie 3, A2)', () => { + test("null sans événement et en écriture ; sinon, en profondeur, le code et les détails qu'executer lève, remède compris", async () => { + assert.equal(monter().seance.etat().refus, null); + const enEcritureMontage = await ouvertSur(); + await enEcritureMontage.seance.passerEnEcriture(); + assert.equal(enEcritureMontage.seance.etat().refus, null); + + const lecture = await ouvertSur(); + const bloque = await ouvertSur(chargeBloquee()); + const fsFormat = creerFichiersSimules(); + deposerAuFormat(fsFormat, FORMAT + 1); + const format = monter({ fs: fsFormat }); + await format.seance.ouvrir(BASE); + const portable = monter({ fs: creerFichiersSimules({ executable: 'E:\\soirees' }), racine: PORTABLE }); + await enEcriture(portable); + portable.fs.pannes.ecriture('portable'); + await refus(portable.seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); + + const cas = [ + [lecture, 'MODE_LECTURE', { remede: MODIFIER }], + [bloque, 'LECTURE_SEULE', { raison: 'BLOQUE', remede: DEBLOQUER }], + [format, 'LECTURE_SEULE', { raison: 'FORMAT', remede: null }], + [portable, 'SUPPORT_ROMPU', { ...{ echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }, remede: { geste: 'ecrireAilleurs', racine: DOC } }], + ]; + assert.equal(cas.length, 4); + for (const [{ seance }, code, details] of cas) { + assert.deepEqual(seance.etat().refus, { code, details }, code); + const erreur = await refus(seance.executer('changerEtat', { etat: 'brouillon' }), code); + assert.deepEqual(seance.etat().refus, { code: erreur.code, details: erreur.details }, code); + } + }); + + test("les autres refus de la séance portent un remède null", async () => { + await refus(monter().seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'AUCUN_EVENEMENT', { remede: null }); + const montage = monter(); + await enEcriture(montage); + await refus(montage.seance.defaire(), 'RIEN_A_DEFAIRE', { remede: null }); + await refus(montage.seance.refaire(), 'RIEN_A_REFAIRE', { remede: null }); + await refus(montage.seance.nommer(' '), 'NOM_REQUIS', { remede: null }); + await refus(montage.seance.revenir(99), 'INSTANT_NON_RESTITUABLE', { revision: 99, remede: null }); + await refus(montage.seance.ecrireAilleurs(DOC), 'SUPPORT_INTACT', { remede: null }); + }); +}); + +describe('séance observable : fautives et examen (§ 8.9, A2)', () => { + test("les propositions que l'ouverture écarte restent dans fautives après un geste, et partent à la fermeture ; l'examen d'après un geste n'en porte aucune", async () => { + const charge = chargeEssai(); + charge.prochainsIds.proposition = 2; + charge.propositions = [ + { + id: 1, + graine: 7, + arret: 40, + historique: 1, + produitVersion: V1, + siegesAttribues: false, + tables: [1, 2], + capacites: [2, 2], + tours: 2, + participants: [1, 2, 3, 4], + placement: [ + { sieges: [[1, 2, 3], [4]], reserve: [] }, + { sieges: [[1, 3], [2, 4]], reserve: [] }, + ], + }, + ]; + const { seance } = await ouvertSur(charge); + const { fautives } = seance.etat(); + assert.deepEqual(fautives.map(({ rang, id }) => [rang, id]), [[0, 1]]); + assert.equal(fautives[0].fautes[0].code, 'LONGUEUR'); + assert.deepEqual(seance.etat().examen.fautives, fautives); + await seance.passerEnEcriture(); + await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); + assert.deepEqual(seance.etat().fautives, fautives); + assert.deepEqual(seance.etat().examen.fautives, []); + await seance.fermer(); + assert.deepEqual(seance.etat().fautives, []); + }); +}); + +describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)', () => { + test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => { + const fs = creerFichiersSimules(); + const charge = reserverGrisaille(avecRetenu(chargeEssai())); + charge.participants[3].exclu = true; + await deposerEssai(fs, charge); + const montage = monter({ fs }); + await ouvrirSansEcrire(montage); + const { seance } = montage; + const viole = { + code: 'INVARIANT_VIOLE', + details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }] }, + }; + assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, viole]); + assert.deepEqual(codesDe(seance.ouvert.avertissements), ['DERIVE']); + await seance.passerEnEcriture(); + const renomme = await seance.executer('modifierParticipant', { id: 3, champs: { nom: 'Pervenche-Ombrelle' } }); + assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, viole, ...renomme.avertissements]); + for (const retour of [() => seance.defaire(), () => seance.refaire(), () => seance.revenir(1)]) { + await retour(); + assert.ok(seance.etat().avertissements.some((a) => a.code === 'INVARIANT_VIOLE'), String(retour)); + assert.deepEqual(seance.etat().avertissements.find((a) => a.code === 'INVARIANT_VIOLE'), viole); + } + const exclusion = await seance.executer('exclureParticipant', { id: 1 }); + assert.deepEqual(codesDe(exclusion.avertissements), ['RESERVATIONS_SUSPENDUES', 'DERIVE_NOUVELLE']); + assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, viole, ...exclusion.avertissements]); + }); + + test("un retenu aux surnuméraires versés en réserve s'ouvre sans INVARIANT_VIOLE", async () => { + const charge = avecRetenu(chargeEssai()); + 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]; + const { seance } = await ouvertSur(charge); + assert.deepEqual(codesDe(seance.etat().avertissements), []); + }); +}); + +describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2)", () => { + const TRACES_A2 = [/\bundefined\b/, /\bnull\b/, /\bNaN\b/, /\b(?:true|false)\b/, /\[object /]; + const EXEMPLES_A2 = [ + ['RETOUR_AUTOMATIQUE', {}], + ['INVARIANT_VIOLE', { proposition: 3, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }, { code: 'DOUBLE_PLACE', participant: 4, tour: 2 }] }], + ['DERIVE_NOUVELLE', { touchees: [{ id: 2, raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 3 }] }, { id: 'retenu', raisons: [{ code: 'TABLE_AJOUTEE', table: 5 }] }] }], + ['RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 }], + ['CONFIGURATION_INCOHERENTE', { code: 'RESERVATION_CONFLIT', details: { participant: 1, tour: 1, tables: [1, 2] } }], + ['PLACES_MANQUANTES', { placesManquantes: 4 }], + ['SURRESERVATION', { table: 3, tour: 2, reservees: 9, capacite: 8 }], + ]; + + test('chaque code se compose de ses détails, sans trace de valeur absente', () => { + assert.equal(EXEMPLES_A2.length, 7); + for (const [code, details] of EXEMPLES_A2) { + const texte = libelle(code, details); + assert.ok(texte.trim().length > 0 && texte !== code, code); + for (const trace of TRACES_A2) assert.doesNotMatch(texte, trace, `${code} : « ${texte} »`); + } + }); + + test('le manque dit son nombre ; la surréservation nomme la table, le tour, les réservées et la capacité', () => { + assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 1 }), /\b1 place\b/); + assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 4 }), /\b4 places\b/); + const texte = libelle('SURRESERVATION', { table: 3, tour: 2, reservees: 9, capacite: 8 }); + for (const motif of [/identifiant 3\b/, /tour 2\b/, /\b9 personnes\b/, /\b8 places\b/]) assert.match(texte, motif); + }); + + test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou le dit d'une séance interrompue", () => { + const derive = libelle('DERIVE_NOUVELLE', EXEMPLES_A2[2][1]); + assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive); + assert.match(derive, /n° 2/); + assert.match(derive, /retenu/); + const viole = libelle('INVARIANT_VIOLE', EXEMPLES_A2[1][1]); + assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole); + assert.match(viole, /n° 3/); + assert.match(libelle('VERROU_REPRIS', { seance: 's', depuis: null }), /interrompue/); + assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/); + }); +}); diff --git a/src/csv/apercu.js b/src/csv/apercu.js index bb82f1f..e3e83d5 100644 --- a/src/csv/apercu.js +++ b/src/csv/apercu.js @@ -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[]} */ diff --git a/src/csv/import.js b/src/csv/import.js index 753c337..602ab84 100644 --- a/src/csv/import.js +++ b/src/csv/import.js @@ -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}>} + */ +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. diff --git a/src/csv/import.test.js b/src/csv/import.test.js index 6736247..a107470 100644 --- a/src/csv/import.test.js +++ b/src/csv/import.test.js @@ -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')], diff --git a/src/stockage/operations.js b/src/stockage/operations.js index 9dcd086..2e9c223 100644 --- a/src/stockage/operations.js +++ b/src/stockage/operations.js @@ -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`);