// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Épreuves longues du journal (§ 14.10, série node-long) : une séance de 160 // gestes sur la grande démonstration, propositions comprises, dont chaque // instant se restitue tel qu'il s'est écrit ; puis la même égalité sur // cinquante séances tirées sur la petite démonstration, graine écrite // (§ 14.12). Une séance s'écrit ligne après ligne comme l'écrit // l'enregistreur : défaire et refaire visent ce que le fil courant désigne à // cet instant, revenir une révision précédente. Chaque charge écrite passe // l'analyse du fichier d'état et le contrôle des placements. Les noms des // personnes ajoutées sont inventés, hors des réservoirs des démonstrations. import assert from 'node:assert/strict'; import fc from 'fast-check'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE } from '../demo/catalogue.js'; import { rechercher } from '../moteur/recherche.js'; import { VERSION } from '../version.genere.js'; import { serialiser, serialiserCharge } from './canonique.js'; import { analyser, configurationDepuisCharge, creerCharge, etatDeduit } from './document.js'; import { INTERVALLE_INSTANTANE, estInstantane, fil, ligneEntree, ligneJalon, ligneOuverture, lireJournal, reconstruire, } from './journal.js'; import { examiner, versFichier } from './placements.js'; // Graine et nombre des séances tirées, écrits ici pour que chaque exécution // tire les mêmes (§ 14.12), et nombre de pas de la plus longue. const GRAINE = 61_803; const TIRAGES = 50; const PAS_MAX = 110; // Compte d'arrêt des générations : court, la recherche n'est pas éprouvée ici. const ARRET = 100; const copie = (valeur) => structuredClone(valeur); const texteCanonique = (charge) => serialiserCharge(charge); // Entiers de a à b inclus, croissants. const de = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i); // Horodatage d'affichage de la révision r : une minute par révision à partir // de 8 h, le 17 mai 2031, à l'heure de l'Est. function horodatage(r) { const minutes = 8 * 60 + r; const deux = (n) => String(n).padStart(2, '0'); return `2031-05-17T${deux(Math.floor(minutes / 60) % 24)}:${deux(minutes % 60)}:00-04:00`; } // Charge d'une démonstration du catalogue (§ 15) : ses personnes, ses tables // au défaut de l'événement quand leur capacité y est égale, ses réservations // sans siège, aucune proposition. function chargeDe(cle) { const entree = CATALOGUE.find((candidate) => candidate.cle === cle); const configuration = entree.construire(); const siegesParDefaut = Math.max(...configuration.tables.map(({ capacite }) => capacite)); const charge = creerCharge({ id: `evt-${cle}`, nom: entree.nom, siegesParDefaut, tours: configuration.tours }); charge.participants = configuration.participants.map(({ id, nom, prenom, appartenance }) => ({ id, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: false, })); charge.tables = configuration.tables.map(({ id, numero, capacite }, rang) => ({ id, numero, sieges: capacite === siegesParDefaut ? null : capacite, forme: 'ronde', position: { x: 250 * (rang % 6), y: 250 * Math.floor(rang / 6) }, })); charge.reservations = configuration.reservations.map(({ participant, table, portee }) => ({ participant, table, siege: null, portee, tour: null, })); charge.prochainsIds = { participant: charge.participants.length + 1, table: charge.tables.length + 1, proposition: 1 }; return charge; } // --- Les gestes ------------------------------------------------------------ // // Chaque geste reçoit la charge courante, qu'il ne modifie pas, et un entier // d'où il tire ses paramètres ; il rend une charge neuve, ou la charge reçue // quand il n'a pas d'objet. const NOMS_AJOUTES = ['Ombrelle', 'Grisaille', 'Pervenche', 'Lacasse', 'Mirabelle', 'Quenouille', 'Sarbacane']; const PRENOMS_AJOUTES = ['Iris', 'Théo', 'Ondine', 'Aurèle', null]; const APPARTENANCES_AJOUTEES = ['Club des Merles', 'Société Alpha', null]; const REGLAGES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']; function ajouter(charge, n) { const suivante = copie(charge); const id = suivante.prochainsIds.participant; suivante.prochainsIds.participant = id + 1; suivante.participants.push({ id, nom: NOMS_AJOUTES[n % NOMS_AJOUTES.length], prenom: PRENOMS_AJOUTES[n % PRENOMS_AJOUTES.length], appartenance: APPARTENANCES_AJOUTEES[n % APPARTENANCES_AJOUTEES.length], courriel: null, titrePressenti: null, notes: null, exclu: false, }); return suivante; } function modifier(charge, n) { const suivante = copie(charge); const personne = suivante.participants[n % suivante.participants.length]; personne.notes = `note ${n}`; personne.courriel = n % 2 === 0 ? `p${n}@exemple.test` : null; return suivante; } function exclure(charge, n) { const suivante = copie(charge); const personne = suivante.participants[(3 * n) % suivante.participants.length]; personne.exclu = !personne.exclu; return suivante; } function deplacer(charge, n) { const suivante = copie(charge); const table = suivante.tables[n % suivante.tables.length]; table.position = { x: table.position.x + 10, y: (n % 7) * 25 }; return suivante; } function regler(charge, n) { const suivante = copie(charge); const cle = REGLAGES[n % REGLAGES.length]; suivante.reglages[cle] = !suivante.reglages[cle]; return suivante; } // Pose deux propositions du moteur, numérotées au-delà du compteur ; quand // les présents dépassent les places, ajoute une table à la place. function generer(charge, n) { const configuration = configurationDepuisCharge(charge); const places = configuration.tables.reduce((somme, { capacite }) => somme + capacite, 0); const presents = configuration.participants.filter(({ exclu }) => !exclu).length; const suivante = copie(charge); if (presents > places) { const id = suivante.prochainsIds.table; suivante.prochainsIds.table = id + 1; suivante.tables.push({ id, numero: id, sieges: null, forme: 'carree', position: { x: 0, y: 300 } }); return suivante; } const decalage = suivante.prochainsIds.proposition - 1; const options = { produitVersion: VERSION.affichee, attribuerSieges: suivante.reglages.attribuerSieges, decalage }; const posees = rechercher(configuration, { graine: n, arret: ARRET, nombre: 2 }).map((proposition) => versFichier(proposition, configuration, options), ); suivante.propositions.push(...posees); suivante.prochainsIds.proposition = decalage + posees.length + 1; return suivante; } // Retient une proposition : le retenu en reprend le plan. function retenir(charge, n) { if (charge.propositions.length === 0) return charge; const { id, siegesAttribues, tables, capacites, tours, participants, placement } = copie( charge.propositions[n % charge.propositions.length], ); const suivante = copie(charge); suivante.retenu = { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement }; return suivante; } // Retouche le retenu à la main (§ 5.8) : au tour que n désigne, les // premières personnes de deux tables voisines échangent leurs tables. function retoucher(charge, n) { if (charge.retenu === null) return charge; const suivante = copie(charge); const { sieges } = suivante.retenu.placement[n % suivante.retenu.tours]; const a = n % sieges.length; const b = (a + 1) % sieges.length; if (a === b || sieges[a].length === 0 || sieges[b].length === 0) return charge; [sieges[a][0], sieges[b][0]] = [sieges[b][0], sieges[a][0]]; return suivante; } // Supprime la dernière personne de la liste, et ses réservations. function supprimer(charge) { const suivante = copie(charge); const [retiree] = suivante.participants.splice(-1, 1); suivante.reservations = suivante.reservations.filter(({ participant }) => participant !== retiree.id); return suivante; } // Efface les propositions ; le retenu et le compteur restent. function effacer(charge) { if (charge.propositions.length === 0) return charge; const suivante = copie(charge); suivante.propositions = []; return suivante; } const GESTES = [ ['ajouter', ajouter], ['modifier', modifier], ['exclure', exclure], ['generer', generer], ['retenir', retenir], ['retoucher', retoucher], ['deplacer', deplacer], ['regler', regler], ['supprimer', supprimer], ['effacer', effacer], ]; // Les codes qui suivent ceux des gestes sont des retours, dans cet ordre. const SENS = ['defaire', 'refaire', 'revenir']; const CODES = GESTES.length + SENS.length; const code = (nom) => (SENS.includes(nom) ? GESTES.length + SENS.indexOf(nom) : GESTES.findIndex(([geste]) => geste === nom)); // Une séance écrite : la charge de base à la révision 1, puis un pas par // paire [code, n]. Un code de geste applique son geste, et un geste qui ne // change pas la charge cède la place à un réglage basculé. Un code de // retour défait ou refait vers ce que le fil courant désigne, ou revient // vers la révision 1 + n mod r, r étant la dernière écrite ; quand le fil ne // désigne rien, le pas applique un geste. Un pas dont n est multiple de 9 // pose un jalon. Rend les charges écrites, révision par révision, le texte // du journal et le compte de chaque sorte de pas : geste, poser pour une // génération qui pose des propositions, sens d'un retour. function ecrireSeance(base, pas) { const charges = []; const lignes = [ligneOuverture(base.evenement.id)]; const entrees = []; const compte = new Map(); const ecrire = (apres, sorte, jalon, { sens = null, retour = null } = {}) => { const revision = charges.length + 1; const ligne = ligneEntree({ revision, libelle: `${sorte} ${revision}`, horodatage: horodatage(revision), sens, retour, produitVersion: revision === 1 ? VERSION.affichee : null, avant: charges.at(-1), apres, instantane: estInstantane(revision, 1), }); lignes.push(ligne); entrees.push(JSON.parse(ligne)); charges.push(apres); compte.set(sorte, (compte.get(sorte) ?? 0) + 1); if (jalon) lignes.push(ligneJalon({ revision, nom: `Jalon ${revision}`, horodatage: horodatage(revision) })); }; ecrire(base, 'creer', true); for (const [codeDuPas, n] of pas) { const courante = charges.at(-1); const jalon = n % 9 === 0; if (codeDuPas >= GESTES.length) { const sens = SENS[codeDuPas - GESTES.length]; const { cibleDefaire, cibleRefaire } = fil({ entrees }); const cible = sens === 'defaire' ? cibleDefaire : sens === 'refaire' ? cibleRefaire : 1 + (n % charges.length); if (cible !== null) { ecrire(copie(charges[cible - 1]), sens, jalon, { sens, retour: cible }); continue; } } let [sorte, faire] = GESTES[codeDuPas % GESTES.length]; let apres = faire(courante, n); if (texteCanonique(apres) === texteCanonique(courante)) [sorte, apres] = ['regler', regler(courante, n)]; if (sorte === 'generer' && apres.propositions.length > courante.propositions.length) sorte = 'poser'; apres.evenement.etat = etatDeduit(apres); ecrire(apres, sorte, jalon); } return { charges, texte: `${lignes.join('\n')}\n`, compte }; } // Le fil courant d'un journal sans élagage selon la définition du § 8.3, un // parcours arrière depuis la dernière entrée : le prédécesseur d'une entrée // ordinaire est celle qui la précède, celui d'une entrée de retour l'entrée // qu'elle vise ; le fil est la suite des entrées ordinaires rencontrées. Ce // parcours ne connaît ni position ni pile : il sert de témoin, par une autre // définition, au rejeu que fait le module. function filParcouru(entrees) { const parRevision = new Map(entrees.map((entree) => [entree.revision, entree])); const courant = []; for (let entree = entrees.at(-1); entree !== undefined; ) { if (entree.sens === null) courant.push(entree.revision); entree = parRevision.get(entree.sens === null ? entree.revision - 1 : entree.retour); } return courant; } // Vérifie le fil de chaque préfixe des entrées : le fil courant et la cible // de défaire sont ceux du parcours arrière ; la pile compte les défaire et // revenir qui suivent le dernier geste, moins les refaire (refaire n'existe // que tant que les dernières entrées sont des retours) ; et après un défaire // ou un revenir, refaire vise la position qu'il vient de quitter. function verifierFil(entrees) { let enAttente = 0; entrees.forEach((entree, rang) => { const prefixe = entrees.slice(0, rang + 1); const { position, pile, courant, cibleDefaire, cibleRefaire } = fil({ entrees: prefixe }); const parcouru = filParcouru(prefixe); assert.deepEqual([position, courant, cibleDefaire], [parcouru[0], parcouru, parcouru[1] ?? null], `révision ${entree.revision}`); enAttente = entree.sens === null ? 0 : enAttente + (entree.sens === 'refaire' ? -1 : 1); assert.equal(pile.length, enAttente, `révision ${entree.revision}`); if (entree.sens === 'defaire' || entree.sens === 'revenir') { assert.equal(cibleRefaire, filParcouru(entrees.slice(0, rang))[0], `révision ${entree.revision}`); } }); } // Vérifie une séance écrite : chaque charge admise par l'analyse du fichier // d'état et par le contrôle des placements ; le journal relu en entier ; // chaque instant restitué tel qu'il s'est écrit ; la position courante du // fil porte la dernière charge ; et le fil de chaque préfixe suit le § 8.3. function verifier({ charges, texte }) { charges.forEach((charge, rang) => { analyser(serialiser(charge, { revision: rang + 1, produitVersion: VERSION.affichee })); const { fautives, retenu } = examiner(charge); assert.deepEqual([fautives, retenu.fautes], [[], []], `révision ${rang + 1}`); }); const journal = lireJournal(texte); assert.deepEqual([journal.entrees.length, journal.ecartees], [charges.length, 0]); charges.forEach((charge, rang) => { assert.equal(texteCanonique(reconstruire(journal, rang + 1)), texteCanonique(charge), `révision ${rang + 1}`); }); assert.equal(texteCanonique(reconstruire(journal, fil(journal).position)), texteCanonique(charges.at(-1))); verifierFil(journal.entrees); } describe('chaque instant d’une longue séance se reconstruit (§ 8.3, § 14.10)', () => { test('160 gestes sur la grande démonstration, propositions posées, retenues et retouchées, retours compris : reconstruire(journal, r) égale la charge écrite à la révision r, pour chacune', () => { const cycle = [ 'ajouter', 'modifier', 'generer', 'retenir', 'retoucher', 'exclure', 'defaire', 'refaire', 'deplacer', 'generer', 'retoucher', 'regler', 'supprimer', 'revenir', 'modifier', 'effacer', ].map(code); // Le paramètre d'un pas n'est pas sa révision : revenir vers 1 + r mod (r − 1) // viserait toujours la révision 2. 7 919 et 10 007 sont premiers : les // paramètres des 159 pas sont distincts. const pas = de(2, 160).map((r) => [cycle[r % cycle.length], (r * 7_919) % 10_007]); const seance = ecrireSeance(chargeDe('grande'), pas); assert.equal(seance.charges.length, 160); for (const sorte of ['ajouter', 'modifier', 'exclure', 'poser', 'retenir', 'retoucher', 'defaire', 'refaire', 'revenir']) { assert.ok((seance.compte.get(sorte) ?? 0) >= 3, `${sorte} : ${seance.compte.get(sorte) ?? 0} fois`); } assert.ok(Math.max(...seance.charges.map(({ propositions }) => propositions.length)) >= 4); verifier(seance); }); test('50 séances tirées sur la petite démonstration, graine écrite : chaque instant se restitue tel qu’il s’est écrit', () => { const bilan = { longues: 0, poser: 0, defaire: 0, refaire: 0, revenir: 0 }; const pas = fc.array(fc.tuple(fc.nat({ max: CODES - 1 }), fc.nat({ max: 9_999 })), { minLength: 1, maxLength: PAS_MAX, size: 'max', }); fc.assert( fc.property(pas, (tires) => { const seance = ecrireSeance(chargeDe('petite'), tires); verifier(seance); if (seance.charges.length > INTERVALLE_INSTANTANE) bilan.longues += 1; for (const sorte of ['poser', 'defaire', 'refaire', 'revenir']) bilan[sorte] += seance.compte.get(sorte) ?? 0; }), { seed: GRAINE, numRuns: TIRAGES }, ); // Les séances franchissent des frontières d'instantané, et chaque sorte de // pas rare s'y présente. assert.ok(bilan.longues >= 15, `${bilan.longues} séances de plus de ${INTERVALLE_INSTANTANE} entrées`); for (const sorte of ['poser', 'defaire', 'refaire', 'revenir']) assert.ok(bilan[sorte] >= 20, `${sorte} : ${bilan[sorte]}`); }); });