// © 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 { basesPrises, 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]; // Un disque neuf dont documents porte les fichiers, nom posé en octets. function disqueAvecOctets(fichiers, nom, octets) { const { disque, fs } = disqueAvec(remplacer(fichiers, nom, null)); fs.deposerOctets('documents', nom, octets); return { disque, fs }; } // Les octets UTF-8 du texte, son premier « é » (C3 A9) remplacé par l'octet // E9 qu'écrit un éditeur réglé en windows-1252 : une suite qui n'est pas de // l'UTF-8. function malEncode(texte) { const octets = [...new TextEncoder().encode(texte)]; const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9); assert.ok(rang >= 0, 'aucun « é » à mal encoder'); octets.splice(rang, 2, 0xe9); return new Uint8Array(octets); } // Les octets UTF-8 d'un texte de journal, sa ligne de rang donné (0 pour // l'ouverture) ouverte par l'octet E9 suivi de « { » : une suite qui n'est // pas de l'UTF-8, sur cette ligne seule. function ligneMalEncodee(texte, rang) { const lignes = texte.split('\n'); assert.ok(rang < lignes.length - 1, `aucune ligne ${rang}`); const avant = new TextEncoder().encode(lignes.slice(0, rang).map((ligne) => `${ligne}\n`).join('')); const apres = new TextEncoder().encode(lignes.slice(rang).join('\n')); return new Uint8Array([...avant, 0xe9, ...apres]); } // Le rang de la ligne d'un texte de journal qui porte l'entrée de révision // donnée. const rangDeLEntree = (texte, revision) => texte.split('\n').findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === revision); // 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 et l'identifiant qui l'apparie, 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], // état illisible, son .precedent lisible, sans journal ['Kalmia.gtt.json', '{', 8600], ['Kalmia.gtt.json.precedent', etat('evt-k', 'Kalmia'), 8601], // 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', 'evt-b', 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'), illisible('Kalmia', 'evt-k', 8600, 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 journal effacé entre deux gestes ne reçoit pas d'ajout : le geste suivant le réécrit en entier, ouverture et entrées en mémoire comprises, JOURNAL_RECREE ; un jalon de même ; puis les ajouts reprennent", async () => { const { disque, fs } = nouveauDisque(); let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 2), geste(2)); await fs.supprimer(DOC, JOURNAL); const trace = tracer(fs); ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 3), geste(3)); assert.deepEqual(trace.splice(0), [ ['ecrireAtomique', JOURNAL], ['ecrireAtomique', PRECEDENT], ['ecrireAtomique', ETAT], ]); assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]); let journal = exigerEquivalence(fs, BASE); assert.equal(journal.evenement, 'evt-lucioles'); assert.deepEqual(revisionsEtLibelles(journal), [ [1, CREATION.libelle], [2, 'Geste 2'], [3, 'Geste 3'], ]); await fs.supprimer(DOC, JOURNAL); trace.splice(0); ouvert = await nommer(ouvert, { nom: 'Avant le tirage', horodatage: heure(4) }); assert.deepEqual(trace.splice(0), [['ecrireAtomique', JOURNAL]]); assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]); journal = exigerEquivalence(fs, BASE); assert.deepEqual( journal.jalons.map(({ revision, nom }) => [revision, nom]), [ [1, 'Création'], [3, 'Avant le tirage'], ], ); ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 5), geste(5)); assert.deepEqual(trace.splice(0), [ ['ajouterLigne', JOURNAL], ['ecrireAtomique', PRECEDENT], ['ecrireAtomique', ETAT], ]); assert.deepEqual(ouvert.avertissements, []); assert.equal(exigerEquivalence(fs, BASE).entrees.length, 4); assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []); }); test("le journal présent se reconnaît comme le système compare les noms : sans égard à la casse, une base d'une autre casse ajoute en fin ; sensible à la casse, le nom exact", async () => { const insensible = nouveauDisque(); await creer(insensible.fs, DOC, chargeEssai(), CREATION); const autreCasse = await ouvrir(insensible.fs, DOC, BASE.toUpperCase()); const trace = tracer(insensible.fs); const suite = await enregistrer(autreCasse, modifier(autreCasse.charge, 2), geste(2)); assert.deepEqual(trace[0], ['ajouterLigne', `${BASE.toUpperCase()}${SUFFIXES.journal}`]); assert.deepEqual(suite.avertissements, []); assert.equal(exigerEquivalence(insensible.fs, BASE).entrees.length, 2); const sensible = nouveauDisque({ separateur: '/', insensibleCasse: false, documents: '/exemple/Documents' }); const racine = { id: 'documents', chemin: '/exemple/Documents' }; let ouvert = await creer(sensible.fs, racine, chargeEssai(), CREATION); sensible.fs.deposer('documents', `${BASE.toUpperCase()}${SUFFIXES.journal}`, 'un autre fichier'); ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); assert.deepEqual(ouvert.avertissements, []); await sensible.fs.supprimer(racine, JOURNAL); ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)); assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_RECREE', details: {} }]); assert.equal(sensible.fs.contenu('documents', `${BASE.toUpperCase()}${SUFFIXES.journal}`), 'un autre fichier'); }); test("un dossier qui ne se liste plus au moment d'ajouter au journal lève ECRITURE sur le journal, sans rien écrire : ENOENT quand il a disparu, la cause d'une lecture refusée sinon", async () => { for (const [nom, refus, cause] of [ ['disparu', (chemin) => new ErreurStockage('ABSENT', { chemin }), 'ENOENT'], ['illisible', (chemin) => new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' }), 'EACCES'], ]) { const { disque, fs } = nouveauDisque(); const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); const avant = await fichiersDe(disque); const ecritures = fs.compteurs.ecritures; fs.lister = async (racine, chemin) => { throw refus(chemin); }; const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)), 'ECRITURE', nom); assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause }, nom); assert.equal(fs.compteurs.ecritures, ecritures, nom); assert.deepEqual(await fichiersDe(disque), avant, nom); } }); 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 : des octets hors UTF-8 (§ 8.8)', () => { test("un état aux octets hors UTF-8 lève ETAT_ILLISIBLE, raison ENCODAGE, avec ses secours, sans rien écrire ; lister le montre illisible, apparié par son .precedent", async () => { const { fichiers } = await troisRevisions(); const { fs } = disqueAvecOctets(fichiers, ETAT, malEncode(texteDe(fichiers, ETAT))); const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE'); assert.deepEqual(erreur.details, { base: BASE, raison: 'ENCODAGE', chemin: null, secours: { precedent: 2, journal: 3 }, }); assert.deepEqual( (await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]), [[BASE, 'evt-lucioles', 'ENCODAGE', BASE]], ); assert.equal(fs.compteurs.ecritures, 0); }); test("ouvert d'un secours, le premier geste met l'état aux octets hors UTF-8 dans un dossier neuf de la corbeille, daté du geste, ses octets intacts, puis écrit le journal et l'état ; un jalon n'écrit que le journal, sans toucher l'état", async () => { const { fichiers } = await troisRevisions(); const octets = malEncode(texteDe(fichiers, ETAT)); const ecritureDuJournal = { journal: ['ajouterLigne', JOURNAL], precedent: ['ecrireAtomique', JOURNAL] }; for (const source of ['journal', 'precedent']) { const { disque, fs } = disqueAvecOctets(fichiers, ETAT, octets); // Le dossier de la même seconde, déjà pris pour le secours .precedent, // fait prendre le rang suivant. if (source === 'precedent') fs.deposerDossier('documents', 'corbeille/2031-05-17_08-04-00'); const corbeille = `corbeille/2031-05-17_08-04-00${source === 'precedent' ? '_2' : ''}`; const ouvert = await ouvrirSecours(fs, DOC, BASE, source); assert.deepEqual([ouvert.etatMalEncode, ouvert.texteEtat], [true, null], source); const trace = tracer(fs); const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); assert.deepEqual( trace, [['creerDossier', corbeille], ['deplacer', ETAT], ecritureDuJournal[source], ['ecrireAtomique', ETAT]], source, ); assert.deepEqual(fs.octets('documents', `${corbeille}/${ETAT}`), octets, source); assert.equal(fs.contenu('documents', PRECEDENT), texteDe(fichiers, PRECEDENT), source); assert.equal(suite.etatMalEncode, false, source); exigerEquivalence(fs, BASE); const rouvert = await rouvrir(disque, BASE); assert.deepEqual([rouvert.revision, rouvert.avertissements], [suite.revision, []], source); } const { fs } = disqueAvecOctets(fichiers, ETAT, octets); const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal'); const trace = tracer(fs); const nomme = await nommer(parJournal, { nom: 'Avant la reprise', horodatage: heure(5) }); assert.deepEqual(trace, [['ajouterLigne', JOURNAL]]); assert.equal(nomme.etatMalEncode, true); assert.deepEqual(fs.octets('documents', ETAT), octets); }); test("un geste refusé après la mise de côté de l'état aux octets hors UTF-8 se réessaie du même Ouvert sans rien déplacer de nouveau : un seul dossier daté, qui garde les octets", async () => { const { fichiers } = await troisRevisions(); const octets = malEncode(texteDe(fichiers, ETAT)); const { fs } = disqueAvecOctets(fichiers, ETAT, octets); const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal'); const ajouter = fs.ajouterLigne; fs.ajouterLigne = async (racine, chemin) => { fs.ajouterLigne = ajouter; throw new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause: 'EIO' }); }; const options = geste(4, { libelleReprise: REPRISE }); await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), options), 'ECRITURE'); assert.equal(fs.contenu('documents', JOURNAL), texteDe(fichiers, JOURNAL)); const trace = tracer(fs); await enregistrer(ouvert, modifier(ouvert.charge, 4), options); assert.deepEqual(trace, [ ['ajouterLigne', JOURNAL], ['ecrireAtomique', ETAT], ]); assert.deepEqual( (await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom), ['2031-05-17_08-04-00'], ); assert.deepEqual(fs.octets('documents', `corbeille/2031-05-17_08-04-00/${ETAT}`), octets); exigerEquivalence(fs, BASE); }); test("une corbeille qui ne se liste plus, au moment de mettre de côté l'état aux octets hors UTF-8, lève ECRITURE sur la corbeille sans rien écrire : l'état reste à sa place", async () => { const { fichiers } = await troisRevisions(); const octets = malEncode(texteDe(fichiers, ETAT)); const { fs } = disqueAvecOctets(fichiers, ETAT, octets); const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal'); const lister = fs.lister; fs.lister = async (racine, dossier) => { if (dossier !== 'corbeille') return lister(racine, dossier); throw new ErreurStockage('LECTURE', { chemin: dossier, dossier: racine.chemin, cause: 'EACCES' }); }; const ecritures = fs.compteurs.ecritures; const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4)), 'ECRITURE'); assert.deepEqual(erreur.details, { chemin: 'corbeille', dossier: DOCUMENTS, cause: 'EACCES' }); assert.equal(fs.compteurs.ecritures, ecritures); assert.deepEqual(fs.octets('documents', ETAT), octets); }); test("un .precedent aux octets hors UTF-8 ne gêne pas l'état lisible ; ouvert en secours, il lève ETAT_ILLISIBLE ENCODAGE ; un état illisible à côté de lui s'apparie par son journal", async () => { const { fichiers } = await troisRevisions(); const { fs } = disqueAvecOctets(fichiers, PRECEDENT, malEncode(texteDe(fichiers, PRECEDENT))); const ouvert = await ouvrir(fs, DOC, BASE); assert.deepEqual([ouvert.revision, ouvert.etatMalEncode, ouvert.avertissements], [3, false, []]); const erreur = await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ETAT_ILLISIBLE'); assert.deepEqual(erreur.details, { raison: 'ENCODAGE', chemin: null }); fs.deposer('documents', ETAT, ''); const illisible = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE'); assert.deepEqual(illisible.details, { base: BASE, raison: 'VIDE', chemin: null, secours: { precedent: null, journal: 3 }, }); assert.deepEqual( (await lister(fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]), [[BASE, 'evt-lucioles', 'VIDE']], ); assert.equal(fs.compteurs.ecritures, 0); }); test("un journal dont la ligne d'ouverture n'est pas de l'UTF-8 n'empêche pas d'ouvrir l'état : il n'est ni lu, ni apparié, ni remplacé, JOURNAL_ILLISIBLE {raison: ENCODAGE} ; le premier geste écrit l'historique sous une base libre, que la réouverture apparie", async () => { const { fichiers } = await troisRevisions(); const octets = ligneMalEncodee(texteDe(fichiers, JOURNAL), 0); const { disque, fs } = disqueAvecOctets(fichiers, JOURNAL, octets); const ouvert = await ouvrir(fs, DOC, BASE); assert.equal(fs.compteurs.ecritures, 0); assert.deepEqual([ouvert.revision, ouvert.journal, ouvert.baseJournal], [3, null, null]); assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ILLISIBLE', details: { raison: 'ENCODAGE' } }]); assert.deepEqual( (await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]), [[BASE, 'evt-lucioles', null, null]], ); const secours = await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ETAT_ILLISIBLE'); assert.deepEqual(secours.details, { raison: 'ENCODAGE', chemin: JOURNAL }); const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); assert.notEqual(suite.baseJournal, BASE); assert.deepEqual(fs.octets('documents', JOURNAL), octets); const journal = exigerEquivalence(fs, BASE, { journal: suite.baseJournal }); assert.deepEqual(revisionsEtLibelles(journal), [ [3, REPRISE], [4, 'Geste 4'], ]); const rouvert = await rouvrir(disque, BASE); assert.deepEqual([rouvert.revision, rouvert.baseJournal, rouvert.avertissements], [4, suite.baseJournal, []]); assert.deepEqual( (await lister(fs, DOC)).map(({ base, journal: apparie }) => [base, apparie]), [[BASE, suite.baseJournal]], ); }); test("un journal seul dont la ligne d'ouverture n'est pas de l'UTF-8 se liste sans identifiant, et s'ouvre en ETAT_ILLISIBLE ABSENT sans secours, non en ABSENT : il existe", async () => { const { fichiers } = await troisRevisions(); const { fs } = disqueAvecOctets([], JOURNAL, ligneMalEncodee(texteDe(fichiers, JOURNAL), 0)); assert.deepEqual( (await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]), [[BASE, null, 'ABSENT', BASE]], ); const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE'); assert.deepEqual(erreur.details, { base: BASE, raison: 'ABSENT', chemin: null, secours: { precedent: null, journal: null }, }); assert.equal(fs.compteurs.ecritures, 0); }); test("une dernière ligne de journal coupée au milieu d'un caractère de deux octets s'écarte comme une ligne illisible, JOURNAL_ECARTEES {n: 1} : le journal reste celui de l'événement, apparié et ouvrable en secours, et le premier geste le réécrit sans elle", async () => { const { fichiers } = await troisRevisions(); const texte = texteDe(fichiers, JOURNAL); const coupee = new TextEncoder().encode('{"type":"entree","revision":4,"libelle":"Ajout de Th'); const octets = new Uint8Array([...new TextEncoder().encode(texte), ...coupee, 0xc3]); const { disque, fs } = disqueAvecOctets(fichiers, JOURNAL, octets); const ouvert = await ouvrir(fs, DOC, BASE); assert.equal(fs.compteurs.ecritures, 0); assert.deepEqual([ouvert.revision, ouvert.baseJournal, ouvert.journalAReecrire], [3, BASE, true]); assert.deepEqual(revisionsEtLibelles(ouvert.journal), revisionsEtLibelles(lireJournal(texte))); assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]); assert.deepEqual( (await lister(fs, DOC)).map(({ base, id, illisible, journal }) => [base, id, illisible, journal]), [[BASE, 'evt-lucioles', null, BASE]], ); assert.equal((await ouvrirSecours(fs, DOC, BASE, 'journal')).revision, 3); assert.equal(fs.compteurs.ecritures, 0); const suite = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4)); assert.equal(suite.baseJournal, BASE); const journal = exigerEquivalence(fs, BASE); assert.deepEqual( journal.entrees.map(({ revision }) => revision), [1, 2, 3, 4], ); assert.deepEqual( (await fichiersDe(disque)).map(([nom]) => nom), [JOURNAL, ETAT, PRECEDENT], ); const rouvert = await rouvrir(disque, BASE); assert.deepEqual([rouvert.revision, rouvert.avertissements], [4, []]); }); test("un octet hors UTF-8 dans l'entrée 37 d'un journal de 100 entrées arrête la lecture sur elle : 36 entrées retenues, 64 écartées et comptées, le journal toujours apparié", async () => { const { disque, fs } = nouveauDisque(); let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); for (let n = 2; n <= 100; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); const texte = fs.contenu('documents', JOURNAL); assert.equal(lireJournal(texte).entrees.length, 100); fs.deposerOctets('documents', JOURNAL, ligneMalEncodee(texte, rangDeLEntree(texte, 37))); const rouvert = await rouvrir(disque, BASE); assert.deepEqual( rouvert.journal.entrees.map(({ revision }) => revision), Array.from({ length: 36 }, (_, i) => i + 1), ); assert.equal(rouvert.journal.ecartees, 64); assert.equal(rouvert.baseJournal, BASE); assert.deepEqual(rouvert.avertissements, [ { code: 'JOURNAL_INCOMPLET', details: { etat: 100, journal: 36 } }, { code: 'JOURNAL_ECARTEES', details: { n: 64 } }, ]); }); }); 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 que l'en-tête compte sans que la liste la porte s'annonce par leur nombre, PROPOSITIONS_MANQUANTES {n}, et un retenu annoncé absent, RETENU_FAUTIF : le fichier s'ouvre ; rattrapé du journal, ou après un geste, plus rien ne manque", async () => { const charge = chargeEssai(); charge.propositions.push(placement(2, [[1, 2], [3, 4]])); charge.prochainsIds.proposition = 3; charge.retenu = retenuDe(charge.propositions[0]); const { disque, fs } = nouveauDisque(); const cree = await creer(fs, DOC, charge, CREATION); await enregistrer(cree, modifier(cree.charge, 2), geste(2)); const fichiers = await fichiersDe(disque); // L'état perd la proposition 1 et le retenu, ses comptes intacts. const tronquer = (texte) => { const fichier = JSON.parse(texte); fichier.charge.propositions.shift(); fichier.charge.retenu = null; return JSON.stringify(fichier); }; const tronque = tronquer(texteDe(fichiers, ETAT)); assert.deepEqual(analyser(tronque).manquants, { propositions: 1, retenu: true }); const sansJournal = disqueAvec([[ETAT, tronque]]); const ouvert = await rouvrir(sansJournal.disque, BASE); assert.deepEqual(ouvert.avertissements, [ { code: 'RETENU_FAUTIF', details: {} }, { code: 'PROPOSITIONS_MANQUANTES', details: { n: 1 } }, ]); assert.deepEqual([ouvert.charge.propositions.map(({ id }) => id), ouvert.charge.retenu], [[2], null]); const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3, { libelleReprise: REPRISE })); assert.deepEqual(suite.avertissements, []); const relu = analyser(sansJournal.fs.contenu('documents', ETAT)); assert.deepEqual(relu.manquants, { propositions: 0, retenu: false }); // Le journal en avance d'une entrée rend sa charge, qui ne manque de rien. const enRetard = tronquer(texteDe(fichiers, PRECEDENT)); const avecJournal = disqueAvec(remplacer(fichiers, ETAT, enRetard)); const rattrape = await rouvrir(avecJournal.disque, BASE); assert.deepEqual(rattrape.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]); assert.deepEqual([rattrape.revision, rattrape.charge.propositions.length], [2, 2]); assert.notEqual(rattrape.charge.retenu, null); }); test("un en-tête qui annonce 2^40 propositions s'ouvre : PROPOSITIONS_MANQUANTES ne porte que leur nombre, jamais une liste de cette taille, à côté des fautives nommées", async () => { const fichier = JSON.parse(etatTexte(avecFautive())); fichier.entete.comptes.propositions = 2 ** 40; const { disque } = disqueAvec([[ETAT, JSON.stringify(fichier)]]); const ouvert = await rouvrir(disque, BASE); assert.deepEqual(ouvert.avertissements, [ { code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }, { code: 'PROPOSITIONS_MANQUANTES', details: { n: 2 ** 40 - 2 } }, ]); assert.ok(JSON.stringify(ouvert.avertissements).length < 200); }); test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => { // Par péremption, ce qui a changé dans la charge depuis la production de // sa proposition : périmées, la proposition et le retenu se gardent et se // signalent, sans s'écarter (§ 8.9, point 3 ; § 9). const exclure = (charge) => { charge.participants[2].exclu = true; }; // Chacune avec la raison de dérive qu'elle donne (examiner). const parExclusion = 'PARTICIPANT_EXCLU'; const peremptions = [ ['personne exclue', exclure, parExclusion], [ 'table supprimée', (charge) => { charge.tables = charge.tables.filter(({ id }) => id !== 2); }, 'TABLE_SUPPRIMEE', ], [ // La proposition date d'une table 1 à trois sièges ; elle en a deux, // ceux du défaut. 'capacité réduite', (charge) => { charge.propositions[0].capacites = [3, 2]; }, 'CAPACITE_CHANGEE', ], [ 'tour retiré', (charge) => { charge.evenement.tours = 1; }, 'TOURS_CHANGES', ], [ 'personne ajoutée', (charge) => { charge.participants.push(personne(5, 'Brunelle', null, null)); charge.prochainsIds.participant = 6; }, 'PARTICIPANT_NON_PLACE', ], ]; // Chaque péremption avec une proposition et le retenu qui la reprend ; // l'exclusion aussi avec la proposition seule, et avec le retenu seul. const tout = { retenu: true, propositions: true }; const propositionSeule = { retenu: false, propositions: true }; const retenuSeul = { retenu: true, propositions: false }; const cas = [ ...peremptions.map(([nom, perimer, raison]) => [nom, perimer, raison, tout, { ids: [1], retenu: true }]), ['personne exclue, proposition seule', exclure, parExclusion, propositionSeule, { ids: [1], retenu: false }], ['personne exclue, retenu seul', exclure, parExclusion, retenuSeul, { ids: [], retenu: true }], ]; for (const [nom, perimer, raison, { retenu, propositions }, details] of cas) { const charge = chargeEssai(); perimer(charge); if (retenu) charge.retenu = retenuDe(charge.propositions[0]); if (!propositions) charge.propositions = []; const { disque } = disqueAvec([[ETAT, etatTexte(charge)]]); const ouvert = await rouvrir(disque, BASE); assert.deepEqual(ouvert.avertissements, [{ code: 'DERIVE', details }], nom); assert.deepEqual(ouvert.charge.propositions, charge.propositions, nom); assert.deepEqual(ouvert.charge.retenu, charge.retenu, nom); const codes = (raisons) => raisons.map(({ code }) => code); assert.deepEqual( ouvert.examen.derives.map(({ raisons }) => codes(raisons)), propositions ? [[raison]] : [], nom, ); assert.deepEqual(codes(ouvert.examen.retenu.raisons), retenu ? [raison] : [], nom); } }); test('un retenu qui se contredit donne RETENU_FAUTIF en premier avertissement, et reste', async () => { const charge = avecFautive(); charge.retenu = retenuDe(placement(1, [[1, 3], [2]])); const { disque } = disqueAvec([[ETAT, etatTexte(charge)]]); const ouvert = await rouvrir(disque, BASE); assert.deepEqual( ouvert.avertissements.map(({ code }) => code), ['RETENU_FAUTIF', 'PROPOSITIONS_FAUTIVES'], ); assert.deepEqual(ouvert.charge.retenu, charge.retenu); assert.deepEqual( ouvert.examen.retenu.fautes.map(({ code }) => code), ['MANQUANT'], ); }); test("après une proposition écartée, le geste suivant s'inscrit depuis la charge du journal : sa dernière entrée restitue l'état écrit", async () => { const { disque, fs } = nouveauDisque(); const cree = await creer(fs, DOC, avecFautive(), CREATION); assert.deepEqual(cree.avertissements, [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]); const ouvert = await rouvrir(disque, BASE); const suite = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); exigerEquivalence(ouvert.fs, BASE); assert.deepEqual( analyser(ouvert.fs.contenu('documents', ETAT)).charge.propositions.map(({ id }) => id), [1], ); assert.deepEqual(suite.avertissements, []); }); }); describe('dépôt : format plus récent, élagage', () => { test("un fichier d'un format plus récent s'ouvre en lecture seule : enregistrer, nommer et enregistrerAilleurs refusent LECTURE_SEULE sans rien écrire", async () => { const texte = etatTexte(chargeEssai()).replace('"format":1', '"format":2'); const { disque } = disqueAvec([[ETAT, texte]]); const ouvert = await rouvrir(disque, BASE); assert.equal(ouvert.formatPlusRecent, true); assert.deepEqual(ouvert.avertissements, []); const refus = { raison: 'FORMAT_PLUS_RECENT' }; const enregistrement = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)), 'LECTURE_SEULE'); assert.deepEqual(enregistrement.details, refus); const nom = await echec(nommer(ouvert, { nom: 'Jalon', horodatage: heure(2) }), 'LECTURE_SEULE'); assert.deepEqual(nom.details, refus); const ailleurs = await echec(enregistrerAilleurs(ouvert, DOC, { existantes: [] }), 'LECTURE_SEULE'); assert.deepEqual(ailleurs.details, refus); assert.equal(ouvert.fs.compteurs.ecritures, 0); }); test("ce qu'une construction plus récente laisse garde en lecture seule chaque ouverture, secours compris : un état ou un journal au format 2 ne se réécrit jamais ; le journal de la base se reconnaît plus récent avant toute copie du dossier, et un journal plus récent ne se dit jamais absent", async () => { const { fichiers } = await troisRevisions(); const recent = (texte) => texte.replace('"format":1', '"format":2'); // L'état au format 2, d'une forme que ce code ne connaît pas : illisible, // FORMAT_PLUS_RECENT. Le .precedent reste l'état au format 1 qu'il remplace. const etatForme = recent(texteDe(fichiers, ETAT)).replace('"sieges":null', '"sieges":{"n":3}'); const journalRecent = recent(texteDe(fichiers, JOURNAL)); const plusRecent = { code: 'JOURNAL_FORMAT_PLUS_RECENT', details: { format: 2 } }; const enAvance = { code: 'JOURNAL_EN_AVANCE_DE', details: { n: 1 } }; const illisible = (journal, format = 2) => ({ code: 'ETAT_ILLISIBLE', details: { base: BASE, raison: 'FORMAT_PLUS_RECENT', chemin: 'charge.tables[0].sieges', format, secours: { precedent: 2, journal }, }, }); // Le secours d'un journal plus récent est refusé : il n'est pas absent. const journalPlusRecent = { code: 'ETAT_ILLISIBLE', details: { raison: 'FORMAT_PLUS_RECENT', chemin: JOURNAL, format: 2 }, }; // L'état manque à côté d'un journal plus récent : son refus ne nomme // aucun format, celui du journal s'y ajoute. const sansEtat = (precedent) => ({ code: 'ETAT_ILLISIBLE', details: { base: BASE, raison: 'ABSENT', chemin: null, format: 2, secours: { precedent, journal: null } }, }); const sansPrecedent = { code: 'ABSENT', details: { chemin: PRECEDENT } }; const copieALaMain = `${BASE} - Copie${SUFFIXES.journal}`; // Par cas : l'état et le journal posés (null : celui des trois révisions), // puis ce que rendent ouvrir, ouvrirSecours('precedent') et // ouvrirSecours('journal') — les avertissements d'un Ouvert, ou l'erreur // —, et ce que la main change ensuite aux fichiers posés. const cas = [ ['état et journal au format 2', etatForme, journalRecent, [illisible(null), [plusRecent], journalPlusRecent]], ['état au format 2, journal resté au format 1', etatForme, null, [illisible(3), [enAvance], []]], // Le refus de l'état nomme son propre format, que celui du journal ne // remplace pas. [ 'état au format 3, journal au format 2', etatForme.replace('"format":2', '"format":3'), journalRecent, [illisible(null, 3), [plusRecent], journalPlusRecent], ], ['état au format 2 lisible', recent(texteDe(fichiers, ETAT)), null, [[], [enAvance], []]], ['journal seul au format 2', null, journalRecent, [[plusRecent], [plusRecent], journalPlusRecent]], [ "journal seul au format 2, marque d'ordre d'octets en tête", null, `\u{FEFF}${journalRecent}`, [[plusRecent], [plusRecent], journalPlusRecent], ], [ 'journal au format 2, sa copie à la main au format 1 sous une autre base', null, journalRecent, [[plusRecent], [plusRecent], journalPlusRecent], (poses) => [...poses, [copieALaMain, texteDe(fichiers, JOURNAL)]], ], [ 'journal au format 2 sans état', null, journalRecent, [sansEtat(2), [plusRecent], journalPlusRecent], (poses) => remplacer(poses, ETAT, null), ], [ 'journal au format 2 sans état ni .precedent', null, journalRecent, [sansEtat(null), sansPrecedent, journalPlusRecent], (poses) => remplacer(remplacer(poses, ETAT, null), PRECEDENT, null), ], ]; let enLectureSeule = 0; for (const [nom, etat, journal, attendus, retoucher = (poses) => poses] of cas) { const avecEtat = remplacer(fichiers, ETAT, etat ?? texteDe(fichiers, ETAT)); const restants = retoucher(remplacer(avecEtat, JOURNAL, journal ?? texteDe(fichiers, JOURNAL))); const { disque, fs } = disqueAvec(restants); const avant = await fichiersDe(disque); const ouvertures = [ ['ouvrir', () => ouvrir(fs, DOC, BASE)], ['secours precedent', () => ouvrirSecours(fs, DOC, BASE, 'precedent')], ['secours journal', () => ouvrirSecours(fs, DOC, BASE, 'journal')], ]; for (const [rang, [ouverture, ouvrirAinsi]] of ouvertures.entries()) { const message = `${nom}, ${ouverture}`; const attendu = attendus[rang]; if (!Array.isArray(attendu)) { const erreur = await echec(ouvrirAinsi(), attendu.code); assert.deepEqual(erreur.details, attendu.details, message); continue; } const ouvert = await ouvrirAinsi(); assert.equal(ouvert.formatPlusRecent, true, message); assert.deepEqual(ouvert.avertissements, attendu, message); const ecritures = [ () => enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })), () => nommer(ouvert, { nom: 'Jalon', horodatage: heure(4), libelleReprise: REPRISE }), () => enregistrerAilleurs(ouvert, DOC, { existantes: [] }), ]; for (const ecrire of ecritures) { assert.deepEqual((await echec(ecrire(), 'LECTURE_SEULE')).details, { raison: 'FORMAT_PLUS_RECENT' }, message); } enLectureSeule += 1; } // La liste nomme le journal qu'ouvrir lit : celui de la base, qu'il soit // plus récent ou non, jamais sa copie au format courant. const ligne = (await lister(fs, DOC)).find(({ base }) => base === BASE); assert.equal(ligne.journal, BASE, `${nom}, lister`); assert.equal(fs.compteurs.ecritures, 0, nom); assert.deepEqual(await fichiersDe(disque), avant, nom); } assert.equal(enLectureSeule, 14); }); test("une ouverture de journal dont le format n'est pas un entier plus grand que FORMAT ne se lit pas : écartée, puis remplacée au premier geste", async () => { const { fichiers } = await troisRevisions(); const journal = texteDe(fichiers, JOURNAL); const ouverture = journal.slice(0, journal.indexOf('\n')); const fausses = [ ouverture.replace('"format":1', '"format":"2"'), ouverture.replace('"format":1', '"format":0'), ouverture.replace(/\}$/, ',"inconnue":1}'), ]; for (const fausse of fausses) { const copie = disqueAvec(remplacer(fichiers, JOURNAL, journal.replace(ouverture, fausse))); const ouvert = await rouvrir(copie.disque, BASE); assert.equal(ouvert.formatPlusRecent, false, fausse); assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 5 } }], fausse); await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })); assert.deepEqual( revisionsEtLibelles(exigerEquivalence(ouvert.fs, BASE)), [ [3, REPRISE], [4, 'Geste 4'], ], fausse, ); } }); test("au-delà de PLAFOND_ENTREES, le journal s'élague sur une frontière d'instantané qui garde au moins 500 entrées, au geste qui le dépasse comme à l'écriture ailleurs ; relu, il est la version élaguée", async () => { const { disque, fs } = nouveauDisque(); const trace = tracer(fs); let ouvert = await creer(fs, DOC, chargeEssai(), CREATION); for (let n = 2; n <= PLAFOND_ENTREES; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); const avant = fs.contenu('documents', JOURNAL); assert.equal(lireJournal(avant).entrees.length, PLAFOND_ENTREES); // Jusqu'au plafond compris, chaque geste s'ajoute en fin de journal. const ecrituresDuJournal = trace.filter(([, chemin]) => chemin === JOURNAL).map(([primitive]) => primitive); assert.deepEqual(new Set(ecrituresDuJournal.slice(1)), new Set(['ajouterLigne'])); // Le secours .precedent écrit ailleurs : son journal de PLAFOND_ENTREES // entrées reçoit la reprise, et s'élague comme au geste qui le dépasse. const lecteur = creerFichiersSimules({ disque }); const secours = await ouvrirSecours(lecteur, DOC, BASE, 'precedent'); assert.equal(lecteur.compteurs.ecritures, 0, 'ouvrirSecours écrit'); const options = { existantes: [], libelleReprise: REPRISE, horodatage: heure(601) }; const copie = await enregistrerAilleurs(secours, DOC, options); const copieRelue = exigerEquivalence(lecteur, copie.base); assert.deepEqual( [copie.base, copieRelue.entrees[0].revision, copieRelue.entrees.length, copieRelue.entrees.at(-1).libelle], [`${BASE} (2)`, 101, 501, REPRISE], ); assert.deepEqual(copie.journal, copieRelue); const depart = trace.length; ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 601), geste(601)); assert.deepEqual(trace.slice(depart), [ ['ecrireAtomique', JOURNAL], ['ecrireAtomique', PRECEDENT], ['ecrireAtomique', ETAT], ]); const apres = fs.contenu('documents', JOURNAL); const derniere = apres.slice(apres.lastIndexOf('\n', apres.length - 2) + 1); assert.equal(apres, elaguer(avant + derniere)); const relu = exigerEquivalence(fs, BASE); assert.equal(relu.entrees[0].revision, 101); assert.equal(relu.entrees.length, 501); assert.deepEqual(ouvert.journal, relu); ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 602), geste(602)); assert.deepEqual(trace.at(-3), ['ajouterLigne', JOURNAL]); exigerEquivalence(fs, BASE); const rouvert = await rouvrir(disque, BASE); assert.deepEqual([rouvert.revision, rouvert.avertissements, rouvert.journal.entrees.length], [602, [], 502]); }); }); describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => { test("le verrou de la séance se prend, se refuse à une autre séance avec ce que le système en dit, et se rend", async () => { const { disque, fs } = nouveauDisque(); const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); assert.deepEqual(await verrouiller(ouvert, 'seance-a'), { pris: true }); const texte = fs.contenu('documents', VERROU); const { depuis } = JSON.parse(texte); const voisin = await rouvrir(disque, BASE); assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: false, seance: 'seance-a', depuis, vivant: true }); const lointain = await ouvrir(creerFichiersSimules({ disque, hote: 'autre-poste' }), DOC, BASE); assert.equal(lointain.fs.compteurs.ecritures, 0, 'ouvrir écrit'); assert.deepEqual(await verrouiller(lointain, 'seance-c'), { pris: false, seance: 'seance-a', depuis, vivant: null, }); await deverrouiller(voisin, 'seance-b'); assert.equal(fs.contenu('documents', VERROU), texte); await deverrouiller(ouvert, 'seance-a'); assert.equal(fs.contenu('documents', VERROU), null); assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: true }); voisin.fs.pannes.couperApres(0); const vuDApres = await verrouiller(ouvert, 'seance-a'); assert.deepEqual([vuDApres.pris, vuDApres.seance, vuDApres.vivant], [false, 'seance-b', false]); }); test("après un refus d'écriture, enregistrerAilleurs crée la racine visée, puis y écrit le journal entier et l'état ; une base déjà prise là-bas se dérive ; le geste reprend là", async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); fs.deposerDossier('portable'); const { portable, documents } = await fs.racines(); let ouvert = await creer(fs, portable, chargeEssai(), CREATION); ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)); fs.pannes.ecriture('portable'); const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)), 'ECRITURE'); assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: 'E:\\soirees\\data', cause: 'EROFS' }); // En mode portable, rien n'a sondé ni créé documents : l'opérateur l'a // choisi, l'écriture le crée. const ecritures = fs.compteurs.ecritures; const trace = tracer(fs); const ailleurs = await enregistrerAilleurs(ouvert, documents, { existantes: [] }); assert.deepEqual(trace, [ ['creerDossier', ''], ['ecrireAtomique', JOURNAL], ['ecrireAtomique', ETAT], ]); assert.equal(fs.compteurs.ecritures - ecritures, 3); assert.deepEqual([ailleurs.racine, ailleurs.base, ailleurs.revision], [documents, BASE, 2]); assert.equal(fs.contenu('documents', JOURNAL), fs.contenu('portable', JOURNAL)); assert.equal(fs.contenu('documents', PRECEDENT), null); exigerEquivalence(fs, BASE); // Écrit de nouveau là-bas, l'événement y trouve sa base prise, absente // des bases reçues : la dérivation la voit dans le dossier. trace.length = 0; const libre = 'Soirée des Lucioles (2)'; const seconde = await enregistrerAilleurs(ouvert, documents, { existantes: [] }); assert.deepEqual(trace, [ ['creerDossier', ''], ['ecrireAtomique', `${libre}${SUFFIXES.journal}`], ['ecrireAtomique', `${libre}${SUFFIXES.etat}`], ]); assert.equal(seconde.base, libre); exigerEquivalence(fs, libre); const suite = await enregistrer(ailleurs, modifier(ailleurs.charge, 3), geste(3)); assert.equal(suite.revision, 3); exigerEquivalence(fs, BASE); const rouvert = await rouvrir(disque, BASE); assert.deepEqual([rouvert.revision, rouvert.avertissements], [3, []]); }); test("ETAT_NON_ECRIT : l'Ouvert avancé de ses détails s'écrit ailleurs, son geste compris, sous une base libre parmi celles reçues", async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); fs.deposerDossier('portable'); fs.deposerDossier('documents'); const { portable, documents } = await fs.racines(); const cree = await creer(fs, portable, chargeEssai(), CREATION); refuserRenommageApresPrecedent(fs); const apres = modifier(cree.charge, 2); const erreur = await echec(enregistrer(cree, apres, geste(2)), 'ETAT_NON_ECRIT'); const ailleurs = await enregistrerAilleurs(erreur.details.ouvert, documents, { existantes: [BASE] }); assert.deepEqual([ailleurs.base, ailleurs.revision], ['Soirée des Lucioles (2)', 2]); // creer : journal, état ; le geste : ligne, .precedent, état refusé ; // ailleurs : la racine, qui existe déjà, le journal, l'état. assert.equal(fs.compteurs.ecritures, 2 + 3 + 3); exigerEquivalence(fs, ailleurs.base); assert.equal(texteDeCharge(analyser(fs.contenu('documents', `${ailleurs.base}${SUFFIXES.etat}`)).charge), texteDeCharge(apres)); }); // Un événement du disque portable : créé, puis deux gestes, les notes 2 et // 3 ; son état remplacé par etat quand il est donné. Le secours .precedent // l'ouvre, sans rien écrire, à la note 2 et à la révision 2 ; puis portable // refuse toute écriture, et le premier geste lève ECRITURE. Rend le disque, // son processus, la racine documents et l'Ouvert du secours, à écrire // ailleurs. async function secoursRefuse(etat) { const disque = new Map(); const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); fs.deposerDossier('portable'); const { portable, documents } = await fs.racines(); let ouvert = await creer(fs, portable, chargeEssai(), CREATION); for (const n of [2, 3]) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n)); if (etat !== null) fs.deposer('portable', ETAT, etat); const ecritures = fs.compteurs.ecritures; const secours = await ouvrirSecours(fs, portable, BASE, 'precedent'); assert.equal(fs.compteurs.ecritures, ecritures, 'ouvrirSecours écrit'); assert.deepEqual([secours.revision, secours.charge.participants[0].notes], [2, 'note 2']); fs.pannes.ecriture('portable'); await echec(enregistrer(secours, modifier(secours.charge, 4), geste(4, { libelleReprise: REPRISE })), 'ECRITURE'); return { disque, fs, documents, secours }; } // L'état que le secours laisse sur le disque de départ : lisible, à la // révision 3, ou illisible. const etatsDuSecours = [ ['état lisible', null], ['état illisible', '{"entete":'], ]; test("un secours .precedent s'écrit ailleurs en paire alignée — son journal, une entrée de reprise qui restitue sa charge, puis l'état à la révision de la reprise — : rouvert là-bas, il rend la charge du secours sans rien rattraper", async () => { for (const [nom, etat] of etatsDuSecours) { const { disque, fs, documents, secours } = await secoursRefuse(etat); const trace = tracer(fs); const options = { existantes: [], libelleReprise: REPRISE, horodatage: heure(4) }; const ailleurs = await enregistrerAilleurs(secours, documents, options); assert.deepEqual( trace, [ ['creerDossier', ''], ['ecrireAtomique', JOURNAL], ['ecrireAtomique', ETAT], ], nom, ); const rouvert = await rouvrir(disque, BASE); assert.deepEqual( [rouvert.revision, rouvert.avertissements, rouvert.charge.participants[0].notes], [4, [], 'note 2'], nom, ); assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(secours.charge), nom); const journal = exigerEquivalence(fs, BASE); assert.deepEqual( revisionsEtLibelles(journal), [ [1, CREATION.libelle], [2, 'Geste 2'], [3, 'Geste 3'], [4, REPRISE], ], nom, ); assert.deepEqual([ailleurs.revision, ailleurs.journal, ailleurs.avertissements], [4, journal, []], nom); // Un geste depuis l'Ouvert rendu, qui remet la note 3 : son correctif // part de la charge écrite là-bas, que porte chargeJournal. Parti de la // note 3, il ne changerait rien, et sa dernière entrée ne restituerait // pas l'état écrit. await enregistrer(ailleurs, modifier(ailleurs.charge, 3), geste(5)); assert.deepEqual( revisionsEtLibelles(exigerEquivalence(fs, BASE)).slice(-2), [ [4, REPRISE], [5, 'Geste 5'], ], nom, ); } }); test('écrit ailleurs sans libellé de reprise, un secours .precedent lève TypeError avant toute écriture : la racine ne se crée pas', async () => { for (const [nom, etat] of etatsDuSecours) { const { fs, documents, secours } = await secoursRefuse(etat); const trace = tracer(fs); const sansLibelle = { existantes: [], horodatage: heure(4) }; await assert.rejects(enregistrerAilleurs(secours, documents, sansLibelle), TypeError, nom); assert.deepEqual(trace, [], nom); } }); test("un Ouvert aligné s'écrit ailleurs avec la charge que restitue la dernière entrée de son journal, proposition fautive comprise, sous la version qui l'a produite ; l'Ouvert rendu porte la charge examinée", async () => { const fautives = [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]; // Par cas : la version que la main pose dans l'en-tête de l'état créé, à // la révision du journal, ou null. Retouché, l'en-tête nomme une autre // construction que l'entrée qui a produit la charge : l'état écrit // ailleurs prend celle du journal. const cas = [ ["l'Ouvert de creer", null], ['un en-tête retouché à la main', V2], ]; for (const [nom, version] of cas) { const disque = new Map(); const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); fs.deposerDossier('portable'); const { portable, documents } = await fs.racines(); let ouvert = await creer(fs, portable, avecFautive(), CREATION); if (version !== null) { fs.deposer('portable', ETAT, etatTexte(avecFautive(), 1, version)); const ecritures = fs.compteurs.ecritures; ouvert = await ouvrir(fs, portable, BASE); assert.equal(fs.compteurs.ecritures, ecritures, `${nom} : ouvrir écrit`); assert.deepEqual([ouvert.revision, ouvert.produitVersion, ouvert.avertissements], [1, version, fautives], nom); } assert.notEqual(texteDeCharge(ouvert.charge), texteDeCharge(ouvert.chargeJournal), nom); const ailleurs = await enregistrerAilleurs(ouvert, documents, { existantes: [] }); const ecrit = analyser(fs.contenu('documents', ETAT)); assert.deepEqual([ecrit.entete.revision, ecrit.entete.produitVersion], [1, V1], nom); assert.equal(texteDeCharge(ecrit.charge), texteDeCharge(avecFautive()), nom); exigerEquivalence(fs, BASE); assert.equal(texteDeCharge(ailleurs.chargeJournal), texteDeCharge(avecFautive()), nom); assert.deepEqual( [ailleurs.produitVersion, ailleurs.charge.propositions.map(({ id }) => id), ailleurs.avertissements], [V1, [1], fautives], nom, ); assert.deepEqual((await rouvrir(disque, BASE)).avertissements, fautives, nom); } }); test("un événement sans journal s'écrit ailleurs par son seul état ; là où l'écriture est refusée, un journal lève ECRITURE, un état seul ETAT_NON_ECRIT, et une racine qui ne se crée pas ECRITURE, sans rien écrire", async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' }); fs.deposerDossier('documents'); fs.deposer('portable', ETAT, etatTexte(chargeEssai(), 4)); const { portable, documents } = await fs.racines(); const sansJournal = await ouvrir(fs, portable, BASE); assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit'); const trace = tracer(fs); const ailleurs = await enregistrerAilleurs(sansJournal, documents, { existantes: [] }); assert.deepEqual(trace, [ ['creerDossier', ''], ['ecrireAtomique', ETAT], ]); assert.deepEqual( [ailleurs.journal, ailleurs.revision, ailleurs.texteEtat], [null, 4, fs.contenu('documents', ETAT)], ); // Les renommages de documents refusés : chaque écriture atomique échoue, // le dossier existant passe. const avecJournal = await creer(fs, portable, chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }), CREATION); fs.pannes.renommage('documents'); const refusJournal = await echec(enregistrerAilleurs(avecJournal, documents, { existantes: [] }), 'ECRITURE'); assert.deepEqual(refusJournal.details, { chemin: "Gala d'automne.gtt-journal.jsonl", dossier: DOCUMENTS, cause: 'EBUSY', }); assert.equal(fs.contenu('documents', "Gala d'automne.gtt.json"), null); const refusEtat = await echec(enregistrerAilleurs(sansJournal, documents, { existantes: [] }), 'ETAT_NON_ECRIT'); const { ouvert: refuse, ...details } = refusEtat.details; assert.deepEqual(details, { chemin: 'Soirée des Lucioles (2).gtt.json', dossier: DOCUMENTS, cause: 'EBUSY' }); assert.deepEqual([refuse.base, refuse.texteEtat, refuse.revision], ['Soirée des Lucioles (2)', null, 4]); // Sur un disque où documents n'a jamais été créé, et qui en refuse toute // écriture : creerDossier, seule primitive appelée, lève ECRITURE sur la // racine elle-même ; documents n'existe toujours pas, et rien ne s'écrit. const neuf = new Map(); const sansDocuments = creerFichiersSimules({ disque: neuf, executable: 'E:\\soirees' }); sansDocuments.deposer('portable', ETAT, etatTexte(chargeEssai(), 4)); const seul = await ouvrir(sansDocuments, portable, BASE); assert.equal(sansDocuments.compteurs.ecritures, 0, 'ouvrir écrit'); sansDocuments.pannes.ecriture('documents'); const traceRacine = tracer(sansDocuments); const refusRacine = await echec(enregistrerAilleurs(seul, documents, { existantes: [] }), 'ECRITURE'); assert.deepEqual(refusRacine.details, { chemin: '', dossier: DOCUMENTS, cause: 'EROFS' }); assert.deepEqual(traceRacine, [['creerDossier', '']]); await echec(creerFichiersSimules({ disque: neuf }).lister(DOC, ''), 'ABSENT'); assert.equal(sansDocuments.contenu('portable', ETAT), etatTexte(chargeEssai(), 4)); }); test("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'); }); test("une lecture qui échoue autrement que sur un fichier absent ou mal encodé remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE, une lecture refusée LECTURE", async () => { const { fs } = nouveauDisque(); await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE'); await creer(fs, DOC, chargeEssai(), CREATION); const ecritures = fs.compteurs.ecritures; fs.lireTexte = async (racine, chemin) => { throw new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' }); }; assert.deepEqual((await echec(ouvrir(fs, DOC, BASE), 'LECTURE')).details, { chemin: ETAT, dossier: DOCUMENTS, cause: 'EACCES', }); assert.equal(fs.compteurs.ecritures, ecritures); }); });