gestion_table_tournante_libre/test/fichiers_simules.test.js

697 lines
35 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du système de fichiers d'épreuve (§ 13.4, § 14.10). La suite de
// contrat s'y joue sur trois réglages : Windows, Unix, et celui de la
// plateforme web, sans renommage atomique ni verrou. Suit ce qui n'appartient
// qu'à lui : ses réglages, qui en ressortent tels qu'il les reçoit, ses
// racines et emplacements, la casse des noms, les pannes qu'il simule —
// renommage refusé, support qui refuse l'écriture, processus coupé puis
// rouvert sur le même disque —, le verrou entre processus, les compteurs, et
// ce que l'épreuve pose ou lit sans compter. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { eprouverContrat } from './contrat_fichiers.js';
import { PanneSimulee, creerFichiersSimules } from './fichiers_simules.js';
import { describe, test } from './lanceur.js';
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
async function rejette(promesse, message) {
let raison;
await assert.rejects(
promesse,
(erreur) => {
raison = erreur;
return true;
},
message,
);
return raison;
}
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
async function echoue(promesse, code, details) {
const erreur = await rejette(promesse, code);
assert.ok(erreur instanceof ErreurStockage, String(erreur));
assert.equal(erreur.code, code);
assert.deepEqual(erreur.details, details);
}
// Rend l'erreur que lève la fonction ; échoue quand elle n'en lève pas.
function erreurDe(fonction) {
try {
fonction();
} catch (erreur) {
return erreur;
}
assert.fail('aucune erreur levée');
}
const noms = (entrees) => entrees.map((entree) => entree.nom);
const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
const DOC = { id: 'documents', chemin: DOCUMENTS };
const DATA = 'E:\\soirees\\data';
const PORTABLE = { id: 'portable', chemin: DATA };
// Un système d'épreuve dont la racine documents existe, posée sans compter.
function preparer(options = {}) {
const fs = creerFichiersSimules(options);
fs.deposerDossier('documents');
return fs;
}
// Les trois réglages sur lesquels la suite de contrat se joue.
const REGLAGES = [
['épreuve, sous Windows', {}],
[
'épreuve, sous Unix',
{
separateur: '/',
insensibleCasse: false,
documents: '/home/exemple/Documents/Gestion table tournante Libre',
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
},
],
[
'épreuve, comme la plateforme web',
{
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
separateur: '/',
insensibleCasse: false,
documents: '/gestion_table_tournante_libre',
},
],
];
for (const [nom, options] of REGLAGES) {
eprouverContrat(
nom,
async () => {
const fs = preparer(options);
return { fs, racine: (await fs.racines()).documents };
},
{ describe, test, egal: assert.deepStrictEqual, vrai: assert.ok, rejette },
);
}
describe('fichiers simulés : racines et emplacements', () => {
test('par défaut : Windows, sans égard à la casse, sans exécutable publié, la seule racine documents', async () => {
const fs = creerFichiersSimules();
assert.equal(fs.nature, 'epreuve');
assert.equal(fs.renommageAtomique, true);
assert.equal(fs.verrouDisponible, true);
assert.deepEqual(await fs.emplacements(), {
executable: null,
donneesApplicatives: ['C:\\Users\\Exemple\\AppData\\Roaming', 'C:\\Users\\Exemple\\AppData\\Local'],
insensibleCasse: true,
separateur: '\\',
});
assert.deepEqual(await fs.racines(), { portable: null, documents: DOC });
await echoue(fs.lireTexte(PORTABLE, 'soiree.gtt.json'), 'CHEMIN_REFUSE', { chemin: 'soiree.gtt.json' });
});
test('les réglages donnés en ressortent tels quels : nature, garanties, hôte, emplacements, racine documents', async () => {
// Chaque réglage diffère de sa valeur par défaut : une valeur que le
// double figerait au lieu de la reprendre fait échouer l'épreuve.
// donneesApplicatives est la seule voie par laquelle il simule un
// dossier de travail sous un emplacement de données applicatives
// (§ 8.6, point 2).
const documents = '/home/exemple/Documents/Gestion table tournante Libre';
const fs = creerFichiersSimules({
documents,
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
separateur: '/',
insensibleCasse: false,
nature: 'web',
renommageAtomique: false,
verrouDisponible: false,
hote: 'autre-poste',
});
assert.equal(fs.nature, 'web');
assert.equal(fs.renommageAtomique, false);
assert.equal(fs.verrouDisponible, false);
assert.equal(fs.hote, 'autre-poste');
assert.deepEqual(await fs.emplacements(), {
executable: null,
donneesApplicatives: ['/home/exemple/.config', '/tmp'],
insensibleCasse: false,
separateur: '/',
});
assert.deepEqual(await fs.racines(), { portable: null, documents: { id: 'documents', chemin: documents } });
});
test("un exécutable publié ouvre la racine portable, data/ à côté de lui, au séparateur du système", async () => {
const cas = [
[{ executable: 'E:\\soirees' }, 'E:\\soirees\\data'],
[{ executable: 'E:\\' }, 'E:\\data'],
[{ executable: '/media/cle/soirees', separateur: '/', insensibleCasse: false }, '/media/cle/soirees/data'],
];
for (const [options, chemin] of cas) {
const fs = creerFichiersSimules(options);
assert.deepEqual((await fs.racines()).portable, { id: 'portable', chemin });
assert.equal((await fs.emplacements()).executable, options.executable);
}
});
test('typeSupport rend le support déclaré pour une racine, fixe par défaut', async () => {
const fs = creerFichiersSimules({ executable: 'E:\\soirees', support: { portable: 'amovible' } });
assert.equal(await fs.typeSupport(PORTABLE), 'amovible');
assert.equal(await fs.typeSupport(DOC), 'fixe');
});
test("une racine n'existe qu'une fois créée : avant, lister lève ABSENT et écrire ECRITURE (ENOENT) ; sonder la crée", async () => {
const fs = creerFichiersSimules();
const absent = (chemin) => ({ chemin, dossier: DOCUMENTS, cause: 'ENOENT' });
await echoue(fs.lister(DOC, ''), 'ABSENT', { chemin: '' });
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', absent('soiree.gtt.json'));
await echoue(fs.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', 'x'), 'ECRITURE', absent('soiree.gtt-journal.jsonl'));
await echoue(fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', absent('soiree.gtt.verrou'));
assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null });
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test('deplacer vers un dossier absent nomme la cible et son dossier, et laisse la source', async () => {
const fs = preparer();
fs.deposer('documents', 'soiree.gtt.json', 'état');
await echoue(fs.deplacer(DOC, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'), 'ECRITURE', {
chemin: 'corbeille/soiree.gtt.json',
dossier: `${DOCUMENTS}\\corbeille`,
cause: 'ENOENT',
});
assert.equal(fs.contenu('documents', 'soiree.gtt.json'), 'état');
});
test('une sonde déclarée en échec rend sa cause, et sa racine refuse toute écriture', async () => {
const fs = creerFichiersSimules({ executable: 'E:\\soirees', sonde: { portable: 'EACCES' } });
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EACCES' });
await echoue(fs.creerDossier(PORTABLE, ''), 'ECRITURE', { chemin: '', dossier: DATA, cause: 'EACCES' });
assert.deepEqual(await fs.sonder(DOC), { inscriptible: true, cause: null });
});
test("un fichier qui occupe le chemin de la racine fait échouer la sonde, sans lever (EEXIST)", async () => {
// L'exécutable est publié dans E:\soirees, que la racine documents
// désigne aussi : un fichier data y occupe le chemin de la racine portable.
const fs = creerFichiersSimules({ executable: 'E:\\soirees', documents: 'E:\\soirees' });
fs.deposer('documents', 'data', 'un fichier, pas un dossier');
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EEXIST' });
assert.equal(fs.contenu('documents', 'data'), 'un fichier, pas un dossier');
});
test("les dialogues natifs rendent null, comme quand l'opérateur annule ; ouvrir une racine inconnue est refusé", async () => {
const fs = preparer();
assert.equal(await fs.choisirDossier(), null);
assert.equal(await fs.choisirFichierAImporter(), null);
assert.equal(await fs.enregistrerSous('liste.csv', new Uint8Array([0x61])), null);
assert.equal(await fs.ouvrirDansExplorateur(DOC), undefined);
await echoue(fs.ouvrirDansExplorateur(PORTABLE), 'CHEMIN_REFUSE', { chemin: '' });
});
test("dossiersChoisis : choisirDossier rend chacun dans l'ordre, choisi-<n>, puis null ; la racine rendue s'inscrit, et n'existe qu'une fois créée", async () => {
const SECOURS = 'D:\\Secours';
const fs = creerFichiersSimules({ dossiersChoisis: [SECOURS, 'F:\\Repli'] });
const choisi = { id: 'choisi-1', chemin: SECOURS };
// Avant le choix, la racine n'est pas connue du système.
await echoue(fs.lister(choisi, ''), 'CHEMIN_REFUSE', { chemin: '' });
const avant = { ...fs.compteurs };
assert.deepEqual(await fs.choisirDossier(), choisi);
assert.deepEqual(await fs.choisirDossier(), { id: 'choisi-2', chemin: 'F:\\Repli' });
assert.equal(await fs.choisirDossier(), null);
assert.equal(await fs.choisirDossier(), null);
assert.deepEqual(fs.compteurs, avant, 'le dialogue ne compte pas');
assert.deepEqual(await fs.racines(), { portable: null, documents: DOC }, 'les racines du système restent les deux');
await echoue(fs.lister(choisi, ''), 'ABSENT', { chemin: '' });
const absent = { chemin: 'soiree.gtt.json', dossier: SECOURS, cause: 'ENOENT' };
await echoue(fs.ecrireAtomique(choisi, 'soiree.gtt.json', 'x'), 'ECRITURE', absent);
await fs.creerDossier(choisi, '');
await fs.ecrireAtomique(choisi, 'soiree.gtt.json', 'état');
assert.equal(fs.contenu('choisi-1', 'soiree.gtt.json'), 'état');
assert.deepEqual(noms(await fs.lister(choisi, '')), ['soiree.gtt.json']);
fs.pannes.ecriture('choisi-2');
await echoue(fs.creerDossier({ id: 'choisi-2', chemin: 'F:\\Repli' }, ''), 'ECRITURE', {
chemin: '',
dossier: 'F:\\Repli',
cause: 'EROFS',
});
await echoue(fs.lister({ id: 'choisi-3', chemin: 'G:\\' }, ''), 'CHEMIN_REFUSE', { chemin: '' });
});
test('dossiersChoisis : un chemin qui n\u{2019}est pas une chaîne non vide lève TypeError à la création', () => {
for (const fautif of [[''], [null], 'D:\\Secours']) {
assert.ok(erreurDe(() => creerFichiersSimules({ dossiersChoisis: fautif })) instanceof TypeError, JSON.stringify(fautif));
}
});
});
describe('fichiers simulés : casse des noms', () => {
test('sans égard à la casse, une autre casse désigne le même fichier, et lister rend la casse de sa création', async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, 'Soir\u{E9}e.gtt.json', 'état');
assert.equal(await fs.lireTexte(DOC, 'SOIR\u{C9}E.GTT.JSON'), 'état');
await fs.ajouterLigne(DOC, 'Soir\u{E9}e.gtt-journal.jsonl', 'a');
await fs.ajouterLigne(DOC, 'soir\u{E9}e.GTT-JOURNAL.jsonl', 'b');
assert.deepEqual(noms(await fs.lister(DOC, '')), [
'Soir\u{E9}e.gtt-journal.jsonl',
'Soir\u{E9}e.gtt.json',
]);
assert.equal(fs.contenu('documents', 'SOIR\u{C9}E.gtt-journal.jsonl'), 'a\nb\n');
});
test('la casse se replie caractère par caractère, comme Windows : un sigma final rejoint sa majuscule, un eszett ne devient pas SS', async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, '\u{3A3}\u{391}\u{3A3}.txt', 'majuscules');
assert.equal(await fs.lireTexte(DOC, '\u{3C3}\u{3B1}\u{3C2}.txt'), 'majuscules');
await fs.ecrireAtomique(DOC, 'stra\u{DF}e.txt', 'eszett');
await fs.ecrireAtomique(DOC, 'STRASSE.txt', 'deux s');
assert.equal(await fs.lireTexte(DOC, 'stra\u{DF}e.txt'), 'eszett');
assert.equal(await fs.lireTexte(DOC, 'STRASSE.txt'), 'deux s');
});
test('renommer par-dessus donne au fichier la casse du nouveau nom ; deplacer vers une autre casse du même nom lève EXISTE', async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, 'Soir\u{E9}e.gtt.json', 'un');
await fs.ecrireAtomique(DOC, 'soir\u{E9}e.gtt.json', 'deux');
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soir\u{E9}e.gtt.json']);
assert.equal(fs.contenu('documents', 'SOIR\u{C9}E.gtt.json'), 'deux');
await echoue(fs.deplacer(DOC, 'soir\u{E9}e.gtt.json', 'SOIR\u{C9}E.gtt.json'), 'EXISTE', {
chemin: 'SOIR\u{C9}E.gtt.json',
});
});
test('sensible à la casse, deux casses font deux fichiers', async () => {
const racine = '/home/exemple/Documents/Gestion table tournante Libre';
const fs = preparer({ separateur: '/', insensibleCasse: false, documents: racine });
const doc = { id: 'documents', chemin: racine };
await fs.ecrireAtomique(doc, 'soir\u{E9}e.gtt.json', 'minuscule');
await fs.ecrireAtomique(doc, 'Soir\u{E9}e.gtt.json', 'majuscule');
assert.deepEqual(noms(await fs.lister(doc, '')), ['Soir\u{E9}e.gtt.json', 'soir\u{E9}e.gtt.json']);
await echoue(fs.lireTexte(doc, 'SOIR\u{C9}E.GTT.JSON'), 'ABSENT', { chemin: 'SOIR\u{C9}E.GTT.JSON' });
});
test('un disque partagé garde la casse et le séparateur de sa première instance', () => {
const disque = new Map();
creerFichiersSimules({ disque });
assert.throws(() => creerFichiersSimules({ disque, insensibleCasse: false }));
assert.throws(() => creerFichiersSimules({ disque, separateur: '/' }));
creerFichiersSimules({ disque });
});
});
describe('fichiers simulés : renommage refusé (§ 8.8, § 14.10)', () => {
test("un renommage refusé laisse la cible intacte, retire le fichier d'écriture et lève ECRITURE en nommant fichier et dossier", async () => {
const fs = preparer();
fs.deposer('documents', 'soiree.gtt.json', 'avant');
fs.pannes.renommage('documents');
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'après'), 'ECRITURE', {
chemin: 'soiree.gtt.json',
dossier: DOCUMENTS,
cause: 'EBUSY',
});
assert.equal(fs.contenu('documents', 'soiree.gtt.json'), 'avant');
assert.equal(fs.contenu('documents', 'soiree.gtt.json.ecriture'), null);
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.json']);
});
test("l'écriture réessaie le renommage : dix refus et elle passe, onze et elle échoue", async () => {
const fs = preparer();
fs.pannes.renommage('documents', 10, 'EPERM');
await fs.ecrireAtomique(DOC, 'etat.json', 'après dix refus');
assert.equal(fs.contenu('documents', 'etat.json'), 'après dix refus');
fs.pannes.renommage('documents', 11, 'EPERM');
await echoue(fs.ecrireAtomique(DOC, 'etat.json', 'après onze refus'), 'ECRITURE', {
chemin: 'etat.json',
dossier: DOCUMENTS,
cause: 'EPERM',
});
assert.equal(fs.contenu('documents', 'etat.json'), 'après dix refus');
});
test('la panne de renommage ne touche que sa racine, et refuse aussi deplacer', async () => {
const fs = preparer({ executable: 'E:\\soirees' });
fs.deposer('portable', 'soiree.gtt.json', 'état');
fs.pannes.renommage('portable');
await fs.ecrireAtomique(DOC, 'ailleurs.json', 'passe');
assert.equal(fs.contenu('documents', 'ailleurs.json'), 'passe');
await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'soiree-2.gtt.json'), 'ECRITURE', {
chemin: 'soiree.gtt.json',
dossier: DATA,
cause: 'EBUSY',
});
assert.equal(fs.contenu('portable', 'soiree.gtt.json'), 'état');
assert.equal(fs.contenu('portable', 'soiree-2.gtt.json'), null);
});
test("sans renommage atomique, ecrireAtomique écrit droit sur la cible : une panne de renommage ne l'arrête pas", async () => {
const fs = preparer({ renommageAtomique: false });
fs.deposer('documents', 'etat.json', 'avant');
fs.pannes.renommage('documents');
await fs.ecrireAtomique(DOC, 'etat.json', 'après');
assert.equal(fs.contenu('documents', 'etat.json'), 'après');
assert.deepEqual(noms(await fs.lister(DOC, '')), ['etat.json']);
});
});
describe("fichiers simulés : support qui refuse l'écriture", () => {
test("après pannes.ecriture, chaque primitive d'écriture de la racine lève ECRITURE avec la cause, la sonde échoue, et rien ne change", async () => {
const fs = preparer({ executable: 'E:\\soirees' });
fs.deposer('portable', 'soiree.gtt.json', 'état');
fs.deposer('portable', 'soiree.gtt-journal.jsonl', 'ligne\n');
fs.deposer('portable', 'soiree.gtt.verrou', '{"seance":"seance-a"}');
fs.pannes.ecriture('portable');
const refus = (chemin) => ({ chemin, dossier: DATA, cause: 'EROFS' });
await echoue(fs.ecrireAtomique(PORTABLE, 'soiree.gtt.json', 'nouveau'), 'ECRITURE', refus('soiree.gtt.json'));
await echoue(
fs.ajouterLigne(PORTABLE, 'soiree.gtt-journal.jsonl', 'suite'),
'ECRITURE',
refus('soiree.gtt-journal.jsonl'),
);
await echoue(fs.creerDossier(PORTABLE, 'corbeille'), 'ECRITURE', refus('corbeille'));
await echoue(fs.deplacer(PORTABLE, 'soiree.gtt.json', 'autre.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
await echoue(fs.supprimer(PORTABLE, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json'));
await echoue(fs.verrouiller(PORTABLE, 'neuf.gtt.verrou', 'seance-b'), 'ECRITURE', refus('neuf.gtt.verrou'));
await echoue(fs.deverrouiller(PORTABLE, 'soiree.gtt.verrou', 'seance-a'), 'ECRITURE', refus('soiree.gtt.verrou'));
assert.deepEqual(await fs.sonder(PORTABLE), { inscriptible: false, cause: 'EROFS' });
assert.deepEqual(noms(await fs.lister(PORTABLE, '')), [
'soiree.gtt-journal.jsonl',
'soiree.gtt.json',
'soiree.gtt.verrou',
]);
assert.equal(await fs.lireTexte(PORTABLE, 'soiree.gtt.json'), 'état');
assert.equal(await fs.lireTexte(PORTABLE, 'soiree.gtt-journal.jsonl'), 'ligne\n');
});
test("une cause donnée remplace EROFS, et l'autre racine écrit encore", async () => {
const fs = preparer({ executable: 'E:\\soirees' });
fs.deposerDossier('portable');
fs.pannes.ecriture('documents', 'ENOSPC');
await echoue(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'), 'ECRITURE', {
chemin: 'soiree.gtt.json',
dossier: DOCUMENTS,
cause: 'ENOSPC',
});
await fs.ecrireAtomique(PORTABLE, 'soiree.gtt.json', 'écrit ailleurs');
assert.equal(fs.contenu('portable', 'soiree.gtt.json'), 'écrit ailleurs');
});
});
describe('fichiers simulés : processus coupé (couperApres)', () => {
test('après n écritures réussies, toute primitive lève PanneSimulee ; une nouvelle instance sur le même disque relit les n écritures et écrit à son tour', async () => {
const disque = new Map();
const premiere = creerFichiersSimules({ disque });
premiere.deposerDossier('documents');
premiere.deposer('documents', 'soiree.gtt.json', 'révision 1');
premiere.pannes.couperApres(2);
await premiere.ajouterLigne(DOC, 'soiree.gtt-journal.jsonl', '{"revision":2}');
await premiere.ecrireAtomique(DOC, 'soiree.gtt.json.precedent', 'révision 1');
const panne = await rejette(premiere.ecrireAtomique(DOC, 'soiree.gtt.json', 'révision 2'));
assert.ok(panne instanceof PanneSimulee, String(panne));
assert.ok(!(panne instanceof ErreurStockage));
await assert.rejects(premiere.lireTexte(DOC, 'soiree.gtt.json'), PanneSimulee);
await assert.rejects(premiere.lister(DOC, ''), PanneSimulee);
await assert.rejects(premiere.emplacements(), PanneSimulee);
await assert.rejects(premiere.sonder(DOC), PanneSimulee);
const reouverte = creerFichiersSimules({ disque });
assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt-journal.jsonl'), '{"revision":2}\n');
assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt.json.precedent'), 'révision 1');
assert.equal(await reouverte.lireTexte(DOC, 'soiree.gtt.json'), 'révision 1');
await reouverte.ecrireAtomique(DOC, 'soiree.gtt.json', 'révision 2');
assert.equal(premiere.contenu('documents', 'soiree.gtt.json'), 'révision 2');
});
test('une écriture refusée ne compte pas parmi les n', async () => {
const fs = preparer();
fs.pannes.renommage('documents');
fs.pannes.couperApres(1);
const refus = await rejette(fs.ecrireAtomique(DOC, 'soiree.gtt.json', 'x'));
assert.equal(refus.code, 'ECRITURE');
await fs.creerDossier(DOC, 'corbeille');
await assert.rejects(fs.lister(DOC, ''), PanneSimulee);
});
test('couperApres(0) arrête le processus sur-le-champ', async () => {
const fs = preparer();
fs.pannes.couperApres(0);
await assert.rejects(fs.lister(DOC, ''), PanneSimulee);
});
test("les pannes refusent un compte qui n'est pas un entier positif ou nul", () => {
const fs = preparer();
assert.throws(() => fs.pannes.renommage('documents', -1), TypeError);
assert.throws(() => fs.pannes.renommage('documents', 1.5), TypeError);
assert.throws(() => fs.pannes.couperApres(-1), TypeError);
assert.throws(() => fs.pannes.couperApres(Infinity), TypeError);
});
});
describe('fichiers simulés : verrou', () => {
test("le fichier de verrou porte la séance, le pid et l'hôte du processus, et l'instant de sa prise", async () => {
const fs = preparer();
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const texte = fs.contenu('documents', 'soiree.gtt.verrou');
assert.match(
texte,
/^\{"seance":"seance-a","pid":\d+,"hote":"poste-epreuve","depuis":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00"\}\n$/,
);
const { pid, depuis } = JSON.parse(texte);
assert.equal(pid, fs.pid);
assert.equal(fs.hote, 'poste-epreuve');
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'seance-a',
depuis,
vivant: true,
});
});
test('la création est exclusive, même pour la séance qui tient le verrou', async () => {
const fs = preparer();
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
const second = await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.deepEqual([second.pris, second.seance, second.vivant], [false, 'seance-a', true]);
});
test('vivant : vrai pour un processus en vie sur la même machine, faux pour un processus coupé, null pour une autre machine', async () => {
const disque = new Map();
const a = creerFichiersSimules({ disque });
const b = creerFichiersSimules({ disque });
const ailleurs = creerFichiersSimules({ disque, hote: 'autre-poste' });
a.deposerDossier('documents');
assert.notEqual(a.pid, b.pid);
await a.verrouiller(DOC, 'a.gtt.verrou', 'seance-a');
const vuDeB = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b');
assert.deepEqual([vuDeB.seance, vuDeB.vivant], ['seance-a', true]);
await ailleurs.verrouiller(DOC, 'c.gtt.verrou', 'seance-c');
const vuDeA = await a.verrouiller(DOC, 'c.gtt.verrou', 'seance-x');
assert.deepEqual([vuDeA.seance, vuDeA.vivant], ['seance-c', null]);
// Le pid d'un processus de l'autre machine ne désigne aucun processus de
// celle-ci.
const usurpe = { seance: 'seance-u', pid: ailleurs.pid, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
a.deposer('documents', 'u.gtt.verrou', JSON.stringify(usurpe));
assert.equal((await a.verrouiller(DOC, 'u.gtt.verrou', 'seance-x')).vivant, false);
a.pannes.couperApres(0);
const apresCoupure = await b.verrouiller(DOC, 'a.gtt.verrou', 'seance-b');
assert.deepEqual([apresCoupure.seance, apresCoupure.vivant], ['seance-a', false]);
});
test("un verrou illisible ne se lit ni ne s'efface ; un pid absent de la machine est mort", async () => {
const fs = preparer();
const illisible = { pris: false, seance: null, depuis: null, vivant: null };
fs.deposer('documents', 'tronque.gtt.verrou', '{"seance":');
assert.deepEqual(await fs.verrouiller(DOC, 'tronque.gtt.verrou', 'seance-a'), illisible);
await fs.deverrouiller(DOC, 'tronque.gtt.verrou', 'seance-a');
assert.equal(fs.contenu('documents', 'tronque.gtt.verrou'), '{"seance":');
fs.deposer('documents', 'forme.gtt.verrou', '{"seance":"seance-a","pid":"7","hote":"poste-epreuve","depuis":"x"}');
assert.deepEqual(await fs.verrouiller(DOC, 'forme.gtt.verrou', 'seance-b'), illisible);
const ancien = { seance: 'ancienne', pid: 4242, hote: 'poste-epreuve', depuis: '2026-01-01T08:00:00+00:00' };
fs.deposer('documents', 'mort.gtt.verrou', JSON.stringify(ancien));
assert.deepEqual(await fs.verrouiller(DOC, 'mort.gtt.verrou', 'seance-b'), {
pris: false,
seance: 'ancienne',
depuis: '2026-01-01T08:00:00+00:00',
vivant: false,
});
});
test("sans verrou disponible, verrouiller rend pris sans rien écrire et deverrouiller n'efface rien", async () => {
const fs = preparer({ verrouDisponible: false });
fs.deposer('documents', 'soiree.gtt.verrou', '{"seance":"seance-a"}');
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-a'), { pris: true });
assert.deepEqual(await fs.verrouiller(DOC, 'neuf.gtt.verrou', 'seance-b'), { pris: true });
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
assert.deepEqual(noms(await fs.lister(DOC, '')), ['soiree.gtt.verrou']);
});
});
describe('fichiers simulés : compteurs, préparation, horloge', () => {
test("chaque appel d'une primitive de lecture compte une lecture et chaque appel d'une primitive d'écriture une écriture, refusé compris ; les autres ne comptent pas", async () => {
const fs = preparer();
await fs.ecrireAtomique(DOC, 'a.txt', 'a');
await fs.ajouterLigne(DOC, 'journal.jsonl', 'ligne');
await fs.creerDossier(DOC, 'corbeille');
await fs.deplacer(DOC, 'a.txt', 'corbeille/a.txt');
await fs.supprimer(DOC, 'corbeille/a.txt');
await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-a');
await fs.sonder(DOC);
await assert.rejects(fs.ecrireAtomique(DOC, '../a.txt', 'a'));
await fs.lireTexte(DOC, 'journal.jsonl');
await fs.lister(DOC, '');
await assert.rejects(fs.lireTexte(DOC, 'absent.txt'));
await fs.emplacements();
await fs.racines();
await fs.typeSupport(DOC);
await fs.choisirDossier();
await fs.ouvrirDansExplorateur(DOC);
await fs.choisirFichierAImporter();
await fs.enregistrerSous('liste.csv', new Uint8Array([0x61]));
fs.contenu('documents', 'journal.jsonl');
fs.deposer('documents', 'pose.txt', 'posé');
assert.deepEqual(fs.compteurs, { ecritures: 9, lectures: 3 });
});
test('deposer et deposerDossier posent sans compter, dossiers parents compris ; contenu lit sans compter et rend null hors d\u{2019}un fichier', async () => {
const fs = creerFichiersSimules();
fs.deposerDossier('documents', 'corbeille/2026-01-02_03-04-05');
fs.deposer('documents', 'archives/2025/soiree.gtt.json', 'état', 5);
assert.equal(fs.contenu('documents', 'archives/2025/soiree.gtt.json'), 'état');
assert.equal(fs.contenu('documents', 'ARCHIVES/2025/SOIREE.GTT.JSON'), 'état');
assert.equal(fs.contenu('documents', 'archives/2025'), null);
assert.equal(fs.contenu('documents', 'absent.txt'), null);
assert.deepEqual(fs.compteurs, { ecritures: 0, lectures: 0 });
assert.deepEqual(await fs.lister(DOC, 'archives/2025'), [
{ nom: 'soiree.gtt.json', type: 'fichier', taille: 5, modifie: 5 },
]);
assert.deepEqual(noms(await fs.lister(DOC, '')), ['archives', 'corbeille']);
assert.equal(erreurDe(() => fs.deposer('inconnue', 'x.txt', 'x')).code, 'CHEMIN_REFUSE');
assert.equal(erreurDe(() => fs.pannes.ecriture('inconnue')).code, 'CHEMIN_REFUSE');
});
test("modifie suit l'horloge du disque : chaque écriture l'avance ; deposer pose l'instant courant, ou celui qu'on lui donne", async () => {
const fs = preparer();
fs.deposer('documents', 'a.txt', 'a');
fs.deposer('documents', 'b.txt', 'b');
fs.deposer('documents', 'c.txt', 'c', 7);
await fs.ecrireAtomique(DOC, 'd.txt', 'd');
await fs.ecrireAtomique(DOC, 'e.txt', 'e');
const [a, b, c, d, e] = await fs.lister(DOC, '');
assert.equal(a.modifie, b.modifie);
assert.equal(c.modifie, 7);
assert.ok(d.modifie > a.modifie, `${d.modifie} > ${a.modifie}`);
assert.ok(e.modifie > d.modifie, `${e.modifie} > ${d.modifie}`);
});
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00
describe('fichiers simulés : octets déposés et décodage strict', () => {
// « Selvi » puis l'octet E8, un « è » enregistré en windows-1252 : une suite
// qui n'est pas de l'UTF-8.
const HORS_UTF8 = new Uint8Array([0x7b, 0x22, 0x53, 0x65, 0x6c, 0x76, 0x69, 0xe8, 0x22, 0x7d]);
test("lireTexte refuse des octets qui ne sont pas de l'UTF-8 : LECTURE {chemin, dossier, cause: UTF8_INVALIDE, octets}, comptée, ses octets une copie dont le message ne dit que le nombre ; lister en donne la taille, octets les rend tels quels", async () => {
const fs = preparer();
fs.deposerOctets('documents', 'archives/soiree.gtt.json', HORS_UTF8);
await echoue(fs.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE', {
chemin: 'archives/soiree.gtt.json',
dossier: `${DOCUMENTS}\\archives`,
cause: 'UTF8_INVALIDE',
octets: HORS_UTF8,
});
const refus = await rejette(fs.lireTexte(DOC, 'archives/soiree.gtt.json'), 'LECTURE');
assert.equal(
refus.message,
`LECTURE {"chemin":"archives/soiree.gtt.json","dossier":${JSON.stringify(`${DOCUMENTS}\\archives`)},"cause":"UTF8_INVALIDE","octets":"10 octets"}`,
);
refus.details.octets[0] = 0;
assert.deepEqual(fs.octets('documents', 'archives/soiree.gtt.json'), HORS_UTF8);
assert.deepEqual(fs.compteurs, { ecritures: 0, lectures: 2 });
assert.deepEqual(await fs.lister(DOC, 'archives'), [
{ nom: 'soiree.gtt.json', type: 'fichier', taille: 10, modifie: (await fs.lister(DOC, 'archives'))[0].modifie },
]);
assert.deepEqual(fs.octets('documents', 'archives/soiree.gtt.json'), HORS_UTF8);
assert.equal(erreurDe(() => fs.contenu('documents', 'archives/soiree.gtt.json')).name, 'TypeError');
});
test("des octets UTF-8 déposés se lisent tels quels, marque d'ordre d'octets comprise ; un ajout en fin garde les octets d'avant, une écriture atomique les remplace", async () => {
const fs = preparer();
fs.deposerOctets('documents', 'bom.csv', new Uint8Array([0xef, 0xbb, 0xbf, 0xc3, 0xa9, 0x0d, 0x0a]));
assert.equal(await fs.lireTexte(DOC, 'bom.csv'), '\u{FEFF}\u{E9}\r\n');
assert.equal(fs.contenu('documents', 'bom.csv'), '\u{FEFF}\u{E9}\r\n');
fs.deposerOctets('documents', 'journal.jsonl', HORS_UTF8);
await fs.ajouterLigne(DOC, 'journal.jsonl', '\u{E9}');
assert.deepEqual(fs.octets('documents', 'journal.jsonl'), new Uint8Array([...HORS_UTF8, 0xc3, 0xa9, 0x0a]));
await fs.ecrireAtomique(DOC, 'journal.jsonl', 'neuf');
assert.equal(await fs.lireTexte(DOC, 'journal.jsonl'), 'neuf');
assert.deepEqual(fs.octets('documents', 'journal.jsonl'), new Uint8Array([0x6e, 0x65, 0x75, 0x66]));
assert.equal(fs.octets('documents', 'absent.txt'), null);
assert.equal(erreurDe(() => fs.deposerOctets('documents', 'x.txt', 'x')).name, 'TypeError');
});
test("un verrou aux octets hors UTF-8 se lit comme un verrou illisible", async () => {
const fs = preparer();
fs.deposerOctets('documents', 'soiree.gtt.verrou', HORS_UTF8);
assert.deepEqual(await fs.verrouiller(DOC, 'soiree.gtt.verrou', 'seance-b'), {
pris: false,
seance: null,
depuis: null,
vivant: null,
});
await fs.deverrouiller(DOC, 'soiree.gtt.verrou', 'seance-b');
assert.deepEqual(fs.octets('documents', 'soiree.gtt.verrou'), HORS_UTF8);
});
});
describe('fichiers simulés : préconditions et dossiers', () => {
test("ajouterLigne refuse une ligne qui porte une fin de ligne ; ecrireAtomique, un texte qui n'est pas une chaîne ; verrouiller, une séance vide", async () => {
const fs = preparer();
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\nb'), TypeError);
await assert.rejects(fs.ajouterLigne(DOC, 'journal.jsonl', 'a\r'), TypeError);
await assert.rejects(fs.ecrireAtomique(DOC, 'etat.json', new Uint8Array([0x61])), TypeError);
await assert.rejects(fs.verrouiller(DOC, 'soiree.gtt.verrou', ''), TypeError);
assert.deepEqual(await fs.lister(DOC, ''), []);
});
test('un dossier ne se lit, ne s\u{2019}écrit, ne se déplace ni ne se supprime comme un fichier, et un fichier ne se liste pas', async () => {
const fs = preparer();
fs.deposerDossier('documents', 'corbeille');
fs.deposer('documents', 'soiree.gtt.json', 'état');
const refus = (chemin, cause, dossier = DOCUMENTS) => ({ chemin, dossier, cause });
await echoue(fs.lireTexte(DOC, 'corbeille'), 'ABSENT', { chemin: 'corbeille' });
await echoue(fs.lister(DOC, 'soiree.gtt.json'), 'ABSENT', { chemin: 'soiree.gtt.json' });
await echoue(fs.ecrireAtomique(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.ajouterLigne(DOC, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.verrouiller(DOC, 'corbeille', 'seance-a'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.deplacer(DOC, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.supprimer(DOC, 'corbeille'), 'ECRITURE', refus('corbeille', 'EISDIR'));
await echoue(fs.creerDossier(DOC, 'soiree.gtt.json'), 'ECRITURE', refus('soiree.gtt.json', 'EEXIST'));
await echoue(
fs.creerDossier(DOC, 'soiree.gtt.json/sous'),
'ECRITURE',
refus('soiree.gtt.json/sous', 'ENOTDIR', `${DOCUMENTS}\\soiree.gtt.json`),
);
assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']);
});
[ADD] storage: copy, rename, import, delete events; local settings § 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:05 -04:00
test("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']);
});
});