gestion_table_tournante_libre/src/stockage/journal.long.test.js

393 lines
17 KiB
JavaScript
Raw Normal View History

[ADD] storage: append-only journal, snapshots every fifty, undo threads The journal is one JSON line per entry, after a line that pairs it with its event by id. An entry carries its frozen label, a display timestamp, and either a snapshot or a patch from the line before; a snapshot every fifty revisions keeps any instant within 49 applications. Reading stops at the first unreadable line and counts what it drops. Returns never truncate: undo, redo and jumps are entries, and the current thread is told from abandoned ones. Past 600 entries it prunes on a snapshot boundary, never below 500. Checked: unit tests, and every instant rebuilt over long sequences. --- FR --- [ADD] stockage : journal en ajout, instantané tous les cinquante, fils Le journal est une ligne JSON par entrée, après une ligne qui l'apparie à son événement par identifiant. Une entrée porte son libellé figé, un horodatage d'affichage, et soit un instantané, soit un correctif depuis la ligne précédente ; un instantané toutes les cinquante révisions tient tout instant à 49 applications. La lecture s'arrête à la première ligne illisible et compte ce qu'elle écarte. Revenir ne tronque jamais : défaire, refaire et sauter sont des entrées, et le fil courant se distingue des fils abandonnés. Au-delà de 600 entrées, élagage sur une frontière d'instantané, jamais sous 500. Vérifié : épreuves unitaires ; tous les instants reconstruits. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 14:45:28 -04:00
// © 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]}`);
});
});