From fa32ad9fae0d2db31a5d5c8d9a05321961b51060 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 20:39:49 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20storage:=20event=20store=20=E2=80=94=20?= =?UTF-8?q?list,=20create,=20open,=20save,=20recover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An event is a state file and a journal paired by its internal identifier, never by name (§ 8.6): a hand copy of a state does not take the original's journal, and a renamed journal stays paired. Each gesture writes its journal line first, then the .precedent and the state (§ 8.2); recovery compares revisions, never file dates, so a cut leaves the journal one entry ahead at most. Opening never writes (§ 8.4); an unreadable state names its readable secours instead of opening an empty event, and a newer format stays read-only. Writing elsewhere after a removed medium aligns the pair first. Checked: 60 tests, 100 % branches of depot.js, 1257 node tests on HEAD. --- FR --- [ADD] stockage : dépôt d'événements — lister, créer, ouvrir, reprendre Un événement est un fichier d'état et un journal appariés par l'identifiant interne, jamais par le nom (§ 8.6) : un état copié à la main ne prend pas le journal de l'original, et un journal renommé reste apparié. Chaque geste écrit sa ligne de journal d'abord, puis le .precedent et l'état (§ 8.2) ; la reprise compare les révisions, jamais la date des fichiers : une coupure laisse le journal en avance d'une entrée au plus. Ouvrir n'écrit jamais (§ 8.4) ; un état illisible nomme ses secours lisibles au lieu d'ouvrir un événement vide, et un format plus récent reste en lecture seule. Écrire ailleurs après un support retiré aligne d'abord la paire. Vérifié : 60 épreuves, 100 % des branches de depot.js, 1257 épreuves node. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- src/stockage/depot.js | 1140 ++++++++++++++++++ src/stockage/depot.test.js | 2311 ++++++++++++++++++++++++++++++++++++ 2 files changed, 3451 insertions(+) create mode 100644 src/stockage/depot.js create mode 100644 src/stockage/depot.test.js diff --git a/src/stockage/depot.js b/src/stockage/depot.js new file mode 100644 index 0000000..a3e5e8b --- /dev/null +++ b/src/stockage/depot.js @@ -0,0 +1,1140 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le dépôt des événements (§ 8.1, § 8.2, § 8.4, § 8.6, § 8.8, § 8.9) : lister +// un dossier de travail, créer un événement, l'ouvrir, enregistrer ses gestes, +// nommer ses instants, le reprendre après une panne, l'écrire ailleurs. Chaque +// fonction reçoit le système de fichiers (§ 13.4, § 14.10) ; aucune ne lit +// l'horloge ni un aléa : horodatage et version entrent en paramètre, et les +// libellés, textes figés, viennent de l'appelant (§ 14.6). +// +// Un événement est une paire de fichiers que l'identifiant interne apparie, +// jamais le nom (§ 8.6) : .gtt.json, l'état, et le journal de la même +// base quand il porte cet identifiant, sinon le premier du dossier, par nom, +// qui le porte sans appartenir à sa propre base — dont l'état, ou faute +// d'état lisible le .precedent, porte le même identifiant. Un état copié à la +// main dans le même dossier ne s'apparie donc pas au journal de l'original. +// Un état illisible s'apparie par l'identifiant de son .precedent ; sans +// lui, au journal de sa base, quand aucun état lisible du dossier ne porte +// l'identifiant de ce journal. L'écriture suit la même règle : le journal +// d'un événement dont le journal de la base porte un autre événement s'écrit +// dans celui que son identifiant apparie, et ne se crée sous une base libre +// qu'à défaut. Lister, ouvrir et ouvrir un secours ne font que lire (§ 8.4) : +// ce qu'ils trouvent à réparer, le premier geste le répare. +// +// Un geste écrit le journal d'abord, l'état ensuite (§ 8.2). Le journal reçoit +// une ligne par ajout en fin quand il continue tel quel. Il se réécrit en +// entier, par une écriture atomique, quand il reçoit une entrée de reprise, +// seule ou avant la ligne d'un geste ou d'un jalon, quand sa lecture a écarté +// des lignes, que la lecture suivante ne dépasserait pas, quand il manque, et +// quand il dépasse PLAFOND_ENTREES. Le correctif d'une entrée part de la +// charge que restitue l'entrée qui la précède dans le journal, jamais de la +// charge ouverte : un correctif appliqué à une autre charge que la sienne +// s'applique sans lever et rend autre chose. +// +// La reprise à l'ouverture (§ 8.8) compare les révisions, jamais la date des +// fichiers (§ 14.10). Un journal en avance d'une entrée sur l'état se +// rattrape en mémoire. Un journal plus en avance, ou en retard, laisse l'état +// tel quel : la première écriture inscrit une entrée de reprise, qui +// restitue la charge ouverte à la suite du journal. La comparaison ne +// rattrape un geste interrompu que si l'état sur le disque porte la révision +// qui précède sa ligne. Quand il en porte une autre — après un rattrapage, +// une reprise, un secours, un état resté non écrit —, le geste pose d'abord +// la reprise, s'il en faut une, puis l'état à la révision de la dernière +// entrée, et seulement ensuite sa ligne : une coupure laisse le journal en +// avance d'une entrée au plus. Un état illisible lève ETAT_ILLISIBLE et nomme +// les secours lisibles, sans jamais ouvrir un événement vide. +// +// Écrire l'événement ailleurs (§ 8.6) pose une paire alignée, comme un +// geste : le journal en mémoire, en entier — suivi de l'entrée de reprise +// quand il finit sur une autre révision que la charge ouverte —, puis l'état +// que restitue sa dernière entrée, à sa révision. Rouvert là-bas, +// l'événement rend la charge écrite sans rien rattraper. +// +// Un état sur le disque, ou un journal de la base, d'un format plus récent +// que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours +// compris : rien de ce qu'une construction plus récente a écrit dans l'état +// ou dans le journal de la base ne se remplace (§ 8.8). Un journal de la base +// plus récent, dont ce code ne lit pas l'identifiant, se reconnaît avant tout +// appariement, à l'ouverture comme dans la liste, et son secours se refuse au +// lieu de se dire absent. Un .precedent d'un format plus récent +// ne garde que l'Ouvert que son secours ouvre : un geste parti d'une autre +// ouverture le remplace, comme tout .precedent, quand l'état sur le disque +// se lit. +import { canoniser, serialiser } from './canonique.js'; +import { FORMAT, analyser } from './document.js'; +import { ErreurStockage } from './erreurs.js'; +import { + PLAFOND_ENTREES, + elaguer, + estInstantane, + ligneEntree, + ligneJalon, + ligneOuverture, + lireJournal, + reconstruire, + versionDe, +} from './journal.js'; +import { SUFFIXES, deriverBase } from './noms.js'; +import { examiner } from './placements.js'; + +/** + * Un événement ouvert. Chaque fonction qui écrit rend l'Ouvert suivant, et ne + * modifie pas celui qu'elle reçoit. + * + * @typedef {Object} Ouvert + * @property {import('./systeme_fichiers.js').SystemeFichiers} fs + * @property {import('./systeme_fichiers.js').Racine} racine + * @property {string} base base du fichier d'état + * @property {import('./types.js').Charge} charge courante, que rend + * examiner : propositions fautives écartées, compteur relevé + * @property {number} revision révision de la charge courante + * @property {string} produitVersion construction qui a produit la charge + * courante : en-tête lu, version que le journal note pour sa + * révision, ou celle du dernier geste + * @property {string|null} texteEtat texte lisible du fichier d'état sur le + * disque ; null quand il ne l'est pas, ou qu'il manque + * @property {number|null} revisionEtat révision de ce texte, null avec lui : + * quand elle n'est pas celle de la dernière entrée du journal, le + * geste suivant écrit d'abord l'état à cette entrée + * @property {import('./journal.js').Journal|null} journal le journal de + * l'événement, lu jusqu'à son dernier instant restituable ; null + * quand il n'a aucune entrée à lui + * @property {string|null} baseJournal base du fichier du journal, ou de celui + * que la prochaine écriture crée ; null quand le fichier de la base + * porte un autre événement : la prochaine écriture prend le journal + * que l'identifiant de l'événement apparie (apparier), sinon une + * base libre que son nom dérive + * @property {import('./types.js').Charge|null} chargeJournal charge que + * restitue la dernière entrée du journal, d'où part le correctif + * de la suivante ; null sans journal + * @property {boolean} journalAReecrire le fichier du journal porte des + * lignes que la lecture a écartées : la prochaine écriture le + * réécrit sans elles + * @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur + * le disque, le .precedent qu'un secours ouvre ou le journal de la + * base est d'un format plus récent que FORMAT + * @property {import('./placements.js').Examen} examen + * @property {Array<{code: string, details: Object}>} avertissements dans + * cet ordre, chacun au plus une fois : RETENU_FAUTIF {} ; + * JOURNAL_REPRIS {}, le journal en avance d'une entrée rattrapé en + * mémoire ; JOURNAL_EN_AVANCE_DE {n} ou JOURNAL_INCOMPLET {etat, + * journal}, le journal qui finit n révisions après la charge, ou + * avant elle ; JOURNAL_ECARTEES {n} ; JOURNAL_FORMAT_PLUS_RECENT + * {format}, le journal de la base, d'un format plus récent, que + * rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement}, le journal + * de la base, d'un autre événement, que rien ne lit ; + * PROPOSITIONS_FAUTIVES {ids}, par rang, null pour un identifiant + * illisible ; DERIVE {ids, retenu}, les propositions périmées par + * identifiant, et vrai quand le retenu l'est (§ 9) + */ + +/** + * Un événement du dossier, tel que lister le résume. + * + * Les champs qui viennent de l'état lu — nom, date, etat, participants — + * valent null quand il ne se lit pas ou qu'il manque ; id aussi, sauf pour un + * journal sans état, dont il est l'événement que nomme sa première ligne. + * + * @typedef {Object} Resume + * @property {string} base + * @property {string|null} id + * @property {string|null} nom + * @property {string|null} date + * @property {string|null} etat + * @property {number|null} participants exclus compris + * @property {number} modifie du fichier d'état, ou du journal sans + * état ; pour l'affichage seulement + * @property {string|null} journal base du journal apparié + * @property {string|null} illisible raison d'un état illisible, ABSENT + * pour un journal sans état + * @property {boolean} doublon un autre état lisible du dossier porte + * le même identifiant + */ + +// Suffixes des fichiers d'un événement : une base que l'un d'eux porte dans +// un dossier n'est pas libre. +const SUFFIXES_EVENEMENT = [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal, SUFFIXES.verrou]; + +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +// Base d'un nom de fichier qui finit par suffixe, ou null. +const baseDe = (nom, suffixe) => (nom.endsWith(suffixe) ? nom.slice(0, nom.length - suffixe.length) : null); + +// Bases que portent les fichiers d'événement d'une liste d'entrées de dossier. +const basesDe = (entrees) => + entrees.flatMap(({ nom }) => + SUFFIXES_EVENEMENT.map((suffixe) => baseDe(nom, suffixe)).filter((base) => base !== null), + ); + +// Bases des fichiers d'une liste d'entrées de dossier dont le nom finit par +// suffixe, dans son ordre. +const basesAuSuffixe = (entrees, suffixe) => + entrees + .filter(({ type }) => type === 'fichier') + .map(({ nom }) => baseDe(nom, suffixe)) + .filter((base) => base !== null); + +// Texte du fichier chemin, ou null quand il manque ; toute autre erreur +// remonte telle quelle. +async function lireSiPresent(fs, racine, chemin) { + try { + return await fs.lireTexte(racine, chemin); + } catch (erreur) { + if (erreur.code === 'ABSENT') return null; + throw erreur; + } +} + +// Lecture d'un texte d'état : { lecture } quand l'analyse l'admet, sinon +// { details } de son refus, raison et chemin. analyser ne lève que +// ETAT_ILLISIBLE. +function lireEtat(texte) { + try { + return { lecture: analyser(texte), details: null }; + } catch (erreur) { + return { lecture: null, details: erreur.details }; + } +} + +// Ce que l'Ouvert tient de l'état sur le disque, d'après le texte et sa +// lecture (lireEtat), lecture et details null quand il manque : texteEtat et +// revisionEtat quand il se lit, null sinon ; formatPlusRecent quand il vient +// d'un format plus récent, qu'il se lise, réduit, ou que son refus porte ce +// format (analyser). Un tel état garde l'Ouvert en lecture seule, même ouvert +// d'un secours (§ 8.8). +function surDisque(texte, { lecture, details }) { + if (lecture !== null) { + return { texteEtat: texte, revisionEtat: lecture.entete.revision, formatPlusRecent: lecture.formatPlusRecent }; + } + return { texteEtat: null, revisionEtat: null, formatPlusRecent: (details?.format ?? FORMAT) > FORMAT }; +} + +// Le texte d'état, que l'analyse doit relire : un état que ce module écrit se +// rouvre toujours. Une charge qu'elle refuserait lève TypeError, avant toute +// écriture. +function relisible(texte, fonction) { + const { details } = lireEtat(texte); + if (details !== null) { + throw new TypeError(`${fonction} : l'état ne se relirait pas, ${details.raison} ${details.chemin}`); + } + return texte; +} + +// Événement que nomme la première ligne d'un texte de journal, null quand le +// texte manque ou que cette ligne ne se lit pas : la lecture du journal +// réduite à cette ligne. +const evenementDe = (texte) => (texte === null ? null : lireJournal(texte.slice(0, texte.indexOf('\n') + 1)).evenement); + +// Format que déclare la première ligne d'un texte de journal quand c'est un +// entier plus grand que FORMAT, null sinon. lireJournal refuse une telle +// ouverture comme toute ligne illisible ; son format dit qu'elle vient d'une +// construction plus récente, dont le journal ne se remplace pas (§ 8.8). Une +// première ligne sans fin de ligne, qui n'est pas du JSON, ou dont la valeur +// est null, ne déclare rien. +function formatPlusRecentDe(texte) { + let format; + try { + format = JSON.parse(texte.slice(0, texte.indexOf('\n') + 1).replace(/^\u{FEFF}/u, '')).format; + } catch { + return null; + } + return Number.isSafeInteger(format) && format > FORMAT ? format : null; +} + +// Base que prend l'événement nommé nom dans racine (deriverBase) : aucune des +// bases d'autres, ni de celles que portent les fichiers du dossier, à la casse +// près. +async function deriver(fs, racine, nom, autres) { + const { separateur } = await fs.emplacements(); + const existantes = [...autres, ...basesDe(await fs.lister(racine, ''))]; + return deriverBase(nom, { racine: racine.chemin, separateur, existantes }); +} + +// Les fichiers de racine tels qu'une lecture les trouve : la liste du +// dossier, le texte de chaque fichier — null quand il manque, toute autre +// erreur de lecture remontant telle quelle —, et la lecture de chaque texte +// d'état ou de .precedent (lireEtat), chacun lu une fois, à la demande. Les +// décisions d'appariement d'une même opération se prennent ainsi sur les +// mêmes fichiers, sans relire un journal. Une vue ne voit pas ce qui s'écrit +// après sa lecture : chaque ouverture en prend une, et l'écriture qui cherche +// le journal d'un événement en prend une neuve (baseCherchee). +function vueDe(fs, racine) { + let entrees = null; + const textes = new Map(); + const lectures = new Map(); + const texte = async (chemin) => { + if (!textes.has(chemin)) textes.set(chemin, await lireSiPresent(fs, racine, chemin)); + return textes.get(chemin); + }; + return { + entrees: async () => { + entrees ??= await fs.lister(racine, ''); + return entrees; + }, + texte, + // { texte, lecture, details } d'un fichier d'état ou d'un .precedent, + // les trois null quand il manque. + lecture: async (chemin) => { + if (!lectures.has(chemin)) { + const lu = await texte(chemin); + const absent = { texte: null, lecture: null, details: null }; + lectures.set(chemin, lu === null ? absent : { texte: lu, ...lireEtat(lu) }); + } + return lectures.get(chemin); + }, + }; +} + +// L'événement auquel appartient base dans le dossier de la vue : celui que +// porte son état quand il se lit, sinon son .precedent quand il se lit ; null +// sinon. Le journal de base qui porte cet événement est le sien, et ne +// s'apparie à aucune autre base (apparier). +async function proprietaire(vue, base) { + const { lecture } = await vue.lecture(base + SUFFIXES.etat); + const lue = lecture ?? (await vue.lecture(base + SUFFIXES.precedent)).lecture; + return lue?.charge.evenement.id ?? null; +} + +// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) : +// { base, texte } du journal de base quand sa première ligne nomme id ; +// sinon du premier journal du dossier, par nom, qui le nomme et n'appartient +// pas à sa propre base (proprietaire) ; null quand aucun ne s'apparie. Un +// journal renommé à la main, ou créé sous une base dérivée quand celui de la +// base portait un autre événement, n'a ni état ni .precedent à sa base : il +// s'apparie. Le journal d'un original reste à l'original, et un état copié à +// la main dans le même dossier ne le prend pas. C'est la seule règle +// d'appariement : lister, ouvrir, ouvrir un secours et l'écriture d'un +// journal sans base la suivent, après la préséance du journal de la base +// d'un format plus récent (formatDuJournalDeBase). +async function apparier(vue, base, id) { + const texte = await vue.texte(base + SUFFIXES.journal); + if (evenementDe(texte) === id) return { base, texte }; + for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) { + const texteAutre = await vue.texte(autre + SUFFIXES.journal); + if (evenementDe(texteAutre) === id && (await proprietaire(vue, autre)) !== id) { + return { base: autre, texte: texteAutre }; + } + } + return null; +} + +// Identifiants que portent les états lisibles du dossier de la vue, un par +// état, dans l'ordre des noms : le même deux fois, l'événement est en double. +async function identifiantsLisibles(vue) { + const identifiants = []; + for (const base of basesAuSuffixe(await vue.entrees(), SUFFIXES.etat)) { + const { lecture } = await vue.lecture(base + SUFFIXES.etat); + if (lecture !== null) identifiants.push(lecture.charge.evenement.id); + } + return identifiants; +} + +// L'identifiant par lequel une base dont l'état ne se lit pas, ou manque, +// s'apparie à un journal (§ 8.6) : celui de son .precedent quand il se lit ; +// sinon celui que nomme la première ligne du journal de la base, quand aucun +// état lisible du dossier ne le porte ; null sinon. Un état lisible de cet +// identifiant passe avant un état qui ne dit pas le sien, et un journal sans +// identifiant lisible n'apparie rien : l'état illisible ne s'apparie jamais +// par le seul nom. +async function identifiantSansEtat(vue, base) { + const { lecture } = await vue.lecture(base + SUFFIXES.precedent); + if (lecture !== null) return lecture.charge.evenement.id; + const id = evenementDe(await vue.texte(base + SUFFIXES.journal)); + if (id === null || (await identifiantsLisibles(vue)).includes(id)) return null; + return id; +} + +// Le format plus récent que FORMAT que déclare le journal de base dans le +// dossier de la vue, null quand il manque ou n'en déclare pas. Un tel journal +// passe avant tout appariement, à l'ouverture (journalDe) comme dans la liste +// (lister) : son identifiant ne se lit pas, et il reste celui de la base. +async function formatDuJournalDeBase(vue, base) { + const texte = await vue.texte(base + SUFFIXES.journal); + return texte === null ? null : formatPlusRecentDe(texte); +} + +// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) : +// lu, sa base, etranger, l'événement que porte le journal de la base quand +// aucun ne s'apparie et qu'il en nomme un autre, et format. Le journal de la +// base qui déclare un format plus récent que FORMAT se reconnaît d'abord, +// avant tout appariement, puisque son identifiant ne se lit pas : lu vaut +// null et format le nomme, ce qui garde l'Ouvert en lecture seule, même +// quand une copie au format courant porte id ailleurs dans le dossier ; rien +// ne le lit ni ne le remplace (§ 8.8). Sinon, c'est le journal qu'apparie +// apparier, aucun quand id est null. Faute de lui, le journal de la base +// reste le sien quand il manque ou que sa première ligne ne se lit pas, une +// écriture le remplaçant. Quand il nomme un autre événement, lu et base +// valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le +// journal de l'événement (baseCherchee). +async function journalDe(vue, base, id) { + const format = await formatDuJournalDeBase(vue, base); + if (format !== null) return { lu: null, base, etranger: null, format }; + const texte = await vue.texte(base + SUFFIXES.journal); + if (id === null) return { lu: null, base: null, etranger: null, format: null }; + const apparie = await apparier(vue, base, id); + if (apparie !== null) return { lu: lireJournal(apparie.texte), base: apparie.base, etranger: null, format: null }; + const etranger = evenementDe(texte); + if (etranger !== null) return { lu: null, base: null, etranger, format: null }; + return { lu: texte === null ? null : lireJournal(texte), base, etranger: null, format: null }; +} + +// Le journal d'une base dont l'état ne se lit pas, ou manque : journalDe +// pour l'identifiant que rend identifiantSansEtat, null compris. Le journal +// de la base d'un format plus récent s'y reconnaît sans identifiant. +async function journalSansEtat(vue, base) { + return journalDe(vue, base, await identifiantSansEtat(vue, base)); +} + +// Le dernier instant restituable d'un journal lu qui a des entrées : la +// charge de sa dernière entrée ou, quand un correctif ne s'applique pas, de +// la dernière entrée qui le précède (§ 8.6). Les entrées qui suivent cet +// instant et leurs jalons s'écartent et se comptent, comme la lecture compte +// les lignes qu'elle écarte : le journal rendu finit sur lui. La première +// entrée porte un instantané, qui se restitue toujours : la recherche +// s'arrête au plus tard sur elle. Rend null pour un journal absent ou sans +// entrée. +function instantDe(lu) { + if (lu === null || lu.entrees.length === 0) return null; + for (let rang = lu.entrees.length - 1; ; rang -= 1) { + const { revision } = lu.entrees[rang]; + let charge; + try { + charge = reconstruire(lu, revision); + } catch { + // Un correctif ne s'applique pas : l'entrée qui précède. + continue; + } + const entrees = lu.entrees.slice(0, rang + 1); + const jalons = lu.jalons.filter((jalon) => jalon.revision <= revision); + const ecartees = lu.ecartees + lu.entrees.length - entrees.length + lu.jalons.length - jalons.length; + return { journal: { ...lu, entrees, jalons, ecartees }, charge, revision }; + } +} + +// Ce qu'une lecture d'état — l'état ou le .precedent — apporte à l'Ouvert, +// avec ce qu'il tient de l'état sur le disque (surDisque) : la lecture seule +// de l'un ou de l'autre. +const depuisLecture = ({ entete, charge, formatPlusRecent }, disque) => ({ + charge, + revision: entete.revision, + produitVersion: entete.produitVersion, + ...disque, + formatPlusRecent: formatPlusRecent || disque.formatPlusRecent, +}); + +// Les avertissements d'un Ouvert, dans l'ordre que décrit le type Ouvert. +// derniere est la révision de la dernière entrée du journal, celle de la +// charge quand il n'y en a pas. +function avertir({ examen, journal, revision, rattrape, ecartees, format, etranger }) { + const avertissements = []; + const ajouter = (code, details = {}) => avertissements.push({ code, details }); + if (examen.retenu.fautes.length > 0) ajouter('RETENU_FAUTIF'); + if (rattrape) ajouter('JOURNAL_REPRIS'); + const derniere = journal === null ? revision : journal.entrees.at(-1).revision; + if (derniere > revision) ajouter('JOURNAL_EN_AVANCE_DE', { n: derniere - revision }); + if (derniere < revision) ajouter('JOURNAL_INCOMPLET', { etat: revision, journal: derniere }); + if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees }); + if (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format }); + if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger }); + if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) }); + const retenu = examen.retenu.raisons.length > 0; + if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu }); + return avertissements; +} + +// L'Ouvert d'une source — état, .precedent ou instant du journal — et du +// journal trouvé pour elle. rattraper : un journal en avance d'une seule +// entrée sur la source donne sa charge, sa révision et sa version. La source +// d'un format plus récent, ou un journal d'un format plus récent, gardent +// l'Ouvert en lecture seule. +function assembler({ fs, racine, base, source, trouve, instant, rattraper }) { + let { charge, revision, produitVersion } = source; + const rattrape = rattraper && instant !== null && instant.revision === revision + 1; + if (rattrape) { + charge = canoniser(instant.charge); + revision = instant.revision; + produitVersion = versionDe(instant.journal, revision); + } + const journal = instant?.journal ?? null; + const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0; + const examen = examiner(charge); + return { + fs, + racine, + base, + charge: examen.charge, + revision, + produitVersion, + texteEtat: source.texteEtat, + revisionEtat: source.revisionEtat, + journal, + baseJournal: trouve.base, + chargeJournal: instant?.charge ?? null, + journalAReecrire: journal !== null && ecartees > 0, + formatPlusRecent: source.formatPlusRecent || trouve.format !== null, + examen, + avertissements: avertir({ + examen, + journal, + revision, + rattrape, + ecartees, + format: trouve.format, + etranger: trouve.etranger, + }), + }; +} + +// L'erreur d'un état qui ne s'ouvre pas : ETAT_ILLISIBLE, que details décrit +// — raison, chemin, format —, complété de la base, du format du journal de la +// base quand il est plus récent et que details n'en nomme pas, et des +// secours lisibles : la révision du .precedent et le dernier instant du +// journal que l'identifiant apparie (journalSansEtat), jamais un journal +// pris par son seul nom ; ABSENT quand ni l'état, ni le .precedent, ni le +// journal de la base n'existent. +async function refuser(vue, base, details) { + const precedent = await vue.lecture(base + SUFFIXES.precedent); + const journal = await vue.texte(base + SUFFIXES.journal); + if (details.raison === 'ABSENT' && precedent.texte === null && journal === null) { + return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat }); + } + const trouve = await journalSansEtat(vue, base); + const format = details.format ?? trouve.format; + const secours = { + precedent: precedent.lecture?.entete.revision ?? null, + journal: instantDe(trouve.lu)?.revision ?? null, + }; + return new ErreurStockage('ETAT_ILLISIBLE', { base, ...details, ...(format === null ? {} : { format }), secours }); +} + +/** + * Événements de racine (§ 8.1, § 8.6), sans rien écrire : un résumé par + * fichier d'état, lisible ou non, et par journal sans état. Un état + * s'apparie à son journal par l'identifiant, jamais par le seul nom + * (apparier) : celui de l'état quand il se lit ; pour un état illisible, + * celui de son .precedent, ou de son journal quand aucun état lisible ne le + * porte (identifiantSansEtat). Un journal renommé à la main reste apparié ; + * le journal d'un original ne s'apparie pas à sa copie faite à la main. Un + * journal sans état dans sa base se résume quand aucun état ne se l'apparie + * et qu'aucun état lisible ne porte son identifiant, illisible ABSENT ; il + * ne se montre pas sinon : une copie d'un journal apparié, ou le journal + * qu'un état illisible s'apparie par son .precedent. Un identifiant que + * portent deux états lisibles les signale doublon. Résumés triés par nom, + * les noms inconnus après les autres, puis par base, unités UTF-16 + * comparées. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @returns {Promise} + */ +export async function lister(fs, racine) { + const vue = vueDe(fs, racine); + const fichiers = (await vue.entrees()).filter(({ type }) => type === 'fichier'); + const identifiants = await identifiantsLisibles(vue); + const sansLecture = (base, id, modifie, journal, illisible) => ({ + base, + id, + nom: null, + date: null, + etat: null, + participants: null, + modifie, + journal, + illisible, + doublon: false, + }); + const apparies = new Set(); + const resumes = []; + for (const { nom, modifie } of fichiers) { + const base = baseDe(nom, SUFFIXES.etat); + if (base === null) continue; + const { lecture, details } = await vue.lecture(nom); + const id = lecture === null ? await identifiantSansEtat(vue, base) : lecture.charge.evenement.id; + let journal = null; + if ((await formatDuJournalDeBase(vue, base)) !== null) journal = base; + else if (id !== null) journal = (await apparier(vue, base, id))?.base ?? null; + if (journal !== null) apparies.add(journal); + if (lecture === null) { + resumes.push(sansLecture(base, null, modifie, journal, details.raison)); + continue; + } + const { evenement, participants } = lecture.charge; + resumes.push({ + base, + id: evenement.id, + nom: evenement.nom, + date: evenement.date, + etat: evenement.etat, + participants: participants.length, + modifie, + journal, + illisible: null, + doublon: identifiants.indexOf(evenement.id) !== identifiants.lastIndexOf(evenement.id), + }); + } + const basesDesEtats = new Set(basesAuSuffixe(fichiers, SUFFIXES.etat)); + for (const { nom, modifie } of fichiers) { + const base = baseDe(nom, SUFFIXES.journal); + if (base === null || basesDesEtats.has(base) || apparies.has(base)) continue; + const evenement = evenementDe(await vue.texte(nom)); + if (!identifiants.includes(evenement)) resumes.push(sansLecture(base, evenement, modifie, base, 'ABSENT')); + } + const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? ''); + return resumes.sort((a, b) => nomsConnus(a.nom, b.nom) || comparerTextes(a.base, b.base)); +} + +/** + * Ouvre l'événement de base dans racine, sans rien écrire (§ 8.4, § 8.8) : + * la charge de son état, examinée (examiner), et son journal, apparié par + * l'identifiant. Un journal en avance d'une entrée donne sa charge, rattrapée + * en mémoire, JOURNAL_REPRIS ; l'état se réécrira au premier geste. Un journal + * plus en avance ou en retard laisse la charge de l'état, et le premier geste + * inscrit une entrée de reprise. Une proposition fautive s'écarte et se + * nomme ; une périmée se garde et se signale ; un retenu fautif se garde et + * se signale en premier (§ 8.9). Un format plus récent s'ouvre en lecture + * seule ; un format plus ancien se lit, et ne se convertit qu'au premier + * geste. Le journal de la base d'un format plus récent ne se lit pas, et + * garde l'Ouvert en lecture seule, JOURNAL_FORMAT_PLUS_RECENT, même quand une + * copie au format courant porte l'identifiant ailleurs dans le dossier. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {string} base + * @returns {Promise} + * @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format], + * secours} : l'état ne se lit pas, ou manque à côté de son .precedent + * ou de son journal (raison ABSENT) ; format est celui de l'état + * quand son refus en nomme un, sinon celui du journal de la base + * quand il est plus récent ; secours nomme la révision lisible du + * .precedent et le dernier instant du journal que l'identifiant du + * .precedent apparie — ou, sans .precedent lisible, celui du journal + * de la base qu'aucun état lisible ne porte —, null pour ce qui ne se + * lit pas ou ne s'apparie pas. ABSENT {chemin} quand aucun fichier de + * l'événement n'existe + */ +export async function ouvrir(fs, racine, base) { + const vue = vueDe(fs, racine); + const etat = await vue.lecture(base + SUFFIXES.etat); + if (etat.texte === null) throw await refuser(vue, base, { raison: 'ABSENT', chemin: null }); + if (etat.lecture === null) throw await refuser(vue, base, etat.details); + const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id); + const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat)); + return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true }); +} + +/** + * Ouvre l'événement de base depuis un secours, sans rien écrire (§ 8.8) : + * source 'precedent', la charge du .precedent, que le journal de son + * identifiant suit sans être rattrapé ; source 'journal', le dernier instant + * restituable du journal, apparié par l'identifiant de l'état quand il se lit, + * sinon par celui du .precedent, et sans .precedent lisible, le journal de + * la base quand aucun état lisible du dossier ne porte son identifiant + * (journalSansEtat) : jamais un journal pris par son seul nom, ni celui d'un + * autre événement. texteEtat est le texte de l'état quand il se lit : le + * premier geste le met au .precedent, et ne met jamais un état illisible. + * L'état sur le disque d'un format plus récent, qu'il se lise ou non, garde + * le secours en lecture seule, comme un journal de la base d'un format plus + * récent : aucun geste de ce secours ne remplace l'état ou le journal qu'une + * construction plus récente a écrits (§ 8.8). Un .precedent d'un format plus + * récent garde en lecture seule le secours 'precedent', qui le lit, et non le + * secours 'journal'. Un journal plus récent ne s'ouvre pas en secours, et ne + * se dit pas absent : il existe, et ce code ne le lit pas. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {string} base + * @param {'precedent'|'journal'} source + * @returns {Promise} + * @throws {ErreurStockage} ABSENT {chemin} : le .precedent manque, ou aucun + * journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors + * le journal de la base ; ETAT_ILLISIBLE {raison, chemin, [format]} : + * celui de l'analyse d'un .precedent illisible, ou, source 'journal', + * {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base, + * format} quand ce journal est d'un format plus récent + * @throws {RangeError} une autre source + */ +export async function ouvrirSecours(fs, racine, base, source) { + if (source !== 'precedent' && source !== 'journal') { + throw new RangeError(`ouvrirSecours : source precedent ou journal attendue, reçu ${JSON.stringify(source)}`); + } + const vue = vueDe(fs, racine); + const etat = await vue.lecture(base + SUFFIXES.etat); + const disque = surDisque(etat.texte, etat); + if (source === 'precedent') { + const lecture = analyser(await fs.lireTexte(racine, base + SUFFIXES.precedent)); + const trouve = await journalDe(vue, base, lecture.charge.evenement.id); + const depuis = depuisLecture(lecture, disque); + return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false }); + } + const trouve = await (etat.lecture === null + ? journalSansEtat(vue, base) + : journalDe(vue, base, etat.lecture.charge.evenement.id)); + if (trouve.format !== null) { + throw new ErreurStockage('ETAT_ILLISIBLE', { + raison: 'FORMAT_PLUS_RECENT', + chemin: base + SUFFIXES.journal, + format: trouve.format, + }); + } + const instant = instantDe(trouve.lu); + if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal }); + const depuis = { + charge: canoniser(instant.charge), + revision: instant.revision, + produitVersion: versionDe(instant.journal, instant.revision), + ...disque, + }; + return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false }); +} + +// Un Ouvert en lecture seule — un fichier qu'il a lu est d'un format plus +// récent — ne s'écrit jamais (§ 8.8). +function exigerEcriture({ formatPlusRecent }) { + if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' }); +} + +// version quand l'entrée qui suit revision la note, null sinon : à la première +// entrée, et quand la construction diffère de la précédente (§ 8.8). +const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version); + +// Le journal en mémoire, la ligne écrite ajoutée à ses entrées ou à ses jalons. +function avec(journal, ligne) { + const lue = JSON.parse(ligne); + if (lue.type === 'jalon') return { ...journal, jalons: [...journal.jalons, lue] }; + return { ...journal, entrees: [...journal.entrees, lue] }; +} + +// Texte d'un journal en mémoire : son ouverture, puis chaque entrée suivie +// des jalons de sa révision, dans leur ordre. Une entrée lue se récrit par +// JSON.stringify, comme ligneEntree l'a écrite : ses clés dans l'ordre lu. +function texteDe({ evenement, entrees, jalons }) { + const lignes = [ligneOuverture(evenement)]; + for (const entree of entrees) { + lignes.push(JSON.stringify(entree)); + for (const jalon of jalons) { + if (jalon.revision === entree.revision) lignes.push(ligneJalon(jalon)); + } + } + return `${lignes.join('\n')}\n`; +} + +// Le journal de l'Ouvert prêt à recevoir une ligne, en mémoire : avant, le +// journal tel que le fichier le porte, vide quand il manque ; reprise, la +// ligne de l'entrée de reprise qui le fait finir sur la charge ouverte, ou +// null ; journal, avant suivi de la reprise ; charge, ce que restitue sa +// dernière entrée ; entier, vrai quand le fichier se réécrit en entier à sa +// prochaine écriture : il manque, sa lecture a écarté des lignes, ou il +// reçoit la reprise. Sans journal, la reprise le commence, instantané de la +// charge ouverte à sa révision et à sa version. Un journal qui finit sur une +// autre révision que la charge ouverte reçoit la reprise à la révision qui +// suit la sienne, qui restitue la charge ouverte. libelleReprise manque : +// ligneEntree lève TypeError, avant toute écriture. +function preparer(ouvert, { libelleReprise, horodatage }) { + const { journal, revision, charge, produitVersion } = ouvert; + if (journal === null) { + const reprise = ligneEntree({ + revision, + libelle: libelleReprise, + horodatage, + produitVersion, + apres: charge, + instantane: true, + }); + const vide = { evenement: charge.evenement.id, entrees: [], jalons: [], ecartees: 0 }; + return { avant: vide, reprise, journal: avec(vide, reprise), charge: canoniser(charge), entier: true }; + } + const derniere = journal.entrees.at(-1).revision; + if (derniere === revision) { + return { avant: journal, reprise: null, journal, charge: ouvert.chargeJournal, entier: ouvert.journalAReecrire }; + } + const suivante = derniere + 1; + const reprise = ligneEntree({ + revision: suivante, + libelle: libelleReprise, + horodatage, + produitVersion: versionNotee(produitVersion, journal, derniere), + avant: ouvert.chargeJournal, + apres: charge, + instantane: estInstantane(suivante, journal.entrees[0].revision), + }); + return { avant: journal, reprise, journal: avec(journal, reprise), charge: canoniser(charge), entier: true }; +} + +// L'en-tête de l'état que restitue la dernière entrée d'un journal : la +// révision de cette entrée, et la version de la construction qui l'a +// produite (§ 8.8). L'état qui le porte, avec la charge de cette entrée, +// fait une paire avec le journal (§ 8.2). +function enteteDeLaDerniere(journal) { + const revision = journal.entrees.at(-1).revision; + return { revision, produitVersion: versionDe(journal, revision) }; +} + +// Le texte d'état que le disque doit porter avant la ligne d'un geste, ou +// null quand il le porte : un état lisible sur le disque, à une autre +// révision que la dernière entrée du journal préparé, reçoit d'abord la +// charge que restitue cette entrée, sous son en-tête (enteteDeLaDerniere). +// Sans ce pas, une coupure entre la ligne du geste et l'état laisserait le +// journal en avance de plus d'une entrée, ou à la révision de l'état avec +// une autre charge, et la comparaison des révisions ne rattraperait pas le +// geste (§ 8.2). Un état illisible ou absent n'a pas de révision : ouvrir le +// refuse et nomme le journal en secours. Une charge que l'analyse refuserait +// lève TypeError, avant toute écriture. +function etatAligne(ouvert, { journal, charge }) { + const entete = enteteDeLaDerniere(journal); + if (ouvert.revisionEtat === null || ouvert.revisionEtat === entete.revision) return null; + return relisible(serialiser(charge, entete), 'enregistrer'); +} + +// Pose texte, que etatAligne a calculé, avant la ligne d'un geste : la +// reprise seule d'abord, que preparer fait écrire en entier, puis l'état à la +// révision de la dernière entrée du journal. Rend l'Ouvert dont le disque +// porte cet état et la préparation de la ligne qui suit. Un refus d'écriture +// remonte tel quel : le geste n'est pas au journal, et l'Ouvert reçu reste +// celui d'où le réessayer. Le nouvel essai réécrit en entier le journal où la +// reprise est déjà, celui de la base de l'Ouvert ou, sans base, celui que +// l'identifiant de l'événement désigne (baseCherchee) : la reprise s'y +// remplace au lieu de s'y doubler. +async function aligner(ouvert, preparation, texte) { + let { journal, entier } = preparation; + let depart = ouvert; + if (preparation.reprise !== null) { + const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise); + depart = { ...ouvert, ...ecrit }; + journal = ecrit.journal; + entier = false; + } + const aligne = await poserEtat({ ...depart, revision: journal.entrees.at(-1).revision }, texte); + return { depart: aligne, preparation: { journal, entier } }; +} + +// Le texte entier d'un journal en mémoire, élagué au-delà de PLAFOND_ENTREES +// (elaguer), et le journal que ce texte porte, sans ligne écartée : ce que +// le fichier porte une fois écrit d'un bloc. +function textePlein(journal) { + const texte = texteDe(journal); + const elague = elaguer(texte); + if (elague === null) return { texte, journal: { ...journal, ecartees: 0 } }; + return { texte: elague, journal: lireJournal(elague) }; +} + +// La base où s'écrit le journal d'un Ouvert sans base de journal, celui de +// sa base portant un autre événement : la base du journal que l'identifiant +// de l'événement apparie dans le dossier tel qu'il est (apparier), sinon une +// base libre que son nom dérive. L'essai repris après un refus réécrit +// ainsi, avec sa reprise, le journal que l'essai refusé a créé, au lieu d'en +// dériver un second que l'ouverture n'apparierait pas. Une recherche qui +// échoue — le dossier qui ne se liste plus, ou un journal qui ne se lit +// plus : ABSENT quand le dossier a disparu avec son support, LECTURE quand +// la lecture est refusée — lève ECRITURE, comme l'écriture qu'elle précède : +// chemin le journal de la base, dossier le chemin de la racine, qui porte +// les fichiers de l'événement, et cause celle de l'échec, ENOENT pour un +// dossier disparu. L'application nomme ainsi le fichier et son dossier, et +// propose d'écrire ailleurs (§ 8.6). Toute autre erreur remonte telle quelle. +async function baseCherchee(ouvert, evenement) { + const { fs, racine, base } = ouvert; + try { + return ( + (await apparier(vueDe(fs, racine), base, evenement))?.base ?? + (await deriver(fs, racine, ouvert.charge.evenement.nom, [])) + ); + } catch (erreur) { + if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') throw erreur; + const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause; + throw new ErreurStockage('ECRITURE', { chemin: base + SUFFIXES.journal, dossier: racine.chemin, cause }); + } +} + +// Écrit au journal la ligne à la suite de journal, le journal en mémoire que +// le fichier porte (§ 8.2, § 8.6) : par un ajout en fin, ou en entier par une +// écriture atomique, élaguée au-delà de PLAFOND_ENTREES (textePlein), quand +// entier le demande ou que le plafond est dépassé. Sans base, le journal +// prend celle que baseCherchee trouve. Rend le journal en mémoire, qui est +// le fichier, et sa base. +async function ecrireJournal(ouvert, preparation, ligne) { + let journal = avec(preparation.journal, ligne); + const { fs, racine } = ouvert; + const base = ouvert.baseJournal ?? (await baseCherchee(ouvert, journal.evenement)); + if (preparation.entier || journal.entrees.length > PLAFOND_ENTREES) { + const plein = textePlein(journal); + journal = plein.journal; + await fs.ecrireAtomique(racine, base + SUFFIXES.journal, plein.texte); + } else { + await fs.ajouterLigne(racine, base + SUFFIXES.journal, ligne); + } + return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: false }; +} + +// Écrit texteEtat, l'état de l'Ouvert à sa révision : le .precedent d'abord, +// quand l'état sur le disque se lit, puis l'état (§ 8.8). Rend l'Ouvert dont +// le disque porte ce texte. Un refus remonte tel quel. +async function poserEtat(ouvert, texteEtat) { + const { fs, racine, base } = ouvert; + if (ouvert.texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, ouvert.texteEtat); + await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat); + return { ...ouvert, texteEtat, revisionEtat: ouvert.revision }; +} + +// Écrit l'état de l'Ouvert avancé, dont le geste est déjà au journal +// (poserEtat). Un refus d'écriture lève ETAT_NON_ECRIT, ses détails — chemin, +// dossier, cause — complétés de l'Ouvert avancé, qui garde le texte de l'état +// resté sur le disque. L'Ouvert s'ajoute après la construction de l'erreur : +// son message, fait pour les traces, ne porte ni la charge ni le journal. +async function ecrireEtat(avance, texteEtat) { + try { + return await poserEtat(avance, texteEtat); + } catch (erreur) { + if (erreur.code !== 'ECRITURE') throw erreur; + const refus = new ErreurStockage('ETAT_NON_ECRIT', { ...erreur.details }); + refus.details.ouvert = avance; + throw refus; + } +} + +// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses +// placements et d'un journal qui finit ailleurs que sur elle. +function apresEcriture(ouvert, { charge, revision, journal }) { + const examen = examiner(charge); + return { + ...ouvert, + charge: examen.charge, + revision, + examen, + avertissements: avertir({ examen, journal, revision, rattrape: false, ecartees: 0, format: null, etranger: null }), + }; +} + +/** + * Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de + * son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit + * d'un bloc — ouverture, entrée 1 en instantané, jalon nomJalon sur elle —, + * puis l'état, à la révision 1. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {import('./types.js').Charge} charge + * @param {{libelle: string, nomJalon: string, horodatage: string, produitVersion: string}} options + * @returns {Promise} + * @throws {TypeError} une charge, un libellé, un nom ou un horodatage que la + * lecture refuserait, avant toute écriture + * @throws {ErreurStockage} ECRITURE : le journal n'a pas pu s'écrire, et rien + * n'est écrit ; ETAT_NON_ECRIT : le journal est écrit, l'état non ; + * CHEMIN_TROP_LONG (deriverBase) ; ABSENT : racine n'existe pas + */ +export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage, produitVersion }) { + const base = await deriver(fs, racine, charge.evenement.nom, []); + const entree = ligneEntree({ revision: 1, libelle, horodatage, produitVersion, apres: charge, instantane: true }); + const jalon = ligneJalon({ revision: 1, nom: nomJalon, horodatage }); + const vide = { evenement: charge.evenement.id, entrees: [], jalons: [], ecartees: 0 }; + const journal = avec(avec(vide, entree), jalon); + const texteEtat = relisible(serialiser(charge, { revision: 1, produitVersion }), 'creer'); + await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteDe(journal)); + const cree = { + fs, + racine, + base, + produitVersion, + texteEtat: null, + revisionEtat: null, + journal, + baseJournal: base, + chargeJournal: canoniser(charge), + journalAReecrire: false, + formatPlusRecent: false, + }; + return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat); +} + +/** + * Enregistre un geste (§ 8.2) : apres devient la charge, à la révision qui + * suit la dernière entrée du journal. (1) Un journal qui manque, ou finit + * ailleurs que sur la charge ouverte, reçoit d'abord une entrée de reprise + * qui la restitue, au libellé libelleReprise ; (2) l'entrée du geste, + * correctif depuis la charge que restitue l'entrée qui la précède, ou + * instantané à la cadence du journal ; le journal s'écrit d'un ajout en fin, + * ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et + * au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat, + * s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien + * s'écrit au format courant : la conversion tombe au premier geste. + * + * Quand l'état lisible sur le disque ne porte pas la révision de la dernière + * entrée du journal — reprise comprise —, la reprise s'écrit seule, puis le + * .precedent et l'état à la révision de cette entrée, et alors seulement (2), + * (3) et (4) : une coupure laisse le journal en avance d'une entrée au plus, + * que l'ouverture rattrape (§ 8.2). + * + * Un geste refusé n'écrit rien : chaque ligne et chaque texte d'état se + * calculent, et se relisent, avant la première écriture. + * + * @param {Ouvert} ouvert + * @param {import('./types.js').Charge} apres + * @param {Object} options + * @param {string} options.libelle texte figé de l'entrée + * @param {string} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM + * @param {string} options.produitVersion la construction qui écrit + * @param {null|'defaire'|'refaire'|'revenir'} [options.sens] + * @param {number|null} [options.retour] révision visée par un retour + * @param {string} [options.libelleReprise] texte de l'entrée de reprise, + * requis quand elle a lieu + * @returns {Promise} + * @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ; + * ECRITURE {chemin, dossier, cause} en (1), (2) ou pendant + * l'alignement, la recherche du journal d'un Ouvert sans base de + * journal comprise (baseCherchee) : le geste n'est pas au journal, et + * l'Ouvert reçu reste celui d'où le réessayer ; ETAT_NON_ECRIT + * {chemin, dossier, cause, ouvert} en (3) ou (4), le geste au journal + * et ouvert l'Ouvert avancé ; CHEMIN_TROP_LONG pour un journal à créer + * sous une base libre + * @throws {TypeError} une ligne, une charge ou un état que la lecture + * refuserait, avant toute écriture + */ +export async function enregistrer(ouvert, apres, options) { + const { libelle, horodatage, produitVersion, sens, retour, libelleReprise } = options; + exigerEcriture(ouvert); + const preparation = preparer(ouvert, { libelleReprise, horodatage }); + const derniere = preparation.journal.entrees.at(-1).revision; + const revision = derniere + 1; + const ligne = ligneEntree({ + revision, + libelle, + horodatage, + sens, + retour, + produitVersion: versionNotee(produitVersion, preparation.journal, derniere), + avant: preparation.charge, + apres, + instantane: estInstantane(revision, preparation.journal.entrees[0].revision), + }); + const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer'); + const aligne = etatAligne(ouvert, preparation); + const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne); + const ecrit = await ecrireJournal(suite.depart, suite.preparation, ligne); + const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) }; + return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal }), texteEtat); +} + +/** + * Pose le jalon nom sur l'instant courant (§ 8.3) : une ligne du journal, + * qui ne prend pas de révision ; l'état ne s'écrit pas. Un journal qui manque + * ou finit ailleurs que sur la charge ouverte reçoit d'abord l'entrée de + * reprise, comme pour enregistrer, et le jalon nomme alors son instant. + * + * @param {Ouvert} ouvert + * @param {{nom: string, horodatage: string, libelleReprise?: string}} options + * @returns {Promise} + * @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE, et rien n'a changé + * @throws {TypeError} un nom vide, un horodatage hors de sa forme + */ +export async function nommer(ouvert, { nom, horodatage, libelleReprise }) { + exigerEcriture(ouvert); + const preparation = preparer(ouvert, { libelleReprise, horodatage }); + const revision = preparation.journal.entrees.at(-1).revision; + const ecrit = await ecrireJournal(ouvert, preparation, ligneJalon({ revision, nom, horodatage })); + const nomme = { ...ouvert, ...ecrit, chargeJournal: preparation.charge }; + return apresEcriture(nomme, { charge: ouvert.charge, revision, journal: ecrit.journal }); +} + +/** + * Prend le verrou de la séance sur l'événement (§ 8.8) : le Verrou que rend + * le système de fichiers, tel quel. + * + * @param {Ouvert} ouvert + * @param {string} seance + * @returns {Promise} + */ +export async function verrouiller({ fs, racine, base }, seance) { + return fs.verrouiller(racine, base + SUFFIXES.verrou, seance); +} + +/** + * Rend le verrou de la séance : seul le sien s'efface. + * + * @param {Ouvert} ouvert + * @param {string} seance + * @returns {Promise} + */ +export async function deverrouiller({ fs, racine, base }, seance) { + await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance); +} + +// La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans +// journal, l'état seul : la charge ouverte, à sa révision et à sa version. +// Sinon le journal préparé (preparer), suivi de l'entrée de reprise quand il +// finit sur une autre révision que la charge ouverte, en entier et élagué +// au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que restitue sa +// dernière entrée, sous l'en-tête de celle-ci (enteteDeLaDerniere), comme +// l'état qu'aligne un geste : la dernière entrée du journal écrit restitue +// l'état écrit (§ 8.2), et la réouverture n'a rien à rattraper. Une reprise +// sans libelleReprise lève TypeError (preparer). +function paireAilleurs(ouvert, options) { + if (ouvert.journal === null) { + const { charge, revision, produitVersion } = ouvert; + return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion }; + } + const preparation = preparer(ouvert, options); + const { texte, journal } = textePlein(preparation.journal); + const { charge } = preparation; + return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) }; +} + +/** + * Écrit l'événement ouvert dans une autre racine (§ 8.6) : la réponse à une + * écriture qui échoue, l'Ouvert reçu étant celui d'avant le geste refusé + * (ECRITURE) ou l'Ouvert avancé d'ETAT_NON_ECRIT. L'opérateur a choisi cette + * racine, que rien n'a sondée — documents en mode portable — : elle se crée + * d'abord, parents compris, un dossier existant restant tel quel. Sa base + * dérive du nom là-bas (deriverBase), libre parmi existantes et les bases du + * dossier. La paire s'y écrit alignée, comme après un geste (§ 8.2) : le + * journal en mémoire, en entier — suivi d'une entrée de reprise, au libellé + * libelleReprise, quand il finit sur une autre révision que la charge + * ouverte, et élagué au-delà de PLAFOND_ENTREES —, puis l'état que restitue + * sa dernière entrée, à la révision et à la version de celle-ci. Rouvert + * là-bas, l'événement rend cette charge sans rien rattraper. Un événement + * sans journal n'écrit que son état : la charge ouverte, à sa révision. Le + * .precedent ne se recopie pas. Les lignes et le texte de l'état se + * calculent, et se relisent, avant la première écriture, celle de la racine + * comprise. + * + * @param {Ouvert} ouvert + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {Object} options + * @param {Iterable} options.existantes bases à ne pas prendre + * là-bas, en plus de celles du dossier + * @param {string} [options.libelleReprise] texte de l'entrée de reprise, + * requis quand elle a lieu + * @param {string} [options.horodatage] AAAA-MM-JJTHH:MM:SS±HH:MM de + * l'entrée de reprise, requis avec elle + * @returns {Promise} l'Ouvert dans racine : le journal et la + * révision écrits, chargeJournal la charge de l'état écrit, et + * charge celle qu'en rend examiner + * @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE quand la racine ne se + * crée pas, rien d'autre n'étant écrit, ou que le journal n'a pas pu + * s'écrire ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire, ouvert + * étant l'Ouvert dans racine ; CHEMIN_TROP_LONG + * @throws {TypeError} une reprise sans libelleReprise ou horodatage, un état + * que la lecture refuserait, avant toute écriture + */ +export async function enregistrerAilleurs(ouvert, racine, { existantes, libelleReprise, horodatage }) { + exigerEcriture(ouvert); + const { fs } = ouvert; + const paire = paireAilleurs(ouvert, { libelleReprise, horodatage }); + const { journal, charge, revision, produitVersion } = paire; + const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs'); + await fs.creerDossier(racine, ''); + const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]); + if (journal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, paire.texteJournal); + const ailleurs = { + ...ouvert, + racine, + base, + produitVersion, + texteEtat: null, + revisionEtat: null, + journal, + baseJournal: base, + chargeJournal: paire.chargeJournal, + journalAReecrire: false, + }; + return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat); +} diff --git a/src/stockage/depot.test.js b/src/stockage/depot.test.js new file mode 100644 index 0000000..63b2b2c --- /dev/null +++ b/src/stockage/depot.test.js @@ -0,0 +1,2311 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du dépôt (§ 8.1, § 8.2, § 8.4, § 8.6, § 8.8, § 8.9, § 14.10), toutes +// sur le système de fichiers d'épreuve, chacune comptant ses écritures : une +// ouverture n'en fait aucune. Un événement se crée, se liste et s'ouvre ; un +// geste donne une entrée puis un état ; une panne à chaque frontière d'un geste +// se reprend à l'ouverture, sans regarder la date des fichiers ; un état +// illisible nomme ses secours, qui s'ouvrent ; le journal s'apparie par +// identifiant, se répare au premier geste qui suit l'ouverture et s'élague ; +// une proposition fautive s'écarte, une périmée se garde ; le verrou ; +// l'écriture ailleurs. Les noms d'épreuve sont inventés ; les versions viennent +// du module engendré. +import assert from 'node:assert/strict'; +import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { describe, test } from '../../test/lanceur.js'; +import { versionVoisine } from '../../test/version_voisine.js'; +import { VERSION } from '../version.genere.js'; +import { serialiser, serialiserCharge } from './canonique.js'; +import { + creer, + deverrouiller, + enregistrer, + enregistrerAilleurs, + lister, + nommer, + ouvrir, + ouvrirSecours, + verrouiller, +} from './depot.js'; +import { analyser, creerCharge } from './document.js'; +import { ErreurStockage } from './erreurs.js'; +import { PLAFOND_ENTREES, elaguer, fil, ligneJalon, ligneOuverture, lireJournal, reconstruire } from './journal.js'; +import { SUFFIXES } from './noms.js'; + +const V1 = VERSION.affichee; +const V2 = versionVoisine(V1); + +// La racine documents du système d'épreuve, à son chemin par défaut. +const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; +const DOC = { id: 'documents', chemin: DOCUMENTS }; + +const BASE = 'Soirée des Lucioles'; +const ETAT = `${BASE}${SUFFIXES.etat}`; +const PRECEDENT = `${BASE}${SUFFIXES.precedent}`; +const JOURNAL = `${BASE}${SUFFIXES.journal}`; +const VERROU = `${BASE}${SUFFIXES.verrou}`; + +const REPRISE = "Reprise de l'état trouvé sur le disque"; + +// Horodatage d'affichage de l'instant n : une minute par instant à partir de +// 8 h, le 17 mai 2031, à l'heure de l'Est. +function heure(n) { + const minutes = 8 * 60 + n; + const deux = (valeur) => String(valeur).padStart(2, '0'); + return `2031-05-17T${deux(Math.floor(minutes / 60))}:${deux(minutes % 60)}:00-04:00`; +} + +const CREATION = { libelle: "Création de l'événement", nomJalon: 'Création', horodatage: heure(0), produitVersion: V1 }; +const geste = (n, plus = {}) => ({ libelle: `Geste ${n}`, horodatage: heure(n), produitVersion: V1, ...plus }); + +// --- Les charges ----------------------------------------------------------- + +const personne = (id, nom, prenom, appartenance) => ({ + id, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, +}); + +const table = (id, x) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x, y: 0 } }); + +// Placement de deux tours sur les tables 1 et 2, deux places chacune ; le +// premier tour est donné, le second fixé. +function placement(id, premierTour) { + return { + id, + graine: 48271 + id, + arret: 200000, + historique: 1000, + produitVersion: V1, + siegesAttribues: false, + tables: [1, 2], + capacites: [2, 2], + tours: 2, + participants: [1, 2, 3, 4], + placement: [ + { sieges: premierTour, reserve: [] }, + { sieges: [[1, 4], [2, 3]], reserve: [] }, + ], + }; +} + +// Retenu repris d'un placement, ses champs d'identification retirés. +function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement: plan }) { + return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement: plan }; +} + +// Charge d'épreuve : quatre personnes, deux tables, une réservation, un titre, +// une proposition cohérente. +function chargeEssai({ id = 'evt-lucioles', nom = BASE } = {}) { + const charge = creerCharge({ id, nom, siegesParDefaut: 2, tours: 2 }); + charge.evenement.etat = 'propose'; + charge.prochainsIds = { participant: 5, table: 3, proposition: 2 }; + charge.participants = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Grisaille', null, 'Club des Merles'), + personne(3, 'Pervenche', 'Théo', null), + personne(4, 'Lacasse', 'Ondine', 'Société Alpha'), + ]; + charge.tables = [table(1, 0), table(2, 250)]; + charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; + charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }]; + charge.propositions = [placement(1, [[1, 3], [2, 4]])]; + return charge; +} + +// La charge d'épreuve et une seconde proposition qui se contredit : la +// personne 3 assise deux fois au premier tour, la 4 nulle part. examiner +// l'écarte (§ 8.9, point 3) : la charge ouverte n'est plus celle du fichier, +// ni celle que restitue le journal. +function avecFautive() { + const charge = chargeEssai(); + charge.propositions.push(placement(2, [[1, 3], [2, 3]])); + charge.prochainsIds.proposition = 3; + return charge; +} + +// La charge reçue, la note de sa première personne changée : un geste. +function modifier(charge, n) { + const suivante = structuredClone(charge); + suivante.participants[0].notes = `note ${n}`; + return suivante; +} + +// La charge reçue, une personne de plus sous le prochain identifiant, le +// compteur relevé : un geste dont le correctif dépend de la charge d'où il +// part. Il pose la personne par son identifiant, ce qui lève sur une charge +// qui la porte déjà ; un correctif calculé depuis un instant d'avant +// l'inscription la pose de nouveau. La personne arrive exclue : aucune +// proposition ne la place, et aucune ne dérive. +function inscrire(charge, n) { + const suivante = structuredClone(charge); + const id = suivante.prochainsIds.participant; + suivante.participants.push({ ...personne(id, 'Brunelle', null, null), notes: `inscrite au geste ${n}`, exclu: true }); + suivante.prochainsIds.participant = id + 1; + return suivante; +} + +// Le geste n sur la charge reçue : une inscription quand n est impair, la +// note changée sinon. Une note se remplace d'où que parte son correctif ; +// une inscription sur deux gestes donne à deux instants voisins des listes +// de personnes différentes, si bien qu'exigerEquivalence voit un correctif +// posé depuis une autre charge que la sienne (§ 14.2). +const gesteSur = (charge, n) => (n % 2 === 1 ? inscrire(charge, n) : modifier(charge, n)); + +const texteDeCharge = (charge) => serialiserCharge(charge); +const etatTexte = (charge, revision = 1, produitVersion = V1) => serialiser(charge, { revision, produitVersion }); + +// --- Les disques ----------------------------------------------------------- + +// Un disque neuf dont la racine documents existe, et son premier processus. +function nouveauDisque(options = {}) { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, ...options }); + fs.deposerDossier('documents'); + return { disque, fs }; +} + +// Un disque neuf dont documents porte les fichiers [nom, texte, modifie?]. +function disqueAvec(fichiers) { + const { disque, fs } = nouveauDisque(); + for (const [nom, texte, modifie] of fichiers) fs.deposer('documents', nom, texte, modifie); + return { disque, fs }; +} + +// Fichiers de documents, [nom, texte], par nom, lus par un processus à part. +async function fichiersDe(disque) { + const lecteur = creerFichiersSimules({ disque }); + const entrees = await lecteur.lister(DOC, ''); + return entrees.filter(({ type }) => type === 'fichier').map(({ nom }) => [nom, lecteur.contenu('documents', nom)]); +} + +// Les fichiers donnés, le texte de nom remplacé, ou retiré quand il est null. +function remplacer(fichiers, nom, texte) { + const autres = fichiers.filter(([autre]) => autre !== nom); + return texte === null ? autres : [...autres, [nom, texte]]; +} + +const texteDe = (fichiers, nom) => fichiers.find(([autre]) => autre === nom)[1]; + +// Ouvre base sur le disque par un processus neuf, en affirmant qu'ouvrir +// n'écrit rien (§ 8.4). +async function rouvrir(disque, base) { + const fs = creerFichiersSimules({ disque }); + const ouvert = await ouvrir(fs, DOC, base); + assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit'); + return ouvert; +} + +// Inscrit, dans l'ordre, chaque appel d'une primitive d'écriture de fichier +// ou de dossier : [primitive, chemin]. +function tracer(fs) { + const trace = []; + for (const nom of ['ecrireAtomique', 'ajouterLigne', 'creerDossier', 'deplacer', 'supprimer']) { + const primitive = fs[nom]; + fs[nom] = (racine, chemin, ...reste) => { + trace.push([nom, chemin]); + return primitive(racine, chemin, ...reste); + }; + } + return trace; +} + +// Dès l'écriture du rang-ième .precedent, le premier par défaut, les +// renommages de sa racine sont refusés : l'écriture atomique de l'état qui +// suit échoue sans toucher sa cible (§ 8.8). Rend la fonction qui retire ce +// piège. +function refuserRenommageApresPrecedent(fs, rang = 1) { + const ecrire = fs.ecrireAtomique; + let precedents = 0; + fs.ecrireAtomique = async (racine, chemin, texte) => { + await ecrire(racine, chemin, texte); + if (!chemin.endsWith(SUFFIXES.precedent)) return; + precedents += 1; + if (precedents >= rang) fs.pannes.renommage(racine.id); + }; + return () => { + fs.ecrireAtomique = ecrire; + fs.pannes.renommage('documents', 0); + }; +} + +// Rend la raison du rejet de la promesse ; échoue quand elle se résout, par +// un constat qui se termine sur message quand il est donné. +async function rejet(promesse, message) { + let raison; + await assert.rejects( + promesse, + (erreur) => { + raison = erreur; + return true; + }, + message, + ); + return raison; +} + +// Attend le rejet d'une ErreurStockage de ce code, et la rend ; message +// nomme le cas d'une boucle dont la promesse se résout. +async function echec(promesse, code, message) { + const erreur = await rejet(promesse, message); + assert.ok(erreur instanceof ErreurStockage, String(erreur)); + assert.equal(erreur.code, code, String(erreur)); + return erreur; +} + +// Un geste ⇔ une entrée ⇔ un état (§ 8.2) : la dernière entrée du journal +// porte la révision de l'état et restitue exactement sa charge ; le journal se +// lit en entier. +function exigerEquivalence(fs, base, { racine = 'documents', journal = base } = {}) { + const etat = analyser(fs.contenu(racine, `${base}${SUFFIXES.etat}`)); + const lu = lireJournal(fs.contenu(racine, `${journal}${SUFFIXES.journal}`)); + const derniere = lu.entrees.at(-1); + assert.equal(lu.ecartees, 0); + assert.equal(derniere.revision, etat.entete.revision); + assert.equal(texteDeCharge(reconstruire(lu, derniere.revision)), texteDeCharge(etat.charge)); + return lu; +} + +const revisionsEtLibelles = (journal) => journal.entrees.map(({ revision, libelle }) => [revision, libelle]); + +// Un événement créé, puis deux gestes, une note puis une inscription +// (gesteSur) : le .precedent porte la révision 2, l'état et le journal la 3, +// et ces deux instants n'ont pas les mêmes personnes. Rend ses fichiers et le +// dernier Ouvert. +async function troisRevisions() { + const { disque, fs } = nouveauDisque(); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + for (const n of [2, 3]) ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, n), geste(n)); + return { fichiers: await fichiersDe(disque), ouvert }; +} + +// Le journal d'un autre événement, créé sur un disque à part : posé sous la +// base, il est JOURNAL_ETRANGER. +async function journalEtranger() { + const { fs } = nouveauDisque(); + await creer(fs, DOC, chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }), CREATION); + return fs.contenu('documents', `Gala d'automne${SUFFIXES.journal}`); +} + +// L'Ouvert que rend ouvrirAinsi sur un disque neuf qui porte fichiers, par un +// processus neuf, sans rien écrire, ses avertissements de codes attendus : +// pour exigerChaqueCoupure. +function depuisFichiers(fichiers, ouvrirAinsi, codes) { + return async () => { + const { disque, fs } = disqueAvec(fichiers); + const ouvert = await ouvrirAinsi(fs); + assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit'); + assert.deepEqual( + ouvert.avertissements.map(({ code }) => code), + codes, + ); + return { disque, ouvert }; + }; +} + +// Chaque coupure du premier geste qui part de l'Ouvert que rend depart, sur un +// disque neuf : après n écritures réussies, de n = 0 jusqu'au geste achevé. Un +// premier passage sans coupure désigne le journal où le geste s'inscrit, par +// la base que rend son Ouvert : celle de l'événement, ou celle que le geste +// dérive quand le journal de la base porte un autre événement. Rouvert après +// la coupure, l'événement rend le geste dès que sa ligne est à ce journal — à +// sa révision, rattrapé en mémoire, sans autre avertissement que +// JOURNAL_REPRIS (§ 8.2) ; achevé, le geste est la dernière entrée de ce +// journal. Rend le nombre de coupures tombées entre la ligne du geste et l'état +// écrit : l'épreuve en exige au moins une. +async function exigerChaqueCoupure(nom, depart) { + const options = geste(99, { libelleReprise: REPRISE }); + const temoin = await depart(); + const { baseJournal } = await enregistrer(temoin.ouvert, modifier(temoin.ouvert.charge, 99), options); + const journal = `${baseJournal}${SUFFIXES.journal}`; + let entreLigneEtEtat = 0; + for (let n = 0; ; n += 1) { + const { disque, ouvert } = await depart(); + const apres = modifier(ouvert.charge, 99); + ouvert.fs.pannes.couperApres(n); + let acheve = true; + try { + await enregistrer(ouvert, apres, options); + } catch (erreur) { + assert.ok(erreur instanceof PanneSimulee, `${nom}, coupure après ${n} écritures : ${String(erreur)}`); + acheve = false; + } + const message = `${nom}, coupure après ${n} écritures`; + const texte = ouvert.fs.contenu('documents', journal); + const derniere = texte === null ? undefined : lireJournal(texte).entrees.at(-1); + if (acheve) assert.equal(derniere?.libelle, options.libelle, `${message} : le geste achevé finit ${journal}`); + if (derniere?.libelle === options.libelle) { + const rouvert = await rouvrir(disque, BASE); + assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(apres), message); + assert.equal(rouvert.revision, derniere.revision, message); + assert.deepEqual( + rouvert.avertissements.filter(({ code }) => code !== 'JOURNAL_REPRIS'), + [], + message, + ); + if (!acheve) entreLigneEtEtat += 1; + } + if (acheve) return entreLigneEtEtat; + } +} + +describe('dépôt : créer, ouvrir, lister', () => { + test("créer écrit le journal, puis l'état ; ouvrir rend la même charge à la révision 1 et son journal d'une entrée, sans rien écrire", async () => { + const { disque, fs } = nouveauDisque(); + const trace = tracer(fs); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + assert.deepEqual(trace, [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', ETAT], + ]); + assert.equal(fs.compteurs.ecritures, 2); + assert.equal(cree.base, BASE); + assert.equal(cree.revision, 1); + const texteEtat = etatTexte(chargeEssai()); + assert.equal(fs.contenu('documents', ETAT), texteEtat); + assert.equal(cree.texteEtat, texteEtat); + + const journal = lireJournal(fs.contenu('documents', JOURNAL)); + assert.equal(journal.evenement, 'evt-lucioles'); + assert.deepEqual(revisionsEtLibelles(journal), [[1, CREATION.libelle]]); + assert.equal(journal.entrees[0].produitVersion, V1); + assert.equal(texteDeCharge(journal.entrees[0].instantane), texteDeCharge(chargeEssai())); + assert.deepEqual( + journal.jalons.map(({ revision, nom }) => [revision, nom]), + [[1, 'Création']], + ); + + const ouvert = await rouvrir(disque, BASE); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(chargeEssai())); + assert.equal(ouvert.revision, 1); + assert.equal(ouvert.base, BASE); + assert.equal(ouvert.texteEtat, texteEtat); + assert.equal(ouvert.produitVersion, V1); + assert.equal(ouvert.formatPlusRecent, false); + assert.deepEqual(ouvert.journal, journal); + assert.deepEqual(ouvert.avertissements, []); + assert.deepEqual(ouvert.examen.fautives, []); + }); + + test("créer dérive la base du nom de l'événement sans reprendre une base du dossier, casse comprise", async () => { + const { fs } = disqueAvec([['SOIRÉE DES LUCIOLES.gtt.verrou', '{}\n']]); + const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + assert.equal(ouvert.base, 'Soirée des Lucioles (2)'); + assert.equal(fs.compteurs.ecritures, 2); + assert.equal(fs.contenu('documents', 'Soirée des Lucioles (2).gtt.json'), etatTexte(chargeEssai())); + }); + + test("créer refuse, sans rien écrire, une charge hors de sa forme ou dont l'état ne se relirait pas", async () => { + const { disque, fs } = nouveauDisque(); + await assert.rejects(creer(fs, DOC, { ...chargeEssai(), inconnue: 1 }, CREATION), TypeError); + const orpheline = chargeEssai(); + orpheline.titres = [{ table: 9, siege: 1, libelle: 'animation' }]; + await assert.rejects(creer(fs, DOC, orpheline, CREATION), /ne se relirait pas/); + assert.equal(fs.compteurs.ecritures, 0); + assert.deepEqual(await fichiersDe(disque), []); + }); + + test('lister rend chaque événement du dossier, trié par nom, avec ou sans journal, sans rien écrire', async () => { + const { disque, fs } = nouveauDisque(); + await creer(fs, DOC, chargeEssai(), CREATION); + const gala = chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }); + gala.evenement.date = '2031-10-04'; + fs.deposer('documents', "Gala d'automne.gtt.json", etatTexte(gala, 4), 12345); + + const lecteur = creerFichiersSimules({ disque }); + const entrees = await lecteur.lister(DOC, ''); + const resumes = await lister(lecteur, DOC); + assert.equal(lecteur.compteurs.ecritures, 0); + assert.deepEqual(resumes, [ + { + base: "Gala d'automne", + id: 'evt-gala', + nom: "Gala d'automne", + date: '2031-10-04', + etat: 'propose', + participants: 4, + modifie: 12345, + journal: null, + illisible: null, + doublon: false, + }, + { + base: BASE, + id: 'evt-lucioles', + nom: BASE, + date: null, + etat: 'propose', + participants: 4, + modifie: entrees.find(({ nom }) => nom === ETAT).modifie, + journal: BASE, + illisible: null, + doublon: false, + }, + ]); + }); + + test("lister apparie par l'identifiant, jamais par le nom : journal renommé, état illisible, journal sans état, doublon, journal d'un autre", async () => { + const ouverture = (evenement) => `${ligneOuverture(evenement)}\n`; + const etat = (id, nom) => etatTexte(chargeEssai({ id, nom })); + const { fs } = disqueAvec([ + // état lisible, son journal renommé à la main ; une copie de ce journal + ['Aubepine.gtt.json', etat('evt-a', 'Aubépine'), 1000], + ['Carnet.gtt-journal.jsonl', ouverture('evt-a'), 1001], + ['Copie.gtt-journal.jsonl', ouverture('evt-a'), 1002], + // état illisible, son journal à côté + ['Bleuet.gtt.json', '', 2000], + ['Bleuet.gtt-journal.jsonl', ouverture('evt-b'), 2001], + // journal sans état + ['Capucine.gtt-journal.jsonl', ouverture('evt-c'), 3000], + // deux états du même identifiant + ['Dahlia.gtt.json', etat('evt-d', 'Dahlia'), 4000], + ['Dahlia bis.gtt.json', etat('evt-d', 'Dahlia'), 4001], + // état lisible, à côté du journal d'un autre événement + ['Esquisse.gtt.json', etat('evt-e', 'Esquisse'), 5000], + ['Esquisse.gtt-journal.jsonl', ouverture('evt-x'), 5001], + // journal sans état, dont l'ouverture ne se lit pas + ['Fougere.gtt-journal.jsonl', 'pas du JSON\n', 6000], + // état illisible dont le journal porte un autre événement, lisible lui + ['Glycine.gtt.json', '{', 7000], + ['Glycine.gtt-journal.jsonl', ouverture('evt-h'), 7001], + ['Hortensia.gtt.json', etat('evt-h', 'Hortensia'), 8000], + // état illisible, ni .precedent ni journal + ['Jacinthe.gtt.json', '{"entete":', 8500], + // ni état ni journal + ['notes.txt', 'à lire', 9000], + ]); + fs.deposerDossier('documents', 'Iris.gtt.json'); + const lisible = (base, id, nom, modifie, journal, doublon = false) => ({ + base, + id, + nom, + date: null, + etat: 'propose', + participants: 4, + modifie, + journal, + illisible: null, + doublon, + }); + const illisible = (base, id, modifie, journal, raison) => ({ + base, + id, + nom: null, + date: null, + etat: null, + participants: null, + modifie, + journal, + illisible: raison, + doublon: false, + }); + assert.deepEqual(await lister(fs, DOC), [ + lisible('Aubepine', 'evt-a', 'Aubépine', 1000, 'Carnet'), + lisible('Dahlia', 'evt-d', 'Dahlia', 4000, null, true), + lisible('Dahlia bis', 'evt-d', 'Dahlia', 4001, null, true), + lisible('Esquisse', 'evt-e', 'Esquisse', 5000, null), + lisible('Hortensia', 'evt-h', 'Hortensia', 8000, 'Glycine'), + illisible('Bleuet', null, 2000, 'Bleuet', 'VIDE'), + illisible('Capucine', 'evt-c', 3000, 'Capucine', 'ABSENT'), + illisible('Fougere', null, 6000, 'Fougere', 'ABSENT'), + illisible('Glycine', null, 7000, null, 'JSON'), + illisible('Jacinthe', null, 8500, null, 'JSON'), + ]); + assert.equal(fs.compteurs.ecritures, 0); + }); +}); + +describe('dépôt : un geste, une entrée, un état (§ 8.2)', () => { + test("chaque geste ajoute son entrée au journal, puis écrit le .precedent et l'état : la dernière entrée restitue la charge écrite", async () => { + const { fs } = nouveauDisque(); + const trace = tracer(fs); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + for (let n = 2; n <= 5; n += 1) { + const etatAvant = fs.contenu('documents', ETAT); + const depart = trace.length; + // Les gestes alternent note et inscription (gesteSur) ; le dernier + // revient à l'instant 3 : un retour est une entrée comme une autre, dont + // la charge est celle de sa cible (§ 8.3). + const retour = n === 5 ? { sens: 'revenir', retour: 3 } : {}; + const apres = n === 5 ? reconstruire(ouvert.journal, 3) : gesteSur(ouvert.charge, n); + ouvert = await enregistrer(ouvert, apres, geste(n, retour)); + assert.deepEqual(trace.slice(depart), [ + ['ajouterLigne', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + assert.equal(fs.contenu('documents', PRECEDENT), etatAvant); + assert.equal(ouvert.revision, n); + assert.equal(ouvert.texteEtat, fs.contenu('documents', ETAT)); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres)); + const journal = exigerEquivalence(fs, BASE); + assert.equal(journal.entrees.length, n); + assert.deepEqual(ouvert.journal, journal); + } + assert.equal(fs.compteurs.ecritures, 2 + 4 * 3); + const derniere = ouvert.journal.entrees.at(-1); + assert.deepEqual([derniere.sens, derniere.retour], ['revenir', 3]); + assert.equal(fil(ouvert.journal).position, 3); + }); + + test("une entrée note la construction quand elle change, et l'état porte celle du dernier geste", async () => { + const { fs } = nouveauDisque(); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3, { produitVersion: V2 })); + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { produitVersion: V2 })); + const { entrees } = lireJournal(fs.contenu('documents', JOURNAL)); + assert.deepEqual( + entrees.map(({ produitVersion }) => produitVersion), + [V1, null, V2, null], + ); + assert.equal(analyser(fs.contenu('documents', ETAT)).entete.produitVersion, V2); + assert.equal(ouvert.produitVersion, V2); + assert.equal(fs.compteurs.ecritures, 2 + 3 * 3); + }); + + test("nommer ajoute un jalon sur l'instant courant, sans écrire l'état", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + const etat = fs.contenu('documents', ETAT); + const trace = tracer(fs); + const nomme = await nommer(cree, { nom: 'Avant la génération', horodatage: heure(1) }); + assert.deepEqual(trace, [['ajouterLigne', JOURNAL]]); + assert.equal(fs.contenu('documents', ETAT), etat); + assert.equal(nomme.revision, 1); + assert.deepEqual( + nomme.journal.jalons.map(({ revision, nom }) => [revision, nom]), + [ + [1, 'Création'], + [1, 'Avant la génération'], + ], + ); + assert.deepEqual((await rouvrir(disque, BASE)).journal, nomme.journal); + }); + + test("un geste refusé n'écrit rien : charge hors de sa forme, état qui ne se relirait pas, version absente, libellé ou nom vides", async () => { + const { disque, fs } = nouveauDisque(); + const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + const avant = await fichiersDe(disque); + const ecritures = fs.compteurs.ecritures; + await assert.rejects(enregistrer(ouvert, { ...ouvert.charge, inconnue: 1 }, geste(2)), TypeError); + const orpheline = modifier(ouvert.charge, 2); + orpheline.reservations = [{ participant: 9, table: 1, siege: null, portee: 'tous', tour: null }]; + await assert.rejects(enregistrer(ouvert, orpheline, geste(2)), /ne se relirait pas/); + const sansVersion = { libelle: 'Geste 2', horodatage: heure(2) }; + await assert.rejects(enregistrer(ouvert, modifier(ouvert.charge, 2), sansVersion), TypeError); + await assert.rejects(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2, { libelle: '' })), TypeError); + await assert.rejects(nommer(ouvert, { nom: '', horodatage: heure(2) }), TypeError); + assert.equal(fs.compteurs.ecritures, ecritures); + assert.deepEqual(await fichiersDe(disque), avant); + }); +}); + +describe("dépôt : une panne à chaque frontière d'un geste (§ 8.8, § 14.10)", () => { + test("coupure après la ligne de journal : la réouverture rattrape le geste en mémoire sans écrire, et le geste suivant écrit d'abord l'état rattrapé", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + const etatCree = fs.contenu('documents', ETAT); + fs.pannes.couperApres(1); + const apres = modifier(cree.charge, 2); + await assert.rejects(enregistrer(cree, apres, geste(2)), PanneSimulee); + assert.equal(fs.contenu('documents', ETAT), etatCree); + assert.equal(fs.contenu('documents', PRECEDENT), null); + + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]); + assert.equal(ouvert.revision, 2); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres)); + assert.equal(ouvert.texteEtat, etatCree); + + const trace = tracer(ouvert.fs); + const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)); + // L'état rattrapé s'écrit avant la ligne du geste : une coupure entre elle + // et l'état ne laisse le journal qu'une entrée en avance (§ 8.2). + assert.deepEqual(trace, [ + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ['ajouterLigne', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + assert.equal(suite.revision, 3); + assert.equal(ouvert.fs.contenu('documents', PRECEDENT), etatTexte(apres, 2)); + exigerEquivalence(ouvert.fs, BASE); + assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []); + }); + + test('coupure après le .precedent : la réouverture rattrape le geste de même', async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + const etatCree = fs.contenu('documents', ETAT); + fs.pannes.couperApres(2); + const apres = modifier(cree.charge, 2); + await assert.rejects(enregistrer(cree, apres, geste(2)), PanneSimulee); + assert.equal(fs.contenu('documents', PRECEDENT), etatCree); + assert.equal(fs.contenu('documents', ETAT), etatCree); + + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]); + assert.equal(ouvert.revision, 2); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres)); + }); + + test("renommage de l'état refusé : ETAT_NON_ECRIT nomme l'état et son dossier, l'état reste intact, le .precedent se lit, et l'Ouvert avancé continue", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + const etatCree = fs.contenu('documents', ETAT); + const retirer = refuserRenommageApresPrecedent(fs); + const apres = modifier(cree.charge, 2); + const erreur = await echec(enregistrer(cree, apres, geste(2)), 'ETAT_NON_ECRIT'); + const { ouvert: avance, ...details } = erreur.details; + assert.deepEqual(details, { chemin: ETAT, dossier: DOCUMENTS, cause: 'EBUSY' }); + assert.ok(!erreur.message.includes('Ombrelle'), "le message de l'erreur ne porte pas l'Ouvert"); + assert.equal(fs.contenu('documents', ETAT), etatCree); + assert.equal(fs.contenu('documents', `${ETAT}.ecriture`), null); + assert.equal(analyser(fs.contenu('documents', PRECEDENT)).entete.revision, 1); + assert.equal(avance.revision, 2); + assert.equal(avance.texteEtat, etatCree); + assert.equal(texteDeCharge(avance.charge), texteDeCharge(apres)); + assert.equal(avance.journal.entrees.length, 2); + + retirer(); + const suite = await enregistrer(avance, modifier(avance.charge, 3), geste(3)); + assert.equal(suite.revision, 3); + // Le geste qui suit écrit d'abord l'état de l'Ouvert avancé, que le + // renommage refusé n'a pas posé : il est le .precedent. + assert.equal(fs.contenu('documents', PRECEDENT), etatTexte(apres, 2)); + exigerEquivalence(fs, BASE); + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual([ouvert.revision, ouvert.avertissements], [3, []]); + }); + + test("ajout au journal refusé : ECRITURE nomme le journal et son dossier, et rien n'a changé ; sans base de journal (JOURNAL_ETRANGER), un dossier qui ne se liste plus lève la même ECRITURE, sur le journal de la base", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + const avant = await fichiersDe(disque); + fs.pannes.ecriture('documents'); + const erreur = await echec(enregistrer(cree, modifier(cree.charge, 2), geste(2)), 'ECRITURE'); + assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause: 'EROFS' }); + assert.equal(fs.compteurs.ecritures, 3); + assert.deepEqual(await fichiersDe(disque), avant); + + // Le journal de la base porte un autre événement : le geste cherche dans + // le dossier le journal du sien (§ 8.6). Par cas, ce que devient le + // dossier pour le processus, et la cause attendue : retiré avec son + // support, chaque lecture lève ABSENT, sa liste comprise, et la cause est + // ENOENT ; illisible, sa liste lève LECTURE, comme sous la coquille, dont + // la cause passe telle quelle. + const poses = [ + [ETAT, etatTexte(chargeEssai())], + [JOURNAL, await journalEtranger()], + ]; + const pannes = [ + [ + 'support retiré', + (lecteur) => { + const absent = async (racine, chemin) => { + throw new ErreurStockage('ABSENT', { chemin }); + }; + lecteur.lireTexte = absent; + lecteur.lister = absent; + }, + 'ENOENT', + ], + [ + 'dossier illisible', + (lecteur) => { + lecteur.lister = async (racine, chemin) => { + throw new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' }); + }; + }, + 'EACCES', + ], + // Le support se retire entre la liste où le geste cherche le journal de + // l'événement et celle d'où il dérive une base libre. + [ + 'support retiré entre les deux listes', + (lecteur) => { + const listerAvant = lecteur.lister; + let appels = 0; + lecteur.lister = async (racine, chemin) => { + appels += 1; + if (appels > 1) throw new ErreurStockage('ABSENT', { chemin }); + return listerAvant(racine, chemin); + }; + }, + 'ENOENT', + ], + ]; + for (const [nom, rompre, cause] of pannes) { + const etranger = disqueAvec(poses); + const ouvert = await ouvrir(etranger.fs, DOC, BASE); + assert.equal(etranger.fs.compteurs.ecritures, 0, `${nom} : ouvrir écrit`); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ETRANGER', details: { evenement: 'evt-gala' } }], nom); + const posesAvant = await fichiersDe(etranger.disque); + rompre(etranger.fs); + const options = geste(2, { libelleReprise: REPRISE }); + const refus = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), options), 'ECRITURE', nom); + assert.deepEqual(refus.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause }, nom); + assert.equal(etranger.fs.compteurs.ecritures, 0, nom); + assert.deepEqual(await fichiersDe(etranger.disque), posesAvant, nom); + } + }); + + test('la reprise ne se fonde pas sur la date des fichiers : un dossier recopié, aux dates égales ou inversées, se rattrape pareil', async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + fs.pannes.couperApres(1); + const apres = modifier(cree.charge, 2); + await assert.rejects(enregistrer(cree, apres, geste(2)), PanneSimulee); + const fichiers = await fichiersDe(disque); + const dates = [() => 0, (nom) => (nom === JOURNAL ? 0 : 9e11)]; + for (const date of dates) { + const copie = disqueAvec(fichiers.map(([nom, texte]) => [nom, texte, date(nom)])); + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]); + assert.equal(ouvert.revision, 2); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres)); + } + }); + + test('une dernière ligne de journal tronquée, sans fin de ligne, est écartée et comptée ; le premier geste réécrit le journal sans elle', async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + await enregistrer(cree, modifier(cree.charge, 2), geste(2)); + const fichiers = await fichiersDe(disque); + const journal = texteDe(fichiers, JOURNAL); + const copie = disqueAvec(remplacer(fichiers, JOURNAL, `${journal}{"type":"entree","revision":3,"lib`)); + + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]); + assert.equal(ouvert.revision, 2); + const trace = tracer(ouvert.fs); + const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)); + assert.deepEqual(trace, [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + const reecrit = ouvert.fs.contenu('documents', JOURNAL); + assert.ok(reecrit.startsWith(journal), 'les lignes lues se recopient telles quelles'); + assert.equal(exigerEquivalence(ouvert.fs, BASE).entrees.length, 3); + assert.deepEqual(suite.avertissements, []); + assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []); + }); + + test("le rattrapage prend la version de la construction qui a écrit le geste rattrapé : l'Ouvert la porte, l'écriture ailleurs et l'état réaligné l'inscrivent", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + fs.pannes.couperApres(1); + await assert.rejects(enregistrer(cree, modifier(cree.charge, 2), geste(2, { produitVersion: V2 })), PanneSimulee); + + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]); + assert.equal(ouvert.produitVersion, V2); + const ailleurs = await enregistrerAilleurs(ouvert, DOC, { existantes: [] }); + const etatAilleurs = analyser(ouvert.fs.contenu('documents', `${ailleurs.base}${SUFFIXES.etat}`)); + assert.equal(etatAilleurs.entete.produitVersion, V2); + // Le geste suivant écrit d'abord l'état rattrapé, à la version de son + // entrée : il est ensuite le .precedent. + await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)); + assert.equal(analyser(ouvert.fs.contenu('documents', PRECEDENT)).entete.produitVersion, V2); + exigerEquivalence(ouvert.fs, BASE); + }); + + test("un refus d'écriture pendant l'alignement lève ECRITURE, le geste hors du journal ; le même Ouvert le réessaie dans le même journal, sans doubler la reprise ni dériver un autre journal", async () => { + const { fichiers } = await troisRevisions(); + const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`; + // Par cas : les fichiers posés, l'ouverture, la base du journal de + // l'événement et ses entrées après le refus. Quand le journal de la base + // porte un autre événement, la reprise crée celui de l'événement sous une + // base que son nom dérive : le nouvel essai doit l'y retrouver. + const cas = [ + [ + 'journal incomplet (JOURNAL_INCOMPLET)', + remplacer(fichiers, JOURNAL, tete), + (fs) => ouvrir(fs, DOC, BASE), + BASE, + [ + [1, CREATION.libelle], + [2, REPRISE], + ], + ], + [ + 'secours .precedent sur un état lisible, le journal de la base à un autre événement (JOURNAL_ETRANGER)', + remplacer(fichiers, JOURNAL, await journalEtranger()), + (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), + `${BASE} (2)`, + [[2, REPRISE]], + ], + ]; + for (const [nom, poses, ouvrirAinsi, base, apresRefus] of cas) { + const { disque, fs } = disqueAvec(poses); + const ouvert = await ouvrirAinsi(fs); + assert.equal(fs.compteurs.ecritures, 0, `${nom} : l'ouverture écrit`); + const journal = `${base}${SUFFIXES.journal}`; + const retirer = refuserRenommageApresPrecedent(fs); + const apres = modifier(ouvert.charge, 4); + const options = geste(4, { libelleReprise: REPRISE }); + const erreur = await echec(enregistrer(ouvert, apres, options), 'ECRITURE'); + assert.deepEqual(erreur.details, { chemin: ETAT, dossier: DOCUMENTS, cause: 'EBUSY' }, nom); + assert.deepEqual(revisionsEtLibelles(lireJournal(fs.contenu('documents', journal))), apresRefus, nom); + assert.equal(fs.contenu('documents', ETAT), texteDe(fichiers, ETAT), nom); + const nomsApresRefus = (await fichiersDe(disque)).map(([fichier]) => fichier); + + retirer(); + const trace = tracer(fs); + const suite = await enregistrer(ouvert, apres, options); + assert.deepEqual( + trace, + [ + ['ecrireAtomique', journal], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ['ajouterLigne', journal], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ], + nom, + ); + assert.deepEqual( + (await fichiersDe(disque)).map(([fichier]) => fichier), + nomsApresRefus, + nom, + ); + assert.deepEqual([suite.revision, suite.baseJournal], [3, base], nom); + assert.deepEqual( + revisionsEtLibelles(exigerEquivalence(fs, BASE, { journal: base })), + [...apresRefus, [3, 'Geste 4']], + nom, + ); + const rouvert = await rouvrir(disque, BASE); + assert.deepEqual([rouvert.revision, rouvert.baseJournal, rouvert.avertissements], [3, base, []], nom); + } + }); + + // Les ouvertures après lesquelles l'état sur le disque ne porte pas la + // révision de la dernière entrée du journal. Chacune pose ses fichiers, puis + // rend la fonction qui rouvre sur un disque neuf l'Ouvert d'où part le + // premier geste. + const departsMalAlignes = [ + [ + 'journal incomplet de deux entrées (JOURNAL_INCOMPLET)', + async () => { + const { fichiers } = await troisRevisions(); + const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`; + return depuisFichiers(remplacer(fichiers, JOURNAL, tete), (fs) => ouvrir(fs, DOC, BASE), ['JOURNAL_INCOMPLET']); + }, + ], + [ + 'journal en avance de deux entrées (JOURNAL_EN_AVANCE_DE)', + async () => { + const { disque, fs } = nouveauDisque(); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); + const etat2 = fs.contenu('documents', ETAT); + for (const n of [3, 4]) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); + const fichiers = remplacer(await fichiersDe(disque), ETAT, etat2); + return depuisFichiers(fichiers, (lecteur) => ouvrir(lecteur, DOC, BASE), ['JOURNAL_EN_AVANCE_DE']); + }, + ], + [ + "journal en avance d'une entrée (JOURNAL_REPRIS)", + async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + fs.pannes.couperApres(1); + await assert.rejects(enregistrer(cree, modifier(cree.charge, 2), geste(2)), PanneSimulee); + return depuisFichiers(await fichiersDe(disque), (lecteur) => ouvrir(lecteur, DOC, BASE), ['JOURNAL_REPRIS']); + }, + ], + [ + 'secours .precedent sur un état lisible', + async () => { + const { fichiers } = await troisRevisions(); + return depuisFichiers(fichiers, (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), ['JOURNAL_EN_AVANCE_DE']); + }, + ], + [ + 'secours .precedent sur un état lisible, sans journal', + async () => { + const { fichiers } = await troisRevisions(); + const sansJournal = remplacer(fichiers, JOURNAL, null); + return depuisFichiers(sansJournal, (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), []); + }, + ], + [ + 'secours .precedent sur un état lisible, le journal de la base à un autre événement (JOURNAL_ETRANGER)', + async () => { + const { fichiers } = await troisRevisions(); + const etranger = remplacer(fichiers, JOURNAL, await journalEtranger()); + return depuisFichiers(etranger, (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), ['JOURNAL_ETRANGER']); + }, + ], + [ + 'secours journal sur un état lisible, le journal incomplet', + async () => { + const { fichiers } = await troisRevisions(); + const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`; + const incomplet = remplacer(fichiers, JOURNAL, tete); + return depuisFichiers(incomplet, (fs) => ouvrirSecours(fs, DOC, BASE, 'journal'), []); + }, + ], + [ + "Ouvert avancé d'un ETAT_NON_ECRIT", + async () => async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + const retirer = refuserRenommageApresPrecedent(fs); + const erreur = await echec(enregistrer(cree, modifier(cree.charge, 2), geste(2)), 'ETAT_NON_ECRIT'); + retirer(); + return { disque, ouvert: erreur.details.ouvert }; + }, + ], + [ + "Ouvert avancé d'un ETAT_NON_ECRIT tombé après l'alignement d'un journal incomplet", + async () => { + const { fichiers } = await troisRevisions(); + const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`; + const incomplet = remplacer(fichiers, JOURNAL, tete); + return async () => { + const { disque, fs } = disqueAvec(incomplet); + const ouvert = await ouvrir(fs, DOC, BASE); + assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit'); + // Le .precedent de l'alignement passe ; celui du geste arme le refus. + const retirer = refuserRenommageApresPrecedent(fs, 2); + const options = geste(4, { libelleReprise: REPRISE }); + const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), options), 'ETAT_NON_ECRIT'); + retirer(); + return { disque, ouvert: erreur.details.ouvert }; + }; + }, + ], + ]; + for (const [nom, poser] of departsMalAlignes) { + test(`coupure à chaque écriture du premier geste après une ouverture mal alignée, ${nom} : le geste inscrit au journal se rouvre toujours`, async () => { + assert.ok((await exigerChaqueCoupure(nom, await poser())) > 0, nom); + }); + } +}); + +describe('dépôt : un état illisible et ses secours (§ 8.8)', () => { + test("un état vide, tronqué ou aux comptes faux lève ETAT_ILLISIBLE, qui nomme la base, la raison et les secours lisibles, sans rien écrire", async () => { + const { fichiers } = await troisRevisions(); + const etat = texteDe(fichiers, ETAT); + const cas = [ + ['', 'VIDE', null], + [etat.slice(0, Math.floor(etat.length / 2)), 'JSON', null], + [ + etat.replace(/"comptes":\{"participants":(\d+)/, (_, n) => `"comptes":{"participants":${Number(n) + 1}`), + 'COMPTES', + 'entete.comptes.participants', + ], + ]; + for (const [texte, raison, chemin] of cas) { + const { fs } = disqueAvec(remplacer(fichiers, ETAT, texte)); + const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE'); + assert.deepEqual(erreur.details, { base: BASE, raison, chemin, secours: { precedent: 2, journal: 3 } }); + assert.equal(fs.compteurs.ecritures, 0); + } + }); + + test("ouvrirSecours ouvre le .precedent, puis le dernier instant du journal, sans écrire ni rendre un événement vide", async () => { + const { fichiers, ouvert: dernier } = await troisRevisions(); + const precedent = analyser(texteDe(fichiers, PRECEDENT)); + const { fs } = disqueAvec(remplacer(fichiers, ETAT, '')); + + const parPrecedent = await ouvrirSecours(fs, DOC, BASE, 'precedent'); + assert.equal(parPrecedent.revision, 2); + assert.equal(texteDeCharge(parPrecedent.charge), texteDeCharge(precedent.charge)); + assert.equal(parPrecedent.texteEtat, null); + assert.deepEqual(parPrecedent.avertissements, [{ code: 'JOURNAL_EN_AVANCE_DE', details: { n: 1 } }]); + + const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal'); + assert.equal(parJournal.revision, 3); + assert.equal(texteDeCharge(parJournal.charge), texteDeCharge(dernier.charge)); + assert.equal(parJournal.texteEtat, null); + assert.equal(parJournal.produitVersion, V1); + assert.deepEqual(parJournal.avertissements, []); + + assert.deepEqual( + [parPrecedent, parJournal].map(({ charge }) => charge.participants.length), + [4, 5], + ); + assert.equal(fs.compteurs.ecritures, 0); + }); + + test("le premier geste après un secours réécrit l'état et ne met pas l'état illisible dans le .precedent", async () => { + const { fichiers } = await troisRevisions(); + const textePrecedent = texteDe(fichiers, PRECEDENT); + + // Depuis le journal : le geste suit son dernier instant. + const parJournal = disqueAvec(remplacer(fichiers, ETAT, '{"entete":')); + const depuisJournal = await ouvrirSecours(parJournal.fs, DOC, BASE, 'journal'); + assert.equal(parJournal.fs.compteurs.ecritures, 0, 'ouvrirSecours écrit'); + const traceJournal = tracer(parJournal.fs); + const suiteJournal = await enregistrer(depuisJournal, modifier(depuisJournal.charge, 4), geste(4)); + assert.deepEqual(traceJournal, [ + ['ajouterLigne', JOURNAL], + ['ecrireAtomique', ETAT], + ]); + assert.equal(parJournal.fs.contenu('documents', PRECEDENT), textePrecedent); + assert.equal(suiteJournal.revision, 4); + exigerEquivalence(parJournal.fs, BASE); + assert.deepEqual((await rouvrir(parJournal.disque, BASE)).avertissements, []); + + // Depuis le .precedent : une entrée de reprise restitue sa charge, puis + // vient le geste. Sans libellé de reprise, le geste est refusé. + const parPrecedent = disqueAvec(remplacer(fichiers, ETAT, '{"entete":')); + const depuisPrecedent = await ouvrirSecours(parPrecedent.fs, DOC, BASE, 'precedent'); + assert.equal(parPrecedent.fs.compteurs.ecritures, 0, 'ouvrirSecours écrit'); + await assert.rejects(enregistrer(depuisPrecedent, modifier(depuisPrecedent.charge, 4), geste(4)), TypeError); + assert.equal(parPrecedent.fs.compteurs.ecritures, 0); + const tracePrecedent = tracer(parPrecedent.fs); + const reprise = geste(4, { libelleReprise: REPRISE }); + const suitePrecedent = await enregistrer(depuisPrecedent, modifier(depuisPrecedent.charge, 4), reprise); + assert.deepEqual(tracePrecedent, [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', ETAT], + ]); + assert.equal(parPrecedent.fs.contenu('documents', PRECEDENT), textePrecedent); + const journal = exigerEquivalence(parPrecedent.fs, BASE); + assert.deepEqual(revisionsEtLibelles(journal), [ + [1, CREATION.libelle], + [2, 'Geste 2'], + [3, 'Geste 3'], + [4, REPRISE], + [5, 'Geste 4'], + ]); + assert.equal(texteDeCharge(reconstruire(journal, 4)), texteDeCharge(analyser(textePrecedent).charge)); + assert.equal(suitePrecedent.revision, 5); + assert.deepEqual((await rouvrir(parPrecedent.disque, BASE)).avertissements, []); + }); + + test("un journal sans état lève ETAT_ILLISIBLE, raison ABSENT, secours du journal ; ouvert par lui, le premier geste écrit l'état", async () => { + const { fichiers } = await troisRevisions(); + const cas = [ + [remplacer(fichiers, ETAT, null), { precedent: 2, journal: 3 }], + [remplacer(remplacer(fichiers, ETAT, null), PRECEDENT, null), { precedent: null, journal: 3 }], + ]; + for (const [restants, secours] of cas) { + const { fs } = disqueAvec(restants); + const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE'); + assert.deepEqual(erreur.details, { base: BASE, raison: 'ABSENT', chemin: null, secours }); + const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal'); + assert.equal(fs.compteurs.ecritures, 0); + assert.equal(ouvert.revision, 3); + await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4)); + exigerEquivalence(fs, BASE); + } + }); + + test('un .precedent seul lève ETAT_ILLISIBLE ABSENT avec son secours ; un événement sans aucun fichier lève ABSENT', async () => { + const { fichiers } = await troisRevisions(); + const { fs } = disqueAvec(fichiers.filter(([nom]) => nom === PRECEDENT)); + assert.deepEqual((await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE')).details, { + base: BASE, + raison: 'ABSENT', + chemin: null, + secours: { precedent: 2, journal: null }, + }); + assert.deepEqual((await echec(ouvrir(fs, DOC, 'Inconnu'), 'ABSENT')).details, { chemin: 'Inconnu.gtt.json' }); + assert.equal(fs.compteurs.ecritures, 0); + }); + + test("ouvrirSecours refuse une source sans instant : .precedent absent ou illisible, journal absent ou sans entrée, source inconnue", async () => { + const { fs } = disqueAvec([[ETAT, etatTexte(chargeEssai())]]); + assert.deepEqual((await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ABSENT')).details, { chemin: PRECEDENT }); + assert.deepEqual((await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ABSENT')).details, { chemin: JOURNAL }); + fs.deposer('documents', PRECEDENT, '{'); + fs.deposer('documents', JOURNAL, `${ligneOuverture('evt-lucioles')}\n`); + assert.deepEqual((await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ETAT_ILLISIBLE')).details, { + raison: 'JSON', + chemin: null, + }); + await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ABSENT'); + await assert.rejects(ouvrirSecours(fs, DOC, BASE, 'etat'), RangeError); + assert.equal(fs.compteurs.ecritures, 0); + }); + + test("ouvrirSecours sur un état lisible : le .precedent s'ouvre ; au premier geste, l'état lisible passe au .precedent et l'état s'aligne sur la reprise, avant le geste ; chaque geste qui suit restitue l'état écrit", async () => { + // Le cas d'un retenu fautif, dont le secours est le .precedent (§ 8.9). + // Le .precedent n'a pas la personne que l'état a inscrite (troisRevisions). + const { fichiers } = await troisRevisions(); + const etat = texteDe(fichiers, ETAT); + const { fs } = disqueAvec(fichiers); + const ouvert = await ouvrirSecours(fs, DOC, BASE, 'precedent'); + assert.equal(ouvert.texteEtat, etat); + assert.equal(ouvert.revision, 2); + const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal'); + assert.equal(parJournal.revision, 3); + assert.equal(fs.compteurs.ecritures, 0); + const trace = tracer(fs); + const precedents = []; + const ecrire = fs.ecrireAtomique; + fs.ecrireAtomique = async (racine, chemin, texte) => { + if (chemin === PRECEDENT) precedents.push(texte); + return ecrire(racine, chemin, texte); + }; + let suite = await enregistrer(ouvert, gesteSur(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); + assert.deepEqual(trace, [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ['ajouterLigne', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + assert.equal(precedents[0], etat); + // Le .precedent final est l'état aligné sur la reprise, d'avant le geste ; + // l'état lisible reste au journal, à sa révision. + const precedent = analyser(fs.contenu('documents', PRECEDENT)); + assert.equal(precedent.entete.revision, 4); + assert.equal(texteDeCharge(precedent.charge), texteDeCharge(ouvert.charge)); + const journal = exigerEquivalence(fs, BASE); + assert.equal(texteDeCharge(reconstruire(journal, 3)), texteDeCharge(analyser(etat).charge)); + // Les gestes suivants, une inscription puis une note, s'ajoutent. + for (const n of [5, 6]) { + suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n)); + exigerEquivalence(fs, BASE); + } + assert.equal(suite.revision, 7); + }); + + test("un état illisible apparie ses secours par l'identifiant de son .precedent, jamais par le nom : le journal de l'événement sous une autre base est proposé et s'ouvre, celui d'un autre événement à la base jamais ; sans .precedent, le journal de la base que porte un état lisible n'est pas le sien", async () => { + const { fichiers } = await troisRevisions(); + const tronque = (texte) => texte.slice(0, Math.floor(texte.length / 2)); + const libre = `${BASE} (2)`; + const carnet = `Carnet${SUFFIXES.journal}`; + // Le journal de la base porte un autre événement : le geste crée celui de + // l'événement sous une base dérivée, puis l'état devient illisible. + const etranger = disqueAvec(remplacer(fichiers, JOURNAL, await journalEtranger())); + const avecEtranger = await ouvrir(etranger.fs, DOC, BASE); + assert.equal(etranger.fs.compteurs.ecritures, 0, 'ouvrir écrit'); + await enregistrer(avecEtranger, gesteSur(avecEtranger.charge, 4), geste(4, { libelleReprise: REPRISE })); + const apresGeste = await fichiersDe(etranger.disque); + const illisibleApresGeste = remplacer(apresGeste, ETAT, tronque(texteDe(apresGeste, ETAT))); + // Un événement dont le journal a été renommé à la main sous Glycine, à + // côté d'un état Glycine illisible et sans .precedent. + const hortensia = nouveauDisque(); + const charge = chargeEssai({ id: 'evt-h', nom: 'Hortensia' }); + await enregistrer(await creer(hortensia.fs, DOC, charge, CREATION), gesteSur(charge, 2), geste(2)); + const glycine = [ + ['Hortensia.gtt.json', hortensia.fs.contenu('documents', 'Hortensia.gtt.json')], + ['Glycine.gtt.json', '{'], + ['Glycine.gtt-journal.jsonl', hortensia.fs.contenu('documents', 'Hortensia.gtt-journal.jsonl')], + ]; + // Par cas : les fichiers posés, la base ouverte, les secours attendus, et + // la base du journal que le secours ouvre, que lister nomme aussi ; null + // quand aucun ne s'apparie, et ouvrirSecours('journal') lève ABSENT. + const cas = [ + [ + "après JOURNAL_ETRANGER, le journal de l'événement sous une base dérivée", + illisibleApresGeste, + BASE, + { precedent: 3, journal: 4 }, + libre, + ], + [ + 'après JOURNAL_ETRANGER, sans le journal dérivé : celui de la base, étranger, ne se propose pas', + remplacer(illisibleApresGeste, `${libre}${SUFFIXES.journal}`, null), + BASE, + { precedent: 3, journal: null }, + null, + ], + [ + 'le journal renommé à la main', + [...remplacer(remplacer(fichiers, JOURNAL, null), ETAT, '{'), [carnet, texteDe(fichiers, JOURNAL)]], + BASE, + { precedent: 2, journal: 3 }, + 'Carnet', + ], + [ + "sans .precedent, le journal de la base porte l'événement d'un état lisible", + glycine, + 'Glycine', + { precedent: null, journal: null }, + null, + ], + ]; + for (const [nom, poses, base, secours, baseJournal] of cas) { + const { disque, fs } = disqueAvec(poses); + const erreur = await echec(ouvrir(fs, DOC, base), 'ETAT_ILLISIBLE'); + assert.deepEqual(erreur.details, { base, raison: 'JSON', chemin: null, secours }, nom); + const resumes = (await lister(fs, DOC)).filter((autre) => autre.base === base || autre.base === baseJournal); + assert.deepEqual( + resumes.map(({ base: autre, journal, illisible }) => [autre, journal, illisible]), + [[base, baseJournal, 'JSON']], + nom, + ); + if (baseJournal === null) { + const absent = await echec(ouvrirSecours(fs, DOC, base, 'journal'), 'ABSENT'); + assert.deepEqual(absent.details, { chemin: `${base}${SUFFIXES.journal}` }, nom); + assert.equal(fs.compteurs.ecritures, 0, nom); + continue; + } + const parJournal = await ouvrirSecours(fs, DOC, base, 'journal'); + assert.equal(fs.compteurs.ecritures, 0, nom); + assert.deepEqual( + [parJournal.revision, parJournal.baseJournal, parJournal.charge.evenement.id, parJournal.avertissements], + [secours.journal, baseJournal, 'evt-lucioles', []], + nom, + ); + // Le geste suivant s'inscrit au journal de l'événement, et nulle part + // ailleurs. + const avant = new Map(await fichiersDe(disque)); + await enregistrer(parJournal, gesteSur(parJournal.charge, 5), geste(5)); + exigerEquivalence(fs, BASE, { journal: baseJournal }); + const changes = (await fichiersDe(disque)).filter(([fichier, texte]) => avant.get(fichier) !== texte); + assert.deepEqual( + changes.map(([fichier]) => fichier).sort(), + [ETAT, `${baseJournal}${SUFFIXES.journal}`].sort(), + nom, + ); + } + }); +}); + +describe("dépôt : l'état sans journal, et le journal apparié par identifiant (§ 8.6)", () => { + test("un état sans journal s'ouvre ; le premier geste crée le journal par une entrée de reprise, instantané de l'état trouvé", async () => { + const charge = chargeEssai(); + const etat = etatTexte(charge, 7, V2); + const { disque, fs } = disqueAvec([[ETAT, etat]]); + const ouvert = await ouvrir(fs, DOC, BASE); + assert.equal(fs.compteurs.ecritures, 0); + assert.equal(ouvert.journal, null); + assert.deepEqual(ouvert.avertissements, []); + assert.equal(ouvert.revision, 7); + + await assert.rejects(enregistrer(ouvert, modifier(ouvert.charge, 8), geste(8)), TypeError); + assert.equal(fs.compteurs.ecritures, 0); + const trace = tracer(fs); + const suite = await enregistrer(ouvert, modifier(ouvert.charge, 8), geste(8, { libelleReprise: REPRISE })); + assert.deepEqual(trace, [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + const journal = exigerEquivalence(fs, BASE); + assert.equal(journal.evenement, 'evt-lucioles'); + assert.deepEqual( + journal.entrees.map(({ revision, libelle, produitVersion, instantane }) => [ + revision, + libelle, + produitVersion, + instantane !== null, + ]), + [ + [7, REPRISE, V2, true], + [8, 'Geste 8', V1, false], + ], + ); + assert.equal(texteDeCharge(reconstruire(journal, 7)), texteDeCharge(charge)); + assert.equal(fs.contenu('documents', PRECEDENT), etat); + assert.equal(suite.revision, 8); + assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []); + }); + + test("nommer un état sans journal crée le journal par l'entrée de reprise, puis le jalon, sans écrire l'état", async () => { + const etat = etatTexte(chargeEssai(), 7); + const { fs } = disqueAvec([[ETAT, etat]]); + const ouvert = await ouvrir(fs, DOC, BASE); + assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit'); + const nomme = await nommer(ouvert, { nom: 'Avant la génération', horodatage: heure(8), libelleReprise: REPRISE }); + assert.equal(fs.compteurs.ecritures, 1); + assert.equal(fs.contenu('documents', ETAT), etat); + const journal = lireJournal(fs.contenu('documents', JOURNAL)); + assert.deepEqual(revisionsEtLibelles(journal), [[7, REPRISE]]); + assert.deepEqual( + journal.jalons.map(({ revision, nom }) => [revision, nom]), + [[7, 'Avant la génération']], + ); + assert.equal(nomme.revision, 7); + assert.deepEqual(nomme.journal, journal); + }); + + test("un journal renommé à la main reste apparié par l'identifiant : ouvrir le lit, le geste l'allonge, lister le nomme", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, chargeEssai(), CREATION); + await enregistrer(cree, modifier(cree.charge, 2), geste(2)); + const fichiers = await fichiersDe(disque); + const carnet = `Carnet${SUFFIXES.journal}`; + const copie = disqueAvec([...remplacer(fichiers, JOURNAL, null), [carnet, texteDe(fichiers, JOURNAL)]]); + + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, []); + assert.equal(ouvert.baseJournal, 'Carnet'); + assert.equal(ouvert.journal.entrees.length, 2); + const trace = tracer(ouvert.fs); + await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)); + assert.deepEqual(trace, [ + ['ajouterLigne', carnet], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + exigerEquivalence(ouvert.fs, BASE, { journal: 'Carnet' }); + const ecritures = ouvert.fs.compteurs.ecritures; + const [resume] = await lister(ouvert.fs, DOC); + assert.equal(ouvert.fs.compteurs.ecritures, ecritures, 'lister écrit'); + assert.deepEqual([resume.base, resume.journal], [BASE, 'Carnet']); + }); + + test("le journal d'un autre événement sous le nom de la base est JOURNAL_ETRANGER, que l'état de cet événement soit dans le dossier ou non : ni lu, ni touché, ni ouvert en secours ; le premier geste crée le journal sous une base libre ; l'autre événement s'apparie à ce journal par son identifiant, même déplacé sous la base", async () => { + const { disque, fs } = nouveauDisque(); + const gala = "Gala d'automne"; + const journalGala = `${gala}${SUFFIXES.journal}`; + await creer(fs, DOC, chargeEssai(), CREATION); + await creer(fs, DOC, chargeEssai({ id: 'evt-gala', nom: gala }), CREATION); + const fichiers = await fichiersDe(disque); + const libre = 'Soirée des Lucioles (2)'; + // Par cas : les fichiers posés — l'état de l'événement à la révision 1, + // sans .precedent, tel que le laissent creer, enregistrerAilleurs ou + // l'import d'un état seul, et le journal de Gala sous la base —, la base + // du journal auquel l'état de Gala s'apparie, null sans cet état, puis ce + // que lister rend après les gestes, [base, journal]. Sans l'état de Gala, + // aucun état lisible du dossier ne porte l'identifiant de ce journal, et + // la règle d'un état illisible (§ 8.6) le prendrait pour celui de la + // base : seul l'identifiant de l'état lisible l'écarte, au secours comme + // à l'ouverture. Sans son propre journal, l'état de Gala s'apparie à + // celui que la main a déplacé sous la base : l'état de cette base porte + // un autre événement, si bien que ce journal n'appartient pas à sa base. + const cas = [ + [ + "l'état de Gala dans le dossier", + remplacer(fichiers, JOURNAL, texteDe(fichiers, journalGala)), + gala, + [ + [gala, gala], + [BASE, libre], + ], + ], + [ + 'le journal de Gala seul, sans son état', + [ + [ETAT, texteDe(fichiers, ETAT)], + [JOURNAL, await journalEtranger()], + ], + null, + [[BASE, libre]], + ], + [ + "l'état de Gala sans son journal, que la main a déplacé sous la base", + remplacer(remplacer(fichiers, JOURNAL, texteDe(fichiers, journalGala)), journalGala, null), + BASE, + [ + [gala, BASE], + [BASE, libre], + ], + ], + ]; + for (const [nom, poses, baseGala, resumesApres] of cas) { + const copie = disqueAvec(poses); + if (baseGala !== null) { + // Gala s'ouvre sur le journal qui porte son identifiant, et son geste + // s'y ajoute. + const apparie = `${baseGala}${SUFFIXES.journal}`; + const ouvertGala = await rouvrir(copie.disque, gala); + assert.equal(ouvertGala.baseJournal, baseGala, nom); + assert.deepEqual( + [ouvertGala.journal, ouvertGala.avertissements], + [lireJournal(texteDe(poses, apparie)), []], + nom, + ); + const traceGala = tracer(ouvertGala.fs); + await enregistrer(ouvertGala, modifier(ouvertGala.charge, 2), geste(2)); + assert.deepEqual( + traceGala, + [ + ['ajouterLigne', apparie], + ['ecrireAtomique', `${gala}${SUFFIXES.precedent}`], + ['ecrireAtomique', `${gala}${SUFFIXES.etat}`], + ], + nom, + ); + exigerEquivalence(ouvertGala.fs, gala, { journal: baseGala }); + } + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ETRANGER', details: { evenement: 'evt-gala' } }], nom); + assert.equal(ouvert.journal, null, nom); + assert.equal(ouvert.revision, 1, nom); + const absent = await echec(ouvrirSecours(ouvert.fs, DOC, BASE, 'journal'), 'ABSENT', nom); + assert.deepEqual(absent.details, { chemin: JOURNAL }, nom); + assert.equal(ouvert.fs.compteurs.ecritures, 0, nom); + const journalDeLaBase = ouvert.fs.contenu('documents', JOURNAL); + const trace = tracer(ouvert.fs); + await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2, { libelleReprise: REPRISE })); + assert.deepEqual( + trace, + [ + ['ecrireAtomique', `${libre}${SUFFIXES.journal}`], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ], + nom, + ); + assert.equal(ouvert.fs.contenu('documents', JOURNAL), journalDeLaBase, nom); + exigerEquivalence(ouvert.fs, BASE, { journal: libre }); + + const rouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual([rouvert.avertissements, rouvert.baseJournal], [[], libre], nom); + const ecritures = rouvert.fs.compteurs.ecritures; + const resumes = await lister(rouvert.fs, DOC); + assert.equal(rouvert.fs.compteurs.ecritures, ecritures, nom); + assert.deepEqual( + resumes.map(({ base, journal }) => [base, journal]), + resumesApres, + nom, + ); + } + }); + + test("un état copié à la main dans le dossier, sans son journal, ne s'apparie pas au journal que l'état de l'original, ou à défaut son .precedent, dit sien : la copie crée le sien au premier geste, et l'original n'en voit rien", async () => { + const { disque, fs } = nouveauDisque(); + let original = await creer(fs, DOC, chargeEssai({ id: 'evt-dahlia', nom: 'Dahlia' }), CREATION); + original = await enregistrer(original, gesteSur(original.charge, 2), geste(2)); + const etatOriginal = `Dahlia${SUFFIXES.etat}`; + const journalOriginal = `Dahlia${SUFFIXES.journal}`; + const copie = 'Dahlia - Copie'; + // Le copier-coller d'un explorateur : l'état seul, sous un autre nom. + const poses = await fichiersDe(disque); + const fichiers = [...poses, [`${copie}${SUFFIXES.etat}`, texteDe(poses, etatOriginal)]]; + // Par cas : l'état posé pour l'original, et ce que lister rend, [base, + // journal, doublon]. + const cas = [ + ["l'original lisible", texteDe(fichiers, etatOriginal), [['Dahlia', 'Dahlia', true], [copie, null, true]]], + ["l'original illisible, que son .precedent dit sien", '{', [[copie, null, false], ['Dahlia', 'Dahlia', false]]], + ]; + for (const [nom, etat, resumes] of cas) { + const dossier = disqueAvec(remplacer(fichiers, etatOriginal, etat)); + assert.deepEqual( + (await lister(dossier.fs, DOC)).map(({ base, journal, doublon }) => [base, journal, doublon]), + resumes, + nom, + ); + assert.equal(dossier.fs.compteurs.ecritures, 0, nom); + const ouvert = await rouvrir(dossier.disque, copie); + assert.deepEqual([ouvert.journal, ouvert.baseJournal, ouvert.avertissements], [null, copie, []], nom); + + // La copie crée son journal par une entrée de reprise ; celui de + // l'original ne bouge pas. + await enregistrer(ouvert, gesteSur(ouvert.charge, 3), geste(3, { libelleReprise: REPRISE })); + assert.equal(ouvert.fs.contenu('documents', journalOriginal), texteDe(fichiers, journalOriginal), nom); + assert.deepEqual( + revisionsEtLibelles(exigerEquivalence(ouvert.fs, copie)), + [ + [2, REPRISE], + [3, 'Geste 3'], + ], + nom, + ); + if (etat === '{') { + const lecteur = creerFichiersSimules({ disque: dossier.disque }); + const erreur = await echec(ouvrir(lecteur, DOC, 'Dahlia'), 'ETAT_ILLISIBLE'); + assert.deepEqual(erreur.details.secours, { precedent: 1, journal: 2 }, nom); + assert.equal(lecteur.compteurs.ecritures, 0, nom); + continue; + } + const rouvert = await rouvrir(dossier.disque, 'Dahlia'); + assert.deepEqual([rouvert.revision, rouvert.avertissements], [2, []], nom); + assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(original.charge), nom); + } + }); + + test("un journal en avance de plusieurs entrées laisse l'état tel quel, JOURNAL_EN_AVANCE_DE ; la première écriture, jalon ou geste, inscrit l'entrée de reprise, et chaque geste restitue l'état écrit", async () => { + // Les gestes 2 à 4 alternent note et inscription (gesteSur) : l'état remis + // à la révision 2 n'a pas la personne que le journal inscrit ensuite. + const { disque, fs } = nouveauDisque(); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 2), geste(2)); + const etat2 = fs.contenu('documents', ETAT); + for (const n of [3, 4]) ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, n), geste(n)); + const fichiers = remplacer(await fichiersDe(disque), ETAT, etat2); + const copie = disqueAvec(fichiers); + + const rouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(rouvert.avertissements, [{ code: 'JOURNAL_EN_AVANCE_DE', details: { n: 2 } }]); + assert.equal(rouvert.revision, 2); + assert.equal(rouvert.texteEtat, etat2); + + // Nommer inscrit la reprise, puis le jalon sur elle ; l'état reste. + const nomme = await nommer(rouvert, { nom: 'Retour à la sauvegarde', horodatage: heure(5), libelleReprise: REPRISE }); + assert.equal(rouvert.fs.compteurs.ecritures, 1); + assert.equal(nomme.revision, 5); + assert.deepEqual(nomme.avertissements, []); + assert.equal(rouvert.fs.contenu('documents', ETAT), etat2); + + const trace = tracer(rouvert.fs); + let suite = await enregistrer(nomme, gesteSur(nomme.charge, 6), geste(6)); + assert.equal(suite.revision, 6); + // L'état s'aligne d'abord sur la reprise que nommer a inscrite : il est + // ensuite le .precedent. + assert.deepEqual(trace, [ + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ['ajouterLigne', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + assert.equal(rouvert.fs.contenu('documents', PRECEDENT), etatTexte(analyser(etat2).charge, 5)); + const journal = exigerEquivalence(rouvert.fs, BASE); + assert.deepEqual( + revisionsEtLibelles(journal).slice(3), + [ + [4, 'Geste 4'], + [5, REPRISE], + [6, 'Geste 6'], + ], + ); + assert.equal(texteDeCharge(reconstruire(journal, 5)), texteDeCharge(analyser(etat2).charge)); + assert.deepEqual(journal.jalons.at(-1).revision, 5); + // Les gestes suivants, une inscription puis une note, s'ajoutent. + for (const n of [7, 8]) { + suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n)); + exigerEquivalence(rouvert.fs, BASE); + } + + // Sans jalon, le premier geste inscrit lui-même la reprise, puis s'inscrit. + const directe = await rouvrir(disqueAvec(fichiers).disque, BASE); + await enregistrer(directe, gesteSur(directe.charge, 6), geste(6, { libelleReprise: REPRISE })); + assert.deepEqual(revisionsEtLibelles(exigerEquivalence(directe.fs, BASE)).slice(3), [ + [4, 'Geste 4'], + [5, REPRISE], + [6, 'Geste 6'], + ]); + }); + + test("un état en avance du journal s'ouvre tel quel, JOURNAL_INCOMPLET ; le premier geste inscrit l'entrée de reprise à la suite du journal, aligne l'état sur elle, puis s'inscrit ; chaque geste restitue l'état écrit", async () => { + const { fichiers, ouvert: dernier } = await troisRevisions(); + // Le journal ne garde que son ouverture, l'entrée 1 et son jalon : il n'a + // pas la personne que l'état a inscrite (troisRevisions). + const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`; + const copie = disqueAvec(remplacer(fichiers, JOURNAL, tete)); + + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 3, journal: 1 } }]); + assert.equal(ouvert.revision, 3); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(dernier.charge)); + + const trace = tracer(ouvert.fs); + let suite = await enregistrer(ouvert, gesteSur(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); + // La reprise seule, l'état aligné sur elle, puis le geste : une coupure ne + // laisse le journal qu'une entrée en avance au plus (§ 8.2). + assert.deepEqual(trace, [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ['ajouterLigne', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + const journal = exigerEquivalence(ouvert.fs, BASE); + // La reprise et le geste viennent de la construction de l'entrée 1 : aucun + // ne note sa version. + assert.deepEqual( + journal.entrees.map(({ revision, libelle, produitVersion }) => [revision, libelle, produitVersion]), + [ + [1, CREATION.libelle, V1], + [2, REPRISE, null], + [3, 'Geste 4', null], + ], + ); + assert.equal(texteDeCharge(reconstruire(journal, 2)), texteDeCharge(dernier.charge)); + assert.equal(suite.revision, 3); + // Les gestes suivants, une inscription puis une note, s'ajoutent. + for (const n of [5, 6]) { + suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n)); + exigerEquivalence(ouvert.fs, BASE); + } + assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []); + }); + + test("une reprise qui ouvre un intervalle s'écrit en instantané : journal des révisions 1 à 50, état à la 52", async () => { + const { disque, fs } = nouveauDisque(); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + for (let n = 2; n <= 50; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); + const fichiers = await fichiersDe(disque); + const copie = disqueAvec(remplacer(fichiers, ETAT, etatTexte(modifier(ouvert.charge, 52), 52))); + + const rouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(rouvert.avertissements, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 52, journal: 50 } }]); + await enregistrer(rouvert, modifier(rouvert.charge, 53), geste(53, { libelleReprise: REPRISE })); + const journal = exigerEquivalence(rouvert.fs, BASE); + assert.deepEqual( + journal.entrees.slice(-2).map(({ revision, libelle, instantane }) => [revision, libelle, instantane !== null]), + [ + [51, REPRISE, true], + [52, 'Geste 53', false], + ], + ); + }); + + test("un correctif qui ne s'applique pas écarte son entrée et ce qui la suit ; le premier geste réécrit le journal jusqu'à l'instant restituable", async () => { + const { fichiers } = await troisRevisions(); + const lignes = texteDe(fichiers, JOURNAL).replace(/\n$/, '').split('\n'); + const rang = lignes.findIndex((ligne) => JSON.parse(ligne).revision === 3); + const fausse = JSON.parse(lignes[rang]); + fausse.correctif = [{ op: 'retirer', chemin: ['participants', { id: 99 }] }]; + lignes[rang] = JSON.stringify(fausse); + lignes.push(ligneJalon({ revision: 3, nom: 'Après le geste 3', horodatage: heure(3) })); + const copie = disqueAvec(remplacer(fichiers, JOURNAL, `${lignes.join('\n')}\n`)); + + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, [ + { code: 'JOURNAL_INCOMPLET', details: { etat: 3, journal: 2 } }, + { code: 'JOURNAL_ECARTEES', details: { n: 2 } }, + ]); + assert.equal(ouvert.journal.entrees.length, 2); + assert.equal(ouvert.revision, 3); + + await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); + assert.equal(ouvert.fs.compteurs.ecritures, 3); + const journal = exigerEquivalence(ouvert.fs, BASE); + assert.deepEqual(revisionsEtLibelles(journal).slice(2), [ + [3, REPRISE], + [4, 'Geste 4'], + ]); + assert.ok(!ouvert.fs.contenu('documents', JOURNAL).includes('"id":99')); + assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []); + }); + + test("un journal dont l'ouverture ne se lit pas est écarté en entier ; le premier geste le remplace par un journal neuf", async () => { + const { fichiers } = await troisRevisions(); + const copie = disqueAvec(remplacer(fichiers, JOURNAL, 'pas du JSON\n{"type":"entree"}\n')); + const ouvert = await rouvrir(copie.disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 2 } }]); + assert.equal(ouvert.journal, null); + assert.equal(ouvert.baseJournal, BASE); + + const trace = tracer(ouvert.fs); + await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); + assert.deepEqual(trace.map(([, chemin]) => chemin), [JOURNAL, PRECEDENT, ETAT]); + const journal = exigerEquivalence(ouvert.fs, BASE); + assert.deepEqual(revisionsEtLibelles(journal), [ + [3, REPRISE], + [4, 'Geste 4'], + ]); + assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []); + }); +}); + +describe('dépôt : propositions fautives et périmées, retenu fautif (§ 8.9)', () => { + test("une proposition qui se contredit est écartée et nommée, PROPOSITIONS_FAUTIVES, et le fichier s'ouvre", async () => { + const { disque } = disqueAvec([[ETAT, etatTexte(avecFautive())]]); + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]); + assert.deepEqual( + ouvert.charge.propositions.map(({ id }) => id), + [1], + ); + assert.deepEqual( + ouvert.examen.fautives.map(({ id, fautes }) => [id, fautes.map(({ code }) => code)]), + [[2, ['DOUBLON', 'MANQUANT']]], + ); + assert.equal(ouvert.charge.participants.length, 4); + }); + + test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => { + // Par péremption, ce qui a changé dans la charge depuis la production de + // sa proposition : périmées, la proposition et le retenu se gardent et se + // signalent, sans s'écarter (§ 8.9, point 3 ; § 9). + const exclure = (charge) => { + charge.participants[2].exclu = true; + }; + // Chacune avec la raison de dérive qu'elle donne (examiner). + const parExclusion = 'PARTICIPANT_EXCLU'; + const peremptions = [ + ['personne exclue', exclure, parExclusion], + [ + 'table supprimée', + (charge) => { + charge.tables = charge.tables.filter(({ id }) => id !== 2); + }, + 'TABLE_SUPPRIMEE', + ], + [ + // La proposition date d'une table 1 à trois sièges ; elle en a deux, + // ceux du défaut. + 'capacité réduite', + (charge) => { + charge.propositions[0].capacites = [3, 2]; + }, + 'CAPACITE_CHANGEE', + ], + [ + 'tour retiré', + (charge) => { + charge.evenement.tours = 1; + }, + 'TOURS_CHANGES', + ], + [ + 'personne ajoutée', + (charge) => { + charge.participants.push(personne(5, 'Brunelle', null, null)); + charge.prochainsIds.participant = 6; + }, + 'PARTICIPANT_NON_PLACE', + ], + ]; + // Chaque péremption avec une proposition et le retenu qui la reprend ; + // l'exclusion aussi avec la proposition seule, et avec le retenu seul. + const tout = { retenu: true, propositions: true }; + const propositionSeule = { retenu: false, propositions: true }; + const retenuSeul = { retenu: true, propositions: false }; + const cas = [ + ...peremptions.map(([nom, perimer, raison]) => [nom, perimer, raison, tout, { ids: [1], retenu: true }]), + ['personne exclue, proposition seule', exclure, parExclusion, propositionSeule, { ids: [1], retenu: false }], + ['personne exclue, retenu seul', exclure, parExclusion, retenuSeul, { ids: [], retenu: true }], + ]; + for (const [nom, perimer, raison, { retenu, propositions }, details] of cas) { + const charge = chargeEssai(); + perimer(charge); + if (retenu) charge.retenu = retenuDe(charge.propositions[0]); + if (!propositions) charge.propositions = []; + const { disque } = disqueAvec([[ETAT, etatTexte(charge)]]); + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'DERIVE', details }], nom); + assert.deepEqual(ouvert.charge.propositions, charge.propositions, nom); + assert.deepEqual(ouvert.charge.retenu, charge.retenu, nom); + const codes = (raisons) => raisons.map(({ code }) => code); + assert.deepEqual( + ouvert.examen.derives.map(({ raisons }) => codes(raisons)), + propositions ? [[raison]] : [], + nom, + ); + assert.deepEqual(codes(ouvert.examen.retenu.raisons), retenu ? [raison] : [], nom); + } + }); + + test('un retenu qui se contredit donne RETENU_FAUTIF en premier avertissement, et reste', async () => { + const charge = avecFautive(); + charge.retenu = retenuDe(placement(1, [[1, 3], [2]])); + const { disque } = disqueAvec([[ETAT, etatTexte(charge)]]); + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual( + ouvert.avertissements.map(({ code }) => code), + ['RETENU_FAUTIF', 'PROPOSITIONS_FAUTIVES'], + ); + assert.deepEqual(ouvert.charge.retenu, charge.retenu); + assert.deepEqual( + ouvert.examen.retenu.fautes.map(({ code }) => code), + ['MANQUANT'], + ); + }); + + test("après une proposition écartée, le geste suivant s'inscrit depuis la charge du journal : sa dernière entrée restitue l'état écrit", async () => { + const { disque, fs } = nouveauDisque(); + const cree = await creer(fs, DOC, avecFautive(), CREATION); + assert.deepEqual(cree.avertissements, [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]); + const ouvert = await rouvrir(disque, BASE); + const suite = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); + exigerEquivalence(ouvert.fs, BASE); + assert.deepEqual( + analyser(ouvert.fs.contenu('documents', ETAT)).charge.propositions.map(({ id }) => id), + [1], + ); + assert.deepEqual(suite.avertissements, []); + }); +}); + +describe('dépôt : format plus récent, élagage', () => { + test("un fichier d'un format plus récent s'ouvre en lecture seule : enregistrer, nommer et enregistrerAilleurs refusent LECTURE_SEULE sans rien écrire", async () => { + const texte = etatTexte(chargeEssai()).replace('"format":1', '"format":2'); + const { disque } = disqueAvec([[ETAT, texte]]); + const ouvert = await rouvrir(disque, BASE); + assert.equal(ouvert.formatPlusRecent, true); + assert.deepEqual(ouvert.avertissements, []); + const refus = { raison: 'FORMAT_PLUS_RECENT' }; + const enregistrement = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)), 'LECTURE_SEULE'); + assert.deepEqual(enregistrement.details, refus); + const nom = await echec(nommer(ouvert, { nom: 'Jalon', horodatage: heure(2) }), 'LECTURE_SEULE'); + assert.deepEqual(nom.details, refus); + const ailleurs = await echec(enregistrerAilleurs(ouvert, DOC, { existantes: [] }), 'LECTURE_SEULE'); + assert.deepEqual(ailleurs.details, refus); + assert.equal(ouvert.fs.compteurs.ecritures, 0); + }); + + test("ce qu'une construction plus récente laisse garde en lecture seule chaque ouverture, secours compris : un état ou un journal au format 2 ne se réécrit jamais ; le journal de la base se reconnaît plus récent avant toute copie du dossier, et un journal plus récent ne se dit jamais absent", async () => { + const { fichiers } = await troisRevisions(); + const recent = (texte) => texte.replace('"format":1', '"format":2'); + // L'état au format 2, d'une forme que ce code ne connaît pas : illisible, + // FORMAT_PLUS_RECENT. Le .precedent reste l'état au format 1 qu'il remplace. + const etatForme = recent(texteDe(fichiers, ETAT)).replace('"sieges":null', '"sieges":{"n":3}'); + const journalRecent = recent(texteDe(fichiers, JOURNAL)); + const plusRecent = { code: 'JOURNAL_FORMAT_PLUS_RECENT', details: { format: 2 } }; + const enAvance = { code: 'JOURNAL_EN_AVANCE_DE', details: { n: 1 } }; + const illisible = (journal, format = 2) => ({ + code: 'ETAT_ILLISIBLE', + details: { + base: BASE, + raison: 'FORMAT_PLUS_RECENT', + chemin: 'charge.tables[0].sieges', + format, + secours: { precedent: 2, journal }, + }, + }); + // Le secours d'un journal plus récent est refusé : il n'est pas absent. + const journalPlusRecent = { + code: 'ETAT_ILLISIBLE', + details: { raison: 'FORMAT_PLUS_RECENT', chemin: JOURNAL, format: 2 }, + }; + // L'état manque à côté d'un journal plus récent : son refus ne nomme + // aucun format, celui du journal s'y ajoute. + const sansEtat = (precedent) => ({ + code: 'ETAT_ILLISIBLE', + details: { base: BASE, raison: 'ABSENT', chemin: null, format: 2, secours: { precedent, journal: null } }, + }); + const sansPrecedent = { code: 'ABSENT', details: { chemin: PRECEDENT } }; + const copieALaMain = `${BASE} - Copie${SUFFIXES.journal}`; + // Par cas : l'état et le journal posés (null : celui des trois révisions), + // puis ce que rendent ouvrir, ouvrirSecours('precedent') et + // ouvrirSecours('journal') — les avertissements d'un Ouvert, ou l'erreur + // —, et ce que la main change ensuite aux fichiers posés. + const cas = [ + ['état et journal au format 2', etatForme, journalRecent, [illisible(null), [plusRecent], journalPlusRecent]], + ['état au format 2, journal resté au format 1', etatForme, null, [illisible(3), [enAvance], []]], + // Le refus de l'état nomme son propre format, que celui du journal ne + // remplace pas. + [ + 'état au format 3, journal au format 2', + etatForme.replace('"format":2', '"format":3'), + journalRecent, + [illisible(null, 3), [plusRecent], journalPlusRecent], + ], + ['état au format 2 lisible', recent(texteDe(fichiers, ETAT)), null, [[], [enAvance], []]], + ['journal seul au format 2', null, journalRecent, [[plusRecent], [plusRecent], journalPlusRecent]], + [ + "journal seul au format 2, marque d'ordre d'octets en tête", + null, + `\u{FEFF}${journalRecent}`, + [[plusRecent], [plusRecent], journalPlusRecent], + ], + [ + 'journal au format 2, sa copie à la main au format 1 sous une autre base', + null, + journalRecent, + [[plusRecent], [plusRecent], journalPlusRecent], + (poses) => [...poses, [copieALaMain, texteDe(fichiers, JOURNAL)]], + ], + [ + 'journal au format 2 sans état', + null, + journalRecent, + [sansEtat(2), [plusRecent], journalPlusRecent], + (poses) => remplacer(poses, ETAT, null), + ], + [ + 'journal au format 2 sans état ni .precedent', + null, + journalRecent, + [sansEtat(null), sansPrecedent, journalPlusRecent], + (poses) => remplacer(remplacer(poses, ETAT, null), PRECEDENT, null), + ], + ]; + let enLectureSeule = 0; + for (const [nom, etat, journal, attendus, retoucher = (poses) => poses] of cas) { + const avecEtat = remplacer(fichiers, ETAT, etat ?? texteDe(fichiers, ETAT)); + const restants = retoucher(remplacer(avecEtat, JOURNAL, journal ?? texteDe(fichiers, JOURNAL))); + const { disque, fs } = disqueAvec(restants); + const avant = await fichiersDe(disque); + const ouvertures = [ + ['ouvrir', () => ouvrir(fs, DOC, BASE)], + ['secours precedent', () => ouvrirSecours(fs, DOC, BASE, 'precedent')], + ['secours journal', () => ouvrirSecours(fs, DOC, BASE, 'journal')], + ]; + for (const [rang, [ouverture, ouvrirAinsi]] of ouvertures.entries()) { + const message = `${nom}, ${ouverture}`; + const attendu = attendus[rang]; + if (!Array.isArray(attendu)) { + const erreur = await echec(ouvrirAinsi(), attendu.code); + assert.deepEqual(erreur.details, attendu.details, message); + continue; + } + const ouvert = await ouvrirAinsi(); + assert.equal(ouvert.formatPlusRecent, true, message); + assert.deepEqual(ouvert.avertissements, attendu, message); + const ecritures = [ + () => enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })), + () => nommer(ouvert, { nom: 'Jalon', horodatage: heure(4), libelleReprise: REPRISE }), + () => enregistrerAilleurs(ouvert, DOC, { existantes: [] }), + ]; + for (const ecrire of ecritures) { + assert.deepEqual((await echec(ecrire(), 'LECTURE_SEULE')).details, { raison: 'FORMAT_PLUS_RECENT' }, message); + } + enLectureSeule += 1; + } + // La liste nomme le journal qu'ouvrir lit : celui de la base, qu'il soit + // plus récent ou non, jamais sa copie au format courant. + const ligne = (await lister(fs, DOC)).find(({ base }) => base === BASE); + assert.equal(ligne.journal, BASE, `${nom}, lister`); + assert.equal(fs.compteurs.ecritures, 0, nom); + assert.deepEqual(await fichiersDe(disque), avant, nom); + } + assert.equal(enLectureSeule, 14); + }); + + test("une ouverture de journal dont le format n'est pas un entier plus grand que FORMAT ne se lit pas : écartée, puis remplacée au premier geste", async () => { + const { fichiers } = await troisRevisions(); + const journal = texteDe(fichiers, JOURNAL); + const ouverture = journal.slice(0, journal.indexOf('\n')); + const fausses = [ + ouverture.replace('"format":1', '"format":"2"'), + ouverture.replace('"format":1', '"format":0'), + ouverture.replace(/\}$/, ',"inconnue":1}'), + ]; + for (const fausse of fausses) { + const copie = disqueAvec(remplacer(fichiers, JOURNAL, journal.replace(ouverture, fausse))); + const ouvert = await rouvrir(copie.disque, BASE); + assert.equal(ouvert.formatPlusRecent, false, fausse); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 5 } }], fausse); + await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); + assert.deepEqual( + revisionsEtLibelles(exigerEquivalence(ouvert.fs, BASE)), + [ + [3, REPRISE], + [4, 'Geste 4'], + ], + fausse, + ); + } + }); + + test("au-delà de PLAFOND_ENTREES, le journal s'élague sur une frontière d'instantané qui garde au moins 500 entrées, au geste qui le dépasse comme à l'écriture ailleurs ; relu, il est la version élaguée", async () => { + const { disque, fs } = nouveauDisque(); + const trace = tracer(fs); + let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + for (let n = 2; n <= PLAFOND_ENTREES; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); + const avant = fs.contenu('documents', JOURNAL); + assert.equal(lireJournal(avant).entrees.length, PLAFOND_ENTREES); + // Jusqu'au plafond compris, chaque geste s'ajoute en fin de journal. + const ecrituresDuJournal = trace.filter(([, chemin]) => chemin === JOURNAL).map(([primitive]) => primitive); + assert.deepEqual(new Set(ecrituresDuJournal.slice(1)), new Set(['ajouterLigne'])); + + // Le secours .precedent écrit ailleurs : son journal de PLAFOND_ENTREES + // entrées reçoit la reprise, et s'élague comme au geste qui le dépasse. + const lecteur = creerFichiersSimules({ disque }); + const secours = await ouvrirSecours(lecteur, DOC, BASE, 'precedent'); + assert.equal(lecteur.compteurs.ecritures, 0, 'ouvrirSecours écrit'); + const options = { existantes: [], libelleReprise: REPRISE, horodatage: heure(601) }; + const copie = await enregistrerAilleurs(secours, DOC, options); + const copieRelue = exigerEquivalence(lecteur, copie.base); + assert.deepEqual( + [copie.base, copieRelue.entrees[0].revision, copieRelue.entrees.length, copieRelue.entrees.at(-1).libelle], + [`${BASE} (2)`, 101, 501, REPRISE], + ); + assert.deepEqual(copie.journal, copieRelue); + + const depart = trace.length; + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 601), geste(601)); + assert.deepEqual(trace.slice(depart), [ + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', PRECEDENT], + ['ecrireAtomique', ETAT], + ]); + const apres = fs.contenu('documents', JOURNAL); + const derniere = apres.slice(apres.lastIndexOf('\n', apres.length - 2) + 1); + assert.equal(apres, elaguer(avant + derniere)); + const relu = exigerEquivalence(fs, BASE); + assert.equal(relu.entrees[0].revision, 101); + assert.equal(relu.entrees.length, 501); + assert.deepEqual(ouvert.journal, relu); + + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 602), geste(602)); + assert.deepEqual(trace.at(-3), ['ajouterLigne', JOURNAL]); + exigerEquivalence(fs, BASE); + const rouvert = await rouvrir(disque, BASE); + assert.deepEqual([rouvert.revision, rouvert.avertissements, rouvert.journal.entrees.length], [602, [], 502]); + }); +}); + +describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => { + test("le verrou de la séance se prend, se refuse à une autre séance avec ce que le système en dit, et se rend", async () => { + const { disque, fs } = nouveauDisque(); + const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + assert.deepEqual(await verrouiller(ouvert, 'seance-a'), { pris: true }); + const texte = fs.contenu('documents', VERROU); + const { depuis } = JSON.parse(texte); + + const voisin = await rouvrir(disque, BASE); + assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: false, seance: 'seance-a', depuis, vivant: true }); + const lointain = await ouvrir(creerFichiersSimules({ disque, hote: 'autre-poste' }), DOC, BASE); + assert.equal(lointain.fs.compteurs.ecritures, 0, 'ouvrir écrit'); + assert.deepEqual(await verrouiller(lointain, 'seance-c'), { + pris: false, + seance: 'seance-a', + depuis, + vivant: null, + }); + + await deverrouiller(voisin, 'seance-b'); + assert.equal(fs.contenu('documents', VERROU), texte); + await deverrouiller(ouvert, 'seance-a'); + assert.equal(fs.contenu('documents', VERROU), null); + + assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: true }); + voisin.fs.pannes.couperApres(0); + const vuDApres = await verrouiller(ouvert, 'seance-a'); + assert.deepEqual([vuDApres.pris, vuDApres.seance, vuDApres.vivant], [false, 'seance-b', false]); + }); + + test("après un refus d'écriture, enregistrerAilleurs crée la racine visée, puis y écrit le journal entier et l'état ; une base déjà prise là-bas se dérive ; le geste reprend là", async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); + fs.deposerDossier('portable'); + const { portable, documents } = await fs.racines(); + let ouvert = await creer(fs, portable, chargeEssai(), CREATION); + ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); + fs.pannes.ecriture('portable'); + const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)), 'ECRITURE'); + assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: 'E:\\soirees\\data', cause: 'EROFS' }); + + // En mode portable, rien n'a sondé ni créé documents : l'opérateur l'a + // choisi, l'écriture le crée. + const ecritures = fs.compteurs.ecritures; + const trace = tracer(fs); + const ailleurs = await enregistrerAilleurs(ouvert, documents, { existantes: [] }); + assert.deepEqual(trace, [ + ['creerDossier', ''], + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', ETAT], + ]); + assert.equal(fs.compteurs.ecritures - ecritures, 3); + assert.deepEqual([ailleurs.racine, ailleurs.base, ailleurs.revision], [documents, BASE, 2]); + assert.equal(fs.contenu('documents', JOURNAL), fs.contenu('portable', JOURNAL)); + assert.equal(fs.contenu('documents', PRECEDENT), null); + exigerEquivalence(fs, BASE); + + // Écrit de nouveau là-bas, l'événement y trouve sa base prise, absente + // des bases reçues : la dérivation la voit dans le dossier. + trace.length = 0; + const libre = 'Soirée des Lucioles (2)'; + const seconde = await enregistrerAilleurs(ouvert, documents, { existantes: [] }); + assert.deepEqual(trace, [ + ['creerDossier', ''], + ['ecrireAtomique', `${libre}${SUFFIXES.journal}`], + ['ecrireAtomique', `${libre}${SUFFIXES.etat}`], + ]); + assert.equal(seconde.base, libre); + exigerEquivalence(fs, libre); + + const suite = await enregistrer(ailleurs, modifier(ailleurs.charge, 3), geste(3)); + assert.equal(suite.revision, 3); + exigerEquivalence(fs, BASE); + const rouvert = await rouvrir(disque, BASE); + assert.deepEqual([rouvert.revision, rouvert.avertissements], [3, []]); + }); + + test("ETAT_NON_ECRIT : l'Ouvert avancé de ses détails s'écrit ailleurs, son geste compris, sous une base libre parmi celles reçues", async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); + fs.deposerDossier('portable'); + fs.deposerDossier('documents'); + const { portable, documents } = await fs.racines(); + const cree = await creer(fs, portable, chargeEssai(), CREATION); + refuserRenommageApresPrecedent(fs); + const apres = modifier(cree.charge, 2); + const erreur = await echec(enregistrer(cree, apres, geste(2)), 'ETAT_NON_ECRIT'); + + const ailleurs = await enregistrerAilleurs(erreur.details.ouvert, documents, { existantes: [BASE] }); + assert.deepEqual([ailleurs.base, ailleurs.revision], ['Soirée des Lucioles (2)', 2]); + // creer : journal, état ; le geste : ligne, .precedent, état refusé ; + // ailleurs : la racine, qui existe déjà, le journal, l'état. + assert.equal(fs.compteurs.ecritures, 2 + 3 + 3); + exigerEquivalence(fs, ailleurs.base); + assert.equal(texteDeCharge(analyser(fs.contenu('documents', `${ailleurs.base}${SUFFIXES.etat}`)).charge), texteDeCharge(apres)); + }); + + // Un événement du disque portable : créé, puis deux gestes, les notes 2 et + // 3 ; son état remplacé par etat quand il est donné. Le secours .precedent + // l'ouvre, sans rien écrire, à la note 2 et à la révision 2 ; puis portable + // refuse toute écriture, et le premier geste lève ECRITURE. Rend le disque, + // son processus, la racine documents et l'Ouvert du secours, à écrire + // ailleurs. + async function secoursRefuse(etat) { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); + fs.deposerDossier('portable'); + const { portable, documents } = await fs.racines(); + let ouvert = await creer(fs, portable, chargeEssai(), CREATION); + for (const n of [2, 3]) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); + if (etat !== null) fs.deposer('portable', ETAT, etat); + const ecritures = fs.compteurs.ecritures; + const secours = await ouvrirSecours(fs, portable, BASE, 'precedent'); + assert.equal(fs.compteurs.ecritures, ecritures, 'ouvrirSecours écrit'); + assert.deepEqual([secours.revision, secours.charge.participants[0].notes], [2, 'note 2']); + fs.pannes.ecriture('portable'); + await echec(enregistrer(secours, modifier(secours.charge, 4), geste(4, { libelleReprise: REPRISE })), 'ECRITURE'); + return { disque, fs, documents, secours }; + } + + // L'état que le secours laisse sur le disque de départ : lisible, à la + // révision 3, ou illisible. + const etatsDuSecours = [ + ['état lisible', null], + ['état illisible', '{"entete":'], + ]; + + test("un secours .precedent s'écrit ailleurs en paire alignée — son journal, une entrée de reprise qui restitue sa charge, puis l'état à la révision de la reprise — : rouvert là-bas, il rend la charge du secours sans rien rattraper", async () => { + for (const [nom, etat] of etatsDuSecours) { + const { disque, fs, documents, secours } = await secoursRefuse(etat); + const trace = tracer(fs); + const options = { existantes: [], libelleReprise: REPRISE, horodatage: heure(4) }; + const ailleurs = await enregistrerAilleurs(secours, documents, options); + assert.deepEqual( + trace, + [ + ['creerDossier', ''], + ['ecrireAtomique', JOURNAL], + ['ecrireAtomique', ETAT], + ], + nom, + ); + const rouvert = await rouvrir(disque, BASE); + assert.deepEqual( + [rouvert.revision, rouvert.avertissements, rouvert.charge.participants[0].notes], + [4, [], 'note 2'], + nom, + ); + assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(secours.charge), nom); + const journal = exigerEquivalence(fs, BASE); + assert.deepEqual( + revisionsEtLibelles(journal), + [ + [1, CREATION.libelle], + [2, 'Geste 2'], + [3, 'Geste 3'], + [4, REPRISE], + ], + nom, + ); + assert.deepEqual([ailleurs.revision, ailleurs.journal, ailleurs.avertissements], [4, journal, []], nom); + // Un geste depuis l'Ouvert rendu, qui remet la note 3 : son correctif + // part de la charge écrite là-bas, que porte chargeJournal. Parti de la + // note 3, il ne changerait rien, et sa dernière entrée ne restituerait + // pas l'état écrit. + await enregistrer(ailleurs, modifier(ailleurs.charge, 3), geste(5)); + assert.deepEqual( + revisionsEtLibelles(exigerEquivalence(fs, BASE)).slice(-2), + [ + [4, REPRISE], + [5, 'Geste 5'], + ], + nom, + ); + } + }); + + test('écrit ailleurs sans libellé de reprise, un secours .precedent lève TypeError avant toute écriture : la racine ne se crée pas', async () => { + for (const [nom, etat] of etatsDuSecours) { + const { fs, documents, secours } = await secoursRefuse(etat); + const trace = tracer(fs); + const sansLibelle = { existantes: [], horodatage: heure(4) }; + await assert.rejects(enregistrerAilleurs(secours, documents, sansLibelle), TypeError, nom); + assert.deepEqual(trace, [], nom); + } + }); + + test("un Ouvert aligné s'écrit ailleurs avec la charge que restitue la dernière entrée de son journal, proposition fautive comprise, sous la version qui l'a produite ; l'Ouvert rendu porte la charge examinée", async () => { + const fautives = [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]; + // Par cas : la version que la main pose dans l'en-tête de l'état créé, à + // la révision du journal, ou null. Retouché, l'en-tête nomme une autre + // construction que l'entrée qui a produit la charge : l'état écrit + // ailleurs prend celle du journal. + const cas = [ + ["l'Ouvert de creer", null], + ['un en-tête retouché à la main', V2], + ]; + for (const [nom, version] of cas) { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); + fs.deposerDossier('portable'); + const { portable, documents } = await fs.racines(); + let ouvert = await creer(fs, portable, avecFautive(), CREATION); + if (version !== null) { + fs.deposer('portable', ETAT, etatTexte(avecFautive(), 1, version)); + const ecritures = fs.compteurs.ecritures; + ouvert = await ouvrir(fs, portable, BASE); + assert.equal(fs.compteurs.ecritures, ecritures, `${nom} : ouvrir écrit`); + assert.deepEqual([ouvert.revision, ouvert.produitVersion, ouvert.avertissements], [1, version, fautives], nom); + } + assert.notEqual(texteDeCharge(ouvert.charge), texteDeCharge(ouvert.chargeJournal), nom); + const ailleurs = await enregistrerAilleurs(ouvert, documents, { existantes: [] }); + const ecrit = analyser(fs.contenu('documents', ETAT)); + assert.deepEqual([ecrit.entete.revision, ecrit.entete.produitVersion], [1, V1], nom); + assert.equal(texteDeCharge(ecrit.charge), texteDeCharge(avecFautive()), nom); + exigerEquivalence(fs, BASE); + assert.equal(texteDeCharge(ailleurs.chargeJournal), texteDeCharge(avecFautive()), nom); + assert.deepEqual( + [ailleurs.produitVersion, ailleurs.charge.propositions.map(({ id }) => id), ailleurs.avertissements], + [V1, [1], fautives], + nom, + ); + assert.deepEqual((await rouvrir(disque, BASE)).avertissements, fautives, nom); + } + }); + + test("un événement sans journal s'écrit ailleurs par son seul état ; là où l'écriture est refusée, un journal lève ECRITURE, un état seul ETAT_NON_ECRIT, et une racine qui ne se crée pas ECRITURE, sans rien écrire", async () => { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); + fs.deposerDossier('documents'); + fs.deposer('portable', ETAT, etatTexte(chargeEssai(), 4)); + const { portable, documents } = await fs.racines(); + const sansJournal = await ouvrir(fs, portable, BASE); + assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit'); + const trace = tracer(fs); + const ailleurs = await enregistrerAilleurs(sansJournal, documents, { existantes: [] }); + assert.deepEqual(trace, [ + ['creerDossier', ''], + ['ecrireAtomique', ETAT], + ]); + assert.deepEqual( + [ailleurs.journal, ailleurs.revision, ailleurs.texteEtat], + [null, 4, fs.contenu('documents', ETAT)], + ); + + // Les renommages de documents refusés : chaque écriture atomique échoue, + // le dossier existant passe. + const avecJournal = await creer(fs, portable, chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }), CREATION); + fs.pannes.renommage('documents'); + const refusJournal = await echec(enregistrerAilleurs(avecJournal, documents, { existantes: [] }), 'ECRITURE'); + assert.deepEqual(refusJournal.details, { + chemin: "Gala d'automne.gtt-journal.jsonl", + dossier: DOCUMENTS, + cause: 'EBUSY', + }); + assert.equal(fs.contenu('documents', "Gala d'automne.gtt.json"), null); + + const refusEtat = await echec(enregistrerAilleurs(sansJournal, documents, { existantes: [] }), 'ETAT_NON_ECRIT'); + const { ouvert: refuse, ...details } = refusEtat.details; + assert.deepEqual(details, { chemin: 'Soirée des Lucioles (2).gtt.json', dossier: DOCUMENTS, cause: 'EBUSY' }); + assert.deepEqual([refuse.base, refuse.texteEtat, refuse.revision], ['Soirée des Lucioles (2)', null, 4]); + + // Sur un disque où documents n'a jamais été créé, et qui en refuse toute + // écriture : creerDossier, seule primitive appelée, lève ECRITURE sur la + // racine elle-même ; documents n'existe toujours pas, et rien ne s'écrit. + const neuf = new Map(); + const sansDocuments = creerFichiersSimules({ disque: neuf, executable: 'E:\\soirees' }); + sansDocuments.deposer('portable', ETAT, etatTexte(chargeEssai(), 4)); + const seul = await ouvrir(sansDocuments, portable, BASE); + assert.equal(sansDocuments.compteurs.ecritures, 0, 'ouvrir écrit'); + sansDocuments.pannes.ecriture('documents'); + const traceRacine = tracer(sansDocuments); + const refusRacine = await echec(enregistrerAilleurs(seul, documents, { existantes: [] }), 'ECRITURE'); + assert.deepEqual(refusRacine.details, { chemin: '', dossier: DOCUMENTS, cause: 'EROFS' }); + assert.deepEqual(traceRacine, [['creerDossier', '']]); + await echec(creerFichiersSimules({ disque: neuf }).lister(DOC, ''), 'ABSENT'); + assert.equal(sansDocuments.contenu('portable', ETAT), etatTexte(chargeEssai(), 4)); + }); + + test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => { + const { fs } = nouveauDisque(); + await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE'); + assert.equal(fs.compteurs.ecritures, 0); + }); +});