From a3d7badf6d173ed36d5c7dabbef3ef54fecf3032 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 23:17:05 -0400 Subject: [PATCH] [ADD] storage: copy, rename, import, delete events; local settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- electron/fichiers.js | 28 +- src/stockage/depot.js | 181 +- src/stockage/depot.test.js | 148 ++ src/stockage/fichiers_web.js | 7 +- src/stockage/fichiers_web.navigateur.test.js | 13 +- src/stockage/noms.js | 5 +- src/stockage/operations.js | 524 ++++++ src/stockage/operations.test.js | 1554 ++++++++++++++++++ src/stockage/reglages_locaux.js | 321 ++++ src/stockage/reglages_locaux.test.js | 437 +++++ src/stockage/systeme_fichiers.js | 6 +- src/stockage/types.js | 16 +- test/fichiers_electron.long.test.js | 16 + test/fichiers_electron.test.js | 29 +- test/fichiers_simules.js | 21 +- test/fichiers_simules.test.js | 14 + 16 files changed, 3241 insertions(+), 79 deletions(-) create mode 100644 src/stockage/operations.js create mode 100644 src/stockage/operations.test.js create mode 100644 src/stockage/reglages_locaux.js create mode 100644 src/stockage/reglages_locaux.test.js diff --git a/electron/fichiers.js b/electron/fichiers.js index ee295b5..aee578e 100644 --- a/electron/fichiers.js +++ b/electron/fichiers.js @@ -331,16 +331,18 @@ export function creerFichiers({ if (portable !== null) bases.set('portable', portable); let choisis = 0; - // Ce que désigne un chemin relatif à une racine : sa racine et sa cible en - // absolu, et le dossier que nomme un échec — celui qui porte la cible, la - // racine elle-même pour ''. Seul le texte se lit : CHEMIN_REFUSE pour un - // chemin que la règle des segments refuse ou une racine inconnue. + // Ce que désigne un chemin relatif à une racine : ses segments, sa racine + // et sa cible en absolu, et le dossier que nomme un échec — celui qui porte + // la cible, la racine elle-même pour ''. Seul le texte se lit : + // CHEMIN_REFUSE pour un chemin que la règle des segments refuse ou une + // racine inconnue. function localiser(racine, chemin) { const segments = segmentsAdmis(chemin); const base = estObjet(racine) && typeof racine.id === 'string' ? bases.get(racine.id) : undefined; if (base === undefined) throw new RefusFichier('CHEMIN_REFUSE', { chemin }); return { chemin, + segments, racine: chemins.join(base), absolu: chemins.join(base, ...segments), dossier: chemins.join(base, ...segments.slice(0, -1)), @@ -650,11 +652,27 @@ export function creerFichiers({ } }, + // '' crée la racine, parents compris. Un autre chemin se crée dossier par + // dossier sous la racine, sans recursive : mkdir lève ENOENT quand son + // parent manque, si bien qu'une racine qui disparaît, même entre deux + // appels, ne renaît pas (§ 8.6). Un dossier présent passe ; un fichier en + // travers lève EEXIST sur le chemin même, ENOTDIR au-dessus. async creerDossier(racine, chemin) { const loc = localiser(racine, chemin); + const { segments } = loc; try { await confiner(loc); - await fs.mkdir(loc.absolu, { recursive: true }); + if (segments.length === 0) await fs.mkdir(loc.absolu, { recursive: true }); + for (let rang = 1; rang <= segments.length; rang += 1) { + const dossier = chemins.join(loc.racine, ...segments.slice(0, rang)); + try { + await fs.mkdir(dossier); + } catch (erreur) { + if (erreur?.code !== 'EEXIST') throw erreur; + const cause = rang === segments.length ? 'EEXIST' : 'ENOTDIR'; + if (!(await etatSuivi(dossier))?.isDirectory()) throw refus(loc, cause); + } + } } catch (erreur) { throw echecEcriture(loc, erreur); } diff --git a/src/stockage/depot.js b/src/stockage/depot.js index a3e5e8b..d70647b 100644 --- a/src/stockage/depot.js +++ b/src/stockage/depot.js @@ -49,7 +49,13 @@ // geste : le journal en mémoire, en entier — suivi de l'entrée de reprise // quand il finit sur une autre révision que la charge ouverte —, puis l'état // que restitue sa dernière entrée, à sa révision. Rouvert là-bas, -// l'événement rend la charge écrite sans rien rattraper. +// l'événement rend la charge écrite sans rien rattraper. Avec un geste, la +// paire porte l'entrée de ce geste à la suite du journal, et sa charge : le +// renommage (§ 8.7) écrit ainsi la paire complète sous le nouveau nom, dans +// le dossier même, sans toucher l'ancienne ni créer le dossier. Son +// .precedent, l'état d'avant le geste, s'écrit avant le journal : la +// nouvelle base appartient à l'événement dès que son journal existe, et +// l'ancienne paire ne s'apparie jamais ce journal. // // Un état sur le disque, ou un journal de la base, d'un format plus récent // que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours @@ -243,12 +249,27 @@ function formatPlusRecentDe(texte) { return Number.isSafeInteger(format) && format > FORMAT ? format : null; } +/** + * Bases que portent les fichiers d'événement — état, .precedent, journal, + * verrou — à la racine de racine, dans l'ordre de sa liste, sans rien + * écrire : une base que l'un d'eux porte n'est pas libre (deriverBase). Une + * base revient une fois par fichier qui la porte. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @returns {Promise} + * @throws {ErreurStockage} ABSENT quand la racine n'existe pas + */ +export async function basesPrises(fs, racine) { + return basesDe(await fs.lister(racine, '')); +} + // Base que prend l'événement nommé nom dans racine (deriverBase) : aucune des // bases d'autres, ni de celles que portent les fichiers du dossier, à la casse // près. async function deriver(fs, racine, nom, autres) { const { separateur } = await fs.emplacements(); - const existantes = [...autres, ...basesDe(await fs.lister(racine, ''))]; + const existantes = [...autres, ...(await basesPrises(fs, racine))]; return deriverBase(nom, { racine: racine.chemin, separateur, existantes }); } @@ -905,6 +926,29 @@ function apresEcriture(ouvert, { charge, revision, journal }) { }; } +// La ligne de l'entrée d'un geste à la suite du journal que preparer a +// préparé, et sa révision, celle qui suit la dernière entrée de ce journal : +// un correctif depuis la charge que restitue cette entrée, ou un instantané à +// la cadence du journal ; produitVersion n'y paraît que quand la construction +// change (versionNotee). Une ligne que la lecture refuserait lève TypeError +// (ligneEntree), avant toute écriture. +function ligneDuGeste(preparation, apres, { libelle, horodatage, produitVersion, sens, retour }) { + const derniere = preparation.journal.entrees.at(-1).revision; + const revision = derniere + 1; + const ligne = ligneEntree({ + revision, + libelle, + horodatage, + sens, + retour, + produitVersion: versionNotee(produitVersion, preparation.journal, derniere), + avant: preparation.charge, + apres, + instantane: estInstantane(revision, preparation.journal.entrees[0].revision), + }); + return { ligne, revision }; +} + /** * Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de * son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit @@ -990,22 +1034,10 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage, * refuserait, avant toute écriture */ export async function enregistrer(ouvert, apres, options) { - const { libelle, horodatage, produitVersion, sens, retour, libelleReprise } = options; + const { horodatage, produitVersion, libelleReprise } = options; exigerEcriture(ouvert); const preparation = preparer(ouvert, { libelleReprise, horodatage }); - const derniere = preparation.journal.entrees.at(-1).revision; - const revision = derniere + 1; - const ligne = ligneEntree({ - revision, - libelle, - horodatage, - sens, - retour, - produitVersion: versionNotee(produitVersion, preparation.journal, derniere), - avant: preparation.charge, - apres, - instantane: estInstantane(revision, preparation.journal.entrees[0].revision), - }); + const { ligne, revision } = ligneDuGeste(preparation, apres, options); const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer'); const aligne = etatAligne(ouvert, preparation); const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne); @@ -1058,43 +1090,72 @@ export async function deverrouiller({ fs, racine, base }, seance) { await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance); } +// L'état d'avant un geste, que le .precedent garde (§ 8.8) : le texte de +// l'état sur le disque quand il se lit ; sinon, pour un Ouvert de secours, la +// charge ouverte sous son en-tête, celle que l'entrée de reprise restitue. +// Une charge que l'analyse refuserait lève TypeError, avant toute écriture. +function etatDAvant({ texteEtat, charge, revision, produitVersion }) { + return texteEtat ?? relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs'); +} + // La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans -// journal, l'état seul : la charge ouverte, à sa révision et à sa version. -// Sinon le journal préparé (preparer), suivi de l'entrée de reprise quand il -// finit sur une autre révision que la charge ouverte, en entier et élagué -// au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que restitue sa -// dernière entrée, sous l'en-tête de celle-ci (enteteDeLaDerniere), comme -// l'état qu'aligne un geste : la dernière entrée du journal écrit restitue -// l'état écrit (§ 8.2), et la réouverture n'a rien à rattraper. Une reprise -// sans libelleReprise lève TypeError (preparer). -function paireAilleurs(ouvert, options) { - if (ouvert.journal === null) { +// journal ni geste, l'état seul : la charge ouverte, à sa révision et à sa +// version. Sinon le journal préparé (preparer), suivi de l'entrée de reprise +// quand il finit sur une autre révision que la charge ouverte, ou qu'il +// manque, puis de l'entrée du geste quand il y en a un (ligneDuGeste), en +// entier et élagué au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que +// restitue sa dernière entrée : la charge du geste, à sa révision et sous sa +// version, comme l'écrit enregistrer ; sans geste, la charge préparée sous +// l'en-tête de cette entrée (enteteDeLaDerniere), comme l'état qu'aligne un +// geste. La dernière entrée du journal écrit restitue l'état écrit (§ 8.2), +// et la réouverture n'a rien à rattraper. Une reprise sans libelleReprise, ou +// un geste dont la ligne ne se relirait pas, lève TypeError. +function paireAilleurs(ouvert, { libelleReprise, horodatage, geste }) { + if (ouvert.journal === null && geste === null) { const { charge, revision, produitVersion } = ouvert; return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion }; } - const preparation = preparer(ouvert, options); - const { texte, journal } = textePlein(preparation.journal); - const { charge } = preparation; - return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) }; + const preparation = preparer(ouvert, { libelleReprise, horodatage }); + if (geste === null) { + const { texte, journal } = textePlein(preparation.journal); + const { charge } = preparation; + return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) }; + } + const { apres, produitVersion } = geste; + const { ligne, revision } = ligneDuGeste(preparation, apres, { ...geste, horodatage }); + const { texte, journal } = textePlein(avec(preparation.journal, ligne)); + return { journal, texteJournal: texte, charge: apres, chargeJournal: canoniser(apres), revision, produitVersion }; } /** - * Écrit l'événement ouvert dans une autre racine (§ 8.6) : la réponse à une - * écriture qui échoue, l'Ouvert reçu étant celui d'avant le geste refusé - * (ECRITURE) ou l'Ouvert avancé d'ETAT_NON_ECRIT. L'opérateur a choisi cette - * racine, que rien n'a sondée — documents en mode portable — : elle se crée - * d'abord, parents compris, un dossier existant restant tel quel. Sa base - * dérive du nom là-bas (deriverBase), libre parmi existantes et les bases du + * Écrit l'événement ouvert sous une base libre d'une racine : dans une autre + * racine (§ 8.6), la réponse à une écriture qui échoue, l'Ouvert reçu étant + * celui d'avant le geste refusé (ECRITURE) ou l'Ouvert avancé + * d'ETAT_NON_ECRIT ; ou, avec un geste, sous le nom que ce geste donne, dans + * le dossier même, ce qu'écrit le renommage (§ 8.7). Une racine que + * l'opérateur a choisie, que rien n'a sondée — documents en mode portable —, + * se crée d'abord, parents compris, un dossier existant restant tel quel ; + * creerRacine faux ne la crée pas : le dossier de travail qui disparaît en + * cours de séance ne renaît pas vide (§ 8.6). La base dérive du nom que porte + * la charge écrite (deriverBase), libre parmi existantes et les bases du * dossier. La paire s'y écrit alignée, comme après un geste (§ 8.2) : le * journal en mémoire, en entier — suivi d'une entrée de reprise, au libellé * libelleReprise, quand il finit sur une autre révision que la charge - * ouverte, et élagué au-delà de PLAFOND_ENTREES —, puis l'état que restitue - * sa dernière entrée, à la révision et à la version de celle-ci. Rouvert - * là-bas, l'événement rend cette charge sans rien rattraper. Un événement - * sans journal n'écrit que son état : la charge ouverte, à sa révision. Le - * .precedent ne se recopie pas. Les lignes et le texte de l'état se - * calculent, et se relisent, avant la première écriture, celle de la racine - * comprise. + * ouverte, puis de l'entrée du geste, et élagué au-delà de PLAFOND_ENTREES —, + * puis l'état que restitue sa dernière entrée, à la révision et à la version + * de celle-ci. Rouvert là-bas, l'événement rend cette charge sans rien + * rattraper. Un événement sans journal ni geste n'écrit que son état : la + * charge ouverte, à sa révision ; avec un geste, l'entrée de reprise commence + * son journal. Le .precedent de l'Ouvert ne se recopie pas. Avec un geste, la + * nouvelle base reçoit d'abord le sien, l'état d'avant le geste (etatDAvant), + * comme enregistrer le pose, mais avant le journal : la base appartient alors + * à l'événement (proprietaire), si bien qu'aucun autre état de son + * identifiant — l'ancienne paire d'un renommage interrompu, dont le journal + * de la base n'est pas le sien — ne s'apparie le journal neuf (apparier). La + * paire va sous une base qu'aucun fichier du dossier ne porte : elle n'y + * remplace rien, pas même la paire de l'Ouvert quand ce dossier est le sien. + * Les lignes et les textes d'état se calculent, et se relisent, avant la + * première écriture, celle de la racine comprise. * * @param {Ouvert} ouvert * @param {import('./systeme_fichiers.js').Racine} racine @@ -1104,25 +1165,41 @@ function paireAilleurs(ouvert, options) { * @param {string} [options.libelleReprise] texte de l'entrée de reprise, * requis quand elle a lieu * @param {string} [options.horodatage] AAAA-MM-JJTHH:MM:SS±HH:MM de - * l'entrée de reprise, requis avec elle + * l'entrée de reprise et de celle du geste, requis avec elles + * @param {{apres: import('./types.js').Charge, libelle: string, produitVersion: string}|null} [options.geste] + * un geste à la suite de la charge ouverte, comme enregistrer le + * reçoit : apres devient la charge écrite, à la révision qui suit la + * dernière entrée du journal, l'entrée au libellé libelle, l'état + * sous la version produitVersion ; null par défaut + * @param {boolean} [options.creerRacine] faux : la racine n'est pas créée, + * et doit exister ; vrai par défaut * @returns {Promise} l'Ouvert dans racine : le journal et la * révision écrits, chargeJournal la charge de l'état écrit, et * charge celle qu'en rend examiner * @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE quand la racine ne se - * crée pas, rien d'autre n'étant écrit, ou que le journal n'a pas pu - * s'écrire ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire, ouvert - * étant l'Ouvert dans racine ; CHEMIN_TROP_LONG - * @throws {TypeError} une reprise sans libelleReprise ou horodatage, un état - * que la lecture refuserait, avant toute écriture + * crée pas, ou que le .precedent d'un geste n'a pas pu s'écrire, rien + * d'autre n'étant écrit, ou que le journal n'a pas pu s'écrire, le + * .precedent d'un geste restant écrit avant lui ; ABSENT {chemin: ''} + * quand creerRacine est faux et que la racine n'existe pas, rien + * n'étant écrit ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire, + * ouvert étant l'Ouvert dans racine ; CHEMIN_TROP_LONG + * @throws {TypeError} une reprise sans libelleReprise ou horodatage, un geste + * ou un état que la lecture refuserait, avant toute écriture */ -export async function enregistrerAilleurs(ouvert, racine, { existantes, libelleReprise, horodatage }) { +export async function enregistrerAilleurs( + ouvert, + racine, + { existantes, libelleReprise, horodatage, geste = null, creerRacine = true }, +) { exigerEcriture(ouvert); const { fs } = ouvert; - const paire = paireAilleurs(ouvert, { libelleReprise, horodatage }); + const paire = paireAilleurs(ouvert, { libelleReprise, horodatage, geste }); const { journal, charge, revision, produitVersion } = paire; const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs'); - await fs.creerDossier(racine, ''); + const precedent = geste === null ? null : etatDAvant(ouvert); + if (creerRacine) await fs.creerDossier(racine, ''); const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]); + if (precedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, precedent); if (journal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, paire.texteJournal); const ailleurs = { ...ouvert, diff --git a/src/stockage/depot.test.js b/src/stockage/depot.test.js index 63b2b2c..d564783 100644 --- a/src/stockage/depot.test.js +++ b/src/stockage/depot.test.js @@ -18,6 +18,7 @@ import { versionVoisine } from '../../test/version_voisine.js'; import { VERSION } from '../version.genere.js'; import { serialiser, serialiserCharge } from './canonique.js'; import { + basesPrises, creer, deverrouiller, enregistrer, @@ -2303,6 +2304,153 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => { 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 remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => { const { fs } = nouveauDisque(); await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE'); diff --git a/src/stockage/fichiers_web.js b/src/stockage/fichiers_web.js index 8376b4c..da46c19 100644 --- a/src/stockage/fichiers_web.js +++ b/src/stockage/fichiers_web.js @@ -135,12 +135,15 @@ async function ecrire(loc, travail) { } // Poignée du dossier que désignent les segments sous le dossier du produit ; -// creer crée ce qui manque, dossier du produit compris. Lève l'exception de +// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se +// crée que pour des segments vides, qui le désignent : un chemin sous un +// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de // l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un // fichier à sa place. async function dossierDe(segments, creer = false) { const options = { create: creer }; - let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, options); + const produit = { create: creer && segments.length === 0 }; + let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit); for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options); return dossier; } diff --git a/src/stockage/fichiers_web.navigateur.test.js b/src/stockage/fichiers_web.navigateur.test.js index 0525123..d6d9a23 100644 --- a/src/stockage/fichiers_web.navigateur.test.js +++ b/src/stockage/fichiers_web.navigateur.test.js @@ -152,19 +152,26 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => { expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); }); - test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien ; creerDossier le crée", async () => { + test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien, creerDossier d'un chemin sous lui aussi ; creerDossier de '' le crée", async () => { const fs = creerFichiersWeb(); - const refus = (chemin) => ({ chemin, dossier: CHEMIN_DOCUMENTS, cause: 'NotFoundError' }); + const refus = (chemin, dossier = CHEMIN_DOCUMENTS) => ({ chemin, dossier, cause: 'NotFoundError' }); await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json')); await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl')); await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' }); await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' }); await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' }); await fs.supprimer(DOCUMENTS, 'etat.json'); + await echoue( + fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05'), + 'ECRITURE', + refus('corbeille/2026-01-02_03-04-05', `${CHEMIN_DOCUMENTS}/corbeille`), + ); expect(await nomsOpfs()).toStrictEqual([]); - await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05'); + await fs.creerDossier(DOCUMENTS, ''); expect(await nomsOpfs()).toStrictEqual([DOSSIER]); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05'); expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']); }); }); diff --git a/src/stockage/noms.js b/src/stockage/noms.js index c033a2b..83d5b69 100644 --- a/src/stockage/noms.js +++ b/src/stockage/noms.js @@ -182,7 +182,10 @@ function tenir(brut, limite) { // nom, qui se lisent pareil à l'écran. Une majuscule de plusieurs lettres (ß // donne SS) réunit des noms que Windows distingue : un rang de plus, jamais un // fichier écrasé. -const cle = (base) => base.normalize('NFC').toUpperCase(); +// Exportée : supprimer et le rang d'une démonstration (operations.js) +// comparent les noms de fichier par cette même clé. +export const cleSansCasse = (base) => base.normalize('NFC').toUpperCase(); +const cle = cleSansCasse; /** * Base de nom de fichier d'un nom d'événement dans un dossier de travail diff --git a/src/stockage/operations.js b/src/stockage/operations.js new file mode 100644 index 0000000..a3c36a9 --- /dev/null +++ b/src/stockage/operations.js @@ -0,0 +1,524 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les opérations sur les événements d'un dossier de travail (§ 8.6, § 8.7, +// § 15) : copier, renommer, importer un fichier d'état venu d'ailleurs ou les +// paires de l'autre emplacement, supprimer vers la corbeille, la lister, et +// charger une démonstration livrée. Chaque fonction reçoit le système de +// fichiers (§ 13.4) ; aucune ne lit l'horloge ni un aléa, ni ne porte de +// texte : identifiant, horodatage, version et libellés entrent en paramètre +// (§ 14.6). +// +// Ce qui s'écrit d'un événement passe par le dépôt, qui seul apparie un état +// à son journal et les aligne l'un sur l'autre : copier et charger créent +// par creer ; renommer écrit par enregistrer, en place, ou par +// enregistrerAilleurs, sous le nouveau nom ; le journal d'un événement est +// celui qu'ouvrir lui apparie, ou, faute d'état lisible, celui que lister +// lui apparie. Ce module ne fait par lui-même que recopier les octets d'un +// fichier, le déplacer ou le supprimer en entier. +// +// Un fichier d'un format plus récent que FORMAT ne se réécrit sous aucun nom +// (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît +// pas. Copier, renommer et charger le refusent avant toute écriture ; +// importer en recopie les octets, et l'événement importé s'ouvre en lecture +// seule. +// +// Une base se dérive du nom de l'événement (deriverBase), libre parmi les +// bases que portent les fichiers d'événement à la racine du dossier +// (basesPrises), comme le dépôt la dérive pour creer. +import { canoniser } from './canonique.js'; +import { basesPrises, creer, enregistrer, enregistrerAilleurs, lister, ouvrir } from './depot.js'; +import { analyser, etatDeduit } from './document.js'; +import { ErreurStockage } from './erreurs.js'; +import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js'; + +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + + +// Celui des noms qui désigne le même fichier que nom : nom lui-même quand il +// y est ; sinon, quand le système ignore la casse, le premier dont la clé +// (cleSansCasse) est la sienne ; null sinon. Sur un système sensible à la +// casse, seul le nom exact désigne le fichier. +function nomSurDisque(noms, nom, insensibleCasse) { + if (noms.includes(nom)) return nom; + if (!insensibleCasse) return null; + const cle = cleSansCasse(nom); + return noms.find((autre) => cleSansCasse(autre) === cle) ?? null; +} + +// Entrées du dossier de racine, triées par nom ; aucune quand il manque. +async function entreesDe(fs, racine, dossier) { + try { + return [...(await fs.lister(racine, dossier))].sort((a, b) => comparerTextes(a.nom, b.nom)); + } catch (erreur) { + if (erreur.code === 'ABSENT') return []; + throw erreur; + } +} + +// Texte du fichier chemin, ou null quand il manque. +async function texteSiPresent(fs, racine, chemin) { + try { + return await fs.lireTexte(racine, chemin); + } catch (erreur) { + if (erreur.code === 'ABSENT') return null; + throw erreur; + } +} + +// Un fichier d'un format plus récent ne se réécrit sous aucun nom (§ 8.8). +function exigerFormatCourant(formatPlusRecent) { + if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' }); +} + +// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa +// révision au journal ; sans elle — un état sans journal, ou que le journal +// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à +// l'entrée de reprise de cet état. TypeError quand il le faut et qu'il manque. +function libelleDeLInstant({ journal, revision }, libelleReprise) { + const libelle = journal?.entrees.find((entree) => entree.revision === revision)?.libelle ?? libelleReprise; + if (typeof libelle !== 'string' || libelle === '') { + throw new TypeError(`copier : la révision ${revision} n'a pas d'entrée au journal, libelleReprise requis`); + } + return libelle; +} + +/** + * Copie l'événement ouvert dans son dossier (§ 8.7) : un événement neuf, + * d'identifiant id et de nom nom, que le logiciel demande avant de créer. + * Sa charge est celle de l'Ouvert ; son état se déduit de son contenu + * (etatDeduit), jamais bloqué ; sa filiation inscrit la source, son + * identifiant et son nom, et l'instant copié, la révision de l'Ouvert et le + * libellé de son entrée au journal. L'historique ne se copie pas : creer + * écrit un journal d'une entrée, en instantané, au libellé libelle, et son + * jalon nomJalon, puis l'état ; sa base se dérive du nom. L'original n'est + * ni lu ni écrit : la copie part de l'Ouvert, en lecture comme en écriture. + * + * @param {import('./depot.js').Ouvert} ouvert + * @param {Object} options + * @param {string} options.id identifiant de la copie + * @param {string} options.nom nom de la copie + * @param {string} options.libelle texte figé de son entrée + * @param {string} options.nomJalon nom du jalon de sa création + * @param {string} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM + * @param {string} options.produitVersion la construction qui écrit + * @param {string} [options.libelleReprise] libellé de l'instant copié quand + * sa révision n'a pas d'entrée au journal ; requis alors + * @returns {Promise} l'Ouvert de la copie + * @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'}, sans + * rien écrire ; ce que lève creer + * @throws {TypeError} une valeur que la lecture refuserait, libelleReprise + * manquant quand il est requis, avant toute écriture + */ +export async function copier(ouvert, { id, nom, libelle, nomJalon, horodatage, produitVersion, libelleReprise }) { + exigerFormatCourant(ouvert.formatPlusRecent); + const source = ouvert.charge.evenement; + const instant = { revision: ouvert.revision, libelle: libelleDeLInstant(ouvert, libelleReprise) }; + const charge = canoniser(ouvert.charge); + charge.evenement = { + ...charge.evenement, + id, + nom, + etat: etatDeduit(charge), + filiation: { source: { id: source.id, nom: source.nom }, instant }, + }; + return creer(ouvert.fs, ouvert.racine, charge, { libelle, nomJalon, horodatage, produitVersion }); +} + +// Vrai quand la base que deriver tire du nom, parmi les autres bases, tombe +// sur base à la casse près : rendre base aux bases prises fait alors passer +// la dérivation au rang suivant, ou lever CHEMIN_TROP_LONG quand ce rang ne +// tient pas sous la borne. Sinon les mêmes rangs restent pris, et la +// dérivation ne change pas. Seule deriverBase compare ainsi deux bases. +function tombeSur(deriver, autres, base) { + const libre = deriver(autres); + try { + return deriver([...autres, base]) !== libre; + } catch (erreur) { + if (erreur.code === 'CHEMIN_TROP_LONG') return true; + throw erreur; + } +} + +// Supprime le fichier chemin de racine. Rend vrai quand il n'y est plus, et +// faux quand le système de fichiers refuse de l'effacer, par une +// ErreurStockage, qui ne lève pas. Toute autre erreur remonte. +async function effacerSiPossible(fs, racine, chemin) { + try { + await fs.supprimer(racine, chemin); + return true; + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + return false; + } +} + +// Efface ce que la recopie a écrit sous base avant un refus : le journal +// quand journal le dit, puis le .precedent, et lui seulement une fois le +// journal parti. Un journal resté garde ainsi son .precedent, qui le tient à +// la nouvelle base : sans lui, l'ancien état pourrait s'apparier ce journal +// (apparier, dans le dépôt). +async function effacerRecopie(fs, racine, base, { journal }) { + if (journal && !(await effacerSiPossible(fs, racine, base + SUFFIXES.journal))) return; + await effacerSiPossible(fs, racine, base + SUFFIXES.precedent); +} + +// La nouvelle paire de l'Ouvert, que le dépôt écrit dans son dossier sous la +// base que dérive le nom de la charge écrite (enregistrerAilleurs, avec le +// geste d'options) : l'état d'avant au .precedent, puis le journal entier +// suivi de l'entrée du geste, puis l'état. Le dossier ne se crée pas : un +// dossier de travail disparu ne renaît pas (§ 8.6). Le .precedent, écrit +// avant le journal, fait de la nouvelle base celle de l'événement : le +// journal neuf ne s'apparie qu'à elle, jamais à l'ancien état, que le journal +// de l'ancienne base soit le sien ou non, et lister le tait tant que l'ancien +// état se lit. +// +// Un refus avant l'état laisse l'ancienne paire seule à porter l'événement, +// et ce que la recopie a écrit avant lui s'efface (effacerRecopie) : sur un +// journal refusé, que nomme le chemin de l'ECRITURE, le .precedent ; sur un +// état refusé, le journal, puis le .precedent. L'ECRITURE d'un journal +// remonte telle quelle ; le refus de l'état remonte en ECRITURE {chemin, +// dossier, cause}, sans Ouvert : celui de l'appelant décrit encore le disque. +// Toute autre erreur remonte telle quelle. +async function ecrireNouvellePaire(ouvert, options) { + const { fs, racine } = ouvert; + try { + return await enregistrerAilleurs(ouvert, racine, { ...options, existantes: [], creerRacine: false }); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + if (erreur.code === 'ETAT_NON_ECRIT') { + const { chemin, dossier, cause, ouvert: neuve } = erreur.details; + await effacerRecopie(fs, racine, neuve.base, { journal: true }); + throw new ErreurStockage('ECRITURE', { chemin, dossier, cause }); + } + const { chemin } = erreur.details; + if (erreur.code === 'ECRITURE' && chemin.endsWith(SUFFIXES.journal)) { + await effacerRecopie(fs, racine, chemin.slice(0, -SUFFIXES.journal.length), { journal: false }); + } + throw erreur; + } +} + +// Supprime l'ancienne paire de l'Ouvert, la nouvelle étant complète : son +// état d'abord, sans lequel lister ne la montre plus, la nouvelle portant +// son identifiant ; puis son .precedent et le journal de l'événement +// (baseJournal), jamais celui d'un autre ; un fichier absent n'est pas une +// faute. Une ErreurStockage reçoit dans ses détails ouvert, l'Ouvert de la +// nouvelle paire, d'où continuer, puis remonte : celui que tient l'appelant +// décrit l'ancienne, et un geste parti de lui ferait diverger deux paires du +// même événement. +async function supprimerAncienne(ouvert, nouvelle) { + const { fs, racine, base, baseJournal } = ouvert; + const chemins = [base + SUFFIXES.etat, base + SUFFIXES.precedent]; + if (baseJournal !== null) chemins.push(baseJournal + SUFFIXES.journal); + try { + for (const chemin of chemins) await fs.supprimer(racine, chemin); + } catch (erreur) { + if (erreur instanceof ErreurStockage) erreur.details.ouvert = nouvelle; + throw erreur; + } +} + +/** + * Renomme l'événement ouvert (§ 8.7) : nom devient le nom de sa charge, par + * un geste au libellé libelle. La base du nouveau nom se dérive, libre dans + * le dossier. Quand elle tomberait sur la base de l'événement, ses fichiers + * mis à part — un nom qui ne diffère que par un caractère que Windows + * refuse, ou par la casse —, le renommage est ce seul geste, en place + * (enregistrer). Sinon, le logiciel écrit d'abord la paire complète sous la + * nouvelle base — l'état d'avant au .precedent, le journal recopié suivi de + * l'entrée du renommage, puis l'état —, puis supprime l'ancienne : son état, + * son .precedent et le journal de l'événement, jamais l'inverse. Rien de + * l'ancienne paire ne s'écrit, et le .precedent neuf, posé avant le journal + * neuf, tient ce journal à la nouvelle base : quel que soit le journal de + * l'ancienne base — le sien, aucun pour un état importé, un illisible, celui + * d'un autre événement, le sien rangé sous une autre base —, l'ancienne + * paire, tant qu'elle existe, s'ouvre sous l'ancien nom, à sa révision, ses + * octets intacts. Une coupure laisse l'ancienne paire, les deux, ou la + * nouvelle, jamais aucune. Le journal d'un autre événement, que porterait + * l'ancienne base, ne se touche pas ; le verrou non plus, que la séance + * déplace. + * + * Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase + * les retire aussi —, ne touche à rien : aucune entrée, aucune recopie, + * aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait + * aujourd'hui une autre base que la sienne. Une entrée est exactement ce qui + * change un fichier d'événement (§ 8.2), et garder son nom n'est pas un geste. + * + * Un refus avant que la nouvelle paire soit complète laisse l'ancienne + * intacte, et l'Ouvert reçu d'où continuer ; ce que la recopie a écrit + * s'efface quand le support le permet (ecrireNouvellePaire). Un refus + * pendant la suppression de l'ancienne porte dans ses détails ouvert, + * l'Ouvert de la nouvelle paire, d'où continuer : l'Ouvert reçu ne décrit + * plus que des fichiers en partance. + * + * @param {import('./depot.js').Ouvert} ouvert en écriture + * @param {Object} options + * @param {string} options.nom + * @param {string} options.libelle texte figé de l'entrée + * @param {string} options.horodatage + * @param {string} options.produitVersion + * @param {string} [options.libelleReprise] texte de l'entrée de reprise, + * requis quand le journal ne finit pas sur la charge ouverte + * @returns {Promise} l'Ouvert renommé, sous la + * base que le nom dérive ; l'Ouvert reçu quand le nom ne change pas + * @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison: + * 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand + * le dossier n'existe plus ; en place, ce que lève enregistrer ; + * ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de + * l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin, + * dossier, cause, ouvert} de la suppression de l'ancienne + * @throws {TypeError} une valeur que la lecture refuserait, avant toute + * écriture + */ +export async function renommer(ouvert, { nom, libelle, horodatage, produitVersion, libelleReprise }) { + exigerFormatCourant(ouvert.formatPlusRecent); + if (nom.trim() === ouvert.charge.evenement.nom.trim()) return ouvert; + const { fs, racine, base } = ouvert; + const autres = (await basesPrises(fs, racine)).filter((autre) => autre !== base); + const { separateur } = await fs.emplacements(); + const deriver = (existantes) => deriverBase(nom, { racine: racine.chemin, separateur, existantes }); + const apres = { ...ouvert.charge, evenement: { ...ouvert.charge.evenement, nom } }; + if (tombeSur(deriver, autres, base)) { + return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise }); + } + const geste = { apres, libelle, produitVersion }; + const nouvelle = await ecrireNouvellePaire(ouvert, { geste, libelleReprise, horodatage }); + await supprimerAncienne(ouvert, nouvelle); + return nouvelle; +} + +// Décodage strict : une suite d'octets qui n'est pas de l'UTF-8 lève, et la +// marque d'ordre d'octets reste dans le texte. Le texte décodé se réencode +// ainsi en UTF-8 sur exactement les octets reçus. +const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +// Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8 +// ne sont pas un texte JSON : ETAT_ILLISIBLE, raison JSON, comme l'analyse le +// lèverait d'un texte qui ne se lit pas. +function decoder(octets) { + if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array'); + try { + return UTF8_STRICT.decode(octets); + } catch { + throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'JSON', chemin: null }); + } +} + +// Écrit dans racine l'événement id, nom : texteJournal, quand il est donné, +// puis texteEtat, tels quels, sous une base libre que nom dérive. Rend cette +// base. IDENTIFIANT_PRESENT quand un état lisible du dossier porte déjà id. +async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) { + const present = (await lister(fs, racine)).find((resume) => resume.illisible === null && resume.id === id); + if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base }); + const { separateur } = await fs.emplacements(); + const existantes = await basesPrises(fs, racine); + const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes }); + if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal); + await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat); + return base; +} + +/** + * Importe un fichier d'état venu d'ailleurs (§ 8.7) : ses octets, lus en + * UTF-8, se copient tels quels — marque d'ordre d'octets, fins de ligne et + * mise en page comprises — sous une base libre que dérive le nom qu'il + * porte. Aucun journal ne s'écrit, et rien ne se convertit : un fichier d'un + * format plus récent, que l'analyse lit réduit à ce qu'elle connaît, s'ouvre + * ensuite en lecture seule. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {Uint8Array} octets + * @returns {Promise<{base: string}>} + * @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de + * l'analyse, raison JSON pour des octets hors UTF-8 ; ni base ni + * secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id, + * base} quand un état lisible du dossier porte son identifiant ; + * CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de + * l'état + * @throws {TypeError} des octets qui ne sont pas un Uint8Array + */ +export async function importer(fs, racine, octets) { + const texte = decoder(octets); + const { id, nom } = analyser(texte).charge.evenement; + return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) }; +} + +/** + * Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base + * de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie + * se copient tels quels dans vers, le journal d'abord, sous une base libre + * que dérive le nom que porte l'état. Le .precedent ne se copie pas. Les + * originaux restent où ils sont, sans qu'aucune écriture ne touche depuis. + * Une base qui ne s'importe pas se refuse par son code — ETAT_ILLISIBLE, + * ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les suivantes ; + * l'écriture refusée de son état laisse dans vers le journal déjà écrit. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} depuis + * @param {import('./systeme_fichiers.js').Racine} vers + * @param {Iterable} bases bases de depuis + * @returns {Promise<{importes: Array<{base: string, nouvelle: string}>, refuses: Array<{base: string, code: string}>}>} + * importes : chaque base importée et sa base dans vers ; refuses : + * chaque base refusée et le code de son refus ; l'un et l'autre + * dans l'ordre des bases reçues. Une erreur qui n'est pas une + * ErreurStockage remonte telle quelle + */ +export async function importerDepuis(fs, depuis, vers, bases) { + const importes = []; + const refuses = []; + for (const base of bases) { + try { + const { texteEtat, baseJournal } = await ouvrir(fs, depuis, base); + const { id, nom } = analyser(texteEtat).charge.evenement; + const texteJournal = + baseJournal === null ? null : await texteSiPresent(fs, depuis, baseJournal + SUFFIXES.journal); + importes.push({ base, nouvelle: await deposer(fs, vers, { id, nom, texteEtat, texteJournal }) }); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + refuses.push({ base, code: erreur.code }); + } + } + return { importes, refuses }; +} + +// Base du journal de l'événement de base : celle qu'ouvrir lui apparie quand +// son état se lit ; sinon celle que lister lui apparie par l'identifiant de +// son .precedent, ou du journal sans état, le résumé de base se trouvant +// comme un nom de fichier (nomSurDisque) ; null quand aucun ne lui revient. +// Le journal d'un autre événement n'est jamais le sien. Sur un système qui +// ignore la casse, la base rendue peut différer par la casse du nom du +// journal sur le disque. +async function baseDuJournal(fs, racine, base, insensibleCasse) { + try { + return (await ouvrir(fs, racine, base)).baseJournal; + } catch (erreur) { + if (erreur.code === 'ABSENT') return null; + if (erreur.code !== 'ETAT_ILLISIBLE') throw erreur; + } + const resumes = await lister(fs, racine); + const trouvee = nomSurDisque(resumes.map((resume) => resume.base), base, insensibleCasse); + return resumes.find((resume) => resume.base === trouvee)?.journal ?? null; +} + +/** + * Supprime l'événement de base (§ 8.7) : ses fichiers passent dans un + * dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille), + * créé d'abord — _2, _3… quand la seconde a déjà le sien. Partent l'état, le + * .precedent, le journal de l'événement (baseDuJournal), jamais celui d'un + * autre, le verrou, et chacun de leurs fichiers d'écriture atomique restés ; + * ceux-ci d'abord, puis le verrou, le journal, le .precedent, et l'état en + * dernier. Sur un système de fichiers qui ignore la casse, un fichier se + * reconnaît à la clé de son nom, la majuscule de sa NFC, comme deriverBase + * compare les bases, et part sous son nom sur le disque : un journal, un + * .precedent ou un verrou d'une autre casse est le même fichier. Sur un + * système sensible à la casse, seul le nom exact compte. Chaque déplacement + * porte un fichier entier : une coupure laisse + * chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se + * liste encore tant que son état n'est pas parti. Rien ne vide la corbeille. + * + * Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un + * dossier de travail qui disparaît pendant la suppression ne renaît pas + * (§ 8.6). Disparu avant la création du dossier daté, il fait lever + * ECRITURE, cause ENOENT ; après, le déplacement du premier fichier qui n'est + * plus là lève ABSENT. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {string} base + * @param {{horodatage: string}} options AAAA-MM-JJTHH:MM:SS±HH:MM + * @returns {Promise<{dossier: string}>} chemin relatif du dossier daté + * @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun + * fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ; + * ECRITURE de la création du dossier daté ou d'un déplacement + * refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît + * pendant les déplacements + * @throws {TypeError} un horodatage hors de sa forme, sans rien écrire + */ +export async function supprimer(fs, racine, base, { horodatage }) { + const { insensibleCasse } = await fs.emplacements(); + const journal = await baseDuJournal(fs, racine, base, insensibleCasse); + const fichiers = [ + base + SUFFIXES.verrou, + ...(journal === null ? [] : [journal + SUFFIXES.journal]), + base + SUFFIXES.precedent, + base + SUFFIXES.etat, + ]; + const presents = (await fs.lister(racine, '')) + .filter(({ type }) => type === 'fichier') + .map(({ nom }) => nom) + .sort(comparerTextes); + const partants = [...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers] + .map((nom) => nomSurDisque(presents, nom, insensibleCasse)) + .filter((nom) => nom !== null); + if (partants.length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat }); + const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom); + const dossier = dossierCorbeille(horodatage, existants); + await fs.creerDossier(racine, dossier); + for (const nom of partants) await fs.deplacer(racine, nom, `${dossier}/${nom}`); + return { dossier }; +} + +/** + * Le contenu de la corbeille de racine (§ 8.7), sans rien écrire : un + * élément par dossier daté, son chemin relatif et les noms de ses fichiers, + * les dossiers et les fichiers triés par nom. Une corbeille absente est + * vide ; ce qui n'est pas un dossier daté ne s'y liste pas. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @returns {Promise>} + */ +export async function listerCorbeille(fs, racine) { + const contenu = []; + for (const { nom, type } of await entreesDe(fs, racine, DOSSIER_CORBEILLE)) { + if (type !== 'dossier') continue; + const dossier = `${DOSSIER_CORBEILLE}/${nom}`; + const entrees = await entreesDe(fs, racine, dossier); + const fichiers = entrees.filter((entree) => entree.type === 'fichier').map((entree) => entree.nom); + contenu.push({ dossier, fichiers }); + } + return contenu; +} + +/** + * Charge une démonstration livrée (§ 15) : un événement neuf, d'identifiant + * id, nommé d'après la démonstration suivie d'un rang, « ‹nom› N », N le + * premier rang libre à partir de 1 parmi les noms des événements lisibles du + * dossier. Les noms se comparent en NFC et, quand le système de fichiers + * ignore la casse, par leur majuscule, comme deriverBase compare les bases. + * Son état se déduit de son contenu ; creer l'écrit, journal d'une entrée au + * libellé libelle et son jalon nomJalon, sous une base que le nom dérive. Le + * texte livré ne fait que se lire ; ni lui ni l'événement courant ne + * changent. Le mode de l'événement chargé, la lecture, appartient à la + * séance. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {string} texteLivre le fichier d'état livré + * @param {{id: string, libelle: string, nomJalon: string, horodatage: string, produitVersion: string}} options + * @returns {Promise} + * @throws {ErreurStockage} ETAT_ILLISIBLE de l'analyse ; LECTURE_SEULE + * {raison: 'FORMAT_PLUS_RECENT'} ; l'un et l'autre sans rien écrire ; + * ce que lève creer + */ +export async function chargerDemonstration( + fs, + racine, + texteLivre, + { id, libelle, nomJalon, horodatage, produitVersion }, +) { + const { charge, formatPlusRecent } = analyser(texteLivre); + exigerFormatCourant(formatPlusRecent); + const { insensibleCasse } = await fs.emplacements(); + const cle = (nom) => (insensibleCasse ? cleSansCasse(nom) : nom.normalize('NFC')); + const pris = new Set( + (await lister(fs, racine)).filter((resume) => resume.nom !== null).map((resume) => cle(resume.nom)), + ); + let rang = 1; + while (pris.has(cle(`${charge.evenement.nom} ${rang}`))) rang += 1; + const evenement = { ...charge.evenement, id, nom: `${charge.evenement.nom} ${rang}`, etat: etatDeduit(charge) }; + return creer(fs, racine, { ...charge, evenement }, { libelle, nomJalon, horodatage, produitVersion }); +} diff --git a/src/stockage/operations.test.js b/src/stockage/operations.test.js new file mode 100644 index 0000000..5b6c4f6 --- /dev/null +++ b/src/stockage/operations.test.js @@ -0,0 +1,1554 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des opérations sur les événements (§ 8.6, § 8.7, § 15), toutes sur +// le système de fichiers d'épreuve, chacune traçant ou comptant ses +// écritures : copier, renommer, importer un fichier d'état ou les paires de +// l'autre emplacement, supprimer vers une corbeille datée, la lister, charger +// une démonstration livrée. Une coupure à chaque écriture d'un renommage ou +// d'une suppression laisse le dossier sans perte. 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 { texteLivre } from '../demo/livrees.js'; +import { VERSION } from '../version.genere.js'; +import { serialiser, serialiserCharge } from './canonique.js'; +import { creer, enregistrer, lister, ouvrir, ouvrirSecours, verrouiller } from './depot.js'; +import { analyser, creerCharge } from './document.js'; +import { ErreurStockage } from './erreurs.js'; +import { lireJournal, reconstruire } from './journal.js'; +import { SUFFIXES, deriverBase } from './noms.js'; +import { + chargerDemonstration, + copier, + importer, + importerDepuis, + listerCorbeille, + renommer, + supprimer, +} from './operations.js'; + +const V1 = VERSION.affichee; + +// 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"; +const UTF8 = new TextEncoder(); + +// Le texte tel que l'écrit un éditeur réglé en Windows-1252 : un octet par +// caractère, « é » donnant E9, qui n'est pas de l'UTF-8. Le texte ne porte +// que des caractères que Windows-1252 code comme ISO 8859-1, sur leur point +// de code. +function enWindows1252(texte) { + const points = Array.from(texte, (caractere) => caractere.codePointAt(0)); + assert.ok(points.every((point) => point < 0x80 || (point >= 0xa0 && point <= 0xff))); + return Uint8Array.from(points); +} + +// 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`; +} + +// La suppression tombe à 8 h 30, et son dossier daté. +const HEURE_SUPPRESSION = heure(30); +const DATE = 'corbeille/2031-05-17_08-30-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 } }); + +// Une proposition de deux tours sur les tables 1 et 2, deux places chacune. +function proposition(id) { + 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: [[1, 3], [2, 4]], reserve: [] }, + { sieges: [[1, 4], [2, 3]], reserve: [] }, + ], + }; +} + +// Le retenu tiré d'une proposition, ses champs d'identification retirés. +function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement }) { + return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement }; +} + +// Charge d'épreuve : quatre personnes, deux tables, une réservation, un titre, +// une proposition. +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 = [proposition(1)]; + return charge; +} + +// La charge reçue, la note de sa première personne changée : un geste. +function noter(charge, n) { + const suivante = structuredClone(charge); + suivante.participants[0].notes = `note ${n}`; + return suivante; +} + +// La charge reçue, son plan bloqué. +function bloquer(charge) { + const suivante = structuredClone(charge); + suivante.evenement.etat = 'bloque'; + return suivante; +} + +const texteDeCharge = (charge) => serialiserCharge(charge); +const etatTexte = (charge, revision = 1) => serialiser(charge, { revision, produitVersion: V1 }); + +// Le fichier d'état de charge au format 2 : l'événement porte une clé que le +// format 1 ne connaît pas ; sansNom lui retire son nom. +function formatDeux(charge, { sansNom = false } = {}) { + const fichier = JSON.parse(etatTexte(charge)); + fichier.entete.format = 2; + fichier.charge.evenement.couleur = 'ocre'; + if (sansNom) delete fichier.charge.evenement.nom; + return `${JSON.stringify(fichier, null, 2)}\n`; +} + +// --- 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]. +function disqueAvec(fichiers, options = {}) { + const { disque, fs } = nouveauDisque(options); + for (const [nom, texte] of fichiers) fs.deposer('documents', nom, texte); + return { disque, fs }; +} + +const parChemin = ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0); + +// Fichiers de racine à toute profondeur, [chemin, texte] triés par chemin, +// lus par un processus à part, que reglages décrit — l'exécutable publié pour +// la racine portable. +async function arbre(disque, racine = DOC, reglages = {}) { + const lecteur = creerFichiersSimules({ disque, ...reglages }); + const fichiers = []; + const parcourir = async (dossier) => { + for (const { nom, type } of await lecteur.lister(racine, dossier)) { + const chemin = dossier === '' ? nom : `${dossier}/${nom}`; + if (type === 'dossier') await parcourir(chemin); + else fichiers.push([chemin, lecteur.contenu(racine.id, chemin)]); + } + }; + await parcourir(''); + return fichiers.sort(parChemin); +} + +// Ouvre base par un processus neuf, en affirmant qu'ouvrir n'écrit rien. +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], ou [primitive, de, vers] pour deplacer. +function tracer(fs) { + const trace = []; + for (const nom of ['ecrireAtomique', 'ajouterLigne', 'creerDossier', 'deplacer', 'supprimer']) { + const primitive = fs[nom]; + fs[nom] = (racine, ...reste) => { + trace.push([nom, ...reste.slice(0, nom === 'deplacer' ? 2 : 1)]); + return primitive(racine, ...reste); + }; + } + return trace; +} + +// Efface du disque le dossier documents et tout ce qu'il porte juste avant +// la primitive d'écriture de rang n du processus, la première pour n = 0, +// comme un dossier de travail supprimé dans l'explorateur, ou retiré par un +// agent de synchronisation, en cours de séance : sa liste lève ensuite +// ABSENT, et une écriture ECRITURE, cause ENOENT. Rend un témoin dont efface +// devient vrai quand l'effacement a lieu. +function effacerDocumentsAvant(disque, fs, n) { + const temoin = { efface: false }; + const nom = DOCUMENTS.slice(DOCUMENTS.lastIndexOf('\\') + 1); + let rang = 0; + for (const primitive of ['ecrireAtomique', 'ajouterLigne', 'creerDossier', 'deplacer', 'supprimer']) { + const appeler = fs[primitive]; + fs[primitive] = (...parametres) => { + if (rang === n) { + const [cle] = [...disque].find(([, noeud]) => noeud.type === 'dossier' && noeud.nom === nom); + for (const autre of [...disque.keys()]) { + if (autre === cle || autre.startsWith(`${cle}\\`)) disque.delete(autre); + } + temoin.efface = true; + } + rang += 1; + return appeler(...parametres); + }; + } + return temoin; +} + +// Rend la raison du rejet, une ErreurStockage du code attendu ; message nomme +// le cas d'une boucle. +async function echec(promesse, code, message = '') { + let raison; + await assert.rejects( + promesse, + (erreur) => { + raison = erreur; + return true; + }, + message, + ); + assert.ok(raison instanceof ErreurStockage, `${message} ${String(raison)}`); + assert.equal(raison.code, code, `${message} ${String(raison)}`); + return raison; +} + +// 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. +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 de deux révisions sur un disque neuf : créé, puis noté ; le +// .precedent porte la révision 1. +async function deuxRevisions(charge = chargeEssai(), options = {}) { + const { disque, fs } = nouveauDisque(options); + let ouvert = await creer(fs, DOC, charge, CREATION); + ouvert = await enregistrer(ouvert, noter(ouvert.charge, 2), geste(2)); + return { disque, fs, ouvert }; +} + +// Les textes de l'état et du journal d'un événement de deux révisions, créé +// sur un disque à part. +async function paireDe(charge) { + const { fs, ouvert } = await deuxRevisions(charge); + return { + etat: fs.contenu('documents', `${ouvert.base}${SUFFIXES.etat}`), + journal: fs.contenu('documents', `${ouvert.base}${SUFFIXES.journal}`), + }; +} + +// Chaque événement que lister montre après la coupure d'un renommage de +// BASE en nouvelle, rouvert par un processus neuf, sans rien écrire. depart +// décrit l'ancienne paire : son identifiant, sa révision, ses avertissements, +// et la première révision que la nouvelle garde de son historique. Un +// événement lisible est l'ancienne paire, sous l'ancien nom, à sa révision, +// aux mêmes avertissements — aucun journal neuf ne lui revient, rattrapé ou +// non —, ou la nouvelle, sous le nouveau nom, à la révision qui suit, sans +// avertissement, son journal partant de cette première révision ; le journal +// de chacun restitue sa charge. Ce qui ne se lit pas n'est qu'un journal +// resté sous l'ancienne base, illisible ou d'un autre événement, jamais +// celui-ci sans état. Rend [base, nom, révision] de chaque événement lisible. +async function evenementsApresCoupure(disque, nouvelle, depart, message) { + const fs = creerFichiersSimules({ disque }); + const lisibles = []; + for (const { base, id, illisible } of await lister(fs, DOC)) { + if (illisible !== null) { + assert.deepEqual([base, illisible], [BASE, 'ABSENT'], `${message} : ${base} ne se lit pas`); + assert.notEqual(id, depart.id, `${message} : l'événement se liste sans état sous ${base}`); + continue; + } + const ouvert = await ouvrir(fs, DOC, base); + const ancienne = base === BASE; + assert.deepEqual( + [base, ouvert.charge.evenement.nom, ouvert.revision, ouvert.avertissements], + ancienne ? [BASE, BASE, depart.revision, depart.avertissements] : [nouvelle, nouvelle, depart.revision + 1, []], + message, + ); + if (!ancienne) { + assert.notEqual(ouvert.journal, null, `${message} : ${base} n'a pas de journal`); + assert.equal(ouvert.journal.entrees[0].revision, depart.premiere, `${message} : ${base} a perdu son historique`); + } + if (ouvert.journal !== null) { + assert.equal( + texteDeCharge(reconstruire(ouvert.journal, ouvert.revision)), + texteDeCharge(ouvert.charge), + `${message} : le journal de ${base} ne restitue pas sa charge`, + ); + } + lisibles.push([base, ouvert.charge.evenement.nom, ouvert.revision]); + } + assert.equal(fs.compteurs.ecritures, 0, `${message} : lister ou ouvrir écrit`); + return lisibles; +} + +// Les options d'une copie nommée nom. +const optionsCopie = (nom, plus = {}) => ({ + id: 'evt-variante', + nom, + libelle: `Créée par copie de ${BASE}`, + nomJalon: 'Création', + horodatage: heure(10), + produitVersion: V1, + ...plus, +}); + +// Les options d'un renommage en nom à l'instant n, au libellé « Renommé ». +const renommage = (nom, n, plus = {}) => ({ + nom, + libelle: 'Renommé', + horodatage: heure(n), + produitVersion: V1, + ...plus, +}); + +// Les options du chargement d'une démonstration sous l'identifiant id, à +// l'instant n, au libellé « Chargée ». +const chargement = (id, n) => ({ + id, + libelle: 'Chargée', + nomJalon: 'Création', + horodatage: heure(n), + produitVersion: V1, +}); + +describe('opérations : copier (§ 8.7)', () => { + test("copier un plan bloqué : identité et nom reçus, journal d'une entrée en instantané et son jalon, filiation de l'instant copié, état déduit du contenu ; l'original reste octet pour octet", async () => { + const avecRetenu = chargeEssai(); + avecRetenu.retenu = retenuDe(proposition(1)); + const sansProposition = chargeEssai(); + sansProposition.propositions = []; + // Par cas : la charge d'origine, que le geste 2 bloque, et l'état que son + // contenu impose à la copie. + const cas = [ + ['un retenu', avecRetenu, 'retenu'], + ['des propositions', chargeEssai(), 'propose'], + ['ni proposition ni retenu', sansProposition, 'brouillon'], + ]; + const variante = 'Variante des Lucioles'; + for (const [nom, depart, etat] of cas) { + const { disque, fs } = nouveauDisque(); + let ouvert = await creer(fs, DOC, depart, CREATION); + ouvert = await enregistrer(ouvert, bloquer(ouvert.charge), { ...geste(2), libelle: 'Plan bloqué' }); + const avant = await arbre(disque); + const trace = tracer(fs); + const options = optionsCopie(variante, { libelle: `Créée par copie de ${BASE}, à l'instant « Plan bloqué »` }); + const copie = await copier(ouvert, options); + assert.deepEqual( + trace, + [ + ['ecrireAtomique', `${variante}${SUFFIXES.journal}`], + ['ecrireAtomique', `${variante}${SUFFIXES.etat}`], + ], + nom, + ); + assert.deepEqual( + (await arbre(disque)).filter(([chemin]) => !chemin.startsWith(variante)), + avant, + nom, + ); + + const attendue = structuredClone(ouvert.charge); + attendue.evenement = { + ...attendue.evenement, + id: 'evt-variante', + nom: variante, + etat, + filiation: { source: { id: 'evt-lucioles', nom: BASE }, instant: { revision: 2, libelle: 'Plan bloqué' } }, + }; + assert.equal(texteDeCharge(copie.charge), texteDeCharge(attendue), nom); + assert.deepEqual([copie.base, copie.revision, copie.avertissements], [variante, 1, []], nom); + const journal = lireJournal(fs.contenu('documents', `${variante}${SUFFIXES.journal}`)); + assert.equal(journal.evenement, 'evt-variante', nom); + assert.deepEqual( + journal.entrees.map(({ revision, libelle, instantane }) => [revision, libelle, instantane === null]), + [[1, options.libelle, false]], + nom, + ); + assert.deepEqual( + journal.jalons.map(({ revision, nom: jalon }) => [revision, jalon]), + [[1, 'Création']], + nom, + ); + exigerEquivalence(fs, variante); + const rouverte = await rouvrir(disque, variante); + assert.equal(texteDeCharge(rouverte.charge), texteDeCharge(attendue), nom); + assert.deepEqual(rouverte.avertissements, [], nom); + } + }); + + test("la filiation nomme l'instant que l'Ouvert restitue : l'entrée de sa révision, celle d'un secours ; sans entrée, le libellé de reprise, requis avant toute écriture ; une base prise se dérive", async () => { + const { disque, fs, ouvert } = await deuxRevisions(); + await enregistrer(ouvert, noter(ouvert.charge, 3), geste(3)); + const secours = await ouvrirSecours(fs, DOC, BASE, 'precedent'); + assert.equal(secours.revision, 2); + const copie = await copier(secours, optionsCopie(BASE)); + assert.deepEqual(copie.charge.evenement.filiation, { + source: { id: 'evt-lucioles', nom: BASE }, + instant: { revision: 2, libelle: 'Geste 2' }, + }); + assert.equal(copie.charge.participants[0].notes, 'note 2'); + assert.equal(copie.base, `${BASE} (2)`); + assert.equal(fs.contenu('documents', ETAT), etatTexte(noter(noter(chargeEssai(), 2), 3), 3)); + assert.ok((await arbre(disque)).some(([chemin]) => chemin === `${BASE} (2)${SUFFIXES.journal}`)); + + // Un état sans journal, à la révision 4 : son instant n'a pas d'entrée. + const seul = disqueAvec([[ETAT, etatTexte(chargeEssai(), 4)]]); + const sansJournal = await rouvrir(seul.disque, BASE); + await assert.rejects(copier(sansJournal, optionsCopie('Variante des Lucioles')), TypeError); + await assert.rejects(copier(sansJournal, optionsCopie('', { libelleReprise: REPRISE })), TypeError); + assert.equal(sansJournal.fs.compteurs.ecritures, 0); + const reprise = await copier(sansJournal, optionsCopie('Variante des Lucioles', { libelleReprise: REPRISE })); + assert.deepEqual(reprise.charge.evenement.filiation.instant, { revision: 4, libelle: REPRISE }); + }); +}); + +describe('opérations : renommer (§ 8.7)', () => { + test("renommer écrit d'abord la paire complète sous la nouvelle base — l'état d'avant au .precedent, le journal recopié avec l'entrée du renommage, puis l'état —, sans écrire l'ancienne ni créer le dossier, puis supprime l'ancienne : état, .precedent, journal ; le verrou reste à la séance, et le geste suivant continue sous la nouvelle base", async () => { + const { disque, fs, ouvert } = await deuxRevisions(); + assert.deepEqual(await verrouiller(ouvert, 'seance-a'), { pris: true }); + const verrou = fs.contenu('documents', VERROU); + const etatAvant = fs.contenu('documents', ETAT); + const nouvelle = 'Gala des Saules'; + const options = renommage(nouvelle, 3, { libelle: 'Renommé en « Gala des Saules »' }); + const trace = tracer(fs); + const renomme = await renommer(ouvert, options); + assert.deepEqual(trace, [ + ['ecrireAtomique', `${nouvelle}${SUFFIXES.precedent}`], + ['ecrireAtomique', `${nouvelle}${SUFFIXES.journal}`], + ['ecrireAtomique', `${nouvelle}${SUFFIXES.etat}`], + ['supprimer', ETAT], + ['supprimer', PRECEDENT], + ['supprimer', JOURNAL], + ]); + assert.deepEqual( + (await arbre(disque)).map(([chemin]) => chemin), + [`${nouvelle}${SUFFIXES.journal}`, `${nouvelle}${SUFFIXES.etat}`, `${nouvelle}${SUFFIXES.precedent}`, VERROU], + ); + assert.equal(fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), etatAvant); + assert.equal(fs.contenu('documents', VERROU), verrou); + + const attendue = noter(chargeEssai(), 2); + attendue.evenement.nom = nouvelle; + assert.deepEqual( + [renomme.base, renomme.baseJournal, renomme.revision, renomme.charge.evenement.nom], + [nouvelle, nouvelle, 3, nouvelle], + ); + assert.equal(texteDeCharge(renomme.charge), texteDeCharge(attendue)); + const journal = exigerEquivalence(fs, nouvelle); + assert.equal(journal.evenement, 'evt-lucioles'); + assert.deepEqual(revisionsEtLibelles(journal), [ + [1, CREATION.libelle], + [2, 'Geste 2'], + [3, options.libelle], + ]); + assert.deepEqual( + journal.jalons.map(({ revision, nom }) => [revision, nom]), + [[1, 'Création']], + ); + + const rouvert = await rouvrir(disque, nouvelle); + assert.deepEqual([rouvert.revision, rouvert.avertissements], [3, []]); + const lecteur = creerFichiersSimules({ disque }); + assert.deepEqual( + (await lister(lecteur, DOC)).map(({ base, nom, journal: apparie }) => [base, nom, apparie]), + [[nouvelle, nouvelle, nouvelle]], + ); + + const suite = await enregistrer(renomme, noter(renomme.charge, 4), geste(4)); + assert.deepEqual([suite.base, suite.revision], [nouvelle, 4]); + exigerEquivalence(fs, nouvelle); + }); + + test("une coupure à chaque écriture et à chaque suppression laisse au moins une paire, deux dès que la nouvelle est complète, jamais aucune ; l'ancienne garde ses octets et s'ouvre sous l'ancien nom sans jamais s'apparier le journal neuf, que le journal de sa base soit le sien ou non — état importé sans journal, journal d'un autre événement, journal illisible, journal sous une autre base que le nouveau nom précède — ; achevé, une seule paire, sous la nouvelle base", async () => { + const nouvelle = 'Gala des Saules'; + const etranger = (await paireDe(chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }))).journal; + const sien = await paireDe(chargeEssai()); + const registre = `Registre des Lucioles${SUFFIXES.journal}`; + // Par cas : les fichiers de l'événement de départ, posés sur un disque + // neuf, et la première révision que le journal de la nouvelle paire garde. + const cas = [ + [ + 'un événement de deux révisions', + [ + [ETAT, sien.etat], + [PRECEDENT, etatTexte(chargeEssai())], + [JOURNAL, sien.journal], + ], + 1, + ], + ['un état importé, sans journal', [[ETAT, etatTexte(chargeEssai(), 4)]], 4], + ["un journal de la base d'un autre événement", [[ETAT, etatTexte(chargeEssai())], [JOURNAL, etranger]], 1], + ['un journal de la base illisible', [[ETAT, etatTexte(chargeEssai(), 4)], [JOURNAL, '{"type":"jour']], 4], + ['un journal sous une autre base, après le nouveau nom', [[ETAT, sien.etat], [registre, sien.journal]], 1], + ]; + for (const [nom, poses, premiere] of cas) { + let deuxPaires = 0; + for (let n = 0; ; n += 1) { + const { disque, fs } = disqueAvec(poses); + const ouvert = await ouvrir(fs, DOC, BASE); + const { revision, avertissements } = ouvert; + const depart = { id: 'evt-lucioles', revision, avertissements, premiere }; + const avant = new Map(await arbre(disque)); + fs.pannes.couperApres(n); + let acheve = true; + try { + await renommer(ouvert, renommage(nouvelle, 5, { libelleReprise: REPRISE })); + } 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`; + // Rien de ce que le dossier portait ne se réécrit : chaque fichier + // reste octet pour octet, ou s'en va ; seule la nouvelle base reçoit. + for (const [chemin, texte] of await arbre(disque)) { + if (avant.has(chemin)) assert.equal(texte, avant.get(chemin), `${message} : ${chemin} réécrit`); + else assert.ok(chemin.startsWith(`${nouvelle}.`), `${message} : ${chemin} écrit`); + } + const lisibles = await evenementsApresCoupure(disque, nouvelle, depart, message); + assert.ok(lisibles.length >= 1, `${message} : aucune paire ne s'ouvre`); + if (lisibles.length === 2) deuxPaires += 1; + if (acheve) { + assert.deepEqual(lisibles, [[nouvelle, nouvelle, revision + 1]], message); + break; + } + } + assert.ok(deuxPaires >= 1, `${nom} : aucune coupure ne laisse les deux paires`); + } + }); + + test("un refus avant que la nouvelle paire soit complète laisse l'ancienne octet pour octet et l'Ouvert reçu d'où continuer — .precedent, journal ou état neufs refusés —, ce que la recopie a écrit s'effaçant quand le support le permet, le journal d'abord, son .precedent seulement après lui ; un refus pendant la suppression de l'ancienne remonte avec l'Ouvert de la nouvelle", async () => { + const nouvelle = 'Gala des Saules'; + const journalNeuf = `${nouvelle}${SUFFIXES.journal}`; + const etatNeuf = `${nouvelle}${SUFFIXES.etat}`; + const precedentNeuf = `${nouvelle}${SUFFIXES.precedent}`; + const refus = (chemin) => new ErreurStockage('ECRITURE', { chemin, dossier: DOCUMENTS, cause: 'EBUSY' }); + // Pièges posés sur le processus : l'écriture atomique, ou la suppression, + // du chemin donné est refusée. + const refuserEcriture = (fs, chemin) => { + const ecrire = fs.ecrireAtomique; + fs.ecrireAtomique = async (racine, cible, texte) => { + if (cible === chemin) throw refus(cible); + return ecrire(racine, cible, texte); + }; + }; + const refuserSuppression = (fs, chemin) => { + const effacer = fs.supprimer; + fs.supprimer = async (racine, cible) => { + if (cible === chemin) throw refus(cible); + return effacer(racine, cible); + }; + }; + // Par cas : le piège, le chemin refusé, la base d'où continuer, les + // fichiers de la nouvelle paire que le dossier porte après le refus, et + // les bases que lister montre alors, chacune avec son drapeau doublon. + const cas = [ + [ + 'le .precedent neuf refusé', + (fs) => refuserEcriture(fs, precedentNeuf), + precedentNeuf, + BASE, + [], + [[BASE, false]], + ], + ['le journal neuf refusé', (fs) => refuserEcriture(fs, journalNeuf), journalNeuf, BASE, [], [[BASE, false]]], + [ + "le journal neuf refusé, et le .precedent neuf qui ne s'efface pas", + (fs) => { + refuserEcriture(fs, journalNeuf); + refuserSuppression(fs, precedentNeuf); + }, + journalNeuf, + BASE, + [precedentNeuf], + [[BASE, false]], + ], + ["l'état neuf refusé", (fs) => refuserEcriture(fs, etatNeuf), etatNeuf, BASE, [], [[BASE, false]]], + [ + "l'état neuf refusé, et le journal neuf qui ne s'efface pas", + (fs) => { + refuserEcriture(fs, etatNeuf); + refuserSuppression(fs, journalNeuf); + }, + etatNeuf, + BASE, + [journalNeuf, precedentNeuf], + [[BASE, false]], + ], + [ + "l'ancien état qui ne se supprime pas", + (fs) => refuserSuppression(fs, ETAT), + ETAT, + nouvelle, + [journalNeuf, etatNeuf, precedentNeuf], + [ + [nouvelle, true], + [BASE, true], + ], + ], + ]; + for (const [nom, pieger, chemin, base, neufs, listees] of cas) { + const { disque, fs, ouvert } = await deuxRevisions(); + const avant = await arbre(disque); + pieger(fs); + const erreur = await echec(renommer(ouvert, renommage(nouvelle, 3)), 'ECRITURE', nom); + const { ouvert: suite = ouvert, ...details } = erreur.details; + assert.deepEqual(details, { chemin, dossier: DOCUMENTS, cause: 'EBUSY' }, nom); + const apres = await arbre(disque); + assert.deepEqual( + apres.filter(([fichier]) => !neufs.includes(fichier)), + avant, + nom, + ); + assert.deepEqual( + apres.map(([fichier]) => fichier).filter((fichier) => neufs.includes(fichier)), + neufs, + nom, + ); + assert.deepEqual( + (await lister(creerFichiersSimules({ disque }), DOC)).map((resume) => [resume.base, resume.doublon]), + listees, + nom, + ); + const attendu = base === BASE ? [BASE, BASE, 2] : [nouvelle, nouvelle, 3]; + assert.deepEqual([suite.base, suite.charge.evenement.nom, suite.revision], attendu, nom); + const avance = await enregistrer(suite, noter(suite.charge, 4), geste(4)); + assert.equal(avance.revision, suite.revision + 1, nom); + assert.deepEqual( + revisionsEtLibelles(exigerEquivalence(fs, base)).slice(-2), + [ + [suite.revision, base === BASE ? 'Geste 2' : 'Renommé'], + [avance.revision, 'Geste 4'], + ], + nom, + ); + const rouvert = await rouvrir(disque, base); + assert.deepEqual( + [rouvert.revision, rouvert.charge.evenement.nom, rouvert.avertissements], + [avance.revision, attendu[1], []], + nom, + ); + } + + // Un état importé, sans journal à sa base : l'état neuf refusé, et le + // journal neuf qui ne s'efface pas. Son .precedent reste avec lui, si + // bien que la nouvelle base appartient à l'événement : l'état importé ne + // s'apparie pas ce journal, et se rouvre sous l'ancien nom, à sa révision. + const importe = disqueAvec([[ETAT, etatTexte(chargeEssai(), 4)]]); + const seul = await ouvrir(importe.fs, DOC, BASE); + refuserEcriture(importe.fs, etatNeuf); + refuserSuppression(importe.fs, journalNeuf); + const refusSeul = await echec(renommer(seul, renommage(nouvelle, 3, { libelleReprise: REPRISE })), 'ECRITURE'); + assert.deepEqual(refusSeul.details, { chemin: etatNeuf, dossier: DOCUMENTS, cause: 'EBUSY' }); + assert.deepEqual( + (await arbre(importe.disque)).map(([fichier]) => fichier), + [journalNeuf, precedentNeuf, ETAT], + ); + const rouvertSeul = await rouvrir(importe.disque, BASE); + assert.deepEqual( + [rouvertSeul.charge.evenement.nom, rouvertSeul.revision, rouvertSeul.journal, rouvertSeul.avertissements], + [BASE, 4, null, []], + ); + + // Le processus coupé pendant l'effacement du journal neuf : la panne + // remonte telle quelle, sans se donner pour un refus d'écriture. + const coupe = await deuxRevisions(); + const ecrire = coupe.fs.ecrireAtomique; + coupe.fs.ecrireAtomique = async (racine, cible, texte) => { + if (cible !== etatNeuf) return ecrire(racine, cible, texte); + coupe.fs.pannes.couperApres(0); + throw refus(cible); + }; + await assert.rejects(renommer(coupe.ouvert, renommage(nouvelle, 3)), PanneSimulee); + }); + + test("un dossier de travail qui disparaît pendant le renommage ne renaît pas : à chaque écriture avant laquelle il disparaît, renommer lève ECRITURE, ENOENT, ou achève, sans jamais créer de dossier", async () => { + const nouvelle = 'Gala des Saules'; + // Rangs où le dossier a disparu : au moins un par primitive que le + // renommage appelle — trois écritures, trois suppressions —, sans quoi le + // balayage n'a rien éprouvé. + let effacements = 0; + for (let n = 0; ; n += 1) { + const { disque, fs, ouvert } = await deuxRevisions(); + const temoin = effacerDocumentsAvant(disque, fs, n); + const trace = tracer(fs); + let refus = null; + try { + await renommer(ouvert, renommage(nouvelle, 3)); + } catch (erreur) { + refus = erreur; + } + const message = `dossier effacé avant l'écriture de rang ${n}`; + const enoent = refus instanceof ErreurStockage && refus.code === 'ECRITURE' && refus.details.cause === 'ENOENT'; + assert.ok(refus === null || enoent, `${message} : ${String(refus)}`); + assert.deepEqual( + trace.filter(([primitive]) => primitive === 'creerDossier'), + [], + message, + ); + if (!temoin.efface) { + assert.equal(refus, null, message); + break; + } + effacements += 1; + await echec(creerFichiersSimules({ disque }).lister(DOC, ''), 'ABSENT', message); + } + assert.ok(effacements >= 6, `${effacements} rangs éprouvés, 6 attendus au moins`); + }); + + test("un nom inchangé, aux blancs de bord près, ne touche à rien : aucune écriture — ni entrée, ni recopie, ni suppression — et l'Ouvert reçu revient, même sous une autre base que celle que le nom dérive", async () => { + const { disque, fs, ouvert } = await deuxRevisions(); + const avant = await arbre(disque); + const ecritures = fs.compteurs.ecritures; + const trace = tracer(fs); + for (const nom of [BASE, ` ${BASE} `, `\t${BASE}\n`]) { + assert.equal(await renommer(ouvert, renommage(nom, 3)), ouvert, JSON.stringify(nom)); + } + assert.deepEqual([trace, fs.compteurs.ecritures], [[], ecritures]); + assert.deepEqual(await arbre(disque), avant); + + // Le nom de la charge porte lui-même des blancs de bord : le même nom + // sans eux ne change rien non plus. + const borde = disqueAvec([[ETAT, etatTexte(chargeEssai({ nom: ` ${BASE} ` }))]]); + const ouvertBorde = await rouvrir(borde.disque, BASE); + const traceBorde = tracer(ouvertBorde.fs); + assert.equal(await renommer(ouvertBorde, renommage(BASE, 3)), ouvertBorde); + assert.deepEqual(traceBorde, []); + + // Créé quand un autre événement tenait la base de son nom, l'événement a + // pris « (2) » ; l'autre parti à la corbeille, son nom dériverait la base + // libérée. Il reste sous « (2) » : seul un geste change un fichier + // (§ 8.2), et garder son nom n'en est pas un. + const gala = 'Gala des Saules'; + const deux = `${gala} (2)`; + const { disque: disqueDeux, fs: second } = nouveauDisque(); + await creer(second, DOC, chargeEssai({ id: 'evt-autre', nom: gala }), CREATION); + let ouvertDeux = await creer(second, DOC, chargeEssai({ nom: gala }), CREATION); + ouvertDeux = await enregistrer(ouvertDeux, noter(ouvertDeux.charge, 2), geste(2)); + assert.equal(ouvertDeux.base, deux); + await supprimer(second, DOC, gala, { horodatage: HEURE_SUPPRESSION }); + const avantDeux = await arbre(disqueDeux); + const ecrituresDeux = second.compteurs.ecritures; + const traceDeux = tracer(second); + assert.equal(await renommer(ouvertDeux, renommage(` ${gala}`, 3)), ouvertDeux); + assert.deepEqual([traceDeux, second.compteurs.ecritures], [[], ecrituresDeux]); + assert.deepEqual(await arbre(disqueDeux), avantDeux); + + // Un état sans journal sous « (2) », de même. + const seul = disqueAvec([[`${deux}${SUFFIXES.etat}`, etatTexte(chargeEssai({ nom: gala }), 2)]]); + const sansJournal = await rouvrir(seul.disque, deux); + const traceSeul = tracer(sansJournal.fs); + assert.equal(await renommer(sansJournal, renommage(gala, 3)), sansJournal); + assert.deepEqual(traceSeul, []); + assert.deepEqual( + (await arbre(seul.disque)).map(([chemin]) => chemin), + [`${deux}${SUFFIXES.etat}`], + ); + }); + + test("un nouveau nom qui dérive vers la base de l'événement, ses fichiers mis à part, se renomme en place : un seul geste, aucune suppression ; une base prise par un autre fichier se dérive", async () => { + // Par cas : le nom de l'événement, son nouveau nom, et le nom d'un autre + // événement créé avant lui, ou null. + const cas = [ + ['un caractère que Windows refuse', BASE, `${BASE}?`, null], + ['la casse seule', BASE, 'SOIRÉE DES LUCIOLES', null], + ['la base « (2) » qu’il occupe déjà', `${BASE}*`, BASE, BASE], + ]; + for (const [nom, ancien, nouveau, autre] of cas) { + const { fs } = nouveauDisque(); + if (autre !== null) await creer(fs, DOC, chargeEssai({ id: 'evt-autre', nom: autre }), CREATION); + const ouvert = await creer(fs, DOC, chargeEssai({ nom: ancien }), CREATION); + const { base } = ouvert; + const trace = tracer(fs); + const renomme = await renommer(ouvert, renommage(nouveau, 2)); + assert.deepEqual( + trace, + [ + ['ajouterLigne', `${base}${SUFFIXES.journal}`], + ['ecrireAtomique', `${base}${SUFFIXES.precedent}`], + ['ecrireAtomique', `${base}${SUFFIXES.etat}`], + ], + nom, + ); + assert.deepEqual([renomme.base, renomme.charge.evenement.nom, renomme.revision], [base, nouveau, 2], nom); + exigerEquivalence(fs, base); + } + + // Une racine de 193 unités laisse quatre unités à la base, aucune au + // rang « (2) » : le nom qui tombe sur la base de l'événement se renomme + // encore en place. + const longue = `C:\\${'x'.repeat(190)}`; + const borne = nouveauDisque({ documents: longue }); + const racine = { id: 'documents', chemin: longue }; + const gala = await creer(borne.fs, racine, chargeEssai({ nom: 'Gala' }), CREATION); + const majuscules = await renommer(gala, renommage('GALA', 2)); + assert.deepEqual([gala.base, majuscules.base, majuscules.charge.evenement.nom], ['Gala', 'Gala', 'GALA']); + + // La base du nouveau nom porte déjà un fichier, d'une autre casse. + const { fs } = disqueAvec([['GALA DES SAULES.gtt.verrou', '{}\n']]); + const ouvert = await creer(fs, DOC, chargeEssai(), CREATION); + const renomme = await renommer(ouvert, renommage('Gala des Saules', 2)); + assert.equal(renomme.base, 'Gala des Saules (2)'); + assert.equal(fs.contenu('documents', 'GALA DES SAULES.gtt.verrou'), '{}\n'); + exigerEquivalence(fs, 'Gala des Saules (2)'); + }); + + test("renommer ne supprime jamais le journal d'un autre événement, que porte l'ancienne base, et ne crée de journal que sous la nouvelle, la reprise, requise, avant l'entrée du renommage ; le journal de l'événement sous une autre base part avec l'ancienne paire", async () => { + const etranger = (await paireDe(chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }))).journal; + const { disque } = disqueAvec([ + [ETAT, etatTexte(chargeEssai())], + [JOURNAL, etranger], + ]); + const ouvert = await rouvrir(disque, BASE); + assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ETRANGER', details: { evenement: 'evt-gala' } }]); + const nouvelle = 'Bal des Hérons'; + const options = renommage(nouvelle, 2); + await assert.rejects(renommer(ouvert, options), TypeError); + assert.equal(ouvert.fs.compteurs.ecritures, 0); + + const trace = tracer(ouvert.fs); + const renomme = await renommer(ouvert, { ...options, libelleReprise: REPRISE }); + assert.deepEqual(trace, [ + ['ecrireAtomique', `${nouvelle}${SUFFIXES.precedent}`], + ['ecrireAtomique', `${nouvelle}${SUFFIXES.journal}`], + ['ecrireAtomique', `${nouvelle}${SUFFIXES.etat}`], + ['supprimer', ETAT], + ['supprimer', PRECEDENT], + ]); + assert.equal(renomme.base, nouvelle); + assert.deepEqual(await arbre(disque), [ + [`${nouvelle}${SUFFIXES.journal}`, ouvert.fs.contenu('documents', `${nouvelle}${SUFFIXES.journal}`)], + [`${nouvelle}${SUFFIXES.etat}`, ouvert.fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)], + [`${nouvelle}${SUFFIXES.precedent}`, etatTexte(chargeEssai())], + [JOURNAL, etranger], + ]); + assert.deepEqual(revisionsEtLibelles(exigerEquivalence(ouvert.fs, nouvelle)), [ + [1, REPRISE], + [2, 'Renommé'], + ]); + + // Le journal de l'événement sous une autre base, celui d'un autre sous + // la sienne : le premier part avec l'ancienne paire, le second reste. + const sien = await paireDe(chargeEssai()); + const archives = `Archives des Lucioles${SUFFIXES.journal}`; + const deux = disqueAvec([ + [ETAT, sien.etat], + [JOURNAL, etranger], + [archives, sien.journal], + ]); + const apparie = await rouvrir(deux.disque, BASE); + assert.deepEqual([apparie.baseJournal, apparie.avertissements], ['Archives des Lucioles', []]); + const traceDeux = tracer(apparie.fs); + await renommer(apparie, renommage(nouvelle, 3)); + assert.deepEqual(traceDeux, [ + ['ecrireAtomique', `${nouvelle}${SUFFIXES.precedent}`], + ['ecrireAtomique', `${nouvelle}${SUFFIXES.journal}`], + ['ecrireAtomique', `${nouvelle}${SUFFIXES.etat}`], + ['supprimer', ETAT], + ['supprimer', PRECEDENT], + ['supprimer', archives], + ]); + assert.deepEqual( + (await arbre(deux.disque)).map(([chemin]) => chemin), + [`${nouvelle}${SUFFIXES.journal}`, `${nouvelle}${SUFFIXES.etat}`, `${nouvelle}${SUFFIXES.precedent}`, JOURNAL], + ); + assert.equal(apparie.fs.contenu('documents', JOURNAL), etranger); + assert.deepEqual(revisionsEtLibelles(exigerEquivalence(apparie.fs, nouvelle)), [ + [1, CREATION.libelle], + [2, 'Geste 2'], + [3, 'Renommé'], + ]); + }); +}); + +describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => { + test("importer écrit les octets reçus tels quels sous la base que dérive le nom qu'ils portent, sans journal ni conversion — marque d'ordre d'octets, CRLF, mise en page libre et NFD compris ; l'événement s'ouvre ; une base prise se dérive", async () => { + const charge = chargeEssai({ id: 'evt-herons', nom: 'Bal des He\u{301}rons: été' }); + const texte = `\u{FEFF}${JSON.stringify(JSON.parse(etatTexte(charge)), null, 4).replaceAll('\n', '\r\n')}\r\n`; + const octets = UTF8.encode(texte); + const { disque, fs } = nouveauDisque(); + const trace = tracer(fs); + const { base } = await importer(fs, DOC, octets); + assert.equal(base, 'Bal des Hérons été'); + assert.deepEqual(trace, [['ecrireAtomique', `${base}${SUFFIXES.etat}`]]); + assert.deepEqual(UTF8.encode(fs.contenu('documents', `${base}${SUFFIXES.etat}`)), octets); + const ouvert = await rouvrir(disque, base); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(charge)); + assert.deepEqual( + [ouvert.journal, ouvert.revision, ouvert.formatPlusRecent, ouvert.avertissements], + [null, 1, false, []], + ); + + const autre = UTF8.encode(etatTexte(chargeEssai({ id: 'evt-herons-bis', nom: 'Bal des Hérons été' }))); + assert.deepEqual(await importer(fs, DOC, autre), { base: 'Bal des Hérons été (2)' }); + }); + + test("importer refuse ce que la lecture refuse, avec son code et ses détails, sans rien écrire : octets hors UTF-8, dans une chaîne JSON comprise, texte vide, JSON invalide, comptes faux", async () => { + const comptesFaux = etatTexte(chargeEssai()).replace('"participants":4', '"participants":5'); + // Un état valide en tout, sauf son codage : lu en Windows-1252, il + // s'analyse ; ses « é » sont des octets E9 à l'intérieur de chaînes JSON. + const windows1252 = enWindows1252(etatTexte(chargeEssai())); + assert.ok(windows1252.includes(0xe9)); + assert.equal(analyser(new TextDecoder('windows-1252').decode(windows1252)).charge.evenement.nom, BASE); + const cas = [ + ['des octets hors UTF-8', Uint8Array.of(0x7b, 0xc3, 0x28, 0x7d), { raison: 'JSON', chemin: null }], + ['un état écrit en Windows-1252', windows1252, { raison: 'JSON', chemin: null }], + ['un texte vide', new Uint8Array(0), { raison: 'VIDE', chemin: null }], + ['du JSON invalide', UTF8.encode('{"entete":'), { raison: 'JSON', chemin: null }], + ['des comptes faux', UTF8.encode(comptesFaux), { raison: 'COMPTES', chemin: 'entete.comptes.participants' }], + ]; + for (const [nom, octets, details] of cas) { + const { disque, fs } = nouveauDisque(); + const erreur = await echec(importer(fs, DOC, octets), 'ETAT_ILLISIBLE', nom); + assert.deepEqual(erreur.details, details, nom); + assert.equal(fs.compteurs.ecritures, 0, nom); + assert.deepEqual(await arbre(disque), [], nom); + } + await assert.rejects(importer(nouveauDisque().fs, DOC, etatTexte(chargeEssai())), TypeError); + }); + + test("un identifiant que porte déjà un état lisible du dossier lève IDENTIFIANT_PRESENT {id, base}, quel que soit le nom, sans rien écrire ; ni un état illisible ni un journal sans état ne le portent", async () => { + const { disque, fs } = nouveauDisque(); + await creer(fs, DOC, chargeEssai(), CREATION); + const avant = await arbre(disque); + const ecritures = fs.compteurs.ecritures; + const octets = UTF8.encode(etatTexte(chargeEssai({ nom: 'Bal des Hérons' }))); + const erreur = await echec(importer(fs, DOC, octets), 'IDENTIFIANT_PRESENT'); + assert.deepEqual(erreur.details, { id: 'evt-lucioles', base: BASE }); + assert.equal(fs.compteurs.ecritures, ecritures); + assert.deepEqual(await arbre(disque), avant); + + const illisible = disqueAvec([[ETAT, '{']]); + assert.deepEqual(await importer(illisible.fs, DOC, octets), { base: 'Bal des Hérons' }); + const journalSeul = disqueAvec([[JOURNAL, (await paireDe(chargeEssai())).journal]]); + assert.deepEqual( + (await lister(journalSeul.fs, DOC)).map(({ base, id, illisible: raison }) => [base, id, raison]), + [[BASE, 'evt-lucioles', 'ABSENT']], + ); + assert.deepEqual(await importer(journalSeul.fs, DOC, octets), { base: 'Bal des Hérons' }); + }); + + test("un fichier au format 2 s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => { + const { disque, fs } = nouveauDisque(); + const texte = formatDeux(chargeEssai()); + assert.deepEqual(await importer(fs, DOC, UTF8.encode(texte)), { base: BASE }); + assert.equal(fs.contenu('documents', ETAT), texte); + const ouvert = await rouvrir(disque, BASE); + assert.equal(ouvert.formatPlusRecent, true); + await echec(enregistrer(ouvert, noter(ouvert.charge, 2), geste(2, { libelleReprise: REPRISE })), 'LECTURE_SEULE'); + + const sansNom = nouveauDisque(); + const octets = UTF8.encode(formatDeux(chargeEssai(), { sansNom: true })); + const erreur = await echec(importer(sansNom.fs, DOC, octets), 'ETAT_ILLISIBLE'); + assert.deepEqual(erreur.details, { raison: 'FORMAT_PLUS_RECENT', chemin: 'charge.evenement.nom', format: 2 }); + assert.equal(sansNom.fs.compteurs.ecritures, 0); + }); +}); + +describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => { + test("importerDepuis copie chaque paire — l'état et le journal qu'ouvrir lui apparie, octets compris — sous une base libre, dans l'ordre des bases reçues ; il recueille chaque refus par base et code, et laisse les originaux", async () => { + const disque = new Map(); + const PORTABLE = { executable: 'E:\\soirees' }; + const fs = creerFichiersSimules({ disque, ...PORTABLE }); + fs.deposerDossier('portable'); + fs.deposerDossier('documents'); + const { portable, documents } = await fs.racines(); + const lucioles = await paireDe(chargeEssai()); + const saules = await paireDe(chargeEssai({ id: 'evt-saules', nom: 'Fête des Saules' })); + const herons = etatTexte(chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), 3); + // Dans portable : un événement de deux révisions et son .precedent ; un + // état sans journal ; un état illisible ; un état dont le journal est sous + // une autre base ; un état dont l'identifiant est déjà dans documents. + const poses = [ + [ETAT, lucioles.etat], + [PRECEDENT, etatTexte(chargeEssai())], + [JOURNAL, lucioles.journal], + [`Bal des Hérons${SUFFIXES.etat}`, herons], + [`Gala d'automne${SUFFIXES.etat}`, '{'], + [`Fête des Saules${SUFFIXES.etat}`, saules.etat], + [`Archives des Saules${SUFFIXES.journal}`, saules.journal], + [`Kermesse des Ifs${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-ifs', nom: 'Kermesse des Ifs' }))], + ]; + for (const [nom, texte] of poses) fs.deposer('portable', nom, texte); + const kermesse = etatTexte(chargeEssai({ id: 'evt-ifs', nom: 'Kermesse' })); + fs.deposer('documents', `Kermesse${SUFFIXES.etat}`, kermesse); + const avant = await arbre(disque, portable, PORTABLE); + + const racines = []; + for (const nom of ['ecrireAtomique', 'ajouterLigne', 'creerDossier', 'deplacer', 'supprimer']) { + const primitive = fs[nom]; + fs[nom] = (racine, ...reste) => { + racines.push(racine.id); + return primitive(racine, ...reste); + }; + } + const bases = ['Fête des Saules', "Gala d'automne", BASE, 'Inexistant', 'Kermesse des Ifs', 'Bal des Hérons']; + assert.deepEqual(await importerDepuis(fs, portable, documents, bases), { + importes: [ + { base: 'Fête des Saules', nouvelle: 'Fête des Saules' }, + { base: BASE, nouvelle: BASE }, + { base: 'Bal des Hérons', nouvelle: 'Bal des Hérons' }, + ], + refuses: [ + { base: "Gala d'automne", code: 'ETAT_ILLISIBLE' }, + { base: 'Inexistant', code: 'ABSENT' }, + { base: 'Kermesse des Ifs', code: 'IDENTIFIANT_PRESENT' }, + ], + }); + assert.ok(racines.length > 0 && racines.every((id) => id === 'documents'), racines.join(' ')); + assert.deepEqual(await arbre(disque, portable, PORTABLE), avant); + assert.deepEqual(await arbre(disque, documents), [ + [`Bal des Hérons${SUFFIXES.etat}`, herons], + [`Fête des Saules${SUFFIXES.journal}`, saules.journal], + [`Fête des Saules${SUFFIXES.etat}`, saules.etat], + [`Kermesse${SUFFIXES.etat}`, kermesse], + [JOURNAL, lucioles.journal], + [ETAT, lucioles.etat], + ]); + for (const [base, revision, entrees] of [ + [BASE, 2, 2], + ['Fête des Saules', 2, 2], + ['Bal des Hérons', 3, null], + ]) { + const ouvert = await rouvrir(disque, base); + assert.deepEqual( + [ouvert.revision, ouvert.journal?.entrees.length ?? null, ouvert.avertissements], + [revision, entrees, []], + base, + ); + } + }); + + test("une lecture que le système de fichiers refuse autrement que par l'absence refuse la base par son code, sans rien écrire là-bas", async () => { + const disque = new Map(); + const PORTABLE = { executable: 'E:\\soirees' }; + const fs = creerFichiersSimules({ disque, ...PORTABLE }); + fs.deposerDossier('portable'); + fs.deposerDossier('documents'); + const { portable, documents } = await fs.racines(); + const lucioles = await paireDe(chargeEssai()); + fs.deposer('portable', ETAT, lucioles.etat); + fs.deposer('portable', JOURNAL, lucioles.journal); + // Le support se retire après la lecture d'ouvrir : la recopie du journal + // échoue. + const lire = fs.lireTexte; + let lectures = 0; + fs.lireTexte = async (racine, chemin) => { + if (chemin === JOURNAL && (lectures += 1) > 1) { + throw new ErreurStockage('LECTURE', { chemin, dossier: racine.chemin, cause: 'EIO' }); + } + return lire(racine, chemin); + }; + const ecritures = fs.compteurs.ecritures; + assert.deepEqual(await importerDepuis(fs, portable, documents, [BASE]), { + importes: [], + refuses: [{ base: BASE, code: 'LECTURE' }], + }); + assert.equal(fs.compteurs.ecritures, ecritures); + assert.deepEqual(await arbre(disque, documents), []); + }); +}); + +describe('opérations : supprimer vers la corbeille (§ 8.7)', () => { + test("supprimer déplace l'état, le .precedent, le journal apparié, le verrou et les écritures restées dans un seul dossier daté, créé d'abord, l'état en dernier ; listerCorbeille le montre ; le reste du dossier ne bouge pas", async () => { + const { disque, fs, ouvert } = await deuxRevisions(); + await verrouiller(ouvert, 'seance-a'); + fs.deposer('documents', `${ETAT}.ecriture`, 'partiel'); + fs.deposer('documents', `${JOURNAL}.ecriture`, 'partiel aussi'); + await creer(fs, DOC, chargeEssai({ id: 'evt-herons', nom: 'Bal des Hérons' }), CREATION); + const avant = await arbre(disque); + const trace = tracer(fs); + assert.deepEqual(await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), { dossier: DATE }); + const deplaces = [`${JOURNAL}.ecriture`, `${ETAT}.ecriture`, VERROU, JOURNAL, PRECEDENT, ETAT]; + assert.deepEqual(trace, [['creerDossier', DATE], ...deplaces.map((nom) => ['deplacer', nom, `${DATE}/${nom}`])]); + assert.deepEqual( + await arbre(disque), + avant.map(([chemin, texte]) => [deplaces.includes(chemin) ? `${DATE}/${chemin}` : chemin, texte]).sort(parChemin), + ); + assert.deepEqual(await listerCorbeille(fs, DOC), [ + { dossier: DATE, fichiers: deplaces.map((nom) => [nom]).sort(parChemin).flat() }, + ]); + assert.deepEqual( + (await lister(fs, DOC)).map(({ base }) => base), + ['Bal des Hérons'], + ); + }); + + test('deux suppressions dans la même seconde vont dans deux dossiers, le second suffixé _2 ; listerCorbeille les rend par nom de dossier ; aucune opération ne vide la corbeille', async () => { + const { disque, fs } = nouveauDisque(); + for (const [id, nom] of [ + ['evt-a', 'Bal des Hérons'], + ['evt-b', 'Gala des Saules'], + ['evt-c', 'Fête des Ifs'], + ['evt-d', BASE], + ]) { + await creer(fs, DOC, chargeEssai({ id, nom }), CREATION); + } + const suppressions = []; + const primitive = fs.supprimer; + fs.supprimer = (racine, chemin) => { + suppressions.push(chemin); + return primitive(racine, chemin); + }; + const a = await supprimer(fs, DOC, 'Bal des Hérons', { horodatage: heure(30) }); + const b = await supprimer(fs, DOC, 'Gala des Saules', { horodatage: heure(30) }); + const c = await supprimer(fs, DOC, 'Fête des Ifs', { horodatage: '2031-05-17T08:29:59-04:00' }); + assert.deepEqual( + [a, b, c], + [{ dossier: DATE }, { dossier: `${DATE}_2` }, { dossier: 'corbeille/2031-05-17_08-29-59' }], + ); + const paire = (nom) => [`${nom}${SUFFIXES.journal}`, `${nom}${SUFFIXES.etat}`]; + const corbeille = [ + { dossier: 'corbeille/2031-05-17_08-29-59', fichiers: paire('Fête des Ifs') }, + { dossier: DATE, fichiers: paire('Bal des Hérons') }, + { dossier: `${DATE}_2`, fichiers: paire('Gala des Saules') }, + ]; + assert.deepEqual(await listerCorbeille(fs, DOC), corbeille); + + // Les opérations qui suivent n'en retirent rien. + const avant = (await arbre(disque)).filter(([chemin]) => chemin.startsWith('corbeille/')); + const ouvert = await ouvrir(fs, DOC, BASE); + const renomme = await renommer(ouvert, renommage('Kermesse des Ifs', 31)); + await copier(renomme, optionsCopie('Variante des Ifs')); + await importer(fs, DOC, UTF8.encode(etatTexte(chargeEssai({ id: 'evt-e', nom: 'Bal des Hérons' })))); + await supprimer(fs, DOC, 'Kermesse des Ifs', { horodatage: heure(30) }); + const apres = (await arbre(disque)).filter(([chemin]) => chemin.startsWith('corbeille/')); + assert.deepEqual( + apres.filter(([chemin]) => !chemin.startsWith(`${DATE}_3/`)), + avant, + ); + assert.deepEqual((await listerCorbeille(fs, DOC)).slice(0, 3), corbeille); + assert.ok(suppressions.length > 0, 'le renommage supprime son ancienne paire'); + assert.deepEqual( + suppressions.filter((chemin) => chemin.startsWith('corbeille/')), + [], + ); + }); + + test("supprimer ne déplace jamais le journal d'un autre événement, que la base porte ; le journal de l'événement sous une autre base part avec lui", async () => { + const etat = etatTexte(chargeEssai()); + const etranger = (await paireDe(chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }))).journal; + const sien = (await paireDe(chargeEssai())).journal; + const archives = `Archives des Lucioles${SUFFIXES.journal}`; + // Par cas : les fichiers posés, et ceux qui restent à la racine. + const cas = [ + ["le journal d'un autre événement sous la base", [[ETAT, etat], [JOURNAL, etranger]], [JOURNAL]], + [ + "le journal de l'événement sous une autre base, celui d'un autre sous la sienne", + [[ETAT, etat], [JOURNAL, etranger], [archives, sien]], + [JOURNAL], + ], + ]; + for (const [nom, poses, restants] of cas) { + const { disque, fs } = disqueAvec(poses); + const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); + assert.deepEqual( + await arbre(disque), + poses + .map(([chemin, texte]) => [restants.includes(chemin) ? chemin : `${dossier}/${chemin}`, texte]) + .sort(parChemin), + nom, + ); + } + }); + + test("un état illisible part avec le .precedent et le journal que son identifiant apparie ; un journal sans état se supprime par sa base", async () => { + const { etat, journal } = await paireDe(chargeEssai()); + const archives = `Archives des Lucioles${SUFFIXES.journal}`; + const cas = [ + ['un état illisible', BASE, [[ETAT, '{'], [PRECEDENT, etat], [JOURNAL, journal]]], + ['un journal sans état', 'Archives des Lucioles', [[archives, journal]]], + ]; + for (const [nom, base, poses] of cas) { + const { disque, fs } = disqueAvec(poses); + assert.deepEqual( + (await lister(fs, DOC)).map(({ base: listee }) => listee), + [base], + nom, + ); + const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION }); + assert.deepEqual( + await arbre(disque), + poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin), + nom, + ); + } + }); + + test("sur un système de fichiers insensible à la casse, supprimer reconnaît les fichiers de l'événement dans une autre casse, comme deriverBase compare les bases, et les déplace sous leur nom sur le disque", async () => { + const { etat, journal } = await paireDe(chargeEssai()); + // Le verrou est en NFD : seule sa NFC rejoint la base. + const poses = [ + [ETAT, etat], + [`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal], + [`soirée des lucioles${SUFFIXES.precedent}`, etat], + [`Soire\u0301e Des Lucioles${SUFFIXES.verrou}`, '{}\n'], + [`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'], + ]; + const { disque, fs } = disqueAvec(poses); + const trace = tracer(fs); + const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); + assert.deepEqual( + await arbre(disque), + poses.map(([chemin, texte]) => [`${dossier}/${chemin}`, texte]).sort(parChemin), + ); + assert.deepEqual( + trace.filter(([primitive]) => primitive === 'deplacer').map(([, de, vers]) => [de, vers]), + [4, 3, 1, 2, 0].map((rang) => [poses[rang][0], `${dossier}/${poses[rang][0]}`]), + ); + + // L'événement nommé dans une autre casse que celle de son état part de + // même. + const autre = disqueAvec(poses); + const { dossier: dossierAutre } = await supprimer(autre.fs, DOC, 'SOIRÉE DES LUCIOLES', { + horodatage: HEURE_SUPPRESSION, + }); + assert.deepEqual( + await arbre(autre.disque), + poses.map(([chemin, texte]) => [`${dossierAutre}/${chemin}`, texte]).sort(parChemin), + ); + + // Un état illisible, nommé dans une autre casse : son résumé se trouve à + // la clé de sa base, et le journal que son .precedent apparie part avec + // lui. + const illisible = [[ETAT, '{'], ...poses.slice(1)]; + const troisieme = disqueAvec(illisible); + const { dossier: dossierIllisible } = await supprimer(troisieme.fs, DOC, 'soirée des LUCIOLES', { + horodatage: HEURE_SUPPRESSION, + }); + assert.deepEqual( + await arbre(troisieme.disque), + illisible.map(([chemin, texte]) => [`${dossierIllisible}/${chemin}`, texte]).sort(parChemin), + ); + + // Un sigma qui finit la base, suivi du point d'un suffixe : la majuscule + // réunit « ς » et « Σ », la minuscule les sépare, car elle n'écrit « ς » + // qu'en fin de mot et le point n'en finit pas un. + const lac = 'Χορος της Λιμνης'; + const grecs = [ + [`${lac}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-lac', nom: lac }))], + [`ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ${SUFFIXES.verrou}`, '{}\n'], + ]; + const quatrieme = disqueAvec(grecs); + const { dossier: dossierLac } = await supprimer(quatrieme.fs, DOC, lac, { horodatage: HEURE_SUPPRESSION }); + assert.deepEqual( + await arbre(quatrieme.disque), + grecs.map(([chemin, texte]) => [`${dossierLac}/${chemin}`, texte]).sort(parChemin), + ); + }); + + test("sur un système de fichiers insensible à la casse, supprimer emporte un fichier d'une autre base exactement quand deriverBase tient cette base pour prise par la sienne : NFD, sigma final, ß et SS ; une base distincte reste", async () => { + // Par cas : la base de l'événement, celle d'un verrou posé à côté, et ce + // que deriverBase doit en dire quand la règle est fixée (null : la + // comparaison seule fait foi). + const cas = [ + ['Soirée des Lucioles', 'SOIRE\u0301E DES LUCIOLES', true], + ['Χορος της Λιμνης', 'ΧΟΡΟΣ ΤΗΣ ΛΙΜΝΗΣ', true], + ['Abend in der Gartenstraße', 'ABEND IN DER GARTENSTRASSE', null], + ['Soirée des Lucioles', 'Soirée des Lucioles bis', false], + ]; + for (const [base, voisine, attendue] of cas) { + const prise = deriverBase(base, { racine: DOCUMENTS, separateur: '\\', existantes: [voisine] }) !== base; + if (attendue !== null) assert.equal(prise, attendue, `deriverBase, ${voisine}`); + const verrou = `${voisine}${SUFFIXES.verrou}`; + const poses = [ + [`${base}${SUFFIXES.etat}`, etatTexte(chargeEssai({ id: 'evt-voisine', nom: base }))], + [verrou, '{}\n'], + ]; + const { disque, fs } = disqueAvec(poses); + const { dossier } = await supprimer(fs, DOC, base, { horodatage: HEURE_SUPPRESSION }); + assert.deepEqual( + await arbre(disque), + poses + .map(([chemin, texte]) => [chemin === verrou && !prise ? chemin : `${dossier}/${chemin}`, texte]) + .sort(parChemin), + voisine, + ); + } + }); + + test("sur un système de fichiers sensible à la casse, supprimer ne déplace que les fichiers au nom exact de la base : ceux d'une autre casse sont d'autres bases, et restent", async () => { + const { etat, journal } = await paireDe(chargeEssai()); + const poses = [ + [ETAT, etat], + [`SOIRÉE DES LUCIOLES${SUFFIXES.journal}`, journal], + [`soirée des lucioles${SUFFIXES.precedent}`, etat], + [`Soirée Des Lucioles${SUFFIXES.verrou}`, '{}\n'], + [`SOIRÉE des lucioles${SUFFIXES.etat}.ecriture`, 'partiel'], + ]; + const sensible = { insensibleCasse: false }; + const { disque, fs } = disqueAvec(poses, sensible); + const { dossier } = await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); + // Le journal d'une autre base que son identifiant apparie part avec + // l'événement (apparier, dans le dépôt) ; le reste ne lui appartient pas. + const partis = [ETAT, poses[1][0]]; + assert.deepEqual( + await arbre(disque, DOC, sensible), + poses + .map(([chemin, texte]) => [partis.includes(chemin) ? `${dossier}/${chemin}` : chemin, texte]) + .sort(parChemin), + ); + await echec( + supprimer(creerFichiersSimules({ disque, ...sensible }), DOC, 'SOIRÉE DES LUCIOLES', { + horodatage: HEURE_SUPPRESSION, + }), + 'ABSENT', + ); + }); + + test('une coupure à chaque écriture de la suppression laisse chaque fichier de l’événement à sa place ou dans la corbeille, son texte intact, jamais perdu', async () => { + for (let n = 0; ; n += 1) { + const { disque, fs, ouvert } = await deuxRevisions(); + await verrouiller(ouvert, 'seance-a'); + fs.deposer('documents', `${ETAT}.ecriture`, 'partiel'); + const avant = await arbre(disque); + fs.pannes.couperApres(n); + let acheve = true; + try { + await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); + } catch (erreur) { + assert.ok(erreur instanceof PanneSimulee, `coupure après ${n} écritures : ${String(erreur)}`); + acheve = false; + } + const message = `coupure après ${n} écritures`; + const apres = await arbre(disque); + assert.deepEqual( + apres.map(([chemin, texte]) => [chemin.replace(`${DATE}/`, ''), texte]).sort(parChemin), + avant, + message, + ); + if (acheve) { + assert.ok(apres.every(([chemin]) => chemin.startsWith(`${DATE}/`)), message); + break; + } + } + }); + + test("un dossier de travail qui disparaît pendant la suppression ne renaît pas : à chaque écriture avant laquelle il disparaît, supprimer lève ECRITURE, ENOENT, ou ABSENT pour le fichier qui n'est plus là, ou achève, et le dossier reste absent", async () => { + // Rangs où le dossier a disparu : au moins un par primitive que la + // suppression appelle — la création du dossier daté, trois déplacements. + let effacements = 0; + for (let n = 0; ; n += 1) { + const { disque, fs } = await deuxRevisions(); + const temoin = effacerDocumentsAvant(disque, fs, n); + let refus = null; + try { + await supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }); + } catch (erreur) { + refus = erreur; + } + const message = `dossier effacé avant l'écriture de rang ${n}`; + const enoent = refus?.code === 'ECRITURE' && refus.details.cause === 'ENOENT'; + assert.ok(refus === null || enoent || refus.code === 'ABSENT', `${message} : ${String(refus)}`); + if (!temoin.efface) { + assert.equal(refus, null, message); + break; + } + effacements += 1; + assert.ok(refus instanceof ErreurStockage, `${message} : ${String(refus)}`); + await echec(creerFichiersSimules({ disque }).lister(DOC, ''), 'ABSENT', message); + } + assert.ok(effacements >= 4, `${effacements} rangs éprouvés, 4 attendus au moins`); + }); + + test('supprimer lève, sans rien écrire : ABSENT pour un événement sans fichier, TypeError pour un horodatage hors de sa forme, CORBEILLE_SATUREE quand la seconde a ses cent dossiers', async () => { + const { disque, fs } = nouveauDisque(); + await creer(fs, DOC, chargeEssai(), CREATION); + const avant = await arbre(disque); + const ecritures = fs.compteurs.ecritures; + const absent = await echec(supprimer(fs, DOC, 'Inexistant', { horodatage: HEURE_SUPPRESSION }), 'ABSENT'); + assert.deepEqual(absent.details, { chemin: `Inexistant${SUFFIXES.etat}` }); + await assert.rejects(supprimer(fs, DOC, BASE, { horodatage: '17 mai 2031' }), TypeError); + fs.deposerDossier('documents', DATE); + for (let rang = 2; rang <= 99; rang += 1) fs.deposerDossier('documents', `${DATE}_${rang}`); + const saturee = await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), 'CORBEILLE_SATUREE'); + assert.deepEqual(saturee.details, { dossier: DATE }); + assert.equal(fs.compteurs.ecritures, ecritures); + assert.deepEqual(await arbre(disque), avant); + assert.equal((await listerCorbeille(fs, DOC)).length, 99); + }); + + test("une lecture que le système de fichiers refuse autrement que par l'absence remonte telle quelle, sans rien écrire : un chemin refusé, une corbeille illisible", async () => { + const { fs } = await deuxRevisions(); + const ecritures = fs.compteurs.ecritures; + await echec(supprimer(fs, DOC, '../ailleurs', { horodatage: HEURE_SUPPRESSION }), 'CHEMIN_REFUSE'); + const lister = fs.lister; + fs.lister = async (racine, dossier) => { + if (dossier === 'corbeille') { + throw new ErreurStockage('LECTURE', { chemin: dossier, dossier: racine.chemin, cause: 'EIO' }); + } + return lister(racine, dossier); + }; + await echec(listerCorbeille(fs, DOC), 'LECTURE'); + await echec(supprimer(fs, DOC, BASE, { horodatage: HEURE_SUPPRESSION }), 'LECTURE'); + assert.equal(fs.compteurs.ecritures, ecritures); + }); + + test('une corbeille absente se liste vide ; un fichier posé à même la corbeille ne se liste pas comme un dossier', async () => { + const { fs } = nouveauDisque(); + assert.deepEqual(await listerCorbeille(fs, DOC), []); + fs.deposer('documents', 'corbeille/note.txt', 'à garder'); + fs.deposer('documents', `${DATE}/${ETAT}`, '{'); + assert.deepEqual(await listerCorbeille(fs, DOC), [{ dossier: DATE, fichiers: [ETAT] }]); + assert.equal(fs.compteurs.ecritures, 0); + }); +}); + +describe('opérations : charger une démonstration (§ 15)', () => { + test("charger deux fois « Petite démonstration » crée « Petite démonstration 1 » puis « … 2 » : identités reçues, état déduit, journal d'une entrée et son jalon ; le texte livré reste tel quel", async () => { + const texte = texteLivre('petite'); + const livree = analyser(texte).charge; + assert.equal(livree.evenement.nom, 'Petite démonstration'); + const { disque, fs } = nouveauDisque(); + const options = (n) => ({ + id: `evt-demo-${n}`, + libelle: 'Chargée depuis la démonstration', + nomJalon: 'Création', + horodatage: heure(n), + produitVersion: V1, + }); + const trace = tracer(fs); + const premiere = await chargerDemonstration(fs, DOC, texte, options(1)); + const seconde = await chargerDemonstration(fs, DOC, texte, options(2)); + for (const [ouvert, n] of [ + [premiere, 1], + [seconde, 2], + ]) { + const nom = `Petite démonstration ${n}`; + const attendue = { ...livree, evenement: { ...livree.evenement, id: `evt-demo-${n}`, nom, etat: 'brouillon' } }; + assert.deepEqual( + [ouvert.base, ouvert.charge.evenement.nom, ouvert.revision, ouvert.avertissements], + [nom, nom, 1, []], + ); + assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(attendue), nom); + const journal = exigerEquivalence(fs, nom); + assert.equal(journal.evenement, `evt-demo-${n}`, nom); + assert.deepEqual(revisionsEtLibelles(journal), [[1, 'Chargée depuis la démonstration']], nom); + } + assert.deepEqual( + trace, + ['Petite démonstration 1', 'Petite démonstration 2'].flatMap((nom) => [ + ['ecrireAtomique', `${nom}${SUFFIXES.journal}`], + ['ecrireAtomique', `${nom}${SUFFIXES.etat}`], + ]), + ); + assert.equal(texteLivre('petite'), texte); + assert.deepEqual( + (await arbre(disque)).map(([chemin]) => chemin), + [ + `Petite démonstration 1${SUFFIXES.journal}`, + `Petite démonstration 1${SUFFIXES.etat}`, + `Petite démonstration 2${SUFFIXES.journal}`, + `Petite démonstration 2${SUFFIXES.etat}`, + ], + ); + }); + + test("le rang est le premier libre parmi les noms des événements lisibles du dossier, comparés en NFC, sans égard à la casse quand le système de fichiers l'ignore ; un état illisible ne prend aucun rang", async () => { + const texte = texteLivre('petite'); + const livree = analyser(texte).charge; + const nomme = (id, nom) => etatTexte({ ...livree, evenement: { ...livree.evenement, id, nom } }); + const fichiers = [ + [`petite démonstration 1${SUFFIXES.etat}`, nomme('evt-p1', 'petite démonstration 1')], + [`Petite démonstration 2${SUFFIXES.etat}`, nomme('evt-p2', 'Petite de\u{301}monstration 2')], + [`Petite démonstration 3${SUFFIXES.etat}`, '{'], + [`Petite démonstration 4${SUFFIXES.etat}`, nomme('evt-p4', 'Petite démonstration 4')], + ]; + const sensible = { + separateur: '/', + insensibleCasse: false, + documents: '/home/exemple/Documents/Gestion table tournante Libre', + }; + // Par cas : le système de fichiers, puis le nom et la base attendus. + const cas = [ + ['sans égard à la casse', {}, 'Petite démonstration 3', 'Petite démonstration 3 (2)'], + ['casse distinguée', sensible, 'Petite démonstration 1', 'Petite démonstration 1 (2)'], + ]; + for (const [nom, reglages, attendu, base] of cas) { + const { fs } = disqueAvec(fichiers, reglages); + const racine = { id: 'documents', chemin: reglages.documents ?? DOCUMENTS }; + const ouvert = await chargerDemonstration(fs, racine, texte, chargement('evt-demo', 1)); + assert.deepEqual([ouvert.charge.evenement.nom, ouvert.base], [attendu, base], nom); + } + }); +}); + +describe("opérations : ce qu'une construction plus récente a écrit (§ 8.8)", () => { + test("copier, renommer et charger refusent un fichier d'un format plus récent, LECTURE_SEULE, sans rien écrire", async () => { + const { disque } = disqueAvec([[ETAT, formatDeux(chargeEssai())]]); + const ouvert = await rouvrir(disque, BASE); + assert.equal(ouvert.formatPlusRecent, true); + const refus = [ + ['copier', copier(ouvert, optionsCopie('Variante des Lucioles', { libelleReprise: REPRISE }))], + ['renommer', renommer(ouvert, renommage('Gala des Saules', 2, { libelleReprise: REPRISE }))], + [ + 'charger', + chargerDemonstration( + ouvert.fs, + DOC, + formatDeux(chargeEssai({ id: 'evt-demo', nom: 'Démonstration' })), + chargement('evt-demo-1', 2), + ), + ], + ]; + for (const [nom, promesse] of refus) { + const erreur = await echec(promesse, 'LECTURE_SEULE', nom); + assert.deepEqual(erreur.details, { raison: 'FORMAT_PLUS_RECENT' }, nom); + } + assert.equal(ouvert.fs.compteurs.ecritures, 0); + await echec(chargerDemonstration(ouvert.fs, DOC, '{', chargement('evt-demo-2', 3)), 'ETAT_ILLISIBLE'); + assert.equal(ouvert.fs.compteurs.ecritures, 0); + }); +}); diff --git a/src/stockage/reglages_locaux.js b/src/stockage/reglages_locaux.js new file mode 100644 index 0000000..0fb3d04 --- /dev/null +++ b/src/stockage/reglages_locaux.js @@ -0,0 +1,321 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les réglages locaux (§ 8.5) : le fichier reglages_locaux.json, à la racine +// du dossier de travail, à côté des fichiers d'événement et hors d'eux. Il +// porte deux choses, et deux seulement : le cadrage de chaque événement, +// indexé par son identifiant — le zoom k en pixels par centimètre, la +// translation tx, ty en pixels —, et le format de papier des planches, +// global à la machine (§ 11.2), null tant que l'opérateur ne l'a pas choisi. +// Ni l'un ni l'autre ne voyage avec un événement ni ne revient par un retour +// arrière. +// +// La lecture ne bloque jamais : un fichier absent donne les défauts ; un +// fichier qui ne se lit pas, ou dont une valeur connue sort de sa règle, +// donne les défauts et l'avertissement REGLAGES_ILLISIBLES. Un fichier +// lisible garde ses clés inconnues, à tout niveau, et son format, un entier +// d'au moins 1 : le réglage qu'une construction plus récente y ajoute +// survit à la réécriture par une plus ancienne. +// +// Le texte est canonique : JSON sur une ligne, les clés de chaque objet +// rangées par unités UTF-16, les cadrages par identifiant d'événement, une +// fin de ligne finale. L'écriture en est différée (§ 8.2, § 8.5) : un +// écrivain regroupe les demandes d'un délai borné, si bien qu'un zoom +// continu écrit une fois par délai, jamais à chaque image. +import { clesRangees } from './document.js'; +import { ErreurStockage } from './erreurs.js'; + +/** Le fichier des réglages locaux, à la racine du dossier de travail (§ 8.5). */ +export const FICHIER_REGLAGES = 'reglages_locaux.json'; + +// Format d'un fichier que ce code crée. +const FORMAT_REGLAGES = 1; + +// Les papiers d'une planche (§ 11.2). +const PAPIERS = ['A4', 'Lettre']; + +const MARQUE_ORDRE_OCTETS = '\u{FEFF}'; + +/** + * Les réglages locaux, tels que reglages_locaux.json les porte. + * + * @typedef {Object} ReglagesLocaux + * @property {number} format entier ≥ 1, gardé tel que lu + * @property {null|'A4'|'Lettre'} papier + * @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages + * un par événement, triés par identifiant ; evenement non vide, k + * nombre fini > 0, tx et ty nombres finis. Toute autre clé, à tout + * niveau, se garde telle quelle + */ + +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const parEvenement = (a, b) => comparerTextes(a.evenement, b.evenement); + +// Vrai pour un objet qui n'est ni null ni une liste. +const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); + +// Les réglages d'un dossier qui n'en a pas, neufs à chaque appel. +const defauts = () => ({ format: FORMAT_REGLAGES, papier: null, cadrages: [] }); + +// Chemin de la première valeur connue d'un cadrage qui sort de sa règle, ou +// null ; chemin désigne le cadrage lui-même. +function fauteDeCadrage(cadrage, chemin) { + if (!estObjet(cadrage)) return chemin; + const { evenement, k, tx, ty } = cadrage; + if (typeof evenement !== 'string' || evenement === '') return `${chemin}.evenement`; + if (!Number.isFinite(k) || k <= 0) return `${chemin}.k`; + if (!Number.isFinite(tx)) return `${chemin}.tx`; + if (!Number.isFinite(ty)) return `${chemin}.ty`; + return null; +} + +// Chemin de la première valeur connue des réglages qui sort de sa règle, ou +// null ; '' quand les réglages ne sont pas un objet. Le second cadrage d'un +// même événement est fautif. +function fauteDeReglages(reglages) { + if (!estObjet(reglages)) return ''; + const { format, papier, cadrages } = reglages; + if (!Number.isSafeInteger(format) || format < 1) return 'format'; + if (papier !== null && !PAPIERS.includes(papier)) return 'papier'; + if (!Array.isArray(cadrages)) return 'cadrages'; + const vus = new Set(); + for (let rang = 0; rang < cadrages.length; rang += 1) { + const chemin = `cadrages[${rang}]`; + const faute = fauteDeCadrage(cadrages[rang], chemin); + if (faute !== null) return faute; + if (vus.has(cadrages[rang].evenement)) return `${chemin}.evenement`; + vus.add(cadrages[rang].evenement); + } + return null; +} + +// Copie de valeur dont chaque objet reçoit ses clés rangées (clesRangees), +// chaque liste gardant son ordre. JSON.stringify écrit les clés de la copie +// dans cet ordre, les clés d'index entier en tête, croissantes, comme +// JavaScript les énumère : le texte ne dépend que de l'ensemble des clés. +// Object.fromEntries pose chaque clé en propre, « __proto__ » comprise. +function rangee(valeur) { + if (Array.isArray(valeur)) return valeur.map(rangee); + if (!estObjet(valeur)) return valeur; + return Object.fromEntries(clesRangees(valeur).map((cle) => [cle, rangee(valeur[cle])])); +} + +// Ce que rend lireReglages d'un fichier illisible : les défauts, et +// l'avertissement qui nomme le fichier et la raison. +const illisibles = (raison) => ({ + reglages: defauts(), + avertissement: { code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } }, +}); + +/** + * Lit les réglages locaux de racine (§ 8.5), sans rien écrire ni jamais + * bloquer. Un fichier absent rend les défauts, sans avertissement. Un + * fichier illisible rend les défauts et l'avertissement REGLAGES_ILLISIBLES + * {chemin, raison} : raison JSON pour un texte qui ne se lit pas, vide + * compris ; FORME pour une valeur connue hors de sa règle — un format qui + * n'est pas un entier ≥ 1, un papier hors de null, A4 et Lettre, un cadrage + * fautif ou en double ; le code d'une lecture que le système de fichiers + * refuse autrement que par ABSENT. Une marque d'ordre d'octets en tête est + * ignorée. Les réglages lus gardent leurs clés inconnues et leur format ; + * leurs cadrages sont triés par identifiant d'événement. + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @returns {Promise<{reglages: ReglagesLocaux, avertissement: {code: string, details: Object}|null}>} + * une erreur qui n'est pas une ErreurStockage remonte telle quelle + */ +export async function lireReglages(fs, racine) { + let texte; + try { + texte = await fs.lireTexte(racine, FICHIER_REGLAGES); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) throw erreur; + if (erreur.code === 'ABSENT') return { reglages: defauts(), avertissement: null }; + return illisibles(erreur.code); + } + let lu; + try { + lu = JSON.parse(texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte); + } catch { + return illisibles('JSON'); + } + if (fauteDeReglages(lu) !== null) return illisibles('FORME'); + return { reglages: { ...lu, cadrages: [...lu.cadrages].sort(parEvenement) }, avertissement: null }; +} + +/** + * Le cadrage de l'événement d'identifiant evenement, ou null quand les + * réglages n'en portent pas. + * + * @param {ReglagesLocaux} reglages + * @param {string} evenement + * @returns {{k: number, tx: number, ty: number}|null} + */ +export function cadrageDe({ cadrages }, evenement) { + const cadrage = cadrages.find((candidat) => candidat.evenement === evenement); + return cadrage === undefined ? null : { k: cadrage.k, tx: cadrage.tx, ty: cadrage.ty }; +} + +/** + * Les réglages reçus, le cadrage de l'événement remplacé, ou ajouté à son + * rang ; ceux reçus ne changent pas. + * + * @param {ReglagesLocaux} reglages + * @param {string} evenement identifiant de l'événement, non vide + * @param {{k: number, tx: number, ty: number}} cadrage k fini > 0, tx et + * ty finis + * @returns {ReglagesLocaux} + * @throws {TypeError} une valeur hors de sa règle + */ +export function avecCadrage(reglages, evenement, { k, tx, ty }) { + const cadrage = { evenement, k, tx, ty }; + const faute = fauteDeCadrage(cadrage, 'cadrage'); + if (faute !== null) throw new TypeError(`avecCadrage : ${faute} hors de sa règle`); + const autres = reglages.cadrages.filter((autre) => autre.evenement !== evenement); + return { ...reglages, cadrages: [...autres, cadrage].sort(parEvenement) }; +} + +/** + * Les réglages reçus, le papier remplacé ; ceux reçus ne changent pas. + * + * @param {ReglagesLocaux} reglages + * @param {null|'A4'|'Lettre'} papier + * @returns {ReglagesLocaux} + * @throws {TypeError} un autre papier + */ +export function avecPapier(reglages, papier) { + if (papier !== null && !PAPIERS.includes(papier)) { + throw new TypeError(`avecPapier : papier null, A4 ou Lettre attendu, reçu ${JSON.stringify(papier)}`); + } + return { ...reglages, papier }; +} + +/** + * Texte canonique des réglages : JSON sur une ligne, les clés de chaque + * objet rangées par unités UTF-16, clés inconnues comprises, les cadrages + * triés par identifiant d'événement, une fin de ligne finale. Deux réglages + * égaux s'écrivent pareil, quel que soit l'ordre de leurs clés ou de leurs + * cadrages. + * + * @param {ReglagesLocaux} reglages + * @returns {string} + * @throws {TypeError} une valeur connue hors de sa règle, que la lecture + * refuserait + */ +export function serialiserReglages(reglages) { + const faute = fauteDeReglages(reglages); + if (faute !== null) throw new TypeError(`serialiserReglages : ${faute || 'les réglages'} hors de sa règle`); + return `${JSON.stringify(rangee({ ...reglages, cadrages: [...reglages.cadrages].sort(parEvenement) }))}\n`; +} + +/** + * Écrit les réglages dans racine : leur texte canonique, par une écriture + * atomique de FICHIER_REGLAGES à sa racine. Le dossier de travail doit + * exister : aucune écriture ne le recrée (§ 8.6). + * + * @param {import('./systeme_fichiers.js').SystemeFichiers} fs + * @param {import('./systeme_fichiers.js').Racine} racine + * @param {ReglagesLocaux} reglages + * @returns {Promise} + * @throws {TypeError} des réglages hors de leur règle, avant toute écriture + * @throws {ErreurStockage} ECRITURE, le fichier intact + */ +export async function ecrireReglages(fs, racine, reglages) { + await fs.ecrireAtomique(racine, FICHIER_REGLAGES, serialiserReglages(reglages)); +} + +const rien = () => {}; + +/** + * Écrivain différé des réglages (§ 8.5). La première demande arme un + * minuteur de delaiMs ; celles qui suivent avant son terme remplacent les + * réglages en attente sans le réarmer : le délai est borné, et un zoom + * continu écrit une fois par délai. À son terme, ecrire reçoit les derniers + * réglages demandés. Les écritures s'enchaînent sans jamais se chevaucher : + * chacune attend la fin de la précédente. + * + * La dernière demande l'emporte sur tous les chemins. Chaque demande avance + * un compteur, que chaque écriture relève au moment où elle prend les + * réglages en attente. Une écriture refusée ne remet ses réglages en attente + * que quand rien n'attend et que le compteur n'a pas bougé depuis ce relevé ; + * la demande suivante, ou vider, les écrit alors. Une demande venue + * entre-temps, qu'elle attende encore ou que son écriture soit déjà lancée, + * est plus récente que les réglages refusés, qui ne reviennent pas. + * + * Le refus d'une écriture qu'a lancée le minuteur ne devient jamais un rejet + * que rien ne traite : il va à signaler quand il est donné, et se tait sinon. + * vider annule le minuteur, écrit tout de suite les réglages en attente et + * rend la promesse de cette écriture, qui rejette sur son refus ; sans + * réglages en attente, il n'écrit rien et rend une promesse que la fin + * de l'écriture en cours résout. + * + * @param {Object} parametres + * @param {(reglages: ReglagesLocaux) => Promise} parametres.ecrire + * @param {(fonction: () => void, delaiMs: number) => *} parametres.planifier + * arme un minuteur et rend de quoi l'annuler + * @param {(minuteur: *) => void} parametres.annuler + * @param {number} parametres.delaiMs nombre fini ≥ 0 + * @param {((erreur: *) => void)|null} [parametres.signaler] + * @returns {{demander: (reglages: ReglagesLocaux) => void, vider: () => Promise}} + * @throws {TypeError} un paramètre hors de sa forme + */ +export function creerEcrivainDiffere({ ecrire, planifier, annuler, delaiMs, signaler = null }) { + for (const [nom, fonction] of [ + ['ecrire', ecrire], + ['planifier', planifier], + ['annuler', annuler], + ]) { + if (typeof fonction !== 'function') throw new TypeError(`creerEcrivainDiffere : ${nom} n'est pas une fonction`); + } + if (signaler !== null && typeof signaler !== 'function') { + throw new TypeError("creerEcrivainDiffere : signaler n'est pas une fonction"); + } + if (!Number.isFinite(delaiMs) || delaiMs < 0) { + throw new TypeError(`creerEcrivainDiffere : délai fini ≥ 0 attendu, reçu ${JSON.stringify(delaiMs)}`); + } + // Les réglages à écrire, ou null ; le nombre de demandes reçues ; le + // minuteur armé, ou null ; la fin de la dernière écriture lancée, qui ne + // rejette jamais. + let attente = null; + let demandes = 0; + let minuteur = null; + let file = Promise.resolve(); + + // Lance l'écriture des réglages en attente à la suite de la précédente, et + // relève le nombre de demandes au moment où elle les prend. Rend sa + // promesse, qui rejette sur son refus après avoir remis ces réglages en + // attente quand rien n'attend et qu'aucune demande n'a suivi ce relevé. + function ecrireEnAttente() { + const reglages = attente; + const releve = demandes; + attente = null; + const ecriture = file.then(() => ecrire(reglages)); + file = ecriture.then(rien, rien); + return ecriture.then(rien, (erreur) => { + if (attente === null && demandes === releve) attente = reglages; + throw erreur; + }); + } + + return { + demander(reglages) { + demandes += 1; + attente = reglages; + if (minuteur !== null) return; + minuteur = planifier(() => { + minuteur = null; + ecrireEnAttente().catch((erreur) => { + if (signaler !== null) signaler(erreur); + }); + }, delaiMs); + }, + vider() { + if (minuteur !== null) { + annuler(minuteur); + minuteur = null; + } + return attente === null ? file : ecrireEnAttente(); + }, + }; +} diff --git a/src/stockage/reglages_locaux.test.js b/src/stockage/reglages_locaux.test.js new file mode 100644 index 0000000..2f6bb99 --- /dev/null +++ b/src/stockage/reglages_locaux.test.js @@ -0,0 +1,437 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des réglages locaux (§ 8.5) : la lecture, jamais bloquante, qui +// rend les défauts et un avertissement pour un fichier illisible ; la forme +// canonique, qui garde les clés inconnues d'une construction plus récente ; +// l'écriture atomique à la racine ; l'écrivain différé, à délai borné, sous +// un planificateur factice. Les réglages se lisent et s'écrivent sur le +// système de fichiers d'épreuve. +import assert from 'node:assert/strict'; +import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { describe, test } from '../../test/lanceur.js'; +import { ErreurStockage } from './erreurs.js'; +import { + FICHIER_REGLAGES, + avecCadrage, + avecPapier, + cadrageDe, + creerEcrivainDiffere, + ecrireReglages, + lireReglages, + serialiserReglages, +} from './reglages_locaux.js'; + +const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' }; + +// Les réglages d'un dossier qui n'en a pas : ni papier choisi, ni cadrage. +const DEFAUTS = Object.freeze({ format: 1, papier: null, cadrages: Object.freeze([]) }); + +// Un disque neuf dont la racine documents existe, et son premier processus ; +// texte, quand il est donné, est le fichier des réglages. +function disque(texte = null) { + const fs = creerFichiersSimules({ disque: new Map() }); + fs.deposerDossier('documents'); + if (texte !== null) fs.deposer('documents', FICHIER_REGLAGES, texte); + return fs; +} + +// L'avertissement d'un fichier illisible, de raison donnée. +const illisibles = (raison) => ({ code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } }); + +// Trois réglages successifs : le papier, puis un cadrage, puis ce cadrage +// zoomé et déplacé. +const R1 = { format: 1, papier: 'A4', cadrages: [] }; +const R2 = { format: 1, papier: 'A4', cadrages: [{ evenement: 'evt-a', k: 1, tx: 0, ty: 0 }] }; +const R3 = { format: 1, papier: 'A4', cadrages: [{ evenement: 'evt-a', k: 2, tx: -40, ty: 15 }] }; + +// Un planificateur factice : planifier inscrit un minuteur et rend son +// numéro, annuler le retire, echoir déclenche ceux qui restent, dans l'ordre +// de leur armement ; armements et annulations comptent les appels. +function planificateur() { + const minuteurs = new Map(); + const compte = { armements: 0, annulations: 0 }; + return { + compte, + planifier: (fonction, delai) => { + compte.armements += 1; + minuteurs.set(compte.armements, { fonction, delai }); + return compte.armements; + }, + annuler: (numero) => { + compte.annulations += 1; + minuteurs.delete(numero); + }, + echoir: () => { + const echus = [...minuteurs.values()]; + minuteurs.clear(); + for (const { fonction } of echus) fonction(); + }, + delais: () => [...minuteurs.values()].map(({ delai }) => delai), + }; +} + +// Laisse s'écouler les promesses en attente et les rejets non traités. +const attendre = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// Exécute travail en relevant chaque rejet de promesse que rien ne traite ; +// rend ce relevé. +async function rejetsNonTraites(travail) { + const rejets = []; + const ecouteur = (raison) => rejets.push(raison); + process.on('unhandledRejection', ecouteur); + try { + await travail(); + await attendre(); + await attendre(); + } finally { + process.off('unhandledRejection', ecouteur); + } + return rejets; +} + +describe('réglages locaux : lire, jamais bloquant (§ 8.5)', () => { + test('un fichier absent rend les défauts, sans avertissement ni écriture', async () => { + const fs = disque(); + assert.deepEqual(await lireReglages(fs, DOC), { reglages: DEFAUTS, avertissement: null }); + assert.equal(fs.compteurs.ecritures, 0); + }); + + test('un fichier illisible rend les défauts et REGLAGES_ILLISIBLES, sa raison nommée, sans lever ni écrire', async () => { + const cadrage = (plus) => JSON.stringify({ evenement: 'evt-a', k: 1, tx: 0, ty: 0, ...plus }); + const avecCadrages = (...cadrages) => `{"cadrages":[${cadrages.join(',')}],"format":1,"papier":null}\n`; + // Par cas : le texte du fichier, et la raison de l'avertissement. + const cas = [ + ['un texte vide', '', 'JSON'], + ['du JSON invalide', '{"papier":', 'JSON'], + ['une liste', '[]\n', 'FORME'], + ['un format absent', '{"cadrages":[],"papier":null}\n', 'FORME'], + ['un format non entier', '{"cadrages":[],"format":1.5,"papier":null}\n', 'FORME'], + ['un format nul', '{"cadrages":[],"format":0,"papier":null}\n', 'FORME'], + ['un papier absent', '{"cadrages":[],"format":1}\n', 'FORME'], + ['un papier inconnu', '{"cadrages":[],"format":1,"papier":"A3"}\n', 'FORME'], + ['des cadrages absents', '{"format":1,"papier":null}\n', 'FORME'], + ['un cadrage qui n’est pas un objet', avecCadrages('[]'), 'FORME'], + ['un événement vide', avecCadrages(cadrage({ evenement: '' })), 'FORME'], + ['un zoom nul', avecCadrages(cadrage({ k: 0 })), 'FORME'], + ['un zoom négatif', avecCadrages(cadrage({ k: -1 })), 'FORME'], + ['une translation en texte', avecCadrages(cadrage({ tx: '10' })), 'FORME'], + ['une translation nulle', avecCadrages(cadrage({ ty: null })), 'FORME'], + ['un cadrage en double', avecCadrages(cadrage({}), cadrage({ k: 2 })), 'FORME'], + ]; + for (const [nom, texte, raison] of cas) { + const fs = disque(texte); + assert.deepEqual( + await lireReglages(fs, DOC), + { reglages: DEFAUTS, avertissement: illisibles(raison) }, + nom, + ); + assert.equal(fs.compteurs.ecritures, 0, nom); + } + }); + + test('une lecture refusée par le système de fichiers donne les défauts et son code pour raison ; une panne du processus remonte', async () => { + const fs = disque('{"cadrages":[],"format":1,"papier":"A4"}\n'); + fs.lireTexte = async (racine, chemin) => { + throw new ErreurStockage('LECTURE', { chemin, dossier: racine.chemin, cause: 'EACCES' }); + }; + assert.deepEqual(await lireReglages(fs, DOC), { reglages: DEFAUTS, avertissement: illisibles('LECTURE') }); + + const coupe = disque('{"cadrages":[],"format":1,"papier":"A4"}\n'); + coupe.pannes.couperApres(0); + await assert.rejects(lireReglages(coupe, DOC), PanneSimulee); + }); + + test('un fichier lisible rend son papier et ses cadrages triés par événement, ses clés inconnues et son format gardés ; une marque d’ordre d’octets en tête est admise', async () => { + const texte = + '\u{FEFF}{"format":2,"papier":"Lettre","theme":"sombre","cadrages":[' + + '{"evenement":"evt-b","k":0.5,"tx":10,"ty":-20,"rotation":90},' + + '{"evenement":"evt-a","k":1.25,"tx":0,"ty":0}]}\n'; + const fs = disque(texte); + const { reglages, avertissement } = await lireReglages(fs, DOC); + assert.equal(avertissement, null); + assert.deepEqual(reglages, { + format: 2, + papier: 'Lettre', + theme: 'sombre', + cadrages: [ + { evenement: 'evt-a', k: 1.25, tx: 0, ty: 0 }, + { evenement: 'evt-b', k: 0.5, tx: 10, ty: -20, rotation: 90 }, + ], + }); + assert.deepEqual(cadrageDe(reglages, 'evt-b'), { k: 0.5, tx: 10, ty: -20 }); + assert.equal(cadrageDe(reglages, 'evt-c'), null); + assert.equal(fs.compteurs.ecritures, 0); + }); +}); + +describe('réglages locaux : la forme canonique et l’écriture', () => { + test('serialiserReglages range les clés à chaque niveau et les cadrages par identifiant d’événement, et finit sur une fin de ligne : deux constructions dans deux ordres donnent le même texte', () => { + const attendu = + '{"cadrages":[{"evenement":"evt-a","k":1.25,"tx":0,"ty":0},' + + '{"evenement":"evt-b","k":0.5,"tx":10,"ty":-20}],"format":1,"papier":"A4"}\n'; + const a = avecCadrage( + avecCadrage(avecPapier(DEFAUTS, 'A4'), 'evt-b', { k: 0.5, tx: 10, ty: -20 }), + 'evt-a', + { k: 1.25, tx: 0, ty: 0 }, + ); + const b = avecPapier( + avecCadrage(avecCadrage(DEFAUTS, 'evt-a', { k: 1.25, tx: 0, ty: 0 }), 'evt-b', { k: 0.5, tx: 10, ty: -20 }), + 'A4', + ); + const aLaMain = { + papier: 'A4', + format: 1, + cadrages: [ + { ty: -20, tx: 10, k: 0.5, evenement: 'evt-b' }, + { evenement: 'evt-a', k: 1.25, tx: 0, ty: 0 }, + ], + }; + for (const reglages of [a, b, aLaMain]) assert.equal(serialiserReglages(reglages), attendu); + assert.equal( + serialiserReglages({ zeta: { b: 1, a: [2, { d: 3, c: 4 }] }, ...DEFAUTS }), + '{"cadrages":[],"format":1,"papier":null,"zeta":{"a":[2,{"c":4,"d":3}],"b":1}}\n', + ); + assert.throws(() => serialiserReglages({ ...DEFAUTS, papier: 'A3' }), TypeError); + const zoomIndefini = { evenement: 'evt-a', k: Number.NaN, tx: 0, ty: 0 }; + assert.throws(() => serialiserReglages({ ...DEFAUTS, cadrages: [zoomIndefini] }), TypeError); + assert.throws(() => serialiserReglages(null), TypeError); + }); + + test("avecCadrage remplace le cadrage d'un événement ou l'ajoute à son rang, avecPapier pose le papier, sans modifier les réglages reçus ; une valeur hors de sa règle lève TypeError", () => { + const depart = avecCadrage(DEFAUTS, 'evt-b', { k: 0.5, tx: 10, ty: -20 }); + const copie = structuredClone(depart); + const ajoute = avecCadrage(depart, 'evt-a', { k: 1, tx: 1, ty: 1 }); + const remplace = avecCadrage(ajoute, 'evt-b', { k: 2, tx: 0, ty: 0 }); + assert.deepEqual(depart, copie); + assert.deepEqual(DEFAUTS, { format: 1, papier: null, cadrages: [] }); + assert.deepEqual(remplace.cadrages, [ + { evenement: 'evt-a', k: 1, tx: 1, ty: 1 }, + { evenement: 'evt-b', k: 2, tx: 0, ty: 0 }, + ]); + const lettre = avecPapier(remplace, 'Lettre'); + assert.deepEqual([remplace.papier, lettre.papier, avecPapier(lettre, null).papier], [null, 'Lettre', null]); + assert.deepEqual(lettre.cadrages, remplace.cadrages); + const refus = [ + ['un zoom nul', () => avecCadrage(DEFAUTS, 'evt-a', { k: 0, tx: 0, ty: 0 })], + ['un zoom négatif', () => avecCadrage(DEFAUTS, 'evt-a', { k: -1, tx: 0, ty: 0 })], + ['une translation infinie', () => avecCadrage(DEFAUTS, 'evt-a', { k: 1, tx: Number.POSITIVE_INFINITY, ty: 0 })], + ['une translation absente', () => avecCadrage(DEFAUTS, 'evt-a', { k: 1, tx: 0 })], + ['un événement vide', () => avecCadrage(DEFAUTS, '', { k: 1, tx: 0, ty: 0 })], + ['un papier inconnu', () => avecPapier(DEFAUTS, 'A3')], + ]; + for (const [nom, appel] of refus) assert.throws(appel, TypeError, nom); + }); + + test("ecrireReglages écrit le texte canonique par une seule écriture atomique à la racine, sans créer un dossier de travail qui manque", async () => { + const fs = disque(); + const ecritures = []; + const ecrire = fs.ecrireAtomique; + fs.ecrireAtomique = (racine, chemin, texte) => { + ecritures.push(chemin); + return ecrire(racine, chemin, texte); + }; + await ecrireReglages(fs, DOC, R3); + assert.deepEqual(ecritures, [FICHIER_REGLAGES]); + assert.equal(fs.compteurs.ecritures, 1); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), serialiserReglages(R3)); + assert.deepEqual(await lireReglages(fs, DOC), { reglages: R3, avertissement: null }); + + const sansDossier = creerFichiersSimules({ disque: new Map() }); + await assert.rejects(ecrireReglages(sansDossier, DOC, R1), (erreur) => erreur.code === 'ECRITURE'); + }); + + test("une clé inconnue d'un fichier lisible survit à la réécriture, à tout niveau, son format compris : le réglage d'une construction plus récente reste", async () => { + const texte = + '{"cadrages":[{"evenement":"evt-a","k":1,"rotation":90,"tx":0,"ty":0}],' + + '"format":2,"papier":null,"theme":"sombre"}\n'; + const fs = disque(texte); + const { reglages } = await lireReglages(fs, DOC); + await ecrireReglages(fs, DOC, avecPapier(reglages, 'A4')); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte.replace('"papier":null', '"papier":"A4"')); + }); +}); + +describe('réglages locaux : l’écriture différée (§ 8.2, § 8.5)', () => { + test('trois demandes rapprochées arment un seul minuteur, jamais réarmé, et donnent une écriture, la dernière ; une demande après elle en arme un autre', async () => { + const ecrits = []; + const minuterie = planificateur(); + const ecrivain = creerEcrivainDiffere({ + ecrire: async (reglages) => { + ecrits.push(reglages); + }, + planifier: minuterie.planifier, + annuler: minuterie.annuler, + delaiMs: 500, + }); + ecrivain.demander(R1); + ecrivain.demander(R2); + ecrivain.demander(R3); + assert.deepEqual(minuterie.compte, { armements: 1, annulations: 0 }); + assert.deepEqual(minuterie.delais(), [500]); + assert.deepEqual(ecrits, []); + minuterie.echoir(); + await attendre(); + assert.deepEqual(ecrits, [R3]); + + ecrivain.demander(R1); + assert.deepEqual(minuterie.compte, { armements: 2, annulations: 0 }); + minuterie.echoir(); + await attendre(); + assert.deepEqual(ecrits, [R3, R1]); + }); + + test("vider annule le minuteur et écrit tout de suite la demande en attente, et rend la promesse de l'écriture ; sans demande en attente, il n'écrit rien", async () => { + const ecrits = []; + const minuterie = planificateur(); + const ecrivain = creerEcrivainDiffere({ + ecrire: async (reglages) => { + ecrits.push(reglages); + }, + planifier: minuterie.planifier, + annuler: minuterie.annuler, + delaiMs: 500, + }); + await ecrivain.vider(); + assert.deepEqual([ecrits, minuterie.compte], [[], { armements: 0, annulations: 0 }]); + ecrivain.demander(R1); + ecrivain.demander(R2); + const promesse = ecrivain.vider(); + assert.deepEqual([minuterie.delais(), minuterie.compte.annulations], [[], 1]); + await promesse; + assert.deepEqual(ecrits, [R2]); + minuterie.echoir(); + await ecrivain.vider(); + assert.deepEqual(ecrits, [R2]); + }); + + test("une écriture refusée au terme du minuteur va au signaleur, jamais en rejet non traité ; les réglages restent en attente pour la demande suivante ou pour vider, qui rejette à son tour", async () => { + let refuser = true; + const ecrits = []; + const signales = []; + const refus = new ErreurStockage('ECRITURE', { chemin: FICHIER_REGLAGES, dossier: DOC.chemin, cause: 'EROFS' }); + const ecrire = async (reglages) => { + if (refuser) throw refus; + ecrits.push(reglages); + }; + const minuterie = planificateur(); + const commun = { ecrire, planifier: minuterie.planifier, annuler: minuterie.annuler, delaiMs: 500 }; + const rejets = await rejetsNonTraites(async () => { + const ecrivain = creerEcrivainDiffere({ ...commun, signaler: (erreur) => signales.push(erreur) }); + ecrivain.demander(R1); + minuterie.echoir(); + await attendre(); + assert.deepEqual([signales, ecrits], [[refus], []]); + await assert.rejects(ecrivain.vider(), (erreur) => erreur === refus); + refuser = false; + await ecrivain.vider(); + assert.deepEqual(ecrits, [R1]); + + // Sans signaleur, le refus se tait ; la demande suivante remplace + // celle qui attend, et l'écrit. + const muet = creerEcrivainDiffere(commun); + refuser = true; + muet.demander(R2); + minuterie.echoir(); + await attendre(); + refuser = false; + muet.demander(R3); + minuterie.echoir(); + await attendre(); + assert.deepEqual(ecrits, [R1, R3]); + }); + assert.deepEqual(rejets, []); + assert.equal(signales.length, 1); + }); + + test("les écritures ne se chevauchent jamais : celle d'une demande suivante attend la fin de la précédente, et vider attend celle en cours", async () => { + const appels = []; + const liberations = []; + const minuterie = planificateur(); + const ecrivain = creerEcrivainDiffere({ + ecrire: (reglages) => { + appels.push(reglages); + return new Promise((resolve) => liberations.push(resolve)); + }, + planifier: minuterie.planifier, + annuler: minuterie.annuler, + delaiMs: 500, + }); + ecrivain.demander(R1); + minuterie.echoir(); + await attendre(); + ecrivain.demander(R2); + minuterie.echoir(); + await attendre(); + assert.deepEqual(appels, [R1]); + liberations[0](); + await attendre(); + assert.deepEqual(appels, [R1, R2]); + let fini = false; + const fin = ecrivain.vider().then(() => { + fini = true; + }); + await attendre(); + assert.equal(fini, false); + liberations[1](); + await fin; + assert.equal(fini, true); + }); + + test("la dernière demande l'emporte : une écriture refusée pendant que la suivante attend la fin ne revient pas en attente, et vider n'écrit ensuite rien de plus", async () => { + // Les deux premières écritures se tiennent jusqu'à ce que l'épreuve les + // libère ou les refuse ; les suivantes réussissent tout de suite. ecrits + // reçoit les réglages de chaque écriture réussie. + const ecrits = []; + const tenues = []; + const signales = []; + const refus = new ErreurStockage('ECRITURE', { chemin: FICHIER_REGLAGES, dossier: DOC.chemin, cause: 'EBUSY' }); + const minuterie = planificateur(); + const rejets = await rejetsNonTraites(async () => { + const ecrivain = creerEcrivainDiffere({ + ecrire: (reglages) => + new Promise((resolve, reject) => { + const liberer = () => { + ecrits.push(reglages); + resolve(); + }; + if (tenues.length < 2) tenues.push({ liberer, refuser: reject }); + else liberer(); + }), + planifier: minuterie.planifier, + annuler: minuterie.annuler, + delaiMs: 500, + signaler: (erreur) => signales.push(erreur), + }); + ecrivain.demander(R1); + minuterie.echoir(); + await attendre(); + ecrivain.demander(R2); + minuterie.echoir(); + await attendre(); + assert.equal(tenues.length, 1); + tenues[0].refuser(refus); + await attendre(); + assert.equal(tenues.length, 2); + tenues[1].liberer(); + await ecrivain.vider(); + assert.deepEqual(ecrits, [R2]); + assert.deepEqual(minuterie.delais(), []); + }); + assert.deepEqual([rejets, signales], [[], [refus]]); + }); + + test("creerEcrivainDiffere refuse des paramètres hors de leur forme, TypeError", () => { + const valides = { ecrire: async () => {}, planifier: () => 1, annuler: () => {}, delaiMs: 500 }; + const refus = [ + ['ecrire absent', { ...valides, ecrire: undefined }], + ['planifier absent', { ...valides, planifier: null }], + ['annuler absent', { ...valides, annuler: 'non' }], + ['un délai négatif', { ...valides, delaiMs: -1 }], + ['un délai qui n’est pas un nombre', { ...valides, delaiMs: '500' }], + ['un signaleur qui n’est pas une fonction', { ...valides, signaler: 1 }], + ]; + for (const [nom, parametres] of refus) assert.throws(() => creerEcrivainDiffere(parametres), TypeError, nom); + assert.equal(typeof creerEcrivainDiffere({ ...valides, signaler: null }).demander, 'function'); + }); +}); diff --git a/src/stockage/systeme_fichiers.js b/src/stockage/systeme_fichiers.js index e790f68..8e4f976 100644 --- a/src/stockage/systeme_fichiers.js +++ b/src/stockage/systeme_fichiers.js @@ -109,7 +109,11 @@ import { ErreurStockage } from './erreurs.js'; * les entrées directes du dossier, triées par nom, unités UTF-16 * comparées une à une ; ABSENT pour un dossier absent * @property {(r: Racine, chemin: string) => Promise} creerDossier - * parents compris ; un dossier existant n'est pas une faute + * '' crée la racine elle-même, parents compris ; un autre chemin + * crée ce qui lui manque sous la racine, qui doit exister : une + * racine absente lève ECRITURE, sans rien créer, car un dossier de + * travail disparu ne renaît pas en silence (§ 8.6). Un dossier + * existant n'est pas une faute * @property {(r: Racine, de: string, vers: string) => Promise} deplacer * un fichier ; refuse une cible existante (EXISTE), comparée comme le * système compare les noms, si bien que sans égard à la casse une diff --git a/src/stockage/types.js b/src/stockage/types.js index 21e747a..ca91fd7 100644 --- a/src/stockage/types.js +++ b/src/stockage/types.js @@ -312,11 +312,21 @@ * @property {string} hote * @property {string} depuis * - * @typedef {Object} ReglagesLocaux reglages_locaux.json - * @property {number} format + * @typedef {Object} ReglagesLocaux reglages_locaux.json (reglages_locaux.js), + * JSON sur une ligne, les clés de chaque objet rangées par unités + * UTF-16 ; une clé inconnue, à tout niveau, se garde à la réécriture + * @property {number} format entier ≥ 1, gardé tel que lu * @property {null|'A4'|'Lettre'} papier * @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages - * triés par identifiant d'événement + * triés par identifiant d'événement, un par événement ; k fini + * > 0, tx et ty finis + * + * Avertissement de la lecture des réglages locaux, qui ne lève jamais : + * + * REGLAGES_ILLISIBLES {chemin, raison} le fichier ne se lit pas, et + * les défauts s'appliquent ; + * raison JSON, FORME, ou le code + * du refus de lecture */ /** diff --git a/test/fichiers_electron.long.test.js b/test/fichiers_electron.long.test.js index 20be7f0..352e8dc 100644 --- a/test/fichiers_electron.long.test.js +++ b/test/fichiers_electron.long.test.js @@ -180,6 +180,22 @@ describe('coquille sur un vrai disque', () => { assert.deepEqual(readdirSync(portable), ['garde.txt']); }); + testNettoye("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => { + const dossier = dossierTemporaire(); + const { fs, portable } = systemeSur(dossier, { publie: join('cle', 'soirees') }); + const racine = (await fs.racines()).portable; + await echoue(fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', { + chemin: 'corbeille/2026-01-02_03-04-05', + dossier: join(portable, 'corbeille'), + cause: 'ENOENT', + }); + assert.deepEqual(readdirSync(dossier), []); + await fs.creerDossier(racine, ''); + assert.deepEqual(readdirSync(portable), []); + await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'); + assert.deepEqual(readdirSync(join(portable, 'corbeille')), ['2026-01-02_03-04-05']); + }); + testNettoye( "un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine", { skip: SOUS_ROOT || SOUS_WINDOWS }, diff --git a/test/fichiers_electron.test.js b/test/fichiers_electron.test.js index b860226..c18eaf0 100644 --- a/test/fichiers_electron.test.js +++ b/test/fichiers_electron.test.js @@ -81,9 +81,10 @@ function erreurSysteme(code, syscall, chemin) { * cible absolue. fs porte les fonctions de node:fs/promises dont la coquille * se sert et lève les erreurs du système dans les mêmes cas — ENOENT, * ENOTDIR, EISDIR, EEXIST, ELOOP. Un lien se suit partout, sauf en dernier - * segment de lstat, unlink, rename et d'une ouverture exclusive (wx). Les - * noms que rend readdir sortent dans l'ordre inverse de leur création : un - * tri omis se voit. + * segment de lstat, unlink, rename, mkdir sans recursive et d'une ouverture + * exclusive (wx). mkdir sans recursive crée un seul dossier, son parent + * présent ; avec, tous ceux qui manquent. Les noms que rend readdir sortent + * dans l'ordre inverse de leur création : un tri omis se voit. * * appels chaque appel, « fonction chemin [drapeaux | cible] », les * écritures d'une poignée comprises (write, sync, close) @@ -229,7 +230,13 @@ function creerDisque() { }, async mkdir(chemin, options) { appel('mkdir', chemin); - assert.deepEqual(options, { recursive: true }, 'mkdir sans recursive'); + if (options === undefined) { + const { chemin: resolu, noeud } = resoudre(chemin, false, 'mkdir'); + if (noeud !== undefined) throw erreurSysteme('EEXIST', 'mkdir', chemin); + creer(resolu, 'dossier'); + return; + } + assert.deepEqual(options, { recursive: true }, 'mkdir aux options inconnues'); const segments = chemin.split('/').filter((segment) => segment !== ''); let courant = '/'; for (let i = 0; i < segments.length; i += 1) { @@ -746,6 +753,20 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi await refuse(fichiers.creerDossier(DOC, 'occupe/sous'), 'ECRITURE', ecriture('occupe/sous', `${DOCUMENTS}/occupe`, 'ENOTDIR')); }); + test("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => { + const { disque, fichiers } = preparer(); + await refuse( + fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'), + 'ECRITURE', + ecriture('corbeille/2026-01-02_03-04-05', `${DATA}/corbeille`, 'ENOENT'), + ); + assert.equal(disque.existe(PUBLIE), false); + await fichiers.creerDossier(PORTABLE, ''); + assert.deepEqual(disque.noms(DATA), []); + await fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'); + assert.deepEqual(disque.noms(`${DATA}/corbeille`), ['2026-01-02_03-04-05']); + }); + test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => { const { disque, fichiers, pauses } = preparer(); disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat'); diff --git a/test/fichiers_simules.js b/test/fichiers_simules.js index ddec98b..c0275ca 100644 --- a/test/fichiers_simules.js +++ b/test/fichiers_simules.js @@ -17,10 +17,11 @@ // quand la casse ne compte pas ; à côté de lui, hors de la Map, son horloge et // sa table des processus. Les pannes et les compteurs sont ceux de l'instance. // -// Une racine n'existe qu'une fois créée — par sonder, creerDossier ou, sans -// compter, deposerDossier —, et aucune écriture ne crée le dossier qui porte -// sa cible : l'épreuve voit ainsi le dossier de travail qu'aucun code n'a -// créé, ou qui a disparu, comme le verrait la coquille. +// Une racine n'existe qu'une fois créée — par sonder, creerDossier de '' ou, +// sans compter, deposerDossier —, et aucune écriture ne crée le dossier qui +// porte sa cible, ni creerDossier d'un autre chemin la racine : l'épreuve voit +// ainsi le dossier de travail qu'aucun code n'a créé, ou qui a disparu, comme +// le verrait la coquille. import { ErreurStockage } from '../src/stockage/erreurs.js'; import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js'; @@ -202,10 +203,10 @@ export function creerFichiersSimules({ const maintenant = () => etat.horloge; // Ce que désigne un chemin relatif à une racine : son chemin absolu en - // parties, la clé dans le disque de chacun de ses préfixes, sa clé et celle - // de son parent, son nom, et le dossier que nomme une ECRITURE — celui qui le - // porte, ou la racine elle-même pour ''. CHEMIN_REFUSE pour un chemin refusé - // ou une racine inconnue. + // parties, la clé dans le disque de chacun de ses préfixes, sa clé, celle + // de son parent et celle de la racine, son nom, et le dossier que nomme une + // ECRITURE — celui qui le porte, ou la racine elle-même pour ''. + // CHEMIN_REFUSE pour un chemin refusé ou une racine inconnue. function localiser(racine, chemin) { const segments = exigerCheminRelatif(chemin); const base = racines.get(racine?.id); @@ -221,6 +222,7 @@ export function creerFichiersSimules({ cleDe, cle: cleDe(n), cleParent: n > 1 ? cleDe(n - 1) : null, + cleRacine: cleDe(n - segments.length), nom: parties[n - 1], dossier: absolu(segments.length > 0 ? n - 1 : n), }; @@ -418,9 +420,12 @@ export function creerFichiersSimules({ return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0)); }), + // '' crée la racine, parents compris ; un autre chemin ne crée que ce qui + // manque sous elle, et une racine absente lève ECRITURE, ENOENT. creerDossier: primitive('ecritures', (racine, chemin) => { const loc = localiser(racine, chemin); exigerInscriptible(loc); + if (chemin !== '' && !disque.has(loc.cleRacine)) throw refus(loc, 'ENOENT'); creerDossiers(loc, loc.parties.length, avancer); }), diff --git a/test/fichiers_simules.test.js b/test/fichiers_simules.test.js index 2ac4ea6..9457607 100644 --- a/test/fichiers_simules.test.js +++ b/test/fichiers_simules.test.js @@ -586,4 +586,18 @@ describe('fichiers simulés : préconditions et dossiers', () => { ); assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']); }); + + test("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => { + const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); + await echoue(fs.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', { + chemin: 'corbeille/2026-01-02_03-04-05', + dossier: `${DATA}\\corbeille`, + cause: 'ENOENT', + }); + await echoue(fs.lister(PORTABLE, ''), 'ABSENT', { chemin: '' }); + await fs.creerDossier(PORTABLE, ''); + assert.deepEqual(await fs.lister(PORTABLE, ''), []); + await fs.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'); + assert.deepEqual(noms(await fs.lister(PORTABLE, 'corbeille')), ['2026-01-02_03-04-05']); + }); });