gestion_table_tournante_libre/src/stockage/depot.test.js

2460 lines
116 KiB
JavaScript
Raw Normal View History

[ADD] storage: event store — list, create, open, save, recover An event is a state file and a journal paired by its internal identifier, never by name (§ 8.6): a hand copy of a state does not take the original's journal, and a renamed journal stays paired. Each gesture writes its journal line first, then the .precedent and the state (§ 8.2); recovery compares revisions, never file dates, so a cut leaves the journal one entry ahead at most. Opening never writes (§ 8.4); an unreadable state names its readable secours instead of opening an empty event, and a newer format stays read-only. Writing elsewhere after a removed medium aligns the pair first. Checked: 60 tests, 100 % branches of depot.js, 1257 node tests on HEAD. --- FR --- [ADD] stockage : dépôt d'événements — lister, créer, ouvrir, reprendre Un événement est un fichier d'état et un journal appariés par l'identifiant interne, jamais par le nom (§ 8.6) : un état copié à la main ne prend pas le journal de l'original, et un journal renommé reste apparié. Chaque geste écrit sa ligne de journal d'abord, puis le .precedent et l'état (§ 8.2) ; la reprise compare les révisions, jamais la date des fichiers : une coupure laisse le journal en avance d'une entrée au plus. Ouvrir n'écrit jamais (§ 8.4) ; un état illisible nomme ses secours lisibles au lieu d'ouvrir un événement vide, et un format plus récent reste en lecture seule. Écrire ailleurs après un support retiré aligne d'abord la paire. Vérifié : 60 épreuves, 100 % des branches de depot.js, 1257 épreuves node. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 20:39:49 -04:00
// © 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 {
[ADD] storage: copy, rename, import, delete events; local settings § 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:05 -04:00
basesPrises,
[ADD] storage: event store — list, create, open, save, recover An event is a state file and a journal paired by its internal identifier, never by name (§ 8.6): a hand copy of a state does not take the original's journal, and a renamed journal stays paired. Each gesture writes its journal line first, then the .precedent and the state (§ 8.2); recovery compares revisions, never file dates, so a cut leaves the journal one entry ahead at most. Opening never writes (§ 8.4); an unreadable state names its readable secours instead of opening an empty event, and a newer format stays read-only. Writing elsewhere after a removed medium aligns the pair first. Checked: 60 tests, 100 % branches of depot.js, 1257 node tests on HEAD. --- FR --- [ADD] stockage : dépôt d'événements — lister, créer, ouvrir, reprendre Un événement est un fichier d'état et un journal appariés par l'identifiant interne, jamais par le nom (§ 8.6) : un état copié à la main ne prend pas le journal de l'original, et un journal renommé reste apparié. Chaque geste écrit sa ligne de journal d'abord, puis le .precedent et l'état (§ 8.2) ; la reprise compare les révisions, jamais la date des fichiers : une coupure laisse le journal en avance d'une entrée au plus. Ouvrir n'écrit jamais (§ 8.4) ; un état illisible nomme ses secours lisibles au lieu d'ouvrir un événement vide, et un format plus récent reste en lecture seule. Écrire ailleurs après un support retiré aligne d'abord la paire. Vérifié : 60 épreuves, 100 % des branches de depot.js, 1257 épreuves node. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 20:39:49 -04:00
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));
});
[ADD] storage: copy, rename, import, delete events; local settings § 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:05 -04:00
test("enregistrerAilleurs avec un geste, sans créer la racine : sous la base que dérive le nom du geste, l'état d'avant le geste au .precedent — le texte sur le disque, sinon la charge ouverte —, puis le journal recopié — reprise comprise quand il le faut — suivi de l'entrée du geste, puis l'état du geste ; la paire de l'Ouvert ne s'écrit pas et ne s'apparie pas le journal neuf, et une racine absente ne se crée pas", async () => {
const { disque, fs } = nouveauDisque();
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
const avant = await fichiersDe(disque);
const nouvelle = 'Gala des Saules';
const renommee = (charge) => ({ ...charge, evenement: { ...charge.evenement, nom: nouvelle } });
const options = {
existantes: [],
horodatage: heure(3),
geste: { apres: renommee(ouvert.charge), libelle: 'Renommé', produitVersion: V2 },
creerRacine: false,
};
const ecritsSous = (base) => [
['ecrireAtomique', `${base}${SUFFIXES.precedent}`],
['ecrireAtomique', `${base}${SUFFIXES.journal}`],
['ecrireAtomique', `${base}${SUFFIXES.etat}`],
];
const trace = tracer(fs);
const ailleurs = await enregistrerAilleurs(ouvert, DOC, options);
assert.deepEqual(trace, ecritsSous(nouvelle));
assert.equal(fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), texteDe(avant, ETAT));
assert.deepEqual(
(await fichiersDe(disque)).filter(([nom]) => !nom.startsWith(nouvelle)),
avant,
);
assert.deepEqual(
[ailleurs.racine, ailleurs.base, ailleurs.baseJournal, ailleurs.revision, ailleurs.produitVersion],
[DOC, nouvelle, nouvelle, 3, V2],
);
assert.equal(texteDeCharge(ailleurs.charge), texteDeCharge(renommee(modifier(chargeEssai(), 2))));
const journal = exigerEquivalence(fs, nouvelle);
assert.deepEqual(revisionsEtLibelles(journal), [
[1, CREATION.libelle],
[2, 'Geste 2'],
[3, 'Renommé'],
]);
assert.deepEqual(
journal.entrees.map(({ produitVersion }) => produitVersion),
[V1, null, V2],
);
assert.equal(analyser(fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).entete.produitVersion, V2);
const rouvert = await rouvrir(disque, nouvelle);
assert.deepEqual([rouvert.revision, rouvert.charge.evenement.nom, rouvert.avertissements], [3, nouvelle, []]);
const suite = await enregistrer(ailleurs, modifier(ailleurs.charge, 4), geste(4, { produitVersion: V2 }));
assert.deepEqual([suite.base, suite.revision], [nouvelle, 4]);
exigerEquivalence(fs, nouvelle);
// Un état sans journal, à la mise en page d'un autre outil : l'entrée de
// reprise, au libellé reçu, précède celle du geste ; sans ce libellé,
// TypeError avant toute écriture. Le .precedent neuf porte ses octets.
const enQuatre = JSON.stringify(JSON.parse(etatTexte(chargeEssai(), 4)), null, 4);
const importe = `${enQuatre.replaceAll('\n', '\r\n')}\r\n`;
const seul = disqueAvec([[ETAT, importe]]);
const sansJournal = await rouvrir(seul.disque, BASE);
const gesteSeul = { ...options, geste: { ...options.geste, apres: renommee(sansJournal.charge), produitVersion: V1 } };
await assert.rejects(enregistrerAilleurs(sansJournal, DOC, gesteSeul), TypeError);
assert.equal(sansJournal.fs.compteurs.ecritures, 0);
const repris = await enregistrerAilleurs(sansJournal, DOC, { ...gesteSeul, libelleReprise: REPRISE });
assert.deepEqual([repris.base, repris.revision], [nouvelle, 5]);
assert.deepEqual(revisionsEtLibelles(exigerEquivalence(sansJournal.fs, nouvelle)), [
[4, REPRISE],
[5, 'Renommé'],
]);
assert.equal(sansJournal.fs.contenu('documents', ETAT), importe);
assert.equal(sansJournal.fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), importe);
// Coupée juste après le journal neuf, l'écriture laisse la nouvelle base
// à l'événement par son .precedent : l'état de l'Ouvert, qu'aucun journal
// de sa base ne suit, ne se l'apparie pas, et s'ouvre tel qu'il était ;
// la liste ne montre que lui.
const coupe = disqueAvec([[ETAT, etatTexte(chargeEssai(), 4)]]);
const avantCoupure = await rouvrir(coupe.disque, BASE);
const ecrire = avantCoupure.fs.ecrireAtomique;
avantCoupure.fs.ecrireAtomique = async (racine, chemin, texte) => {
await ecrire(racine, chemin, texte);
if (chemin === `${nouvelle}${SUFFIXES.journal}`) avantCoupure.fs.pannes.couperApres(0);
};
await assert.rejects(
enregistrerAilleurs(avantCoupure, DOC, { ...gesteSeul, libelleReprise: REPRISE }),
PanneSimulee,
);
const apresCoupure = await rouvrir(coupe.disque, BASE);
assert.deepEqual(
[apresCoupure.revision, apresCoupure.charge.evenement.nom, apresCoupure.journal, apresCoupure.avertissements],
[4, BASE, null, []],
);
assert.deepEqual(
(await lister(creerFichiersSimules({ disque: coupe.disque }), DOC)).map(({ base, journal }) => [base, journal]),
[[BASE, null]],
);
// Un Ouvert de secours, l'état sur le disque illisible : le .precedent
// de la nouvelle base reçoit la charge ouverte, à sa révision.
const secours = disqueAvec([
[ETAT, '{'],
[PRECEDENT, etatTexte(chargeEssai(), 4)],
]);
const depuisPrecedent = await ouvrirSecours(secours.fs, DOC, BASE, 'precedent');
assert.equal(depuisPrecedent.texteEtat, null);
const traceSecours = tracer(secours.fs);
const gesteSecours = { ...gesteSeul.geste, apres: renommee(depuisPrecedent.charge) };
const renommeSecours = await enregistrerAilleurs(depuisPrecedent, DOC, {
...gesteSeul,
geste: gesteSecours,
libelleReprise: REPRISE,
});
assert.deepEqual(traceSecours, ecritsSous(nouvelle));
assert.equal(secours.fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), etatTexte(chargeEssai(), 4));
assert.deepEqual([renommeSecours.base, renommeSecours.revision], [nouvelle, 5]);
assert.deepEqual(
[ETAT, PRECEDENT].map((nom) => secours.fs.contenu('documents', nom)),
['{', etatTexte(chargeEssai(), 4)],
);
// Sur un disque où documents n'existe pas : creerRacine faux ne le crée
// pas, et sa liste lève ABSENT avant toute écriture.
const neuf = new Map();
const sansDocuments = creerFichiersSimules({ disque: neuf, executable: 'E:\\soirees' });
sansDocuments.deposer('portable', ETAT, etatTexte(chargeEssai(), 4));
const { portable, documents } = await sansDocuments.racines();
const portableSeul = await ouvrir(sansDocuments, portable, BASE);
const traceRacine = tracer(sansDocuments);
const absente = await echec(
enregistrerAilleurs(portableSeul, documents, { existantes: [], creerRacine: false }),
'ABSENT',
);
assert.deepEqual(absente.details, { chemin: '' });
assert.deepEqual(traceRacine, []);
await echec(creerFichiersSimules({ disque: neuf }).lister(DOC, ''), 'ABSENT');
});
test("basesPrises rend, sans rien écrire, la base de chaque fichier d'événement du dossier — état, .precedent, journal, verrou —, dans l'ordre de sa liste ; ABSENT quand la racine manque", async () => {
const { fs } = disqueAvec([
[ETAT, '{'],
[PRECEDENT, '{'],
['Archives des Lucioles.gtt-journal.jsonl', '{'],
['GALA DES SAULES.gtt.verrou', '{}\n'],
['notes.txt', 'à garder'],
['reglages_locaux.json', '{}\n'],
]);
fs.deposerDossier('documents', 'corbeille');
assert.deepEqual(await basesPrises(fs, DOC), ['Archives des Lucioles', 'GALA DES SAULES', BASE, BASE]);
assert.equal(fs.compteurs.ecritures, 0);
await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT');
});
[ADD] storage: event store — list, create, open, save, recover An event is a state file and a journal paired by its internal identifier, never by name (§ 8.6): a hand copy of a state does not take the original's journal, and a renamed journal stays paired. Each gesture writes its journal line first, then the .precedent and the state (§ 8.2); recovery compares revisions, never file dates, so a cut leaves the journal one entry ahead at most. Opening never writes (§ 8.4); an unreadable state names its readable secours instead of opening an empty event, and a newer format stays read-only. Writing elsewhere after a removed medium aligns the pair first. Checked: 60 tests, 100 % branches of depot.js, 1257 node tests on HEAD. --- FR --- [ADD] stockage : dépôt d'événements — lister, créer, ouvrir, reprendre Un événement est un fichier d'état et un journal appariés par l'identifiant interne, jamais par le nom (§ 8.6) : un état copié à la main ne prend pas le journal de l'original, et un journal renommé reste apparié. Chaque geste écrit sa ligne de journal d'abord, puis le .precedent et l'état (§ 8.2) ; la reprise compare les révisions, jamais la date des fichiers : une coupure laisse le journal en avance d'une entrée au plus. Ouvrir n'écrit jamais (§ 8.4) ; un état illisible nomme ses secours lisibles au lieu d'ouvrir un événement vide, et un format plus récent reste en lecture seule. Écrire ailleurs après un support retiré aligne d'abord la paire. Vérifié : 60 épreuves, 100 % des branches de depot.js, 1257 épreuves node. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 20:39:49 -04:00
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);
});
});