Storage code receives its file system as a parameter (§ 13.4): one interface, and here the implementation tests inject. It keeps files in memory, shares a disk between two instances to replay a restart, and fails on demand: a refused rename that leaves the target intact, writes refused from a moment on, a process killed after n writes, a failing probe, a removable medium. One contract suite, its assertions passed in, will run unchanged on the Electron and web implementations. Checked: 87 tests, the contract suite run on the test double. --- FR --- [ADD] stockage : interface du système de fichiers, double, contrat Le code du stockage reçoit son système de fichiers en paramètre (§ 13.4) : une interface, et ici l'implémentation que les épreuves injectent. Elle garde les fichiers en mémoire, partage un disque entre deux instances pour rejouer un redémarrage, et tombe en panne à la demande : renommage refusé qui laisse la cible intacte, écritures refusées à partir d'un instant, processus tué après n écritures, sonde qui échoue, support amovible. Une suite de contrat, ses assertions reçues en paramètre, servira telle quelle sous Electron et sous web. Vérifié : 87 épreuves, la suite de contrat jouée sur le double. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
589 lines
29 KiB
JavaScript
589 lines
29 KiB
JavaScript
// © 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: '' });
|
|
});
|
|
});
|
|
|
|
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}`);
|
|
});
|
|
});
|
|
|
|
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']);
|
|
});
|
|
});
|