gestion_table_tournante_libre/src/application/registre.test.js

1057 lines
47 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes, éprouvé en entier (§ 2.1, § 8.2, § 8.3, § 8.4,
// § 9, § 5.8, § 17 point 3, § 14.12, § 14.15) : chaque commande de COMMANDES
// passe par la séance, sur le système de fichiers d'épreuve et l'horloge
// pilotée, et l'épreuve relève ce qui s'écrit.
//
// La table EXEMPLES donne, par commande, un exemple accepté, un exemple
// refusé — un refus du domaine — et un exemple sans effet ; une commande qui
// n'a pas l'un d'eux y nomme sa raison. Un exemple porte ses arguments, ou la
// fonction qui les tire de la charge où il s'exécute, et les gestes qui le
// préparent (avant), exécutés d'abord, chacun une entrée. REMEDE_NUL_ADMIS
// nomme les refus dont le remède est nul, avec la raison : la valeur se
// corrige dans le champ même, ou aucun geste ne lève le refus.
//
// Deux événements servent de départ, construits une fois par la façade et la
// séance, puis copiés fichier pour fichier dans un système neuf pour chaque
// épreuve : un événement vide, créé, et la charge témoin — la petite
// démonstration chargée, « Modifier », une désignation, deux propositions
// générées sur l'exécuteur direct, la seconde retenue. Ni l'un ni l'autre
// n'est un fichier d'épreuve : la démonstration porte des noms du réservoir
// du générateur, et une copie dériverait d'elle.
//
// La trace des écritures enveloppe ajouterLigne et ecrireAtomique du système
// d'épreuve : une ligne d'entrée du journal, ou le journal réécrit entier,
// compte pour l'entrée ; une ligne de jalon ne compte pas. Défaire rend la
// charge d'avant, ses compteurs d'identifiants exceptés : la séance les porte
// au plus grand de leur valeur restituée et de leur valeur courante (§ 8.9),
// et l'épreuve compare la charge d'avant sous les compteurs rendus. 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 { apercevoir } from '../csv/apercu.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { LIVREES } from '../demo/livrees.js';
import { normaliser } from '../moteur/configuration.js';
import { rechercher } from '../moteur/recherche.js';
import { verifierInvariants } from '../moteur/verification.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
import { lireJournal, reconstruire } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { COMMANDES } from './commandes.js';
import { creerFacade } from './evenements.js';
import { creerControleurGeneration, creerExecuteurDirect } from './generation.js';
import { creerHorlogePilotee } from './horloge.js';
import { libelle } from './libelles.js';
import { nomAffiche } from './personnes.js';
import { creerSeance } from './seance.js';
const V = VERSION.affichee;
const DEPART = '2031-03-14T18:00:00-04:00';
const NOM_VIDE = 'Soirée des Hérons';
const TITRE = 'Animation des Hérons';
const AUTRE_TITRE = 'Accueil des Hérons';
const DEMONSTRATIONS = LIVREES.map(({ cle, texte }) => ({
cle,
nom: CATALOGUE.find((entree) => entree.cle === cle).nom,
texte,
}));
// --- Lectures de la charge ---------------------------------------------------
const participantDe = (charge, id) => charge.participants.find((personne) => personne.id === id);
const tableDe = (charge, id) => charge.tables.find((table) => table.id === id);
const derniereTable = (charge) => Math.max(...charge.tables.map(({ id }) => id));
const croissant = (a, b) => a - b;
// Les personnes que le retenu assied au tour à la table d'identifiant
// idTable, sans réservation, par identifiant croissant.
function libresA(charge, tour, idTable) {
const reservees = new Set(charge.reservations.map(({ participant }) => participant));
const rang = charge.retenu.tables.indexOf(idTable);
return charge.retenu.placement[tour - 1].sieges[rang].filter((id) => id !== null && !reservees.has(id)).sort(croissant);
}
// Les violations de verifierInvariants du retenu de la charge, la réserve
// admise (§ 12.6) ; [] sur une configuration qui ne se normalise pas.
function violationsDuRetenu(charge, { reserveAutorisee = true } = {}) {
let instance;
try {
instance = normaliser(configurationDepuisCharge(charge));
} catch {
return [];
}
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee });
}
// Les violations de apres que avant ne porte pas, comparées champ pour champ.
function violationsNouvelles(avant, apres) {
const connues = new Set(violationsDuRetenu(avant).map((violation) => JSON.stringify(violation)));
return violationsDuRetenu(apres).filter((violation) => !connues.has(JSON.stringify(violation)));
}
// --- La table des exemples ---------------------------------------------------
// Les refus admis à remède null, et pourquoi.
const REMEDE_NUL_ADMIS = new Map([
['MOINS_DE_DEUX', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['NON_ENTIER', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['MOINS_D_UN_TOUR', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['LIBELLE_VIDE', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['NOM_REQUIS', 'le nom se saisit dans le champ même'],
['PARTICIPANT_INCONNU', "une personne retirée entre-temps : aucun geste ne la rend"],
['PROPOSITION_INCONNUE', "une proposition effacée entre-temps : aucun geste ne la rend"],
['COMPTEUR_SATURE', "un compteur au bout de sa règle : aucun geste ne le fait reculer"],
['PLAN_NON_BLOQUE', 'il ne reste rien à débloquer'],
['NOM_NON_ASSOCIE', "un refus du CSV relayé par importerParticipants : il se lève dans le fichier ou l'association des colonnes"],
['RESERVATION_ABSENTE', "la réservation visée est absente, aucune commande ne la produit, et l'interface n'offre le retrait que sur une réservation présente"],
['TITRE_ABSENT', "le titre visé est absent, aucune commande ne le produit, et l'interface n'offre le retrait que sur un titre présent"],
['CONVERSION_IMPOSSIBLE', "les réservations « tour » visées sont absentes, aucune commande ne les produit, et l'interface n'offre la conversion que sur des réservations présentes"],
]);
// Le geste qui pose deux places de plus à la table 2, par son champ.
const QUATRE_PLACES_A_LA_TABLE_2 = ['changerPlacesTable', { id: 2, sieges: '4', origine: 'champ' }];
// La personne libre de la table 2 au tour 1, retirée dans la réserve du tour.
const RETIRER_UN_LIBRE_DE_LA_TABLE = (idTable) => [
'retirerDuTour',
(charge) => ({ tour: 1, participant: libresA(charge, 1, idTable)[0] }),
];
/**
* Par commande : accepte, refuse, sansEffet, chacun { args, avant?, sur? }
* — args, un objet ou une fonction de la charge où l'exemple s'exécute ;
* avant, des gestes [nom, args] qui le préparent ; sur, 'vide' pour partir
* de l'événement vide plutôt que de la charge témoin —, ou une chaîne, la
* raison de son absence. refuse porte code, le refus attendu, et remede,
* le remède attendu, quand l'exemple le fixe. personne, une
* fonction des arguments de l'exemple accepté : l'identifiant de la personne
* que son libellé nomme.
*/
const EXEMPLES = new Map([
[
'ajouterParticipant',
{
accepte: { args: { champs: { nom: 'Ombrelle', prenom: 'Iris' } } },
refuse: { args: { champs: { nom: ' ' } }, code: 'NOM_REQUIS' },
sansEffet: 'ajouter crée toujours une personne, sous un identifiant neuf',
},
],
[
'modifierParticipant',
{
accepte: { args: { id: 2, champs: { notes: 'Arrive au deuxième tour' } } },
personne: () => 2,
refuse: { args: { id: 2, champs: { nom: '' } }, code: 'NOM_REQUIS' },
sansEffet: { args: (charge) => ({ id: 2, champs: { nom: participantDe(charge, 2).nom } }) },
},
],
[
'exclureParticipant',
{
accepte: { args: { id: 2 } },
personne: () => 2,
refuse: { args: { id: 999 }, code: 'PARTICIPANT_INCONNU' },
sansEffet: { avant: [['exclureParticipant', { id: 2 }]], args: { id: 2 } },
},
],
[
'reintegrerParticipant',
{
accepte: { avant: [['exclureParticipant', { id: 2 }]], args: { id: 2 } },
personne: () => 2,
refuse: {
avant: [
['exclureParticipant', { id: 1 }],
['reserver', { participant: 3, table: 1, siege: 1, portee: 'tous', tour: null }],
],
args: { id: 1 },
code: 'PLACE_RESERVEE',
},
sansEffet: { args: { id: 2 } },
},
],
[
'supprimerParticipant',
{
accepte: { args: { id: 2 } },
personne: () => 2,
refuse: { args: { id: 999 }, code: 'PARTICIPANT_INCONNU' },
sansEffet: 'supprimer retire toujours une personne ; une personne absente est PARTICIPANT_INCONNU',
},
],
[
'importerParticipants',
{
accepte: {
args: (charge) => ({
apercu: apercevoir('nom;prenom\nPervenche;Théo\n', { participants: charge.participants }),
mode: 'ajouter',
}),
},
refuse: {
args: (charge) => ({
apercu: apercevoir('prenom;courriel\nIris;iris@exemple.test\n', { participants: charge.participants }),
mode: 'ajouter',
}),
code: 'NOM_NON_ASSOCIE',
},
sansEffet: {
args: (charge) => {
const { nom, prenom } = participantDe(charge, 2);
const texte = `nom;prenom\n${nom};${prenom ?? ''}\n`;
return { apercu: apercevoir(texte, { participants: charge.participants, mode: 'mettreAJour' }), mode: 'mettreAJour' };
},
},
},
],
[
'changerEtat',
{
accepte: { args: { etat: 'propose' } },
refuse: { sur: 'vide', args: { etat: 'propose' }, code: 'AUCUNE_PROPOSITION' },
sansEffet: { args: { etat: 'retenu' } },
},
],
[
'debloquer',
{
accepte: 'debloquer passe par seance.debloquer() sur un plan bloqué, éprouvé à part',
refuse: { args: {}, code: 'PLAN_NON_BLOQUE' },
sansEffet: "débloquer un plan qui n'est pas bloqué est son refus, PLAN_NON_BLOQUE",
},
],
[
'enregistrerGeneration',
{
accepte: {
args: (charge) => {
const configuration = configurationDepuisCharge(charge);
const { historique } = charge.reglages.generation;
const propositions = rechercher(configuration, { graine: 7, nombre: 1, arret: 200, historique });
return { propositions, configuration, produitVersion: V };
},
},
refuse:
"ses refus d'avant lancement appartiennent au contrôleur de génération ; la commande ne refuse que " +
"COMPTEUR_SATURE, un compteur au bout de sa règle, qu'aucune charge témoin n'atteint",
sansEffet: { args: (charge) => ({ propositions: [], configuration: configurationDepuisCharge(charge), produitVersion: V }) },
},
],
[
'retenirProposition',
{
accepte: { args: { id: 1 } },
refuse: { args: { id: 99 }, code: 'PROPOSITION_INCONNUE' },
sansEffet: { args: { id: 2 } },
},
],
[
'effacerPropositions',
{
accepte: { args: {} },
refuse: "effacer ne refuse rien du domaine : une liste réduite à la proposition du retenu est sans effet",
sansEffet: { avant: [['effacerPropositions', {}]], args: {} },
},
],
[
'creerTable',
{
accepte: { args: { forme: 'carree' } },
refuse: "créer une table ne refuse que COMPTEUR_SATURE, un compteur au bout de sa règle, qu'aucune charge témoin n'atteint",
sansEffet: 'créer ajoute toujours une table, sous un identifiant neuf',
},
],
[
'supprimerTable',
{
accepte: { args: { id: 4 } },
refuse: "supprimer une table ne refuse rien du domaine ; une table inconnue est une faute de l'appelant (TypeError)",
sansEffet: 'supprimer retire toujours une table',
},
],
[
'deplacerTable',
{
accepte: { args: { id: 2, position: { x: 420, y: -60 } } },
refuse: 'une table se pose partout : déplacer ne refuse rien du domaine',
sansEffet: { args: (charge) => ({ id: 2, position: { ...tableDe(charge, 2).position } }) },
},
],
[
'changerPlacesTable',
{
accepte: { args: QUATRE_PLACES_A_LA_TABLE_2[1] },
refuse: { args: { id: 2, sieges: '2', origine: 'poignee' }, code: 'SOUS_OCCUPES' },
sansEffet: { avant: [QUATRE_PLACES_A_LA_TABLE_2], args: QUATRE_PLACES_A_LA_TABLE_2[1] },
},
],
[
'realignerTable',
{
accepte: { avant: [QUATRE_PLACES_A_LA_TABLE_2], args: { id: 2 } },
refuse: {
avant: [QUATRE_PLACES_A_LA_TABLE_2, ['poserTitre', { table: 2, siege: 4, libelle: AUTRE_TITRE }]],
args: { id: 2 },
code: 'SIEGES_RETIRES',
},
sansEffet: { args: { id: 2 } },
},
],
[
'changerDefaut',
{
accepte: { args: { sieges: '4', realigner: false } },
refuse: {
avant: [['poserTitre', { table: 2, siege: 3, libelle: AUTRE_TITRE }]],
args: { sieges: '2', realigner: false },
code: 'SIEGES_RETIRES',
},
sansEffet: { args: { sieges: '3', realigner: false } },
},
],
[
'changerTours',
{
accepte: { args: { tours: '3' } },
refuse: { args: { tours: '0' }, code: 'MOINS_D_UN_TOUR' },
sansEffet: { args: { tours: '4' } },
},
],
[
'modifierReglage',
{
accepte: { args: { cle: 'nouveauxVoisins', valeur: false } },
refuse: "une clé ou une valeur hors de leur domaine est une faute du code (TypeError) : l'interface n'offre que les réglages admis",
sansEffet: { args: { cle: 'attribuerSieges', valeur: false } },
},
],
[
'reserver',
{
accepte: { args: { participant: 5, table: 2, siege: null, portee: 'tour', tour: 2 } },
personne: () => 5,
refuse: { args: { participant: 3, table: 1, siege: 1, portee: 'tous', tour: null }, code: 'PLACE_RESERVEE' },
sansEffet: { args: { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null } },
},
],
[
'retirerReservation',
{
accepte: { args: { participant: 1, table: 1, portee: 'tous', tour: null } },
personne: () => 1,
refuse: { args: { participant: 2, table: 1, portee: 'tous', tour: null }, code: 'RESERVATION_ABSENTE' },
sansEffet: 'une réservation absente est RESERVATION_ABSENTE ; retirer en retire toujours une',
},
],
[
'deplacerReservation',
{
accepte: { args: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 1, siege: 2 } } },
personne: () => 1,
refuse: {
avant: [['reserver', { participant: 5, table: 2, siege: 2, portee: 'tous', tour: null }]],
args: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 2 } },
code: 'PLACE_RESERVEE',
},
sansEffet: { args: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 1, siege: 1 } } },
},
],
[
'poserTitre',
{
accepte: { args: { table: 2, siege: null, libelle: AUTRE_TITRE } },
refuse: { args: { table: 1, siege: 1, libelle: AUTRE_TITRE }, code: 'PLACE_TITREE' },
sansEffet: 'une place déjà titrée refuse PLACE_TITREE : poser ajoute toujours un titre',
},
],
[
'retirerTitre',
{
accepte: { args: { table: 1, siege: 1 } },
refuse: { args: { table: 2, siege: 1 }, code: 'TITRE_ABSENT' },
sansEffet: 'un titre absent est TITRE_ABSENT ; retirer en retire toujours un',
},
],
[
'designer',
{
accepte: { args: { participant: 5, table: 2, siege: null, libelle: AUTRE_TITRE } },
personne: () => 5,
refuse: { args: { participant: 5, table: 1, siege: 1, libelle: AUTRE_TITRE }, code: 'PLACE_RESERVEE' },
sansEffet: { args: { participant: 1, table: 1, siege: 1, libelle: TITRE } },
},
],
[
'convertirEnTous',
{
accepte: {
avant: [1, 2, 3, 4].map((tour) => ['reserver', { participant: 5, table: 2, siege: null, portee: 'tour', tour }]),
args: { participant: 5, table: 2 },
},
personne: () => 5,
refuse: { args: { participant: 5, table: 2 }, code: 'CONVERSION_IMPOSSIBLE' },
sansEffet: 'hors des conversions possibles, CONVERSION_IMPOSSIBLE ; convertir remplace toujours les réservations « tour » par une',
},
],
[
'deplacerDansRetenu',
{
accepte: {
avant: [['creerTable', { forme: 'ronde' }]],
args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0], table: derniereTable(charge), siege: null }),
},
personne: (args) => args.participant,
refuse: { args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0], table: 3, siege: null }), code: 'TABLE_PLEINE' },
sansEffet: { args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0], table: 2, siege: null }) },
},
],
[
'echangerDansRetenu',
{
accepte: { args: (charge) => ({ tour: 1, a: libresA(charge, 1, 2)[0], b: libresA(charge, 1, 3)[0] }) },
personne: (args) => args.a,
refuse: { args: (charge) => ({ tour: 1, a: 1, b: libresA(charge, 1, 2)[0] }), code: 'RESERVEE_AILLEURS' },
sansEffet: {
avant: [RETIRER_UN_LIBRE_DE_LA_TABLE(2), RETIRER_UN_LIBRE_DE_LA_TABLE(3)],
args: (charge) => {
const [a, b] = charge.retenu.placement[0].reserve;
return { tour: 1, a, b };
},
},
},
],
[
'retirerDuTour',
{
accepte: { args: (charge) => ({ tour: 1, participant: libresA(charge, 1, 2)[0] }) },
personne: (args) => args.participant,
refuse: {
args: { tour: 1, participant: 1 },
code: 'INVARIANT_VIOLE',
remede: { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } },
},
sansEffet: {
avant: [RETIRER_UN_LIBRE_DE_LA_TABLE(2)],
args: (charge) => ({ tour: 1, participant: charge.retenu.placement[0].reserve[0] }),
},
},
],
]);
// Les retouches du retenu, que le vérificateur d'invariants juge (§ 14.12).
const RETOUCHES = ['deplacerDansRetenu', 'echangerDansRetenu', 'retirerDuTour'];
// Les arguments d'un exemple sur la charge où il s'exécute.
const argumentsDe = (args, charge) => (typeof args === 'function' ? args(charge) : structuredClone(args));
// --- Le montage --------------------------------------------------------------
function creerIdentifiants(prefixe) {
let n = 0;
return () => {
n += 1;
return `${prefixe}-${n}`;
};
}
// La façade sur un système neuf, branchée sur une séance comme App la
// branche : changerRacine crée la séance sur la racine reçue.
function monterFacade() {
const fs = creerFichiersSimules();
const horloge = creerHorlogePilotee({ depart: DEPART });
const identifiant = creerIdentifiants('montage');
const monte = { fs, horloge, seance: null, racine: null };
monte.facade = creerFacade({
fs,
horloge,
identifiant,
produitVersion: V,
demonstrations: DEMONSTRATIONS,
ouvert: () => monte.seance?.ouvert?.base ?? null,
ouvrir: (base) => monte.seance.ouvrir(base),
fermer: () => monte.seance.fermer(),
creer: (champs) => monte.seance.creer(champs),
racine: () => monte.racine,
changerRacine: async (nouvelle) => {
monte.racine = nouvelle;
monte.seance = creerSeance({
fs,
racine: nouvelle,
horloge,
identifiant,
produitVersion: V,
planifier: horloge.planifier,
annuler: horloge.annuler,
});
},
});
return monte;
}
// Les fichiers de l'événement base sur le disque : [chemin, texte].
function fichiersDe(fs, racine, base) {
return [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal]
.map((suffixe) => [base + suffixe, fs.contenu(racine.id, base + suffixe)])
.filter(([, texte]) => texte !== null);
}
let departs = null;
// Les deux événements de départ, construits au premier appel puis partagés :
// { vide, temoin }, chacun { racine, base, fichiers }.
function departsPrets() {
departs ??= (async () => {
const monte = monterFacade();
const { facade, fs, horloge } = monte;
await facade.demarrer();
const { base: baseVide } = await facade.creer({ nom: NOM_VIDE });
const vide = { racine: monte.racine, base: baseVide, fichiers: fichiersDe(fs, monte.racine, baseVide) };
const { base } = await facade.chargerDemonstration('petite');
const { seance } = monte;
await seance.passerEnEcriture();
await seance.executer('designer', { participant: 1, table: 1, siege: null, libelle: TITRE });
const controleur = creerControleurGeneration({ seance, executeur: creerExecuteurDirect(), horloge, produitVersion: V });
const { historique } = seance.charge.reglages.generation;
await controleur.generer({ graine: 1, nombre: 2, arret: 2000, historique }, { confirmee: true });
await seance.executer('retenirProposition', { id: 2 });
await seance.passerEnLecture();
const { charge } = seance;
assert.deepEqual(
charge.propositions.map(({ id }) => id),
[1, 2],
);
assert.equal(charge.retenu.proposition, 2);
assert.equal(charge.evenement.etat, 'retenu');
assert.ok(charge.prochainsIds.proposition > Math.max(...charge.propositions.map(({ id }) => id), charge.retenu.proposition));
assert.deepEqual(violationsDuRetenu(charge), []);
const temoin = { racine: monte.racine, base, fichiers: fichiersDe(fs, monte.racine, base) };
return { vide, temoin };
})();
return departs;
}
// Une séance neuve, sur un système neuf qui porte une copie des fichiers du
// départ, l'événement ouvert en lecture ; en écriture sur demande.
async function ouvrirCopie(depart, { ecriture = false } = {}) {
const fs = creerFichiersSimules();
for (const [chemin, texte] of depart.fichiers) fs.deposer(depart.racine.id, chemin, texte);
const horloge = creerHorlogePilotee({ depart: DEPART });
const seance = creerSeance({
fs,
racine: depart.racine,
horloge,
identifiant: creerIdentifiants('copie'),
produitVersion: V,
planifier: horloge.planifier,
annuler: horloge.annuler,
});
await seance.ouvrir(depart.base);
if (ecriture) await seance.passerEnEcriture();
return { fs, seance, racine: depart.racine, base: depart.base };
}
// Le même dossier rouvert par une autre séance, en lecture : l'avertissement
// d'ouverture se calcule sur le fichier écrit, sans l'état de la première.
async function rouvrir({ fs, racine, base }, prefixe) {
const horloge = creerHorlogePilotee({ depart: DEPART });
const seance = creerSeance({
fs,
racine,
horloge,
identifiant: creerIdentifiants(prefixe),
produitVersion: V,
planifier: horloge.planifier,
annuler: horloge.annuler,
});
await seance.ouvrir(base);
return seance;
}
// La même copie, son plan bloqué par changerEtat : la séance revient en
// lecture.
async function ouvrirBloque(depart) {
const ouvert = await ouvrirCopie(depart, { ecriture: true });
await ouvert.seance.executer('changerEtat', { etat: 'bloque' });
assert.equal(ouvert.seance.mode, 'lecture');
assert.equal(ouvert.seance.charge.evenement.etat, 'bloque');
return ouvert;
}
// La trace des écritures de l'état et du journal : [primitive, chemin,
// sorte de ligne] ; la sorte, pour une ligne ajoutée au journal.
function tracer(fs) {
const trace = [];
const ajouterLigne = fs.ajouterLigne;
fs.ajouterLigne = (racine, chemin, ligne, ...reste) => {
trace.push(['ajouterLigne', chemin, JSON.parse(ligne).type]);
return ajouterLigne(racine, chemin, ligne, ...reste);
};
const ecrireAtomique = fs.ecrireAtomique;
fs.ecrireAtomique = (racine, chemin, ...reste) => {
trace.push(['ecrireAtomique', chemin, null]);
return ecrireAtomique(racine, chemin, ...reste);
};
return trace;
}
// La trace réduite à ce qui porte l'entrée, le .precedent et l'état, dans
// l'ordre : 'entree' pour une ligne d'entrée ou le journal réécrit entier.
function ordreDesEcritures(trace, base) {
const sortes = [];
for (const [primitive, chemin, sorte] of trace) {
if (chemin === base + SUFFIXES.journal && (primitive === 'ecrireAtomique' || sorte === 'entree')) sortes.push('entree');
if (chemin === base + SUFFIXES.precedent) sortes.push('precedent');
if (chemin === base + SUFFIXES.etat) sortes.push('etat');
}
return sortes;
}
const journalDe = ({ fs, racine, base }) => lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal));
const chargeEcrite = ({ fs, racine, base }) => analyser(fs.contenu(racine.id, base + SUFFIXES.etat)).charge;
// Le refus que rejette la promesse, null quand elle aboutit.
async function refusDe(promesse) {
try {
await promesse;
return null;
} catch (erreur) {
return erreur;
}
}
// Exécute les gestes qui préparent un exemple, chacun sur la charge courante.
async function preparer(seance, avant = []) {
for (const [nom, args] of avant) await seance.executer(nom, argumentsDe(args, seance.charge));
}
const invariantsAvertis = (seance) => seance.etat().avertissements.filter(({ code }) => code === 'INVARIANT_VIOLE');
// --- La table ----------------------------------------------------------------
describe('registre : la table des exemples couvre chaque commande', () => {
test('le registre porte des commandes, et chacune a son entrée, un exemple ou la raison de son absence', () => {
const noms = [...COMMANDES.keys()];
assert.ok(noms.length > 0, 'le registre COMMANDES est vide');
assert.deepEqual(
noms.filter((nom) => !EXEMPLES.has(nom)),
[],
'commandes du registre sans entrée dans la table',
);
assert.deepEqual(
[...EXEMPLES.keys()].filter((nom) => !COMMANDES.has(nom)),
[],
'entrées de la table hors du registre',
);
const incompletes = [];
for (const [nom, exemple] of EXEMPLES) {
for (const cle of ['accepte', 'refuse', 'sansEffet']) {
const valeur = exemple[cle];
const donne = typeof valeur === 'string' ? valeur.trim() !== '' : valeur?.args !== undefined;
if (!donne) incompletes.push(`${nom}.${cle}`);
}
if (typeof exemple.refuse === 'object' && typeof exemple.refuse.code !== 'string') incompletes.push(`${nom}.refuse.code`);
}
assert.deepEqual(incompletes, []);
});
});
// --- En lecture, et sur un plan bloqué ------------------------------------------
// Pour chaque commande hors debloquer, le refus que la séance lève et le
// compte des écritures : [nom, problème] pour chaque écart.
async function balayerLesRefus(ouvert, attendu) {
const ecarts = [];
const temoin = chargeEcrite(await ouvrirCopie((await departsPrets()).temoin));
const noms = [...COMMANDES.keys()].filter((nom) => nom !== 'debloquer');
assert.ok(noms.length > 0);
for (const nom of noms) {
const exemple = EXEMPLES.get(nom);
const source = typeof exemple?.accepte === 'object' ? exemple.accepte : exemple?.refuse;
let args = {};
try {
args = argumentsDe(source?.args ?? {}, temoin);
} catch {
args = {};
}
const ecritures = ouvert.fs.compteurs.ecritures;
const erreur = await refusDe(ouvert.seance.executer(nom, args));
const ecart = attendu(erreur);
if (ecart !== null) ecarts.push([nom, ecart]);
if (ouvert.fs.compteurs.ecritures !== ecritures) ecarts.push([nom, `${ouvert.fs.compteurs.ecritures - ecritures} écriture(s)`]);
}
return ecarts;
}
const attenduModeLecture = (erreur) => {
if (erreur === null) return 'accepté en lecture';
if (erreur.code !== 'MODE_LECTURE') return `refusé ${erreur.code} au lieu de MODE_LECTURE`;
return JSON.stringify(erreur.details.remede) === JSON.stringify({ geste: 'modifier' }) ? null : 'remède autre que modifier';
};
const attenduBloque = (erreur) => {
if (erreur === null) return 'accepté sur un plan bloqué';
if (erreur.code !== 'LECTURE_SEULE' || erreur.details.raison !== 'BLOQUE') {
return `refusé ${erreur.code} ${JSON.stringify(erreur.details.raison ?? null)} au lieu de LECTURE_SEULE BLOQUE`;
}
return JSON.stringify(erreur.details.remede) === JSON.stringify({ geste: 'debloquer' }) ? null : 'remède autre que debloquer';
};
describe('registre : en lecture et sur un plan bloqué, aucune commande n’écrit (§ 2.1, § 8.4, § 9)', () => {
test('en lecture, sur un événement vide, chaque commande est refusée MODE_LECTURE sans écriture', async () => {
const ouvert = await ouvrirCopie((await departsPrets()).vide);
assert.deepEqual(await balayerLesRefus(ouvert, attenduModeLecture), []);
});
test('en lecture, sur la charge témoin retenue, chaque commande est refusée MODE_LECTURE sans écriture', async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin);
assert.deepEqual(await balayerLesRefus(ouvert, attenduModeLecture), []);
});
test('sur un plan bloqué vide, toujours en lecture, chaque commande est refusée LECTURE_SEULE BLOQUE sans écriture', async () => {
const ouvert = await ouvrirBloque((await departsPrets()).vide);
assert.deepEqual(await balayerLesRefus(ouvert, attenduBloque), []);
});
test('sur la charge témoin bloquée, toujours en lecture, chaque commande est refusée LECTURE_SEULE BLOQUE sans écriture', async () => {
const ouvert = await ouvrirBloque((await departsPrets()).temoin);
assert.deepEqual(await balayerLesRefus(ouvert, attenduBloque), []);
});
});
describe('registre : debloquer (§ 8.5, § 9)', () => {
test('seance.debloquer() sur un plan bloqué prend le verrou, écrit une entrée et un jalon, et laisse la séance en écriture', async () => {
const ouvert = await ouvrirBloque((await departsPrets()).temoin);
const { fs, seance, racine, base } = ouvert;
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
const journal = journalDe(ouvert);
const trace = tracer(fs);
await seance.debloquer();
const verrou = fs.contenu(racine.id, base + SUFFIXES.verrou);
assert.notEqual(verrou, null, 'aucun verrou pris');
assert.equal(JSON.parse(verrou).seance, seance.id);
const apres = journalDe(ouvert);
assert.equal(apres.entrees.length, journal.entrees.length + 1);
assert.equal(apres.jalons.length, journal.jalons.length + 1);
assert.deepEqual(ordreDesEcritures(trace, base), ['entree', 'precedent', 'etat']);
assert.equal(seance.mode, 'ecriture');
assert.equal(chargeEcrite(ouvert).evenement.etat, 'retenu');
});
});
// --- En écriture : l'exemple accepté -------------------------------------------
describe('registre : en écriture, un exemple accepté écrit une entrée, et défaire la rend (§ 8.2, § 8.3)', () => {
const acceptes = [...EXEMPLES].filter(([, exemple]) => typeof exemple.accepte === 'object');
assert.ok(acceptes.length > 0);
for (const [nom, exemple] of acceptes) {
test(`${nom} : une entrée, le journal d'abord, l'état reconstruit, puis défaire`, async () => {
const departsCourants = await departsPrets();
const ouvert = await ouvrirCopie(exemple.accepte.sur === 'vide' ? departsCourants.vide : departsCourants.temoin, {
ecriture: true,
});
const { fs, seance, base } = ouvert;
await preparer(seance, exemple.accepte.avant);
const avant = structuredClone(seance.charge);
const journalAvant = journalDe(ouvert);
const args = argumentsDe(exemple.accepte.args, avant);
const trace = tracer(fs);
const rendu = await seance.executer(nom, args);
assert.equal(typeof rendu.libelle, 'string');
const journal = journalDe(ouvert);
assert.equal(journal.entrees.length, journalAvant.entrees.length + 1, 'une entrée exactement');
assert.deepEqual(ordreDesEcritures(trace, base), ['entree', 'precedent', 'etat']);
const derniere = journal.entrees.at(-1);
const ecrite = chargeEcrite(ouvert);
assert.equal(serialiserCharge(reconstruire(journal, derniere.revision)), serialiserCharge(ecrite));
assert.equal(seance.mode, 'ecriture');
// Aucune commande ne répartit : les réservations ajoutées tiennent à
// une seule place (§ 5.8, § 17 point 3).
const connues = new Set(avant.reservations.map((r) => JSON.stringify(r)));
const ajoutees = ecrite.reservations.filter((r) => !connues.has(JSON.stringify(r)));
assert.ok(new Set(ajoutees.map(({ table, siege }) => `${table}:${siege}`)).size <= 1, JSON.stringify(ajoutees));
// Une retouche n'ajoute aucune violation au retenu reçu (§ 14.12).
if (RETOUCHES.includes(nom)) assert.deepEqual(violationsNouvelles(avant, ecrite), []);
await seance.defaire();
const defait = journalDe(ouvert);
assert.equal(defait.entrees.length, journal.entrees.length + 1, 'une entrée de retour');
assert.deepEqual(defait.entrees.slice(0, journal.entrees.length), journal.entrees, 'le journal ne perd aucune entrée');
assert.equal(defait.entrees.at(-1).sens, 'defaire');
const rendue = seance.charge;
for (const cle of ['participant', 'table', 'proposition']) {
assert.equal(rendue.prochainsIds[cle], Math.max(avant.prochainsIds[cle], ecrite.prochainsIds[cle]), cle);
}
assert.equal(serialiserCharge(rendue), serialiserCharge({ ...avant, prochainsIds: rendue.prochainsIds }));
// Le libellé figé ne suit pas un renommage (§ 8.2).
if (exemple.personne !== undefined) {
const id = exemple.personne(args);
const personne = participantDe(rendue, id);
assert.ok(derniere.libelle.includes(nomAffiche(personne)), `${derniere.libelle} ne nomme pas ${nomAffiche(personne)}`);
await seance.executer('modifierParticipant', { id, champs: { nom: 'Grisaille', prenom: 'Ondine' } });
const renomme = journalDe(ouvert).entrees.find(({ revision }) => revision === derniere.revision);
assert.equal(renomme.libelle, derniere.libelle);
}
});
}
});
// --- En écriture : l'exemple refusé et l'exemple sans effet ------------------------------
describe('registre : un exemple refusé n’écrit rien et nomme son remède (§ 2.1, garantie 3)', () => {
const refuses = [...EXEMPLES].filter(([, exemple]) => typeof exemple.refuse === 'object');
assert.ok(refuses.length > 0);
for (const [nom, exemple] of refuses) {
test(`${nom} : refusé ${exemple.refuse.code}, sans écriture`, async () => {
const departsCourants = await departsPrets();
const ouvert = await ouvrirCopie(exemple.refuse.sur === 'vide' ? departsCourants.vide : departsCourants.temoin, {
ecriture: true,
});
const { fs, seance } = ouvert;
await preparer(seance, exemple.refuse.avant);
const args = argumentsDe(exemple.refuse.args, seance.charge);
const ecritures = fs.compteurs.ecritures;
const entrees = journalDe(ouvert).entrees.length;
const erreur = await refusDe(seance.executer(nom, args));
assert.notEqual(erreur, null, 'accepté');
assert.equal(erreur.code, exemple.refuse.code, erreur.message);
if (erreur.code === 'INVARIANT_VIOLE') {
// La forme du refus de génération : le retenu nommé, et les seules
// violations que la retouche ajouterait.
assert.equal(erreur.details.proposition, seance.charge.retenu.proposition);
assert.ok(erreur.details.violations.length > 0);
const connues = new Set(violationsDuRetenu(seance.charge).map((violation) => JSON.stringify(violation)));
assert.deepEqual(
erreur.details.violations.filter((violation) => connues.has(JSON.stringify(violation))),
[],
);
}
if (Object.hasOwn(exemple.refuse, 'remede')) assert.deepEqual(erreur.details.remede, exemple.refuse.remede);
assert.equal(fs.compteurs.ecritures, ecritures, 'écriture');
assert.equal(journalDe(ouvert).entrees.length, entrees);
});
}
test('chaque refus du domaine nomme son remède, hors des refus admis à remède null', async () => {
const ecarts = [];
for (const [nom, exemple] of refuses) {
const departsCourants = await departsPrets();
const { seance } = await ouvrirCopie(exemple.refuse.sur === 'vide' ? departsCourants.vide : departsCourants.temoin, {
ecriture: true,
});
await preparer(seance, exemple.refuse.avant);
const erreur = await refusDe(seance.executer(nom, argumentsDe(exemple.refuse.args, seance.charge)));
if (erreur === null) {
ecarts.push(`${nom} : accepté`);
} else if (!Object.hasOwn(erreur.details ?? {}, 'remede')) {
ecarts.push(`${nom} : ${erreur.code} sans details.remede`);
} else if (erreur.details.remede === null && !REMEDE_NUL_ADMIS.has(erreur.code)) {
ecarts.push(`${nom} : ${erreur.code} au remède null`);
}
}
assert.deepEqual(ecarts, []);
});
});
describe('registre : un exemple sans effet est refusé SANS_EFFET {commande}, sans entrée ni état (§ 8.2)', () => {
const sansEffet = [...EXEMPLES].filter(([, exemple]) => typeof exemple.sansEffet === 'object');
assert.ok(sansEffet.length > 0);
for (const [nom, exemple] of sansEffet) {
test(`${nom} : SANS_EFFET, remède null, rien n'écrit`, async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { fs, seance, racine, base } = ouvert;
await preparer(seance, exemple.sansEffet.avant);
const args = argumentsDe(exemple.sansEffet.args, seance.charge);
const ecritures = fs.compteurs.ecritures;
const etat = fs.contenu(racine.id, base + SUFFIXES.etat);
const journal = fs.contenu(racine.id, base + SUFFIXES.journal);
const erreur = await refusDe(seance.executer(nom, args));
assert.notEqual(erreur, null, 'accepté');
assert.equal(erreur.code, 'SANS_EFFET', erreur.message);
assert.deepEqual(erreur.details, { commande: nom, remede: null });
assert.equal(fs.compteurs.ecritures, ecritures);
assert.equal(fs.contenu(racine.id, base + SUFFIXES.etat), etat);
assert.equal(fs.contenu(racine.id, base + SUFFIXES.journal), journal);
});
}
});
// --- Le vérificateur d'invariants -------------------------------------------------
describe('registre : le vérificateur d’invariants juge les retouches et l’ouverture (§ 14.12, § 14.15)', () => {
test("une retouche qui assiérait à un tour plus de personnes qu'une table n'a de places est refusée, sans écriture", async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { fs, seance } = ouvert;
const { charge } = seance;
const personne = libresA(charge, 1, 2)[0];
const rang = charge.retenu.tables.indexOf(3);
assert.equal(charge.retenu.placement[0].sieges[rang].length, tableDe(charge, 3).sieges ?? charge.evenement.siegesParDefaut);
const ecritures = fs.compteurs.ecritures;
const erreur = await refusDe(seance.executer('deplacerDansRetenu', { tour: 1, participant: personne, table: 3, siege: null }));
assert.notEqual(erreur, null, 'accepté');
// Un refus du contrôle de pose de la retouche, ou du vérificateur ; une
// faute du code ou du stockage n'en est pas un.
assert.ok(['TABLE_PLEINE', 'INVARIANT_VIOLE'].includes(erreur.code), erreur.message);
assert.ok(Object.hasOwn(erreur.details, 'remede'), erreur.message);
if (erreur.code === 'INVARIANT_VIOLE') {
assert.equal(erreur.details.proposition, charge.retenu.proposition);
assert.ok(erreur.details.violations.length > 0);
}
assert.equal(fs.compteurs.ecritures, ecritures);
});
test("retirerDuTour d'une personne non réservée est accepté : une entrée, la personne dans la réserve du tour, aucun INVARIANT_VIOLE", async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { seance } = ouvert;
const personne = libresA(seance.charge, 1, 2)[0];
const entrees = journalDe(ouvert).entrees.length;
await seance.executer('retirerDuTour', { tour: 1, participant: personne });
assert.equal(journalDe(ouvert).entrees.length, entrees + 1);
assert.ok(chargeEcrite(ouvert).retenu.placement[0].reserve.includes(personne));
assert.deepEqual(invariantsAvertis(seance), []);
});
test('une violation déjà présente, celle d’une dérive, ne bloque pas les retouches, et INVARIANT_VIOLE la tait à l’ouverture comme après défaire, refaire et revenir', async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { seance } = ouvert;
await seance.executer('exclureParticipant', { id: libresA(seance.charge, 1, 4)[0] });
assert.ok(violationsDuRetenu(seance.charge).length > 0, 'la dérive ne laisse aucune violation');
assert.deepEqual(invariantsAvertis(seance), [], 'après la dérive');
const revisionDerive = seance.etat().evenement.revision;
const entrees = journalDe(ouvert).entrees.length;
const { charge } = seance;
await seance.executer('echangerDansRetenu', { tour: 1, a: libresA(charge, 1, 2)[0], b: libresA(charge, 1, 3)[0] });
assert.equal(journalDe(ouvert).entrees.length, entrees + 1);
assert.deepEqual(invariantsAvertis(seance), [], 'après la retouche');
await seance.passerEnLecture();
const autre = await rouvrir(ouvert, 'derive');
assert.ok(violationsDuRetenu(autre.charge).length > 0, 'la dérive ne survit pas à la réouverture');
assert.deepEqual(invariantsAvertis(autre), [], "à l'ouverture");
await autre.passerEnEcriture();
await autre.defaire();
assert.ok(violationsDuRetenu(autre.charge).length > 0, 'défaire efface la dérive');
assert.deepEqual(invariantsAvertis(autre), [], 'après défaire');
await autre.refaire();
assert.deepEqual(invariantsAvertis(autre), [], 'après refaire');
await autre.revenir(revisionDerive);
assert.ok(violationsDuRetenu(autre.charge).length > 0, 'revenir efface la dérive');
assert.deepEqual(invariantsAvertis(autre), [], 'après revenir');
});
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre en lecture avec INVARIANT_VIOLE, sans écriture, et le garde après défaire, refaire et revenir", async () => {
const { temoin } = await departsPrets();
const source = await ouvrirCopie(temoin);
const charge = structuredClone(source.seance.charge);
const personne = libresA(charge, 1, 2)[0];
const reservation = { participant: personne, table: 3, siege: null, portee: 'tour', tour: 1 };
charge.reservations.push(reservation);
const fs = creerFichiersSimules();
fs.deposerDossier(temoin.racine.id);
const ecrit = await creerDansLeDepot(fs, temoin.racine, charge, {
libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }),
nomJalon: libelle('JALON_CREATION'),
horodatage: '2031-03-14T17:00:00-04:00',
produitVersion: V,
});
const horloge = creerHorlogePilotee({ depart: DEPART });
const seance = creerSeance({
fs,
racine: temoin.racine,
horloge,
identifiant: creerIdentifiants('invariant'),
produitVersion: V,
planifier: horloge.planifier,
annuler: horloge.annuler,
});
const ecritures = fs.compteurs.ecritures;
await seance.ouvrir(ecrit.base);
assert.equal(fs.compteurs.ecritures, ecritures, 'ouvrir écrit');
assert.equal(seance.mode, 'lecture');
const attendu = [
{
code: 'INVARIANT_VIOLE',
details: { proposition: charge.retenu.proposition, violations: [{ code: 'RESERVATION', participant: personne, table: 3, tour: 1 }] },
},
];
assert.deepEqual(invariantsAvertis(seance), attendu, "à l'ouverture");
await seance.passerEnEcriture();
await seance.executer('retirerReservation', { participant: personne, table: 3, portee: 'tour', tour: 1 });
assert.deepEqual(invariantsAvertis(seance), [], 'la réservation retirée');
await seance.defaire();
assert.deepEqual(invariantsAvertis(seance), attendu, 'après défaire');
await seance.refaire();
assert.deepEqual(invariantsAvertis(seance), [], 'après refaire');
await seance.revenir(1);
assert.deepEqual(invariantsAvertis(seance), attendu, 'après revenir');
});
test('un retenu dont changerPlacesTable a versé des surnuméraires en réserve s’ouvre sans INVARIANT_VIOLE, et le reste après défaire et refaire', async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { seance } = ouvert;
assert.deepEqual(invariantsAvertis(seance), [], "à l'ouverture de la charge témoin");
await seance.executer('creerTable', { forme: 'ronde' });
await seance.executer('changerPlacesTable', { id: 2, sieges: '2', origine: 'champ' });
const reduite = chargeEcrite(ouvert);
const reserves = reduite.retenu.placement.map(({ reserve }) => reserve.length);
assert.ok(reserves.every((n) => n > 0), `réserves ${JSON.stringify(reserves)}`);
assert.ok(
violationsDuRetenu(reduite, { reserveAutorisee: false }).some(({ code }) => code === 'NON_ASSIS'),
'la réserve ne porte personne que la règle sans réserve refuserait',
);
await seance.passerEnLecture();
const autre = await rouvrir(ouvert, 'reouverture');
assert.deepEqual(invariantsAvertis(autre), [], "à l'ouverture");
await autre.passerEnEcriture();
await autre.defaire();
assert.deepEqual(invariantsAvertis(autre), [], 'après défaire');
await autre.refaire();
assert.deepEqual(invariantsAvertis(autre), [], 'après refaire');
});
});
// --- Créer et charger par la façade ------------------------------------------------
describe('registre : créer un événement et charger une démonstration écrivent sans passer par « Modifier » (§ 2.1, étape 1)', () => {
test('créer, par la façade, écrit l’état et son entrée, la séance restant en lecture, sans verrou', async () => {
const monte = monterFacade();
await monte.facade.demarrer();
assert.equal(monte.seance.mode, 'lecture');
const { base } = await monte.facade.creer({ nom: NOM_VIDE });
const { fs, racine, seance } = monte;
assert.equal(seance.mode, 'lecture');
assert.notEqual(fs.contenu(racine.id, base + SUFFIXES.etat), null);
assert.equal(lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal)).entrees.length, 1);
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
});
test('charger une démonstration, par la façade, écrit l’état et son entrée, la séance restant en lecture, sans verrou', async () => {
const monte = monterFacade();
await monte.facade.demarrer();
const { base } = await monte.facade.chargerDemonstration('petite');
const { fs, racine, seance } = monte;
assert.equal(seance.mode, 'lecture');
assert.equal(seance.ouvert.base, base);
assert.notEqual(fs.contenu(racine.id, base + SUFFIXES.etat), null);
assert.equal(lireJournal(fs.contenu(racine.id, base + SUFFIXES.journal)).entrees.length, 1);
assert.equal(fs.contenu(racine.id, base + SUFFIXES.verrou), null);
});
});