From 4003b1f833890bfc50027d295176abfce11c2ae7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 14:45:28 -0400 Subject: [PATCH] [ADD] storage: append-only journal, snapshots every fifty, undo threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/stockage/journal.js | 459 ++++++++++++ src/stockage/journal.long.test.js | 392 +++++++++++ src/stockage/journal.test.js | 1095 +++++++++++++++++++++++++++++ 3 files changed, 1946 insertions(+) create mode 100644 src/stockage/journal.js create mode 100644 src/stockage/journal.long.test.js create mode 100644 src/stockage/journal.test.js diff --git a/src/stockage/journal.js b/src/stockage/journal.js new file mode 100644 index 0000000..de47d4c --- /dev/null +++ b/src/stockage/journal.js @@ -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 +// .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'); +} diff --git a/src/stockage/journal.long.test.js b/src/stockage/journal.long.test.js new file mode 100644 index 0000000..0c890e9 --- /dev/null +++ b/src/stockage/journal.long.test.js @@ -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]}`); + }); +}); diff --git a/src/stockage/journal.test.js b/src/stockage/journal.test.js new file mode 100644 index 0000000..7412ab4 --- /dev/null +++ b/src/stockage/journal.test.js @@ -0,0 +1,1095 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du journal (§ 8.2, § 8.3, § 8.6, § 8.8) : le texte exact de chaque +// sorte de ligne et ce que l'écriture refuse ; la cadence des instantanés ; +// chaque instant d'une séance de 120 gestes sur la petite démonstration, +// restitué tel qu'il s'est écrit ; la lecture qui s'arrête à la première +// ligne illisible et compte ce qu'elle écarte ; le fil courant, défaire et +// refaire ; la construction notée ; l'élagage. Les journaux s'écrivent comme +// les écrit l'enregistreur, une ligne après l'autre, par les fonctions du +// module ; chaque charge de la séance 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 ; les versions viennent du module +// engendré, seul à en porter le texte. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { versionVoisine } from '../../test/version_voisine.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 { ErreurStockage } from './erreurs.js'; +import { + INTERVALLE_INSTANTANE, + PLAFOND_ENTREES, + PLANCHER_ENTREES, + elaguer, + estInstantane, + fil, + ligneEntree, + ligneJalon, + ligneOuverture, + lireJournal, + reconstruire, + versionDe, +} from './journal.js'; +import { examiner, versFichier } from './placements.js'; + +// L'événement des journaux d'épreuve, et deux constructions : celle du module +// engendré et sa voisine. +const EVENEMENT = 'evt-essai'; +const V1 = VERSION.affichee; +const V2 = versionVoisine(V1); + +const copie = (valeur) => structuredClone(valeur); +// Valeur calculée au premier appel, puis gardée : une donnée d'épreuve que le +// module construit ne se calcule qu'à l'intérieur d'une épreuve, dont l'échec +// n'empêche pas les autres de s'exécuter. +function memoire(calculer) { + let calculee = false; + let valeur; + return () => { + if (!calculee) [calculee, valeur] = [true, calculer()]; + return valeur; + }; +} +const texteCanonique = (charge) => serialiserCharge(charge); +// Entiers de a à b inclus, dans le sens qui mène de l'un à l'autre. +const de = (a, b) => Array.from({ length: Math.abs(b - a) + 1 }, (_, i) => (a <= b ? a + i : a - i)); +// Lignes d'un texte de journal, sa dernière fin de ligne retirée ; et +// l'inverse, chaque ligne suivie d'une fin de ligne. +const lignesDe = (texte) => texte.replace(/\n$/, '').split('\n'); +const texteDe = (lignes) => `${lignes.join('\n')}\n`; + +// 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`; +} + +// Texte du journal qu'écrit un enregistreur fidèle au contrat : l'ouverture, +// puis chaque étape à la révision suivante, depuis premiere — en instantané +// quand la révision ouvre un intervalle, en correctif depuis la charge de +// l'étape précédente sinon, la construction notée à la première entrée et +// quand elle change —, et les jalons d'une étape après elle. Une étape est +// { apres, sens, retour, version, jalons } ; seul apres est requis, et +// version vaut V1 quand elle manque. +function journalDe(etapes, { premiere = 1, evenement = EVENEMENT } = {}) { + const lignes = [ligneOuverture(evenement)]; + etapes.forEach((etape, rang) => { + const revision = premiere + rang; + const version = etape.version ?? V1; + const precedente = rang === 0 ? null : (etapes[rang - 1].version ?? V1); + lignes.push( + ligneEntree({ + revision, + libelle: etape.libelle ?? `Geste ${revision}`, + horodatage: horodatage(revision), + sens: etape.sens ?? null, + retour: etape.retour ?? null, + produitVersion: version === precedente ? null : version, + avant: etapes[rang - 1]?.apres, + apres: etape.apres, + instantane: estInstantane(revision, premiere), + }), + ); + for (const nom of etape.jalons ?? []) lignes.push(ligneJalon({ revision, nom, horodatage: horodatage(revision) })); + }); + return texteDe(lignes); +} + +// --- Les charges ----------------------------------------------------------- + +// La charge du contrat sans liste, brouillon. +const chargeVide = () => creerCharge({ id: EVENEMENT, nom: "Soirée d'essai", siegesParDefaut: 2, tours: 2 }); + +// La même avec Iris Ombrelle, personne 1. +function avecIris() { + const charge = chargeVide(); + charge.prochainsIds.participant = 2; + charge.participants.push({ + id: 1, + nom: 'Ombrelle', + prenom: 'Iris', + appartenance: 'Club des Merles', + courriel: null, + titrePressenti: 'animation', + notes: null, + exclu: false, + }); + return charge; +} + +// n étapes ordinaires sur la charge à une personne : l'étape de rang i porte +// la note « note i+1 », si bien que deux étapes ne s'écrivent jamais pareil. +function suite(n) { + return Array.from({ length: n }, (_, rang) => { + const apres = avecIris(); + apres.participants[0].notes = `note ${rang + 1}`; + return { apres }; + }); +} + +// Charge d'une démonstration du catalogue (§ 15) : ses personnes, ses tables +// au défaut de l'événement, 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 d'une séance ----------------------------------------------- +// +// 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']; +// Compte d'arrêt des générations : court, la recherche n'est pas éprouvée ici. +const ARRET = 200; + +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: V1, 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 CYCLE = [ + ['ajouter', ajouter], + ['modifier', modifier], + ['exclure', exclure], + ['generer', generer], + ['retenir', retenir], + ['retoucher', retoucher], + ['deplacer', deplacer], + ['regler', regler], + ['supprimer', supprimer], + ['generer', generer], + ['retoucher', retoucher], + ['effacer', effacer], +]; + +// Retours de la séance, par révision : [sens, révision visée]. Défaire et +// refaire visent ce que le fil courant désigne à cet instant ; deux d'entre +// eux encadrent l'instantané de la révision 51. +const RETOURS = new Map([ + [30, ['revenir', 20]], + [52, ['defaire', 50]], + [53, ['refaire', 51]], + [99, ['revenir', 60]], + [102, ['defaire', 100]], +]); + +// La séance : la petite démonstration à la révision 1, puis à chaque +// révision jusqu'à 120 le retour que RETOURS y place, ou le geste du cycle. +// Un geste sans effet — retenir sans proposition ou ce qui est déjà retenu, +// retoucher sans retenu, effacer sans proposition — cède la place à un +// réglage basculé : chaque geste change la charge. Un jalon nomme la +// création, et l'instant qui précède chaque génération qui pose (§ 8.3). +function seance() { + const etapes = [{ apres: chargeDe('petite'), geste: 'creer', jalons: ['Création'] }]; + for (let r = 2; r <= 120; r += 1) { + const courante = etapes.at(-1).apres; + if (RETOURS.has(r)) { + const [sens, retour] = RETOURS.get(r); + etapes.push({ apres: copie(etapes[retour - 1].apres), geste: sens, sens, retour, jalons: [] }); + continue; + } + let [geste, faire] = CYCLE[r % CYCLE.length]; + let apres = faire(courante, r); + if (texteCanonique(apres) === texteCanonique(courante)) [geste, apres] = ['regler', regler(courante, r)]; + apres.evenement.etat = etatDeduit(apres); + if (geste === 'generer' && apres.propositions.length > courante.propositions.length) { + geste = 'poser'; + etapes.at(-1).jalons.push('Avant génération'); + } + etapes.push({ apres, geste, jalons: [] }); + } + return etapes; +} + +const SEANCE = seance(); +const texteSeance = memoire(() => journalDe(SEANCE)); + +// --- Les lignes ------------------------------------------------------------ + +// Texte compact de la charge du contrat sans liste, dans l'ordre canonique. +const INSTANTANE_VIDE = + `{"evenement":{"id":"evt-essai","nom":"Soirée d'essai","date":null,"siegesParDefaut":2,"tours":2,"unite":"cm",` + + `"etat":"brouillon","filiation":null},"reglages":{"separerAppartenances":true,"nouveauxVoisins":true,` + + `"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,` + + `"arret":200000,"historique":1000}},"prochainsIds":{"participant":1,"table":1,"proposition":1},` + + `"participants":[],"tables":[],"reservations":[],"titres":[],"propositions":[],"retenu":null}`; + +describe('les lignes du journal : le texte exact, et ce que l’écriture refuse (§ 8.2, § 8.3)', () => { + test('ouverture, entrée en instantané, entrée en correctif, entrée de retour, jalon : le texte du contrat, sans fin de ligne', () => { + const vide = chargeVide(); + const iris = avecIris(); + const tard = copie(iris); + tard.participants[0].notes = 'arrive tard'; + const lignes = [ + ligneOuverture(EVENEMENT), + ligneEntree({ + revision: 1, + libelle: "Création de l'événement « Soirée d'essai »", + horodatage: '2026-10-06T19:02:11-04:00', + produitVersion: V1, + apres: vide, + instantane: true, + }), + ligneJalon({ revision: 1, nom: 'Création', horodatage: '2026-10-06T19:02:11-04:00' }), + ligneEntree({ + revision: 2, + libelle: 'Ajouté Iris Ombrelle', + horodatage: '2026-10-06T19:03:40-04:00', + avant: vide, + apres: iris, + instantane: false, + }), + ligneEntree({ + revision: 3, + libelle: 'Modifié Iris Ombrelle', + horodatage: '2026-10-06T19:04:15-04:00', + avant: iris, + apres: tard, + instantane: false, + }), + ligneEntree({ + revision: 4, + libelle: 'Retour à « Ajouté Iris Ombrelle »', + horodatage: '2026-10-06T19:05:02-04:00', + sens: 'revenir', + retour: 2, + avant: tard, + apres: iris, + instantane: false, + }), + ]; + assert.equal(lignes[0], '{"type":"journal","format":1,"evenement":"evt-essai"}'); + assert.equal( + lignes[1], + `{"type":"entree","revision":1,"libelle":"Création de l'événement « Soirée d'essai »",` + + `"horodatage":"2026-10-06T19:02:11-04:00","sens":null,"retour":null,"produitVersion":"${V1}",` + + `"instantane":${INSTANTANE_VIDE},"correctif":null}`, + ); + assert.equal(lignes[2], '{"type":"jalon","revision":1,"nom":"Création","horodatage":"2026-10-06T19:02:11-04:00"}'); + assert.equal( + lignes[3], + '{"type":"entree","revision":2,"libelle":"Ajouté Iris Ombrelle","horodatage":"2026-10-06T19:03:40-04:00",' + + '"sens":null,"retour":null,"produitVersion":null,"instantane":null,"correctif":[' + + '{"op":"poser","chemin":["prochainsIds","participant"],"valeur":2},' + + '{"op":"poser","chemin":["participants",{"id":1}],"valeur":{"id":1,"nom":"Ombrelle","prenom":"Iris",' + + '"appartenance":"Club des Merles","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false}}]}', + ); + assert.equal( + lignes[5], + '{"type":"entree","revision":4,"libelle":"Retour à « Ajouté Iris Ombrelle »",' + + '"horodatage":"2026-10-06T19:05:02-04:00","sens":"revenir","retour":2,"produitVersion":null,' + + '"instantane":null,"correctif":[{"op":"poser","chemin":["participants",{"id":1},"notes"],"valeur":null}]}', + ); + for (const ligne of lignes) assert.doesNotMatch(ligne, /[\n\r]/); + for (const charge of [vide, iris, tard]) { + assert.doesNotThrow(() => analyser(serialiser(charge, { revision: 1, produitVersion: V1 }))); + } + + // Relu : quatre entrées dans l'ordre des lignes, le jalon, rien d'écarté. + const journal = lireJournal(texteDe(lignes)); + assert.equal(journal.evenement, EVENEMENT); + assert.deepEqual( + journal.entrees.map(({ revision, sens, retour }) => [revision, sens, retour]), + [[1, null, null], [2, null, null], [3, null, null], [4, 'revenir', 2]], + ); + assert.deepEqual(journal.jalons, [ + { type: 'jalon', revision: 1, nom: 'Création', horodatage: '2026-10-06T19:02:11-04:00' }, + ]); + assert.equal(journal.ecartees, 0); + assert.equal(texteCanonique(reconstruire(journal, 4)), texteCanonique(iris)); + }); + + test('une entrée ne dépend ni de l’ordre des clés ni de celui des listes de la charge reçue', () => { + const avant = avecIris(); + const apres = ajouter(avant, 3); + // La même charge, chaque objet refait clés à rebours, les personnes en ordre inverse. + const aRebours = (valeur) => { + if (Array.isArray(valeur)) return valeur.map(aRebours); + if (typeof valeur !== 'object' || valeur === null) return valeur; + return Object.fromEntries(Object.keys(valeur).reverse().map((cle) => [cle, aRebours(valeur[cle])])); + }; + const renversee = aRebours(apres); + renversee.participants.reverse(); + assert.notEqual(JSON.stringify(renversee), JSON.stringify(apres)); + const options = { revision: 2, libelle: 'Ajouté', horodatage: horodatage(2), avant }; + for (const instantane of [true, false]) { + assert.equal( + ligneEntree({ ...options, apres: renversee, instantane }), + ligneEntree({ ...options, apres, instantane }), + ); + } + assert.equal( + ligneEntree({ ...options, avant: aRebours(avant), apres, instantane: false }), + ligneEntree({ ...options, apres, instantane: false }), + ); + }); + + test('l’écriture refuse, par TypeError, toute ligne que la lecture refuserait', () => { + const avant = chargeVide(); + const apres = avecIris(); + const valide = { revision: 2, libelle: 'Ajouté Iris Ombrelle', horodatage: horodatage(2), avant, apres, instantane: false }; + assert.doesNotThrow(() => ligneEntree(valide)); + const nomVide = avecIris(); + nomVide.participants[0].nom = ''; + const sansReglages = avecIris(); + delete sansReglages.reglages; + const cleInconnue = avecIris(); + cleInconnue.participants[0].auteur = 'Théo'; + // Une proposition de forme admise, puis la même hors de sa règle. + const avecProposition = avecIris(); + avecProposition.prochainsIds.proposition = 2; + avecProposition.propositions.push({ + id: 1, + graine: 7, + arret: 100, + historique: 10, + produitVersion: V1, + siegesAttribues: false, + tables: [], + capacites: [], + tours: 2, + participants: [], + placement: [ + { sieges: [], reserve: [] }, + { sieges: [], reserve: [] }, + ], + }); + assert.doesNotThrow(() => ligneEntree({ ...valide, apres: avecProposition, instantane: true })); + const graineNegative = copie(avecProposition); + graineNegative.propositions[0].graine = -1; + const refus = [ + ['révision 0', { revision: 0 }], + ['révision non entière', { revision: 2.5 }], + ['révision en texte', { revision: '2' }], + ['libellé vide', { libelle: '' }], + ['libellé absent', { libelle: undefined }], + ['horodatage sans fuseau', { horodatage: '2026-10-06T19:03:40' }], + ['horodatage en temps universel', { horodatage: '2026-10-06T23:03:40Z' }], + ['horodatage qui n’est pas un texte', { horodatage: 20261006 }], + ['sens inconnu', { sens: 'annuler', retour: 1 }], + ['retour sans sens', { retour: 1 }], + ['sens sans retour', { sens: 'defaire' }], + ['retour vers sa propre révision', { sens: 'revenir', retour: 2 }], + ['retour vers la révision 0', { sens: 'revenir', retour: 0 }], + ['version vide', { produitVersion: '' }], + ['instantane absent', { instantane: undefined }], + ['instantane qui n’est pas un booléen', { instantane: 1 }], + ['charge hors de sa règle, en correctif', { apres: nomVide }], + ['charge hors de sa règle, en instantané', { apres: nomVide, instantane: true }], + ['charge sans réglages', { apres: sansReglages }], + ['charge absente', { apres: undefined }], + ['clé inconnue dans la charge, en correctif', { apres: cleInconnue }], + ['clé inconnue dans la charge, en instantané', { apres: cleInconnue, instantane: true }], + ['proposition hors de sa règle', { apres: graineNegative, instantane: true }], + ['charge précédente absente, en correctif', { avant: undefined }], + ]; + for (const [nom, changement] of refus) { + assert.throws(() => ligneEntree({ ...valide, ...changement }), TypeError, nom); + } + for (const changement of [{ revision: 0 }, { nom: '' }, { horodatage: 'hier' }, { revision: undefined }]) { + const jalon = { revision: 1, nom: 'Création', horodatage: horodatage(1), ...changement }; + assert.throws(() => ligneJalon(jalon), TypeError, JSON.stringify(changement)); + } + for (const evenement of ['', null, 7, undefined]) { + assert.throws(() => ligneOuverture(evenement), TypeError, String(evenement)); + } + }); +}); + +// --- La cadence ------------------------------------------------------------ + +describe('la cadence des instantanés (§ 8.6)', () => { + test('constantes : un instantané toutes les 50 révisions, un plancher de 500 entrées, un plafond de 600', () => { + assert.equal(INTERVALLE_INSTANTANE, 50); + assert.equal(PLANCHER_ENTREES, 500); + assert.equal(PLAFOND_ENTREES, 600); + }); + + test('estInstantane : la première révision du journal, puis toutes les 50 comptées depuis elle, jamais avant elle', () => { + const cas = [ + [1, 1, true], + [2, 1, false], + [50, 1, false], + [51, 1, true], + [101, 1, true], + [120, 1, false], + [151, 151, true], + [200, 151, false], + [201, 151, true], + [101, 151, false], + [1, 51, false], + [37, 37, true], + [87, 37, true], + ]; + for (const [revision, premiere, attendu] of cas) { + assert.equal(estInstantane(revision, premiere), attendu, `${revision} depuis ${premiere}`); + } + }); + + test('120 entrées depuis la révision 1 : instantanés aux révisions 1, 51 et 101, correctifs ailleurs', () => { + const journal = lireJournal(texteSeance()); + assert.equal(journal.entrees.length, 120); + assert.deepEqual( + journal.entrees.filter(({ instantane }) => instantane !== null).map(({ revision }) => revision), + [1, 51, 101], + ); + for (const { revision, instantane, correctif } of journal.entrees) { + assert.equal(Array.isArray(correctif), instantane === null, `révision ${revision}`); + } + // Le texte le dit aussi : chaque ligne d'entrée porte l'un ou l'autre. + const lignes = lignesDe(texteSeance()).filter((ligne) => ligne.startsWith('{"type":"entree"')); + assert.deepEqual( + lignes.flatMap((ligne, rang) => (ligne.includes('"instantane":{') ? [rang + 1] : [])), + [1, 51, 101], + ); + }); +}); + +// --- La restitution -------------------------------------------------------- + +describe('chaque instant se reconstruit (§ 8.3, § 14.10)', () => { + test('la séance : 120 gestes — ajouts, modifications, exclusions, propositions posées, retenues et retouchées, retours —, chaque charge admise, chacune différente de la précédente', () => { + assert.equal(SEANCE.length, 120); + const compte = new Map(); + for (const { geste } of SEANCE) compte.set(geste, (compte.get(geste) ?? 0) + 1); + const planchers = { ajouter: 5, modifier: 5, exclure: 5, poser: 10, retenir: 4, retoucher: 8, supprimer: 5, effacer: 5 }; + for (const [geste, plancher] of Object.entries(planchers)) { + assert.ok((compte.get(geste) ?? 0) >= plancher, `${geste} : ${compte.get(geste) ?? 0} fois, moins de ${plancher}`); + } + assert.deepEqual(['revenir', 'defaire', 'refaire'].map((sens) => compte.get(sens)), [2, 2, 1]); + SEANCE.forEach(({ apres }, rang) => { + const revision = rang + 1; + assert.doesNotThrow(() => analyser(serialiser(apres, { revision, produitVersion: V1 })), `révision ${revision}`); + const { fautives, retenu } = examiner(apres); + assert.deepEqual([fautives, retenu.fautes], [[], []], `révision ${revision}`); + if (rang > 0) assert.notEqual(texteCanonique(apres), texteCanonique(SEANCE[rang - 1].apres), `révision ${revision}`); + }); + // Défaire et refaire visent ce que le fil courant désigne juste avant eux. + for (const [revision, [sens, cible]] of RETOURS) { + if (sens === 'revenir') continue; + const avant = fil(lireJournal(journalDe(SEANCE.slice(0, revision - 1)))); + assert.equal(sens === 'defaire' ? avant.cibleDefaire : avant.cibleRefaire, cible, `révision ${revision}`); + } + }); + + test('reconstruire(journal, r) égale, en texte canonique, la charge écrite à la révision r, pour chacune des 120', () => { + const journal = lireJournal(texteSeance()); + assert.equal(journal.ecartees, 0); + SEANCE.forEach(({ apres }, rang) => { + assert.equal(texteCanonique(reconstruire(journal, rang + 1)), texteCanonique(apres), `révision ${rang + 1}`); + }); + }); + + test('le fil courant de la séance remonte ses retours : les gestes que défaire ou revenir ont quittés en sont absents', () => { + assert.deepEqual(fil(lireJournal(texteSeance())), { + position: 120, + pile: [], + courant: [...de(120, 103), 100, ...de(60, 54), ...de(51, 31), ...de(20, 1)], + cibleDefaire: 119, + cibleRefaire: null, + }); + }); + + test('un correctif qui ne s’applique pas lève CORRECTIF de sa révision à l’instantané suivant, et nulle part ailleurs', () => { + const lignes = lignesDe(texteSeance()); + const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree","revision":33,')); + const entree = JSON.parse(lignes[rang]); + lignes[rang] = JSON.stringify({ ...entree, correctif: [{ op: 'retirer', chemin: ['participants', { id: 999 }] }] }); + const journal = lireJournal(texteDe(lignes)); + assert.equal(journal.entrees.length, 120); + for (const revision of [33, 34, 49, 50]) { + assert.throws( + () => reconstruire(journal, revision), + (erreur) => erreur instanceof ErreurStockage && erreur.code === 'CORRECTIF' && erreur.details.rang === 0, + `révision ${revision}`, + ); + } + for (const revision of [1, 32, 51, 52, 53, 100, 120]) { + assert.equal(texteCanonique(reconstruire(journal, revision)), texteCanonique(SEANCE[revision - 1].apres)); + } + }); + + test('un retenu hors de sa règle, que le contrôle des placements garde, traverse le journal tel quel : posé, gardé, libéré, remis', () => { + // Le retenu porte une clé inconnue et n'a pas de placement : l'analyse + // l'admet comme conteneur, et canonique.js le recopie hors du schéma. + const fautif = { proposition: 1, siegesAttribues: false, tables: [1], capacites: [2], tours: 2, participants: [1], auteur: 'Iris' }; + const etapes = suite(5); + for (const { apres } of etapes) apres.prochainsIds.proposition = 2; + for (const rang of [1, 2]) etapes[rang].apres.retenu = copie(fautif); + // Seule la forme du retenu est fautive : l'analyse admet la charge, et le + // contrôle des placements ne nomme que cette faute. + assert.doesNotThrow(() => analyser(serialiser(etapes[1].apres, { revision: 2, produitVersion: V1 }))); + assert.deepEqual(examiner(etapes[1].apres).retenu.fautes, [{ code: 'FORME', chemin: 'placement' }]); + etapes[4] = { apres: etapes[2].apres, sens: 'revenir', retour: 3 }; + for (const premiere of [1, 2]) { + const liste = etapes.slice(premiere - 1); + const journal = lireJournal(journalDe(liste, { premiere })); + assert.deepEqual([journal.entrees.length, journal.ecartees], [liste.length, 0]); + liste.forEach(({ apres }, rang) => { + assert.equal(texteCanonique(reconstruire(journal, premiere + rang)), texteCanonique(apres), `révision ${premiere + rang}`); + }); + } + assert.match(texteCanonique(reconstruire(lireJournal(journalDe(etapes)), 5)), /"retenu": \{"auteur":"Iris",/); + }); + + test('une révision absente du journal lève RangeError ; la charge rendue ne partage rien avec le journal', () => { + const journal = lireJournal(texteSeance()); + for (const revision of [0, 121, -1, 2.5, '5', null, undefined]) { + assert.throws(() => reconstruire(journal, revision), RangeError, String(revision)); + } + assert.throws(() => reconstruire(lireJournal(''), 1), RangeError); + for (const revision of [1, 51, 77]) { + const rendue = reconstruire(journal, revision); + rendue.participants.length = 0; + rendue.evenement.nom = 'autre'; + assert.equal(texteCanonique(reconstruire(journal, revision)), texteCanonique(SEANCE[revision - 1].apres)); + } + }); +}); + +// --- La lecture ------------------------------------------------------------ + +describe('la lecture s’arrête à la première ligne illisible et compte ce qu’elle écarte (§ 8.6)', () => { + const cent = memoire(() => journalDe(suite(100))); + const lignesCent = memoire(() => lignesDe(cent())); + // Le journal de cent entrées dont la ligne de rang donné — 0 pour + // l'ouverture, r pour l'entrée r — est remplacée par remplacer(ligne, valeur + // relue). + const abime = (rang, remplacer) => { + const lignes = [...lignesCent()]; + lignes[rang] = remplacer(lignes[rang], JSON.parse(lignes[rang])); + return texteDe(lignes); + }; + const retouche = (changer) => (_, valeur) => JSON.stringify(changer(valeur)); + // Le journal de cent entrées où la ligne donnée s'insère au rang donné. + const insere = (rang, ligne) => texteDe(lignesCent().toSpliced(rang, 0, ligne)); + // Ce que la lecture retient et écarte, en nombres. + const bilan = (texte) => { + const { evenement, entrees, jalons, ecartees } = lireJournal(texte); + return { evenement, entrees: entrees.length, derniere: entrees.at(-1)?.revision ?? null, jalons: jalons.length, ecartees }; + }; + + test('journal de 100 entrées, la 37ᵉ abîmée : 36 entrées retenues, 64 annoncées écartées, quelle que soit l’avarie', () => { + assert.deepEqual(bilan(cent()), { evenement: EVENEMENT, entrees: 100, derniere: 100, jalons: 0, ecartees: 0 }); + const instantane1 = JSON.parse(lignesCent()[1]).instantane; + const avaries = [ + ['JSON tronqué', (ligne) => ligne.slice(0, -1)], + ['ligne vide', () => ''], + ['blancs seuls', () => ' '], + ['null', () => 'null'], + ['une liste', () => '[]'], + ['un nombre', () => '37'], + ['type inconnu', retouche((valeur) => ({ ...valeur, type: 'entre' }))], + ['clé inconnue', retouche((valeur) => ({ ...valeur, auteur: 'Iris' }))], + ['clé __proto__', (ligne) => ligne.replace(/\}$/, ',"__proto__":{}}')], + ['clé manquante', retouche(({ libelle, ...reste }) => reste)], + ['révision répétée', retouche((valeur) => ({ ...valeur, revision: 36 }))], + ['révision sautée', retouche((valeur) => ({ ...valeur, revision: 38 }))], + ['révision en texte', retouche((valeur) => ({ ...valeur, revision: '37' }))], + ['libellé vide', retouche((valeur) => ({ ...valeur, libelle: '' }))], + ['horodatage sans fuseau', retouche((valeur) => ({ ...valeur, horodatage: '2031-05-17T08:37:00' }))], + ['horodatage qui n’est pas un texte', retouche((valeur) => ({ ...valeur, horodatage: 20310517 }))], + ['sens inconnu', retouche((valeur) => ({ ...valeur, sens: 'annuler', retour: 3 }))], + ['retour sans sens', retouche((valeur) => ({ ...valeur, retour: 3 }))], + ['sens sans retour', retouche((valeur) => ({ ...valeur, sens: 'revenir' }))], + ['retour vers sa propre révision', retouche((valeur) => ({ ...valeur, sens: 'revenir', retour: 37 }))], + ['version vide', retouche((valeur) => ({ ...valeur, produitVersion: '' }))], + ['correctif qui n’est pas une liste', retouche((valeur) => ({ ...valeur, correctif: {} }))], + ['ni instantané ni correctif', retouche((valeur) => ({ ...valeur, correctif: null }))], + ['instantané et correctif', retouche((valeur) => ({ ...valeur, instantane: instantane1 }))], + ['instantané hors de la cadence', retouche((valeur) => ({ ...valeur, instantane: instantane1, correctif: null }))], + ]; + for (const [nom, remplacer] of avaries) { + assert.deepEqual( + bilan(abime(37, remplacer)), + { evenement: EVENEMENT, entrees: 36, derniere: 36, jalons: 0, ecartees: 64 }, + nom, + ); + } + }); + + test('une dernière ligne sans fin de ligne est écartée et comptée, même lisible', () => { + assert.deepEqual(bilan(cent().slice(0, -1)), { evenement: EVENEMENT, entrees: 99, derniere: 99, jalons: 0, ecartees: 1 }); + const coupee = cent().slice(0, cent().lastIndexOf('{"op"')); + assert.deepEqual(bilan(coupee), { evenement: EVENEMENT, entrees: 99, derniere: 99, jalons: 0, ecartees: 1 }); + }); + + test('une révision qui saute arrête la lecture : l’entrée 37 manquante, la 38 et la suite sont écartées', () => { + const sansLa37 = texteDe(lignesCent().toSpliced(37, 1)); + assert.deepEqual(bilan(sansLa37), { evenement: EVENEMENT, entrees: 36, derniere: 36, jalons: 0, ecartees: 63 }); + }); + + test('la première entrée porte sa version et un instantané de forme admise ; la cadence tient depuis elle', () => { + const toutEcarte = { evenement: EVENEMENT, entrees: 0, derniere: null, jalons: 0, ecartees: 100 }; + const instantaneAvec = (changer) => + retouche((valeur) => { + const instantane = copie(valeur.instantane); + changer(instantane); + return { ...valeur, instantane }; + }); + const proposition = { + id: 1, + graine: 7, + arret: 100, + historique: 10, + produitVersion: V1, + siegesAttribues: false, + tables: [], + capacites: [], + tours: 2, + participants: [], + placement: [ + { sieges: [], reserve: [] }, + { sieges: [], reserve: [] }, + ], + }; + const { graine, ...sansGraine } = proposition; + const avaries = [ + ['sans version', retouche((valeur) => ({ ...valeur, produitVersion: null }))], + ['en correctif', retouche((valeur) => ({ ...valeur, instantane: null, correctif: [] }))], + ['instantané qui n’est pas un objet', retouche((valeur) => ({ ...valeur, instantane: 'charge' }))], + ['instantané dont une personne a un nom vide', instantaneAvec((charge) => { charge.participants[0].nom = ''; })], + ['instantané sans réglages', instantaneAvec((charge) => { delete charge.reglages; })], + ['instantané à clé inconnue', instantaneAvec((charge) => { charge.auteur = 'Iris'; })], + ['instantané dont une proposition sort de sa règle', instantaneAvec((charge) => { charge.propositions.push({ ...proposition, graine: -1 }); })], + ['instantané dont une proposition manque une clé', instantaneAvec((charge) => { charge.propositions.push(sansGraine); })], + ]; + for (const [nom, remplacer] of avaries) assert.deepEqual(bilan(abime(1, remplacer)), toutEcarte, nom); + // Une proposition de forme admise ne l'empêche pas. + const admise = abime(1, instantaneAvec((charge) => { charge.propositions.push(proposition); })); + assert.equal(bilan(admise).entrees, 100); + // La révision 51 ouvre un intervalle : un correctif à sa place arrête la lecture. + const sans51 = abime(51, retouche((valeur) => ({ ...valeur, instantane: null, correctif: [] }))); + assert.deepEqual(bilan(sans51), { evenement: EVENEMENT, entrees: 50, derniere: 50, jalons: 0, ecartees: 50 }); + // Un journal peut commencer à une révision quelconque : sa cadence part de + // sa première entrée. Commencé à 137, il porte ses instantanés à 137 et + // 187 ; un compte depuis la révision 1 les attendrait à 151 et 201, et la + // lecture s'arrêterait à 151. + const depuis137 = journalDe(suite(64), { premiere: 137 }); + assert.deepEqual(bilan(depuis137), { evenement: EVENEMENT, entrees: 64, derniere: 200, jalons: 0, ecartees: 0 }); + assert.deepEqual( + lireJournal(depuis137).entrees.filter(({ instantane }) => instantane !== null).map(({ revision }) => revision), + [137, 187], + ); + }); + + test('une ouverture illisible écarte tout, et le journal n’a plus d’événement', () => { + const avaries = [ + ['JSON invalide', (ligne) => ligne.slice(1)], + ['type inconnu', retouche((valeur) => ({ ...valeur, type: 'jornal' }))], + ['format plus récent', retouche((valeur) => ({ ...valeur, format: 2 }))], + ['format en texte', retouche((valeur) => ({ ...valeur, format: '1' }))], + ['événement vide', retouche((valeur) => ({ ...valeur, evenement: '' }))], + ['événement absent', retouche(({ evenement, ...reste }) => reste)], + ['clé inconnue', retouche((valeur) => ({ ...valeur, nom: "Soirée d'essai" }))], + ['une entrée à sa place', () => lignesCent()[1]], + ]; + for (const [nom, remplacer] of avaries) { + assert.deepEqual(bilan(abime(0, remplacer)), { evenement: null, entrees: 0, derniere: null, jalons: 0, ecartees: 101 }, nom); + } + }); + + test('un jalon nomme une révision lue : avant toute entrée, à venir, élaguée ou sans nom, il arrête la lecture ; les jalons écartés se comptent comme lignes', () => { + const jalon = (revision, nom = `Jalon ${revision}`) => ligneJalon({ revision, nom, horodatage: horodatage(revision) }); + assert.deepEqual(bilan(insere(1, jalon(1))), { evenement: EVENEMENT, entrees: 0, derniere: null, jalons: 0, ecartees: 101 }); + assert.deepEqual(bilan(insere(37, jalon(37))), { evenement: EVENEMENT, entrees: 36, derniere: 36, jalons: 0, ecartees: 65 }); + assert.deepEqual(bilan(insere(37, jalon(36))), { evenement: EVENEMENT, entrees: 100, derniere: 100, jalons: 1, ecartees: 0 }); + assert.deepEqual(bilan(insere(37, jalon(1))), { evenement: EVENEMENT, entrees: 100, derniere: 100, jalons: 1, ecartees: 0 }); + const sansNom = insere(37, jalon(36).replace('"nom":"Jalon 36"', '"nom":""')); + assert.deepEqual(bilan(sansNom), { evenement: EVENEMENT, entrees: 36, derniere: 36, jalons: 0, ecartees: 65 }); + const depuis101 = lignesDe(journalDe(suite(5), { premiere: 101 })); + assert.deepEqual(bilan(texteDe(depuis101.toSpliced(2, 0, jalon(100)))), { + evenement: EVENEMENT, + entrees: 1, + derniere: 101, + jalons: 0, + ecartees: 5, + }); + // Dix entrées, des jalons aux révisions 2, 5 et 8, la 4ᵉ abîmée : trois + // entrées et un jalon retenus ; les sept entrées et deux jalons qui + // suivent, écartés. + const etapes = suite(10).map((etape, rang) => ({ ...etape, jalons: [2, 5, 8].includes(rang + 1) ? ['Jalon'] : [] })); + const lignes = lignesDe(journalDe(etapes)); + const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree","revision":4,')); + lignes[rang] = lignes[rang].slice(0, 20); + assert.deepEqual(bilan(texteDe(lignes)), { evenement: EVENEMENT, entrees: 3, derniere: 3, jalons: 1, ecartees: 9 }); + }); + + test('texte vide, ouverture seule ou coupée ; marque d’ordre d’octets, fins de ligne CRLF, horloge qui recule', () => { + assert.deepEqual(lireJournal(''), { evenement: null, entrees: [], jalons: [], ecartees: 0 }); + assert.deepEqual(lireJournal(texteDe([ligneOuverture(EVENEMENT)])), { + evenement: EVENEMENT, + entrees: [], + jalons: [], + ecartees: 0, + }); + assert.deepEqual(lireJournal(ligneOuverture(EVENEMENT)), { evenement: null, entrees: [], jalons: [], ecartees: 1 }); + assert.deepEqual(lireJournal('\n'), { evenement: null, entrees: [], jalons: [], ecartees: 1 }); + assert.deepEqual(lireJournal(`\u{FEFF}${cent()}`), lireJournal(cent())); + assert.deepEqual(lireJournal(cent().replaceAll('\n', '\r\n')), lireJournal(cent())); + // L'ordre est celui des lignes : un horodatage qui recule n'y change rien. + const etapes = suite(5); + const lignes = [ligneOuverture(EVENEMENT)]; + etapes.forEach(({ apres }, rang) => { + lignes.push( + ligneEntree({ + revision: rang + 1, + libelle: `Geste ${rang + 1}`, + horodatage: horodatage(100 - rang), + produitVersion: rang === 0 ? V1 : null, + avant: etapes[rang - 1]?.apres, + apres, + instantane: rang === 0, + }), + ); + }); + const journal = lireJournal(texteDe(lignes)); + assert.deepEqual(journal.entrees.map(({ revision }) => revision), [1, 2, 3, 4, 5]); + assert.equal(fil(journal).position, 5); + assert.equal(texteCanonique(reconstruire(journal, 5)), texteCanonique(etapes[4].apres)); + }); +}); + +// --- Le fil courant -------------------------------------------------------- + +describe('le fil courant, défaire et refaire (§ 8.3)', () => { + // Les étapes 1 à 5 sont des gestes ; la 6 défait vers 4, la 7 vers 3, la + // 8 refait vers 4, la 9 est un geste. Chaque retour porte la charge de sa + // cible. + const ETAPES = suite(9); + ETAPES[5] = { apres: ETAPES[3].apres, sens: 'defaire', retour: 4 }; + ETAPES[6] = { apres: ETAPES[2].apres, sens: 'defaire', retour: 3 }; + ETAPES[7] = { apres: ETAPES[3].apres, sens: 'refaire', retour: 4 }; + const filApres = (n, etapes = ETAPES) => fil(lireJournal(journalDe(etapes.slice(0, n)))); + + test('gestes 1 à 5, défaire (6 → 4), défaire (7 → 3), refaire (8 → 4), geste 9 : position 9, pile vide, refaire null, défaire → 4, fil courant [9, 4, 3, 2, 1], le geste 5 hors du fil', () => { + assert.deepEqual(filApres(9), { position: 9, pile: [], courant: [9, 4, 3, 2, 1], cibleDefaire: 4, cibleRefaire: null }); + }); + + test('après l’entrée 7 : défaire → 2, refaire → 4 ; après la 6 et la 8, défaire → 3, refaire → 5', () => { + assert.deepEqual(filApres(7), { position: 3, pile: [5, 4], courant: [3, 2, 1], cibleDefaire: 2, cibleRefaire: 4 }); + assert.deepEqual(filApres(6), { position: 4, pile: [5], courant: [4, 3, 2, 1], cibleDefaire: 3, cibleRefaire: 5 }); + assert.deepEqual(filApres(8), { position: 4, pile: [5], courant: [4, 3, 2, 1], cibleDefaire: 3, cibleRefaire: 5 }); + }); + + test('un revenir vers 2 depuis 5 se défait par refaire vers 5', () => { + const etapes = suite(7); + etapes[5] = { apres: etapes[1].apres, sens: 'revenir', retour: 2 }; + etapes[6] = { apres: etapes[4].apres, sens: 'refaire', retour: 5 }; + assert.deepEqual(filApres(6, etapes), { position: 2, pile: [5], courant: [2, 1], cibleDefaire: 1, cibleRefaire: 5 }); + assert.deepEqual(filApres(7, etapes), { position: 5, pile: [], courant: [5, 4, 3, 2, 1], cibleDefaire: 4, cibleRefaire: null }); + }); + + test('un retour vers une entrée de retour prend la position de celle-ci', () => { + const etapes = suite(8); + etapes[5] = { apres: etapes[1].apres, sens: 'revenir', retour: 2 }; + etapes[7] = { apres: etapes[1].apres, sens: 'revenir', retour: 6 }; + assert.deepEqual(filApres(8, etapes), { position: 2, pile: [7], courant: [2, 1], cibleDefaire: 1, cibleRefaire: 7 }); + }); + + test('un journal sans entrée n’a pas de position ; d’une seule entrée, rien à défaire', () => { + const vide = { position: null, pile: [], courant: [], cibleDefaire: null, cibleRefaire: null }; + assert.deepEqual(fil(lireJournal('')), vide); + assert.deepEqual(fil(lireJournal(texteDe([ligneOuverture(EVENEMENT)]))), vide); + assert.deepEqual(filApres(1), { position: 1, pile: [], courant: [1], cibleDefaire: null, cibleRefaire: null }); + }); + + test('un retour dont la cible précède le journal est une position sans prédécesseur ; la pile repart de lui', () => { + // Révisions 101 à 104 : revenir vers 60, un geste, défaire vers 101, + // refaire vers 102. + const etapes = suite(4); + etapes[0] = { ...etapes[0], sens: 'revenir', retour: 60 }; + etapes[2] = { apres: etapes[0].apres, sens: 'defaire', retour: 101 }; + etapes[3] = { apres: etapes[1].apres, sens: 'refaire', retour: 102 }; + const filDepuis101 = (n, liste = etapes) => fil(lireJournal(journalDe(liste.slice(0, n), { premiere: 101 }))); + assert.deepEqual(filDepuis101(1), { position: 101, pile: [], courant: [101], cibleDefaire: null, cibleRefaire: null }); + assert.deepEqual(filDepuis101(3), { position: 101, pile: [102], courant: [101], cibleDefaire: null, cibleRefaire: 102 }); + assert.deepEqual(filDepuis101(4), { position: 102, pile: [], courant: [102, 101], cibleDefaire: 101, cibleRefaire: null }); + // Refaire vers une révision qui précède le journal, pile vide. + const refaireSansPile = suite(2); + refaireSansPile[1] = { ...refaireSansPile[1], sens: 'refaire', retour: 98 }; + assert.deepEqual(filDepuis101(2, refaireSansPile), { + position: 102, + pile: [], + courant: [102], + cibleDefaire: null, + cibleRefaire: null, + }); + }); +}); + +// --- La construction notée ------------------------------------------------- + +describe('la construction notée (§ 8.8)', () => { + test('produitVersion non nulle à la première entrée et au premier geste d’une autre construction, nulle ailleurs ; versionDe rend celle de chaque révision', () => { + const versions = [...Array(10).fill(V1), ...Array(10).fill(V2), ...Array(5).fill(V1)]; + const etapes = suite(25).map((etape, rang) => ({ ...etape, version: versions[rang] })); + const journal = lireJournal(journalDe(etapes)); + assert.deepEqual( + journal.entrees.flatMap(({ revision, produitVersion }) => (produitVersion === null ? [] : [[revision, produitVersion]])), + [[1, V1], [11, V2], [21, V1]], + ); + assert.deepEqual(de(1, 25).map((revision) => versionDe(journal, revision)), versions); + for (const absente of [0, 26, -1, 2.5, '3', null, undefined]) assert.equal(versionDe(journal, absente), null); + assert.equal(versionDe(lireJournal(''), 1), null); + // Une seule construction : notée une fois, à la première entrée. + const seance = lireJournal(texteSeance()); + assert.deepEqual(seance.entrees.filter(({ produitVersion }) => produitVersion !== null).map(({ revision }) => revision), [1]); + assert.equal(versionDe(seance, 120), V1); + }); +}); + +// --- L'élagage ------------------------------------------------------------- + +describe('l’élagage : au-delà du plafond, depuis la frontière d’instantané la plus récente qui garde le plancher (§ 8.6)', () => { + // 700 étapes : la construction V2 de la révision 130 à la 159, V1 ailleurs ; + // un jalon toutes les 25 révisions. + const ETAPES = suite(700).map((etape, rang) => ({ + ...etape, + version: rang + 1 >= 130 && rang + 1 < 160 ? V2 : V1, + jalons: (rang + 1) % 25 === 0 ? [`Jalon ${rang + 1}`] : [], + })); + const lignes700 = memoire(() => lignesDe(journalDe(ETAPES))); + // Texte du journal des n premières étapes : les lignes qui précèdent l'entrée n + 1. + const prefixe = (n) => { + const fin = lignes700().findIndex((ligne) => ligne.startsWith(`{"type":"entree","revision":${n + 1},`)); + return texteDe(lignes700().slice(0, fin === -1 ? lignes700().length : fin)); + }; + + test('650 entrées : la première gardée est la révision 151, un instantané, qui porte sa construction ; 500 entrées ; les jalons des entrées retirées partis ; toute autre ligne gardée intacte', () => { + const texte = prefixe(650); + const elague = elaguer(texte); + const journal = lireJournal(elague); + assert.equal(journal.entrees.length, 500); + assert.equal(journal.entrees[0].revision, 151); + assert.notEqual(journal.entrees[0].instantane, null); + assert.equal(journal.ecartees, 0); + assert.deepEqual( + journal.jalons.map(({ revision }) => revision), + de(151, 650).filter((revision) => revision % 25 === 0), + ); + // Le texte attendu : l'ouverture, l'entrée 151 qui porte V2, la + // construction de la révision 130 dont elle hérite, puis les lignes + // d'origine de révision 151 ou plus, telles quelles. + const origine = lignesDe(texte); + const valeurs = origine.map((ligne) => JSON.parse(ligne)); + const rang = valeurs.findIndex(({ type, revision }) => type === 'entree' && revision === 151); + assert.equal(valeurs[rang].produitVersion, null); + const attendu = [ + origine[0], + JSON.stringify({ ...valeurs[rang], produitVersion: V2 }), + ...origine.slice(rang + 1).filter((_, i) => valeurs[rang + 1 + i].revision >= 151), + ]; + assert.equal(elague, texteDe(attendu)); + // Chaque instant gardé se restitue comme avant l'élagage, avec sa construction. + const avant = lireJournal(texte); + for (const revision of [151, 152, 199, 200, 201, 202, 649, 650]) { + assert.equal(texteCanonique(reconstruire(journal, revision)), texteCanonique(reconstruire(avant, revision))); + } + for (const revision of de(151, 650)) assert.equal(versionDe(journal, revision), versionDe(avant, revision)); + assert.deepEqual(fil(journal), { ...fil(avant), courant: de(650, 151) }); + }); + + test('601 entrées : coupe à 101, 501 gardées ; 600 entrées, et 600 avec des jalons en plus : null', () => { + const journal = lireJournal(elaguer(prefixe(601))); + assert.equal(journal.entrees.length, 501); + assert.equal(journal.entrees[0].revision, 101); + assert.equal(journal.entrees[0].produitVersion, V1); + assert.equal(elaguer(prefixe(600)), null); + const jalonne = suite(600).map((etape) => ({ ...etape, jalons: ['Jalon'] })); + assert.equal(elaguer(journalDe(jalonne)), null); + }); + + test('le plancher l’emporte : de 601 à 700 entrées, la coupe garde de 500 à 549 entrées, depuis un instantané', () => { + for (const n of [601, 602, 625, 649, 650, 651, 675, 699, 700]) { + const coupe = 1 + INTERVALLE_INSTANTANE * Math.floor((n - PLANCHER_ENTREES) / INTERVALLE_INSTANTANE); + const elague = elaguer(prefixe(n)); + const { entrees } = lireJournal(elague); + assert.equal(entrees[0].revision, coupe, `${n} entrées`); + assert.equal(entrees.length, n - coupe + 1, `${n} entrées`); + assert.ok(entrees.length >= PLANCHER_ENTREES && entrees.length < PLANCHER_ENTREES + INTERVALLE_INSTANTANE); + assert.notEqual(entrees[0].instantane, null); + assert.equal(elaguer(elague), null); + } + }); + + test('une première entrée gardée qui porte déjà sa construction garde sa ligne intacte', () => { + const etapes = suite(650).map((etape, rang) => ({ ...etape, version: rang + 1 >= 151 ? V2 : V1 })); + const texte = journalDe(etapes); + const ligne151 = lignesDe(texte).find((ligne) => ligne.startsWith('{"type":"entree","revision":151,')); + assert.match(ligne151, new RegExp(`"produitVersion":"${V2.replaceAll('.', '\\.')}"`)); + assert.equal(lignesDe(elaguer(texte))[1], ligne151); + }); + + test('un journal déjà élagué s’élague depuis sa première révision ; les lignes écartées se recopient ; la marque d’ordre d’octets ne se recopie pas', () => { + // Commencé à la révision 137, le journal porte ses instantanés à 137, + // 187, 237… : la coupe tombe sur 237, le plus récent qui garde le + // plancher, et non sur 201, qu'un compte depuis la révision 1 désignerait. + const depuis137 = lireJournal(elaguer(journalDe(suite(601), { premiere: 137 }))); + assert.deepEqual([depuis137.entrees.length, depuis137.entrees.at(0)?.revision, depuis137.ecartees], [501, 237, 0]); + // Une dernière ligne coupée, puis une ligne illisible suivie d'une autre. + const coupee = `${prefixe(650)}{"type":"entree","revision":651`; + const elaguee = elaguer(coupee); + assert.ok(elaguee.endsWith('\n{"type":"entree","revision":651')); + assert.deepEqual([lireJournal(elaguee).entrees.length, lireJournal(elaguee).ecartees], [500, 1]); + const queue = `${prefixe(650)}illisible\n${lignes700().at(-1)}\n`; + assert.ok(elaguer(queue).endsWith(`\nillisible\n${lignes700().at(-1)}\n`)); + assert.equal(lireJournal(elaguer(queue)).ecartees, 2); + assert.equal(elaguer(`\u{FEFF}${prefixe(601)}`), elaguer(prefixe(601))); + }); + + test('un retour dont la cible est élaguée devient la racine du fil : il porte la charge de sa cible, et défaire s’arrête à lui', () => { + const etapes = suite(601); + etapes[589] = { apres: etapes[59].apres, sens: 'revenir', retour: 60 }; + const texte = journalDe(etapes); + assert.deepEqual(fil(lireJournal(texte)).courant, [...de(601, 591), ...de(60, 1)]); + const journal = lireJournal(elaguer(texte)); + assert.equal(journal.entrees[0].revision, 101); + assert.deepEqual(fil(journal), { position: 601, pile: [], courant: de(601, 590), cibleDefaire: 600, cibleRefaire: null }); + assert.equal(texteCanonique(reconstruire(journal, 590)), texteCanonique(etapes[59].apres)); + }); +});