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); + }); +});