The working screens of an evening. The home screen lists the events of the working folder, asks its question, loads demos, copies, renames, imports and deletes, and shows what the trash holds. An open event frames its mode, offers Modifier, Débloquer and a confirmed lock takeover, and shows its warnings. Participants get a grid, a form and a file; an import goes through a preview and a report; tables, the default and tours have their screens; the plan draws tables, chairs, empty seats and names. Checked: 2342 node, 356 browser, 60 node-long; the Electron start check. --- FR --- [ADD] interface : liste, événement, participants, import, tables, plan Les écrans de travail d'une soirée. L'accueil liste les événements du dossier de travail, pose sa question, charge les démonstrations, copie, renomme, importe et supprime, et montre ce que tient la corbeille. Un événement ouvert encadre son mode, offre Modifier, Débloquer et une reprise de verrou confirmée, et montre ses avertissements. Les participants ont une grille, un formulaire et une fiche ; un import passe par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ; le plan dessine tables, chaises, sièges vides et noms. Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron. Assisted-by: Claude Opus 5.5
696 lines
35 KiB
JavaScript
696 lines
35 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: '' });
|
|
});
|
|
|
|
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}`);
|
|
});
|
|
});
|
|
|
|
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']);
|
|
});
|
|
|
|
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']);
|
|
});
|
|
});
|