[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
This commit is contained in:
Mathieu Benoit 2026-10-06 14:45:28 -04:00
parent 35cc5f5f75
commit 4003b1f833
3 changed files with 1946 additions and 0 deletions

459
src/stockage/journal.js Normal file
View file

@ -0,0 +1,459 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le journal d'un événement (§ 8.2, § 8.3, § 8.6, § 8.8) : le fichier
// <base>.gtt-journal.jsonl, un objet JSON par ligne, fin de ligne LF, dont
// types.js décrit chaque sorte de ligne. La première ligne l'apparie à son
// état par l'identifiant de l'événement ; suivent les entrées, une par geste
// achevé, et les jalons, qui nomment l'instant d'une révision sans changer la
// charge. L'ordre est celui des lignes, jamais celui des horodatages, qui ne
// servent qu'à l'affichage.
//
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
// texte entier d'un journal, restitue la charge d'un instant, suit le fil
// courant et élague. Une table par sorte de ligne — OUVERTURE, ENTREE, JALON
// — donne à la fois l'ordre des clés à l'écriture et la règle de chaque clé
// à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
// refuserait la forme. La suite des révisions et la cadence des instantanés,
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
// appartiennent à l'appelant.
//
// Une entrée porte soit l'instantané de la charge qui en résulte, soit le
// correctif qui y mène depuis la charge de l'entrée qui la précède dans
// l'ordre des lignes (correctifs.js). L'instantané tombe sur la première
// entrée du journal, puis toutes les INTERVALLE_INSTANTANE révisions comptées
// depuis elle : restituer un instant part de l'instantané le plus proche, à
// ou avant lui, et applique au plus INTERVALLE_INSTANTANE − 1 correctifs.
//
// La lecture s'arrête à la première ligne illisible : JSON invalide, forme
// fausse, révision qui ne suit pas la précédente, cadence des instantanés
// rompue, jalon d'une révision qui n'est pas lue, ligne finale sans fin de
// ligne, que laisse une écriture interrompue. Cette ligne et toutes celles
// qui la suivent sont écartées et comptées : reprendre après elle ferait
// suivre un correctif à un instant qui n'est pas le sien (§ 8.6).
import { canoniser } from './canonique.js';
import { appliquer, difference } from './correctifs.js';
import { FORMAT, SCHEMA, clesRangees, premiereFaute } from './document.js';
/** Révisions d'un instantané au suivant (§ 8.6). */
export const INTERVALLE_INSTANTANE = 50;
/** Entrées que l'élagage garde au moins : le plancher l'emporte (§ 8.6). */
export const PLANCHER_ENTREES = 500;
/** Entrées au-delà desquelles le journal s'élague (§ 8.6). */
export const PLAFOND_ENTREES = 600;
/**
* Un journal lu (lireJournal) : l'identifiant de l'événement, null quand
* l'ouverture est illisible ; les entrées, puis les jalons, chacun dans
* l'ordre des lignes, tels que le texte les porte ; et le nombre de lignes
* écartées. Les révisions des entrées se suivent ; la première porte un
* instantané et une version.
*
* @typedef {Object} Journal
* @property {string|null} evenement
* @property {import('./types.js').LigneEntree[]} entrees
* @property {import('./types.js').LigneJalon[]} jalons
* @property {number} ecartees
*/
// Règles de la charge et d'une proposition dans le schéma du fichier d'état.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
const SENS = [null, 'defaire', 'refaire', 'revenir'];
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1;
const estTexte = (valeur) => typeof valeur === 'string' && valeur !== '';
const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur);
// Chemin de la première valeur de charge que l'analyse du fichier d'état
// refuserait, ou null : la forme de la charge, propositions et retenu lus
// comme conteneurs, puis la forme de chaque proposition, que canoniser
// exige. Un retenu hors de sa règle reste admis, comme le contrôle des
// placements le garde (§ 8.9).
function fauteDeCharge(charge) {
return (
premiereFaute(charge, CHARGE, 'charge') ??
charge.propositions
.map((proposition, rang) => premiereFaute(proposition, PROPOSITION, `charge.propositions[${rang}]`))
.find((faute) => faute !== null) ??
null
);
}
// Une sorte de ligne : ses champs, paires [clé, admet] dans l'ordre des clés
// du texte, et l'ensemble de ces clés. admet(valeur, ligne) dit si une valeur
// est admise à sa clé ; il peut lire les clés qui la précèdent, déjà admises.
const sorte = (champs) => ({ champs, cles: new Set(champs.map(([cle]) => cle)) });
const OUVERTURE = sorte([
['type', (valeur) => valeur === 'journal'],
['format', (valeur) => valeur === FORMAT],
['evenement', estTexte],
]);
const ENTREE = sorte([
['type', (valeur) => valeur === 'entree'],
['revision', estRevision],
['libelle', estTexte],
['horodatage', estHorodatage],
['sens', (valeur) => SENS.includes(valeur)],
// Un retour vise une révision antérieure à la sienne ; un geste n'en vise
// aucune.
['retour', (valeur, { sens, revision }) => (sens === null ? valeur === null : estRevision(valeur) && valeur < revision)],
['produitVersion', (valeur) => valeur === null || estTexte(valeur)],
['instantane', (valeur) => valeur === null || fauteDeCharge(valeur) === null],
// Exactement l'un des deux : le correctif quand l'instantané manque.
['correctif', (valeur, { instantane }) => (instantane === null ? Array.isArray(valeur) : valeur === null)],
]);
const JALON = sorte([
['type', (valeur) => valeur === 'jalon'],
['revision', estRevision],
['nom', estTexte],
['horodatage', estHorodatage],
]);
// Première clé de ligne hors de la règle de sa sorte, ou null : '' quand la
// ligne n'est pas un objet, puis chaque champ dans l'ordre de la sorte,
// absent ou refusé, puis la plus petite clé inconnue.
function champFautif(ligne, { champs, cles }) {
if (!estObjet(ligne)) return '';
for (const [cle, admet] of champs) {
if (!Object.hasOwn(ligne, cle) || !admet(ligne[cle], ligne)) return cle;
}
return clesRangees(ligne).find((cle) => !cles.has(cle)) ?? null;
}
// Texte d'une ligne, ses clés dans l'ordre de sa sorte, sans fin de ligne ;
// JSON.stringify n'écrit aucune fin de ligne brute. Lève TypeError, au nom
// de fonction, quand la lecture refuserait la ligne.
function ecrire(sorteDeLigne, valeurs, fonction) {
const ligne = Object.fromEntries(sorteDeLigne.champs.map(([cle]) => [cle, valeurs[cle]]));
const cle = champFautif(ligne, sorteDeLigne);
if (cle !== null) throw new TypeError(`${fonction} : ${cle} hors de sa règle`);
return JSON.stringify(ligne);
}
/**
* Première ligne d'un journal, qui l'apparie à son état par l'identifiant de
* l'événement (§ 8.6) ; sans fin de ligne. Le format est FORMAT, celui des
* charges que le journal porte. Lève TypeError quand evenement n'est pas une
* chaîne non vide.
*
* @param {string} evenement
* @returns {string}
*/
export function ligneOuverture(evenement) {
return ecrire(OUVERTURE, { type: 'journal', format: FORMAT, evenement }, 'ligneOuverture');
}
/**
* Ligne d'une entrée (§ 8.2, § 8.3), sans fin de ligne : sa révision, son
* libellé figé, son horodatage d'affichage, sens et retour pour une entrée
* de retour, la version de la construction quand elle se note (§ 8.8), puis
* l'instantané de la copie canonique de apres quand instantane est vrai, ou
* le correctif de avant à apres sinon (difference). L'instantané et le
* correctif ne dépendent que des deux charges, non de l'ordre de leurs clés
* ni de leurs listes. Ni avant ni apres ne sont modifiées.
*
* L'appelant passe instantane = estInstantane(revision, première révision
* du journal), et produitVersion à la première entrée et quand la
* construction change ; la lecture refuse une ligne qui rompt l'un ou
* l'autre.
*
* Lève TypeError quand la lecture refuserait la ligne — révision qui n'est
* pas un entier ≥ 1, libellé vide, horodatage qui n'a pas la forme
* AAAA-MM-JJTHH:MM:SS±HH:MM, sens inconnu, retour présent sans sens, absent
* avec un sens, ou qui ne précède pas la révision, version vide —, quand
* instantane n'est pas un booléen, quand apres n'a pas la forme que
* l'analyse du fichier d'état admet — clés inconnues comprises —, chaque
* proposition selon sa règle, et, pour un correctif, quand avant manque ou
* ne se canonise pas.
*
* @param {Object} entree
* @param {number} entree.revision
* @param {string} entree.libelle
* @param {string} entree.horodatage
* @param {null|'defaire'|'refaire'|'revenir'} [entree.sens]
* @param {number|null} [entree.retour]
* @param {string|null} [entree.produitVersion]
* @param {import('./types.js').Charge} [entree.avant] requise pour un correctif
* @param {import('./types.js').Charge} entree.apres
* @param {boolean} entree.instantane
* @returns {string}
*/
export function ligneEntree({
revision,
libelle,
horodatage,
sens = null,
retour = null,
produitVersion = null,
avant,
apres,
instantane,
}) {
if (typeof instantane !== 'boolean') {
throw new TypeError(`ligneEntree : instantane booléen attendu, reçu ${JSON.stringify(instantane)}`);
}
// La charge reçue, telle quelle : une clé hors du schéma, que la copie
// canonique tairait, est refusée comme l'analyse la refuserait.
const faute = fauteDeCharge(apres);
if (faute !== null) throw new TypeError(`ligneEntree : ${faute} hors de sa règle`);
const ligne = {
type: 'entree',
revision,
libelle,
horodatage,
sens,
retour,
produitVersion,
instantane: instantane ? canoniser(apres) : null,
correctif: instantane ? null : difference(avant, apres),
};
return ecrire(ENTREE, ligne, 'ligneEntree');
}
/**
* Ligne d'un jalon (§ 8.3), sans fin de ligne : il nomme l'instant de la
* révision donnée, ne change pas la charge et ne prend pas de révision.
* Lève TypeError quand la révision n'est pas un entier ≥ 1, le nom vide, ou
* l'horodatage hors de sa forme.
*
* @param {{revision: number, nom: string, horodatage: string}} jalon
* @returns {string}
*/
export function ligneJalon({ revision, nom, horodatage }) {
return ecrire(JALON, { type: 'jalon', revision, nom, horodatage }, 'ligneJalon');
}
/**
* Vrai quand l'entrée revision porte un instantané, dans un journal dont la
* première entrée est premiere : premiere elle-même, puis toutes les
* INTERVALLE_INSTANTANE révisions comptées depuis elle ; jamais une révision
* qui précède premiere.
*
* @param {number} revision
* @param {number} premiere
* @returns {boolean}
*/
export function estInstantane(revision, premiere) {
return revision >= premiere && (revision - premiere) % INTERVALLE_INSTANTANE === 0;
}
// Valeur JSON d'une ligne, ou undefined quand elle n'en est pas une.
function analyserLigne(ligne) {
try {
return JSON.parse(ligne);
} catch {
return undefined;
}
}
// Vrai quand ligne est l'entrée qui suit entrees : sa forme admise, et la
// révision qui suit la dernière, l'instantané là où la cadence le veut ; la
// première entrée, de révision quelconque, porte un instantané et sa
// version.
function entreeSuivante(ligne, entrees) {
if (champFautif(ligne, ENTREE) !== null) return false;
if (entrees.length === 0) return ligne.produitVersion !== null && ligne.instantane !== null;
return (
ligne.revision === entrees[entrees.length - 1].revision + 1 &&
(ligne.instantane !== null) === estInstantane(ligne.revision, entrees[0].revision)
);
}
// Vrai quand ligne est un jalon d'une révision déjà lue.
function jalonDe(ligne, entrees) {
return (
champFautif(ligne, JALON) === null &&
entrees.length > 0 &&
ligne.revision >= entrees[0].revision &&
ligne.revision <= entrees[entrees.length - 1].revision
);
}
// Lecture d'un texte de journal : le journal lu ; les lignes lues après
// l'ouverture, entrées et jalons dans l'ordre du texte ; et les segments du
// texte coupé à chaque fin de ligne, la marque d'ordre d'octets retirée, le
// dernier segment suivant la dernière fin de ligne. Chaque segment suivi
// d'une fin de ligne est une ligne ; le dernier aussi quand il n'est pas
// vide, et la lecture l'écarte.
function lire(texte) {
const segments = (texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte).split('\n');
const completes = segments.length - 1;
const lignes = segments[completes] === '' ? completes : completes + 1;
const journal = { evenement: null, entrees: [], jalons: [], ecartees: lignes };
const lues = [];
const ouverture = completes > 0 ? analyserLigne(segments[0]) : undefined;
if (champFautif(ouverture, OUVERTURE) !== null) return { journal, lues, segments };
journal.evenement = ouverture.evenement;
for (let rang = 1; rang < completes; rang += 1) {
const ligne = analyserLigne(segments[rang]);
if (entreeSuivante(ligne, journal.entrees)) journal.entrees.push(ligne);
else if (jalonDe(ligne, journal.entrees)) journal.jalons.push(ligne);
else break;
lues.push(ligne);
}
journal.ecartees = lignes - 1 - lues.length;
return { journal, lues, segments };
}
/**
* Lit le texte d'un journal (§ 8.6), sans rien écrire (§ 8.4). Une marque
* d'ordre d'octets en tête est ignorée ; un retour chariot avant la fin de
* ligne aussi, que JSON tient pour un blanc.
*
* La lecture s'arrête à la première ligne illisible : JSON invalide ; forme
* fausse, toute clé absente, refusée ou inconnue, et l'instantané d'une
* forme que l'analyse du fichier d'état refuse ; une ouverture d'un autre
* format ; une entrée dont la révision ne suit pas la précédente, ou dont
* l'instantané manque ou abonde au regard de la cadence (estInstantane) ;
* une première entrée sans version ; un jalon d'une révision qui n'est pas
* encore lue ; une ligne finale sans fin de ligne. Cette ligne et toutes
* celles qui la suivent, entrées et jalons confondus — une ligne illisible
* ne dit pas sa sorte —, sont écartées et comptées dans ecartees. Une
* ouverture illisible écarte tout, et evenement vaut null.
*
* Un correctif se lit sans s'appliquer : reconstruire lève CORRECTIF sur
* celui qui ne s'applique pas.
*
* @param {string} texte
* @returns {Journal}
*/
export function lireJournal(texte) {
return lire(texte).journal;
}
/**
* Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —
* au plus INTERVALLE_INSTANTANE − 1. Rend une charge neuve, dans l'ordre
* canonique, qui ne partage rien avec le journal. Une entrée de retour rend
* la charge de sa cible, que son correctif ou son instantané portent.
*
* Lève ErreurStockage('CORRECTIF', { rang }) d'appliquer (correctifs.js) sur
* un correctif qui ne s'applique pas : il gâte les révisions de la sienne à
* l'instantané suivant, et aucune autre. Lève RangeError quand revision
* n'est pas celle d'une entrée du journal.
*
* @param {Journal} journal
* @param {number} revision
* @returns {import('./types.js').Charge}
*/
export function reconstruire({ entrees }, revision) {
const rang = entrees.findIndex((entree) => entree.revision === revision);
if (rang === -1) throw new RangeError(`reconstruire : révision ${String(revision)} absente du journal`);
let base = rang;
while (entrees[base].instantane === null) base -= 1;
let charge = canoniser(entrees[base].instantane);
for (let suivante = base + 1; suivante <= rang; suivante += 1) {
charge = appliquer(charge, entrees[suivante].correctif);
}
return charge;
}
/**
* Le fil courant (§ 8.3), par le rejeu des entrées dans l'ordre des lignes.
* La position d'une entrée ordinaire est elle-même ; celle d'une entrée de
* retour, la position de l'entrée qu'elle vise. Une entrée ordinaire pose la
* position et vide la pile de refaire ; un retour defaire ou revenir empile
* la position courante et pose celle de sa cible ; un retour refaire dépile
* et pose celle de sa cible.
*
* Le prédécesseur d'une position est la position de l'entrée qui la précède
* dans l'ordre des lignes, null pour la première entrée du journal. Un retour
* dont la cible précède la première entrée — l'élagage l'a retirée — porte
* la charge de cette cible sans en connaître la position : il est sa propre
* position, sans prédécesseur, et le fil s'arrête à lui.
*
* Rend position, la position courante, null pour un journal sans entrée ;
* pile, les positions à refaire, le sommet en dernier ; courant, le fil
* courant, la chaîne des prédécesseurs depuis la position, de la plus récente
* à la plus ancienne ; cibleDefaire, le prédécesseur de la position, et
* cibleRefaire, le sommet de la pile, null quand ils n'existent pas. Les
* entrées ordinaires hors du fil courant forment les fils abandonnés.
*
* @param {Journal} journal
* @returns {{position: number|null, pile: number[], courant: number[], cibleDefaire: number|null, cibleRefaire: number|null}}
*/
export function fil({ entrees }) {
const positions = new Map();
const sansPredecesseur = new Set();
const pile = [];
let position = null;
for (const { revision, sens, retour } of entrees) {
let cible = revision;
if (sens === null) {
pile.length = 0;
} else {
if (positions.has(retour)) cible = positions.get(retour);
else sansPredecesseur.add(revision);
if (sens === 'refaire') pile.pop();
else if (position !== null) pile.push(position);
}
positions.set(revision, cible);
position = cible;
}
const predecesseur = (p) => (sansPredecesseur.has(p) ? null : (positions.get(p - 1) ?? null));
const courant = [];
for (let p = position; p !== null; p = predecesseur(p)) courant.push(p);
return { position, pile, courant, cibleDefaire: courant[1] ?? null, cibleRefaire: pile.at(-1) ?? null };
}
/**
* Version de la construction qui a produit l'entrée revision (§ 8.8) : la
* dernière produitVersion non nulle à ou avant elle. null quand revision
* n'est pas celle d'une entrée du journal.
*
* @param {Journal} journal
* @param {number} revision
* @returns {string|null}
*/
export function versionDe({ entrees }, revision) {
for (let rang = entrees.findIndex((entree) => entree.revision === revision); rang >= 0; rang -= 1) {
if (entrees[rang].produitVersion !== null) return entrees[rang].produitVersion;
}
return null;
}
/**
* Texte du journal élagué (§ 8.6), ou null quand ses entrées lisibles ne
* dépassent pas PLAFOND_ENTREES. La coupe tombe sur l'instantané le plus
* récent qui garde au moins PLANCHER_ENTREES entrées : le plancher l'emporte
* sur le plafond. Le texte rendu porte l'ouverture, puis les lignes lues dont
* la révision atteint la coupe — les jalons des entrées retirées partent
* avec elles —, puis les lignes que la lecture écarte, telles quelles.
* L'entrée de la coupe porte la version de la construction qui l'a produite
* (versionDe), qu'elle hérite sinon d'une entrée retirée ; toute autre
* ligne gardée se recopie octet pour octet. La marque d'ordre d'octets ne
* se recopie pas. Les instantanés gardés suivent la cadence comptée depuis
* la coupe, celle d'avant : le texte rendu se relit, et chaque instant gardé
* s'y restitue comme avant.
*
* @param {string} texte
* @returns {string|null}
*/
export function elaguer(texte) {
const { journal, lues, segments } = lire(texte);
const { entrees } = journal;
if (entrees.length <= PLAFOND_ENTREES) return null;
let rang = entrees.length - PLANCHER_ENTREES;
while (entrees[rang].instantane === null) rang -= 1;
const coupe = entrees[rang];
const gardees = [ligneOuverture(journal.evenement)];
lues.forEach((ligne, i) => {
if (ligne.revision < coupe.revision) return;
if (ligne !== coupe || coupe.produitVersion !== null) gardees.push(segments[i + 1]);
else gardees.push(ecrire(ENTREE, { ...coupe, produitVersion: versionDe(journal, coupe.revision) }, 'elaguer'));
});
return [...gardees, ...segments.slice(1 + lues.length)].join('\n');
}

View file

@ -0,0 +1,392 @@
// © 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]}`);
});
});

1095
src/stockage/journal.test.js Normal file

File diff suppressed because it is too large Load diff