// © 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); }); });