[ADD] storage: file-system interface, failing test double, contract

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
This commit is contained in:
Mathieu Benoit 2026-10-06 13:12:25 -04:00
parent f24896c4a1
commit fdaa79ed32
5 changed files with 1665 additions and 0 deletions

View file

@ -0,0 +1,161 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le système de fichiers, frontière de plateforme du stockage (§ 13.4). Ce
// module en déclare l'interface, que trois implémentations portent : celle de
// la coquille electron, livrée ; celle de la plateforme web, sur l'OPFS ; celle
// d'épreuve, en mémoire, qui sait tomber en panne (test/fichiers_simules.js).
// Toute fonction du stockage qui touche un fichier reçoit un SystemeFichiers
// en paramètre (§ 14.10) ; la suite de contrat test/contrat_fichiers.js
// éprouve les trois implémentations par les mêmes épreuves.
//
// Une primitive ne reçoit jamais de chemin absolu : une racine, connue par son
// identifiant, et un chemin relatif à elle, aux segments séparés par « / »,
// sans « .. » ni chemin absolu ; '' désigne la racine elle-même. Le chemin
// d'une racine sert à l'affichage, jamais à résoudre un fichier. Toutes les
// primitives sont asynchrones. Un échec lève une ErreurStockage :
//
// ABSENT {chemin} lecture d'un fichier, ou liste
// d'un dossier, qui n'existe pas
// ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
// renommage refusés ; la cible est
// intacte. chemin est le chemin
// relatif reçu, dossier le chemin
// affichable du dossier qui le
// porte — la racine elle-même pour
// '' —, cause celle que rapporte
// la plateforme (EBUSY, EROFS…)
// EXISTE {chemin} deplacer vers une cible existante
// CHEMIN_REFUSE {chemin} chemin absolu ou remontant, racine
// inconnue
//
// types.js donne la table entière des codes du stockage.
import { ErreurStockage } from './erreurs.js';
/**
* Une racine : un dossier que l'implémentation connaît par son identifiant
* — portable, documents, ou un dossier choisi.
*
* @typedef {Object} Racine
* @property {string} id
* @property {string} chemin chemin absolu, affichable
*/
/**
* @typedef {Object} Emplacements
* @property {string|null} executable dossier publié par le lanceur portable
* @property {string[]} donneesApplicatives AppData, LocalAppData, temporaire, en absolu
* @property {boolean} insensibleCasse les chemins se comparent sans égard à la casse
* @property {'\\'|'/'} separateur
*/
/**
* Une entrée d'un dossier.
*
* @typedef {Object} Entree
* @property {string} nom
* @property {'fichier'|'dossier'} type
* @property {number} taille octets du fichier en UTF-8 ; 0 pour un dossier
* @property {number} modifie millisecondes, pour l'affichage seulement :
* aucune décision ne s'y fonde, puisque recopier un dossier suffit
* à les fausser (§ 14.10)
*/
/**
* Ce que rend verrouiller. pris : true quand l'appel a créé le verrou. Sinon,
* la séance qui le tient, l'instant de sa prise, et vivant : vrai quand son
* processus tourne sur la même machine, faux quand il y est mort, null quand
* il tourne sur une autre machine. Un verrou qui ne se lit pas rend seance,
* depuis et vivant à null.
*
* @typedef {{pris: true}
* | {pris: false, seance: string|null, depuis: string|null, vivant: boolean|null}} Verrou
*/
/**
* @typedef {Object} SystemeFichiers
* @property {'electron'|'web'|'epreuve'} nature
* @property {boolean} renommageAtomique faux sous web (§ 8.8)
* @property {boolean} verrouDisponible faux sous web (§ 8.8) : verrouiller
* rend alors { pris: true } à chaque appel, et deverrouiller n'efface rien
* @property {() => Promise<Emplacements>} emplacements
* @property {() => Promise<{portable: Racine|null, documents: Racine}>} racines
* portable : data/ à côté de l'exécutable publié ; documents : le
* dossier du produit dans les Documents (§ 8.6)
* @property {() => Promise<Racine|null>} choisirDossier dialogue natif ;
* null quand l'opérateur annule
* @property {(r: Racine) => Promise<{inscriptible: boolean, cause: string|null}>} sonder
* crée la racine, parents compris, efface un .gtt-temoin resté
* d'une séance précédente, écrit .gtt-temoin, le relit, l'efface
* (§ 8.6) ; un échec rend sa cause, sans lever
* @property {(r: Racine) => Promise<'amovible'|'fixe'|'inconnu'>} typeSupport
* @property {(r: Racine, chemin: string) => Promise<string>} lireTexte
* le texte tel qu'écrit : marque d'ordre d'octets, fins de ligne et
* forme de normalisation comprises ; ABSENT pour un fichier absent
* ou un dossier
* @property {(r: Racine, chemin: string, texte: string) => Promise<void>} ecrireAtomique
* <chemin>.ecriture, vidé sur le disque, renommé par-dessus ; réessaie
* le renommage ; un échec laisse la cible intacte (§ 8.8) ; là où
* renommageAtomique est faux, l'écriture va droit sur la cible. Le
* dossier qui porte la cible doit exister : un dossier absent lève
* ECRITURE, car un dossier de travail disparu — un support retiré —
* ne se recrée pas en silence
* @property {(r: Racine, chemin: string, ligne: string) => Promise<void>} ajouterLigne
* ajoute la ligne puis une fin de ligne LF après le dernier octet du
* fichier, sans rien insérer avant, vidé sur le disque ; crée le
* fichier absent, jamais son dossier. La ligne ne porte aucune fin
* de ligne
* @property {(r: Racine, dossier: string) => Promise<Entree[]>} lister
* les entrées directes du dossier, triées par nom, unités UTF-16
* comparées une à une ; ABSENT pour un dossier absent
* @property {(r: Racine, chemin: string) => Promise<void>} creerDossier
* parents compris ; un dossier existant n'est pas une faute
* @property {(r: Racine, de: string, vers: string) => Promise<void>} deplacer
* un fichier ; refuse une cible existante (EXISTE), comparée comme le
* système compare les noms, si bien que sans égard à la casse une
* autre casse du même nom est une cible existante ; ABSENT pour une
* source absente ; le dossier de la cible doit exister
* @property {(r: Racine, chemin: string) => Promise<void>} supprimer
* un fichier ; un fichier absent n'est pas une faute
* @property {(r: Racine, chemin: string, seance: string) => Promise<Verrou>} verrouiller
* création exclusive de {seance, pid, hote, depuis} : un verrou
* présent n'est jamais repris, fût-il de la même séance
* @property {(r: Racine, chemin: string, seance: string) => Promise<void>} deverrouiller
* n'efface que le verrou de cette séance ; un verrou absent n'est
* pas une faute
* @property {(r: Racine) => Promise<void>} ouvrirDansExplorateur
* @property {() => Promise<{nom: string, octets: Uint8Array}|null>} choisirFichierAImporter
* null quand l'opérateur annule
* @property {(nomPropose: string, octets: Uint8Array) => Promise<string|null>} enregistrerSous
* le chemin choisi, ou null quand l'opérateur annule
*/
// Motif d'un segment que toute implémentation accepte. Un segment vide
// vient d'un chemin absolu (« /x »), d'un séparateur doublé ou final. Un
// segment qui finit par un point ou une espace comprend « . » et « .. », et
// ce que Windows ramène à eux ou à un autre nom en retirant points et espaces
// finaux : « ... », « .. » suivi d'une espace, « soiree. ». La barre oblique
// inverse sépare les segments sous Windows ; le deux-points y désigne un
// lecteur (« C:\x », « C:x ») ou un flux de données secondaire ; le
// caractère nul tronque un chemin là où la plateforme le lit en C.
const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u;
/**
* Segments d'un chemin relatif à une racine : [] pour '', la racine
* elle-même. Le contrôle ne lit que le texte du chemin ; il ne connaît ni la
* racine ni les liens, que résout chaque implémentation.
*
* @param {string} chemin
* @returns {string[]}
* @throws {ErreurStockage} CHEMIN_REFUSE {chemin} : autre chose qu'une chaîne,
* ou un segment que SEGMENT_ADMIS refuse
*/
export function exigerCheminRelatif(chemin) {
if (typeof chemin !== 'string') throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
if (chemin === '') return [];
const segments = chemin.split('/');
if (!segments.every((segment) => SEGMENT_ADMIS.test(segment))) {
throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
}
return segments;
}

View file

@ -0,0 +1,84 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du contrôle des chemins relatifs à une racine (§ 13.4, § 14.10) :
// exigerCheminRelatif, que partagent l'implémentation web et celle
// d'épreuve, rend les segments d'un chemin admis, et refuse par
// CHEMIN_REFUSE tout chemin absolu, remontant, ou que Windows lirait
// autrement. Le reste du module est l'interface en JSDoc, que la suite de
// contrat test/contrat_fichiers.js éprouve sur chaque implémentation. Les
// noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { ErreurStockage } from './erreurs.js';
import { exigerCheminRelatif } from './systeme_fichiers.js';
// 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');
}
describe('exigerCheminRelatif', () => {
test("rend les segments d'un chemin relatif, et aucun pour la racine", () => {
const cas = [
['', []],
['soiree.gtt.json', ['soiree.gtt.json']],
[
'corbeille/2026-01-02_03-04-05/soiree.gtt.json',
['corbeille', '2026-01-02_03-04-05', 'soiree.gtt.json'],
],
['.gtt-temoin', ['.gtt-temoin']],
['..soiree', ['..soiree']],
[' devant', [' devant']],
['Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json', ['Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.gtt.json']],
];
for (const [chemin, segments] of cas) assert.deepEqual(exigerCheminRelatif(chemin), segments, chemin);
});
test('refuse un chemin absolu, remontant, ou que Windows lirait autrement : CHEMIN_REFUSE {chemin}', () => {
const refuses = [
// absolus, ou porteurs d'un segment vide
'/x',
'//serveur/partage/x',
'a//b',
'a/',
// remontants ou désignant le dossier courant
'..',
'../x',
'a/../../x',
'.',
'./x',
'a/./b',
// ce que Windows ramène à eux, ou à un autre nom, en retirant points et
// espaces finaux
'...',
'a/.. /x',
'soiree.',
'soiree ',
// lecteur, partage, séparateur de Windows, flux de données secondaire
'C:\\x',
'C:x',
'C:/x',
'\\\\serveur\\partage\\x',
'a\\b',
'soiree.gtt.json:flux',
// caractère nul
'a\u{0}b',
// autre chose qu'une chaîne
null,
undefined,
42,
];
for (const chemin of refuses) {
const erreur = erreurDe(() => exigerCheminRelatif(chemin));
assert.ok(erreur instanceof ErreurStockage, `${String(chemin)} : ${erreur}`);
assert.equal(erreur.code, 'CHEMIN_REFUSE', String(chemin));
assert.deepEqual(erreur.details, { chemin }, String(chemin));
}
});
});

314
test/contrat_fichiers.js Normal file
View file

@ -0,0 +1,314 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Suite de contrat du système de fichiers (§ 13.4, § 14.10) : ce que les trois
// implémentations de l'interface de src/stockage/systeme_fichiers.js —
// electron, web et celle d'épreuve — promettent pareillement au stockage. La
// suite ne connaît que l'interface, et reçoit de l'appelant son lanceur et
// ses assertions : elle se joue sous node avec node:assert comme dans le
// navigateur avec expect.
//
// describe(nom, corps), test(nom, corps) ceux du lanceur de l'appelant
// egal(reel, attendu, message?) égalité profonde et stricte
// vrai(valeur, message?) valeur vraie
// rejette(promesse, message?) échoue quand la promesse se
// résout ; rend la raison du rejet
//
// Chaque épreuve demande à fabrique() un système neuf et une racine qui
// existe et ne porte rien : aucune ne voit ce qu'une autre a écrit. Les
// pannes — renommage refusé, support qui refuse l'écriture, processus coupé —
// ne se provoquent que sur l'implémentation d'épreuve, et s'éprouvent à côté
// d'elle (test/fichiers_simules.test.js). Là où verrouDisponible est faux, la
// suite exige du verrou ce que la plateforme promet à la place : verrouiller
// rend { pris: true } à chaque appel, et deverrouiller ne lève pas.
import { ErreurStockage } from '../src/stockage/erreurs.js';
// Les primitives de l'interface, dans l'ordre où elle les déclare.
const PRIMITIVES = [
'emplacements',
'racines',
'choisirDossier',
'sonder',
'typeSupport',
'lireTexte',
'ecrireAtomique',
'ajouterLigne',
'lister',
'creerDossier',
'deplacer',
'supprimer',
'verrouiller',
'deverrouiller',
'ouvrirDansExplorateur',
'choisirFichierAImporter',
'enregistrerSous',
];
// Chemins qu'aucune implémentation n'accepte : remontant, absolu sous Unix,
// absolu sous Windows, remontant après un détour.
const CHEMINS_REFUSES = ['../x', '/x', 'C:\\x', 'a/../../x'];
// Les primitives qui reçoivent un chemin, chacune appelée avec le chemin
// donné à la place qu'elle éprouve. deplacer s'éprouve par sa source, vers
// une cible absente, puis par sa cible, depuis ici.txt, que l'épreuve pose :
// seul le chemin éprouvé peut alors faire échouer l'appel.
const APPELS_A_CHEMIN = [
['lireTexte', (fs, racine, chemin) => fs.lireTexte(racine, chemin)],
['ecrireAtomique', (fs, racine, chemin) => fs.ecrireAtomique(racine, chemin, 'texte')],
['ajouterLigne', (fs, racine, chemin) => fs.ajouterLigne(racine, chemin, 'ligne')],
['lister', (fs, racine, chemin) => fs.lister(racine, chemin)],
['creerDossier', (fs, racine, chemin) => fs.creerDossier(racine, chemin)],
['deplacer, source', (fs, racine, chemin) => fs.deplacer(racine, chemin, 'ailleurs.txt')],
['deplacer, cible', (fs, racine, chemin) => fs.deplacer(racine, 'ici.txt', chemin)],
['supprimer', (fs, racine, chemin) => fs.supprimer(racine, chemin)],
['verrouiller', (fs, racine, chemin) => fs.verrouiller(racine, chemin, 'seance-a')],
['deverrouiller', (fs, racine, chemin) => fs.deverrouiller(racine, chemin, 'seance-a')],
];
// Vrai pour la description d'une racine : un identifiant et un chemin
// affichable, chaînes non vides.
const estRacine = (racine) =>
racine !== null &&
typeof racine === 'object' &&
typeof racine.id === 'string' &&
racine.id !== '' &&
typeof racine.chemin === 'string' &&
racine.chemin !== '';
// Noms des entrées d'un dossier, dans l'ordre rendu.
const noms = (entrees) => entrees.map((entree) => entree.nom);
/**
* Enregistre la suite de contrat d'une implémentation.
*
* @param {string} nom nom de l'implémentation, repris dans celui de la suite
* @param {() => Promise<{fs: import('../src/stockage/systeme_fichiers.js').SystemeFichiers,
* racine: import('../src/stockage/systeme_fichiers.js').Racine}>} fabrique
* rend à chaque appel un système neuf, et une racine qui existe et ne
* porte rien
* @param {Object} outils describe, test, egal, vrai, rejette, décrits en tête
*/
export function eprouverContrat(nom, fabrique, { describe, test, egal, vrai, rejette }) {
// Attend le rejet d'une ErreurStockage de ce code et, quand ils sont
// donnés, de ces détails exactement ; rend l'erreur.
async function echoue(promesse, code, details, message = code) {
const erreur = await rejette(promesse, message);
vrai(erreur instanceof ErreurStockage, `${message} : ${erreur}`);
egal(erreur.code, code, message);
if (details !== undefined) egal(erreur.details, details, message);
return erreur;
}
// Attend un refus d'écriture : ECRITURE, dont les détails nomment l'un des
// chemins admis, le dossier et la cause, chaînes non vides.
async function refuseEcriture(promesse, ...chemins) {
const { details } = await echoue(promesse, 'ECRITURE', undefined, chemins[0]);
vrai(chemins.includes(details.chemin), `chemin ${details.chemin}`);
vrai(typeof details.dossier === 'string' && details.dossier !== '', `dossier ${details.dossier}`);
vrai(typeof details.cause === 'string' && details.cause !== '', `cause ${details.cause}`);
}
describe(`contrat du système de fichiers : ${nom}`, () => {
test("l'interface porte sa nature, ses deux garanties et ses dix-sept primitives, et décrit ses emplacements et ses racines", async () => {
const { fs, racine } = await fabrique();
vrai(['electron', 'web', 'epreuve'].includes(fs.nature), `nature ${fs.nature}`);
egal(typeof fs.renommageAtomique, 'boolean', 'renommageAtomique');
egal(typeof fs.verrouDisponible, 'boolean', 'verrouDisponible');
for (const primitive of PRIMITIVES) egal(typeof fs[primitive], 'function', primitive);
const emplacements = await fs.emplacements();
vrai(emplacements.executable === null || typeof emplacements.executable === 'string', 'executable');
vrai(
Array.isArray(emplacements.donneesApplicatives) &&
emplacements.donneesApplicatives.every((chemin) => typeof chemin === 'string'),
'donneesApplicatives',
);
egal(typeof emplacements.insensibleCasse, 'boolean', 'insensibleCasse');
vrai(emplacements.separateur === '\\' || emplacements.separateur === '/', 'separateur');
const racines = await fs.racines();
vrai(estRacine(racines.documents), 'racine documents');
vrai(racines.portable === null || estRacine(racines.portable), 'racine portable');
vrai(['amovible', 'fixe', 'inconnu'].includes(await fs.typeSupport(racine)), 'typeSupport');
});
test("lireTexte rend le texte écrit tel quel : marque d'ordre d'octets, accents composés ou non, CRLF, caractère hors du plan de base", async () => {
const { fs, racine } = await fabrique();
const texte = '\u{FEFF}Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}\r\n\u{C9}lan \u{AB} \u{F4} \u{BB} e\u{301}\rfin\n\u{1D11E}';
const chemin = 'Soir\u{E9}e d\u{2019}\u{E9}t\u{E9}.txt';
await fs.ecrireAtomique(racine, chemin, texte);
egal(await fs.lireTexte(racine, chemin), texte);
});
test('ecrireAtomique remplace le contenu entier et ne laisse aucun fichier .ecriture', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'etat.json', 'premier contenu, le plus long des deux');
await fs.ecrireAtomique(racine, 'etat.json', 'second');
egal(await fs.lireTexte(racine, 'etat.json'), 'second');
egal(noms(await fs.lister(racine, '')), ['etat.json']);
});
test('ajouterLigne ajoute la ligne et une fin de ligne LF après le dernier octet, et crée le fichier absent', async () => {
const { fs, racine } = await fabrique();
await fs.ajouterLigne(racine, 'journal.jsonl', '{"type":"journal"}');
egal(await fs.lireTexte(racine, 'journal.jsonl'), '{"type":"journal"}\n');
await fs.ajouterLigne(racine, 'journal.jsonl', '{"revision":1}');
egal(await fs.lireTexte(racine, 'journal.jsonl'), '{"type":"journal"}\n{"revision":1}\n');
// Une dernière ligne sans fin de ligne reste telle : l'ajout la prolonge.
await fs.ecrireAtomique(racine, 'tronque.jsonl', '{"type":"jour');
await fs.ajouterLigne(racine, 'tronque.jsonl', 'nal"}');
egal(await fs.lireTexte(racine, 'tronque.jsonl'), '{"type":"journal"}\n');
});
test("lister rend les entrées directes du dossier, triées par unités UTF-16, typées, la taille d'un fichier en octets UTF-8 et 0 pour un dossier", async () => {
const { fs, racine } = await fabrique();
await fs.creerDossier(racine, 'dossier');
await fs.ecrireAtomique(racine, 'dossier/interne.txt', 'x');
await fs.ecrireAtomique(racine, 'b.txt', 'bb');
await fs.ecrireAtomique(racine, '\u{E9}.txt', '\u{E9}');
await fs.ecrireAtomique(racine, 'a.txt', '');
await fs.ecrireAtomique(racine, 'Z.txt', '\u{1D11E}');
const entrees = await fs.lister(racine, '');
egal(
entrees.map(({ nom: nomEntree, type, taille }) => ({ nom: nomEntree, type, taille })),
[
{ nom: 'Z.txt', type: 'fichier', taille: 4 },
{ nom: 'a.txt', type: 'fichier', taille: 0 },
{ nom: 'b.txt', type: 'fichier', taille: 2 },
{ nom: 'dossier', type: 'dossier', taille: 0 },
{ nom: '\u{E9}.txt', type: 'fichier', taille: 2 },
],
);
vrai(entrees.every((entree) => Number.isFinite(entree.modifie)), 'modifie');
egal(noms(await fs.lister(racine, 'dossier')), ['interne.txt']);
});
test("creerDossier crée les dossiers parents, et un dossier existant n'est pas une faute", async () => {
const { fs, racine } = await fabrique();
await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05');
egal(
(await fs.lister(racine, '')).map(({ nom: nomEntree, type }) => ({ nom: nomEntree, type })),
[{ nom: 'corbeille', type: 'dossier' }],
);
egal(
(await fs.lister(racine, 'corbeille')).map(({ nom: nomEntree, type }) => ({ nom: nomEntree, type })),
[{ nom: '2026-01-02_03-04-05', type: 'dossier' }],
);
egal(await fs.lister(racine, 'corbeille/2026-01-02_03-04-05'), []);
await fs.creerDossier(racine, 'corbeille');
egal(noms(await fs.lister(racine, 'corbeille')), ['2026-01-02_03-04-05']);
});
test("deplacer porte le fichier sous son nouveau chemin et retire l'ancien", async () => {
const { fs, racine } = await fabrique();
await fs.creerDossier(racine, 'corbeille');
await fs.ecrireAtomique(racine, 'soiree.gtt.json', '\u{E9}tat');
await fs.deplacer(racine, 'soiree.gtt.json', 'corbeille/soiree.gtt.json');
egal(await fs.lireTexte(racine, 'corbeille/soiree.gtt.json'), '\u{E9}tat');
egal(noms(await fs.lister(racine, '')), ['corbeille']);
});
test('deplacer refuse une cible existante (EXISTE) et laisse les deux fichiers intacts', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'source.txt', 'source');
await fs.ecrireAtomique(racine, 'cible.txt', 'cible');
await echoue(fs.deplacer(racine, 'source.txt', 'cible.txt'), 'EXISTE', { chemin: 'cible.txt' });
egal(await fs.lireTexte(racine, 'source.txt'), 'source');
egal(await fs.lireTexte(racine, 'cible.txt'), 'cible');
});
test('deplacer une source absente lève ABSENT', async () => {
const { fs, racine } = await fabrique();
await echoue(fs.deplacer(racine, 'absent.txt', 'ailleurs.txt'), 'ABSENT', { chemin: 'absent.txt' });
egal(await fs.lister(racine, ''), []);
});
test("lireTexte d'un fichier absent et lister d'un dossier absent lèvent ABSENT", async () => {
const { fs, racine } = await fabrique();
await echoue(fs.lireTexte(racine, 'absent.gtt.json'), 'ABSENT', { chemin: 'absent.gtt.json' });
await echoue(fs.lister(racine, 'absent'), 'ABSENT', { chemin: 'absent' });
});
test('écrire, ajouter ou déplacer dans un dossier absent lève ECRITURE, sans créer le dossier', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'ici.txt', 'ici');
await refuseEcriture(fs.ecrireAtomique(racine, 'disparu/etat.json', 'x'), 'disparu/etat.json');
await refuseEcriture(fs.ajouterLigne(racine, 'disparu/journal.jsonl', 'x'), 'disparu/journal.jsonl');
await refuseEcriture(fs.deplacer(racine, 'ici.txt', 'disparu/ici.txt'), 'disparu/ici.txt', 'ici.txt');
egal(noms(await fs.lister(racine, '')), ['ici.txt']);
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
});
test("supprimer retire le fichier, et supprimer un absent n'est pas une faute", async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'garde.txt', 'garde');
await fs.ecrireAtomique(racine, 'retire.txt', 'retire');
await fs.supprimer(racine, 'retire.txt');
await echoue(fs.lireTexte(racine, 'retire.txt'), 'ABSENT', { chemin: 'retire.txt' });
await fs.supprimer(racine, 'retire.txt');
egal(noms(await fs.lister(racine, '')), ['garde.txt']);
});
test('un chemin absolu ou remontant lève CHEMIN_REFUSE {chemin} à chaque primitive, sans rien écrire', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'ici.txt', 'ici');
for (const chemin of CHEMINS_REFUSES) {
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await echoue(appel(fs, racine, chemin), 'CHEMIN_REFUSE', { chemin }, `${primitive} ${chemin}`);
}
}
egal(noms(await fs.lister(racine, '')), ['ici.txt']);
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
});
test("une racine inconnue lève CHEMIN_REFUSE, même sous le chemin d'une racine connue", async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'ici.txt', 'ici');
const inconnue = { id: 'racine-inconnue', chemin: racine.chemin };
for (const [primitive, appel] of APPELS_A_CHEMIN) {
await echoue(appel(fs, inconnue, 'ici.txt'), 'CHEMIN_REFUSE', undefined, primitive);
}
await echoue(fs.sonder(inconnue), 'CHEMIN_REFUSE', undefined, 'sonder');
egal(noms(await fs.lister(racine, '')), ['ici.txt']);
egal(await fs.lireTexte(racine, 'ici.txt'), 'ici');
});
test('verrouiller est exclusif : un second appel rend la séance du premier, vivante', async () => {
const { fs, racine } = await fabrique();
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
const second = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
if (!fs.verrouDisponible) {
egal(second, { pris: true });
return;
}
vrai(typeof second.depuis === 'string' && second.depuis !== '', `depuis ${second.depuis}`);
egal(second, { pris: false, seance: 'seance-a', depuis: second.depuis, vivant: true });
});
test("deverrouiller n'efface que le verrou de sa séance, et un verrou absent n'est pas une faute", async () => {
const { fs, racine } = await fabrique();
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
if (!fs.verrouDisponible) {
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true });
return;
}
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c');
egal([tenu.pris, tenu.seance], [false, 'seance-a']);
await fs.deverrouiller(racine, 'soiree.gtt.verrou', 'seance-a');
egal(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-c'), { pris: true });
});
test('sonder rend inscriptible, efface le témoin resté et laisse les autres fichiers', async () => {
const { fs, racine } = await fabrique();
await fs.ecrireAtomique(racine, 'garde.txt', 'garde');
await fs.ecrireAtomique(racine, '.gtt-temoin', 'resté d\u{2019}une séance précédente');
egal(await fs.sonder(racine), { inscriptible: true, cause: null });
egal(noms(await fs.lister(racine, '')), ['garde.txt']);
egal(await fs.lireTexte(racine, 'garde.txt'), 'garde');
});
});
}

517
test/fichiers_simules.js Normal file
View file

@ -0,0 +1,517 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Système de fichiers d'épreuve (§ 13.4) : la troisième implémentation de
// l'interface de src/stockage/systeme_fichiers.js, en mémoire, injectée par
// les épreuves. Elle simule l'échec de renommage du § 8.8 et les cinq issues
// du § 8.6 — chemin publié par le lanceur portable, dossier sous un
// emplacement de données applicatives, sonde qui échoue, perte
// d'inscriptibilité en cours de séance, support amovible —, et compte ses
// lectures et ses écritures : une épreuve affirme ainsi qu'ouvrir n'écrit
// rien (§ 8.4).
//
// Une instance est un processus. Le disque, une Map, se partage : une
// instance créée sur le disque d'une autre est ce processus redémarré, ou un
// second processus de la même machine, d'une autre quand son hôte diffère.
// Le disque porte les fichiers et les dossiers sous leur chemin absolu, replié
// quand la casse ne compte pas ; à côté de lui, hors de la Map, son horloge et
// sa table des processus. Les pannes et les compteurs sont ceux de l'instance.
//
// Une racine n'existe qu'une fois créée — par sonder, creerDossier ou, sans
// compter, deposerDossier —, et aucune écriture ne crée le dossier qui porte
// sa cible : l'épreuve voit ainsi le dossier de travail qu'aucun code n'a
// créé, ou qui a disparu, comme le verrait la coquille.
import { ErreurStockage } from '../src/stockage/erreurs.js';
import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js';
/**
* Levée par chaque primitive d'une instance dont le processus est coupé
* (pannes.couperApres). Ce n'est pas une ErreurStockage : le stockage ne la
* traite pas, il meurt avec le processus.
*/
export class PanneSimulee extends Error {
constructor() {
super('panne simulée : le processus de cette instance est arrêté');
}
}
PanneSimulee.prototype.name = 'PanneSimulee';
// Suffixe du fichier qu'écrit l'écriture atomique avant de le renommer
// par-dessus sa cible, et nom du témoin de la sonde (§ 8.6, § 8.8).
const SUFFIXE_ECRITURE = '.ecriture';
const TEMOIN = '.gtt-temoin';
// Réessais d'un renommage refusé après le premier essai, ceux de
// l'implémentation de la coquille, sans ses pauses.
const REESSAIS_RENOMMAGE = 10;
// Horloge d'un disque neuf, en millisecondes depuis l'époque Unix : le
// 1er janvier 2026 à minuit UTC. Chaque fichier ou dossier qu'écrit une
// primitive l'avance d'une seconde, si bien que modifie et la prise d'un
// verrou ne dépendent que de la suite des écritures.
const ORIGINE_HORLOGE = Date.UTC(2026, 0, 1);
const PAS_HORLOGE = 1000;
// La taille d'un fichier compte les octets de son texte en UTF-8.
const UTF8 = new TextEncoder();
// État de chaque disque, hors de sa Map : la casse et le séparateur de sa
// première instance, son horloge, le prochain pid, et ses processus par pid.
const ETATS_DISQUE = new WeakMap();
function etatDuDisque(disque, insensibleCasse, separateur) {
const etat = ETATS_DISQUE.get(disque);
if (etat === undefined) {
const neuf = { insensibleCasse, separateur, horloge: ORIGINE_HORLOGE, prochainPid: 1, processus: new Map() };
ETATS_DISQUE.set(disque, neuf);
return neuf;
}
if (etat.insensibleCasse !== insensibleCasse || etat.separateur !== separateur) {
throw new Error('un disque partagé garde la casse et le séparateur de sa première instance');
}
return etat;
}
// Clé d'un chemin quand la casse ne compte pas : chaque caractère vers sa
// majuscule, quand elle tient en un caractère, sans regarder le contexte,
// comme Windows compare deux noms. Un sigma final rejoint ainsi sa
// majuscule, et ß, dont la majuscule s'écrit SS, reste lui-même.
function replier(texte) {
return Array.from(texte, (caractere) => {
const majuscule = caractere.toUpperCase();
return Array.from(majuscule).length === 1 ? majuscule : caractere;
}).join('');
}
// Un instant de l'horloge sous la forme de celle de l'application,
// AAAA-MM-JJTHH:MM:SS±HH:MM.
const horodatage = (millisecondes) => `${new Date(millisecondes).toISOString().slice(0, 19)}+00:00`;
function exigerChaine(valeur, quoi) {
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
}
function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
}
// Vrai pour le contenu lu d'un verrou : {seance, pid, hote, depuis}.
const estContenuVerrou = (contenu) =>
contenu !== null &&
typeof contenu === 'object' &&
typeof contenu.seance === 'string' &&
Number.isInteger(contenu.pid) &&
typeof contenu.hote === 'string' &&
typeof contenu.depuis === 'string';
/**
* Système de fichiers d'épreuve, en mémoire, qui sait tomber en panne.
* `disque` se partage entre deux instances : une instance « redémarrée » relit
* ce que l'autre a écrit. Racines : portable dès qu'executable est donné — la
* règle du dossier de travail décide de s'en servir (§ 8.6) —, et documents.
*
* @param {Object} [reglages]
* @param {Map} [reglages.disque] une Map neuve, ou le disque d'une autre
* instance, dont il faut garder la casse et le séparateur
* @param {string|null} [reglages.executable] dossier publié par le lanceur ;
* la racine portable est son data/
* @param {string[]} [reglages.donneesApplicatives]
* @param {string} [reglages.documents] chemin de la racine documents
* @param {'\\'|'/'} [reglages.separateur]
* @param {boolean} [reglages.insensibleCasse]
* @param {'electron'|'web'|'epreuve'} [reglages.nature]
* @param {boolean} [reglages.renommageAtomique] faux : ecrireAtomique écrit
* droit sur la cible, sans fichier .ecriture ni renommage
* @param {boolean} [reglages.verrouDisponible] faux : verrouiller rend
* { pris: true } sans rien écrire, deverrouiller ne fait rien
* @param {Object<string, string>} [reglages.sonde] cause d'échec par racine,
* { portable: 'EACCES' } : la sonde de cette racine la rend, et la
* racine refuse toute écriture avec elle, comme après pannes.ecriture
* @param {Object<string, 'amovible'|'fixe'|'inconnu'>} [reglages.support]
* ce que rend typeSupport, 'fixe' pour une racine absente de l'objet
* @param {string} [reglages.hote] machine du processus ; un verrou d'un autre
* hôte rend vivant à null
* @returns {import('../src/stockage/systeme_fichiers.js').SystemeFichiers & Object}
* le système, et pour l'épreuve :
* - pid, hote : ceux du processus, qu'écrit son verrou ; les pid d'un disque
* se suivent, 1, 2, 3…, dans l'ordre de création des instances ;
* - pannes.renommage(racineId, n = Infinity, cause = 'EBUSY') : les n
* prochains renommages de la racine sont refusés — chaque essai de
* l'écriture atomique, qui en fait onze, et deplacer ;
* - pannes.ecriture(racineId, cause = 'EROFS') : dès lors, toute primitive
* d'écriture de la racine lève ECRITURE, et sa sonde rend la cause ;
* - pannes.couperApres(n) : après n primitives d'écriture réussies —
* rendues sans lever —, toute primitive lève PanneSimulee : le processus
* est mort, la table des processus du disque le dit à ses verrous, et
* l'on rouvre par une nouvelle instance sur le même disque ;
* - compteurs.ecritures, compteurs.lectures : les appels des primitives
* d'écriture (ecrireAtomique, ajouterLigne, creerDossier, deplacer,
* supprimer, verrouiller, deverrouiller, sonder) et de lecture (lireTexte,
* lister), refusés compris ; les autres ne comptent pas ;
* - deposer(racineId, chemin, texte, modifie = l'instant courant) : pose un
* fichier, dossiers parents compris, sans compter ni avancer l'horloge ;
* - deposerDossier(racineId, chemin = '') : pose un dossier, parents compris,
* de même ;
* - contenu(racineId, chemin) : le texte d'un fichier, sans compter ; null
* pour un absent ou un dossier.
* deposer, deposerDossier et contenu servent encore après une coupure : ils
* sont les mains de l'épreuve sur le disque, non celles du processus.
*/
export function creerFichiersSimules({
disque = new Map(),
executable = null,
donneesApplicatives = ['C:\\Users\\Exemple\\AppData\\Roaming', 'C:\\Users\\Exemple\\AppData\\Local'],
documents = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre',
separateur = '\\',
insensibleCasse = true,
nature = 'epreuve',
renommageAtomique = true,
verrouDisponible = true,
sonde = {},
support = {},
hote = 'poste-epreuve',
} = {}) {
const etat = etatDuDisque(disque, insensibleCasse, separateur);
const pid = etat.prochainPid;
etat.prochainPid += 1;
// Chemin absolu de chaque racine connue, par identifiant.
const joindre = (dossier, nom) => (dossier.endsWith(separateur) ? dossier + nom : dossier + separateur + nom);
const racines = new Map([['documents', documents]]);
if (executable !== null) racines.set('portable', joindre(executable, 'data'));
// Cause qui refuse toute écriture d'une racine : celle de sa sonde déclarée,
// puis celle de pannes.ecriture. Renommages refusés restants d'une racine,
// et leur cause.
const refusEcriture = new Map();
for (const id of racines.keys()) if (Object.hasOwn(sonde, id)) refusEcriture.set(id, sonde[id]);
const refusRenommage = new Map();
// Écritures réussies que le processus fait encore avant sa coupure ; null
// tant que couperApres n'est pas appelé. Le processus meurt à zéro.
let restantes = null;
const estMort = () => restantes === 0;
etat.processus.set(pid, { hote, estMort });
const compteurs = { ecritures: 0, lectures: 0 };
const avancer = () => {
etat.horloge += PAS_HORLOGE;
return etat.horloge;
};
const maintenant = () => etat.horloge;
// Ce que désigne un chemin relatif à une racine : son chemin absolu en
// parties, la clé dans le disque de chacun de ses préfixes, sa clé et celle
// de son parent, son nom, et le dossier que nomme une ECRITURE — celui qui le
// porte, ou la racine elle-même pour ''. CHEMIN_REFUSE pour un chemin refusé
// ou une racine inconnue.
function localiser(racine, chemin) {
const segments = exigerCheminRelatif(chemin);
const base = racines.get(racine?.id);
if (base === undefined) throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
const parties = [...base.split(separateur), ...segments];
const absolu = (n) => parties.slice(0, n).join(separateur);
const cleDe = (n) => (insensibleCasse ? replier(absolu(n)) : absolu(n));
const n = parties.length;
return {
racine: racine.id,
chemin,
parties,
cleDe,
cle: cleDe(n),
cleParent: n > 1 ? cleDe(n - 1) : null,
nom: parties[n - 1],
dossier: absolu(segments.length > 0 ? n - 1 : n),
};
}
const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause });
function exigerInscriptible(loc) {
const cause = refusEcriture.get(loc.racine);
if (cause !== undefined) throw refus(loc, cause);
}
function exigerDossierParent(loc) {
if (disque.get(loc.cleParent)?.type !== 'dossier') throw refus(loc, 'ENOENT');
}
function exigerPasDossier(loc) {
if (disque.get(loc.cle)?.type === 'dossier') throw refus(loc, 'EISDIR');
}
// Crée, dans l'ordre, les dossiers des n premières parties du chemin absolu
// qui manquent, datés par horloge(). Un fichier en travers lève ECRITURE :
// EEXIST quand il occupe le chemin même, ENOTDIR un dossier parent.
function creerDossiers(loc, n, horloge) {
for (let k = 1; k <= n; k += 1) {
const noeud = disque.get(loc.cleDe(k));
if (noeud === undefined) {
disque.set(loc.cleDe(k), {
nom: loc.parties[k - 1],
type: 'dossier',
texte: null,
modifie: horloge(),
parent: k > 1 ? loc.cleDe(k - 1) : null,
});
} else if (noeud.type !== 'dossier') {
throw refus(loc, k === loc.parties.length ? 'EEXIST' : 'ENOTDIR');
}
}
}
// Écrit le texte d'un fichier : un fichier présent garde son nom, un fichier
// neuf prend celui du chemin.
function poserFichier(loc, texte, modifie) {
const present = disque.get(loc.cle);
disque.set(loc.cle, { nom: present?.nom ?? loc.nom, type: 'fichier', texte, modifie, parent: loc.cleParent });
}
// Porte le fichier de la source à la cible, sous le nom de la cible : un
// renommage par-dessus donne au fichier la casse du nouveau nom.
function renommer(source, cible) {
const noeud = disque.get(source.cle);
disque.delete(source.cle);
disque.set(cible.cle, { ...noeud, nom: cible.nom, parent: cible.cleParent });
}
// Cause du refus du prochain renommage de la racine, ou null ; chaque refus
// en consomme un.
function renommageRefuse(racineId) {
const panne = refusRenommage.get(racineId);
if (panne === undefined || panne.restants === 0) return null;
panne.restants -= 1;
return panne.cause;
}
// Ce que rend verrouiller d'un verrou présent : sa séance, l'instant de sa
// prise, et vivant — null pour un autre hôte ; pour celui-ci, vrai quand le
// disque porte un processus de cet hôte sous ce pid, et que ce processus
// n'est pas coupé. Un verrou qui ne se lit pas rend seance, depuis et
// vivant à null.
function verrouPresent(texte) {
let contenu;
try {
contenu = JSON.parse(texte);
} catch {
contenu = null;
}
if (!estContenuVerrou(contenu)) return { pris: false, seance: null, depuis: null, vivant: null };
let vivant = null;
if (contenu.hote === hote) {
const processus = etat.processus.get(contenu.pid);
vivant = processus !== undefined && processus.hote === hote && !processus.estMort();
}
return { pris: false, seance: contenu.seance, depuis: contenu.depuis, vivant };
}
// Une primitive : le processus coupé lève PanneSimulee avant tout ; l'appel
// compte selon son genre — 'ecritures', 'lectures' ou null ; une primitive
// d'écriture rendue sans lever rapproche la coupure.
function primitive(genre, travail) {
return async (...parametres) => {
if (estMort()) throw new PanneSimulee();
if (genre !== null) compteurs[genre] += 1;
const resultat = travail(...parametres);
if (genre === 'ecritures' && restantes !== null) restantes -= 1;
return resultat;
};
}
return {
nature,
renommageAtomique,
verrouDisponible,
pid,
hote,
emplacements: primitive(null, () => ({
executable,
donneesApplicatives: [...donneesApplicatives],
insensibleCasse,
separateur,
})),
racines: primitive(null, () => ({
portable: racines.has('portable') ? { id: 'portable', chemin: racines.get('portable') } : null,
documents: { id: 'documents', chemin: documents },
})),
// Les dialogues natifs rendent null, comme quand l'opérateur annule.
choisirDossier: primitive(null, () => null),
// Le témoin s'écrit par-dessus celui qu'une séance précédente a pu
// laisser, puis s'efface ; sa relecture ne peut différer de ce qui vient
// de s'écrire en mémoire. La sonde échoue sur la cause qui refuse
// l'écriture de la racine, ou sur un fichier qui occupe son chemin.
sonder: primitive('ecritures', (racine) => {
const loc = localiser(racine, '');
const cause = refusEcriture.get(loc.racine);
if (cause !== undefined) return { inscriptible: false, cause };
try {
creerDossiers(loc, loc.parties.length, avancer);
} catch (erreur) {
return { inscriptible: false, cause: erreur.details.cause };
}
const temoin = localiser(racine, TEMOIN);
poserFichier(temoin, TEMOIN, avancer());
disque.delete(temoin.cle);
return { inscriptible: true, cause: null };
}),
typeSupport: primitive(null, (racine) => {
const { racine: id } = localiser(racine, '');
return Object.hasOwn(support, id) ? support[id] : 'fixe';
}),
lireTexte: primitive('lectures', (racine, chemin) => {
const noeud = disque.get(localiser(racine, chemin).cle);
if (noeud?.type !== 'fichier') throw new ErreurStockage('ABSENT', { chemin });
return noeud.texte;
}),
ecrireAtomique: primitive('ecritures', (racine, chemin, texte) => {
exigerChaine(texte, 'le texte');
const cible = localiser(racine, chemin);
exigerInscriptible(cible);
exigerDossierParent(cible);
exigerPasDossier(cible);
if (!renommageAtomique) {
poserFichier(cible, texte, avancer());
return;
}
const ecriture = localiser(racine, chemin + SUFFIXE_ECRITURE);
poserFichier(ecriture, texte, avancer());
let cause = null;
for (let essai = 0; essai <= REESSAIS_RENOMMAGE; essai += 1) {
cause = renommageRefuse(cible.racine);
if (cause === null) {
renommer(ecriture, cible);
return;
}
}
disque.delete(ecriture.cle);
throw refus(cible, cause);
}),
ajouterLigne: primitive('ecritures', (racine, chemin, ligne) => {
exigerChaine(ligne, 'la ligne');
if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne');
const loc = localiser(racine, chemin);
exigerInscriptible(loc);
exigerDossierParent(loc);
exigerPasDossier(loc);
const avant = disque.get(loc.cle)?.texte ?? '';
poserFichier(loc, `${avant}${ligne}\n`, avancer());
}),
lister: primitive('lectures', (racine, dossier) => {
const loc = localiser(racine, dossier);
if (disque.get(loc.cle)?.type !== 'dossier') throw new ErreurStockage('ABSENT', { chemin: dossier });
const entrees = [];
for (const noeud of disque.values()) {
if (noeud.parent !== loc.cle) continue;
const taille = noeud.type === 'fichier' ? UTF8.encode(noeud.texte).length : 0;
entrees.push({ nom: noeud.nom, type: noeud.type, taille, modifie: noeud.modifie });
}
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
}),
creerDossier: primitive('ecritures', (racine, chemin) => {
const loc = localiser(racine, chemin);
exigerInscriptible(loc);
creerDossiers(loc, loc.parties.length, avancer);
}),
// Un dossier absent sous la cible nomme la cible ; une panne nomme la
// source, que le renommage n'a pas pu porter.
deplacer: primitive('ecritures', (racine, de, vers) => {
const source = localiser(racine, de);
const cible = localiser(racine, vers);
exigerInscriptible(source);
const noeud = disque.get(source.cle);
if (noeud === undefined) throw new ErreurStockage('ABSENT', { chemin: de });
if (noeud.type === 'dossier') throw refus(source, 'EISDIR');
if (disque.has(cible.cle)) throw new ErreurStockage('EXISTE', { chemin: vers });
exigerDossierParent(cible);
const cause = renommageRefuse(source.racine);
if (cause !== null) throw refus(source, cause);
renommer(source, cible);
}),
supprimer: primitive('ecritures', (racine, chemin) => {
const loc = localiser(racine, chemin);
exigerInscriptible(loc);
exigerPasDossier(loc);
disque.delete(loc.cle);
}),
verrouiller: primitive('ecritures', (racine, chemin, seance) => {
exigerSeance(seance);
const loc = localiser(racine, chemin);
if (!verrouDisponible) return { pris: true };
exigerInscriptible(loc);
exigerDossierParent(loc);
exigerPasDossier(loc);
const present = disque.get(loc.cle);
if (present !== undefined) return verrouPresent(present.texte);
const prise = avancer();
poserFichier(loc, `${JSON.stringify({ seance, pid, hote, depuis: horodatage(prise) })}\n`, prise);
return { pris: true };
}),
deverrouiller: primitive('ecritures', (racine, chemin, seance) => {
exigerSeance(seance);
const loc = localiser(racine, chemin);
if (!verrouDisponible) return;
exigerInscriptible(loc);
const present = disque.get(loc.cle);
if (present?.type === 'fichier' && verrouPresent(present.texte).seance === seance) disque.delete(loc.cle);
}),
ouvrirDansExplorateur: primitive(null, (racine) => {
localiser(racine, '');
}),
choisirFichierAImporter: primitive(null, () => null),
enregistrerSous: primitive(null, () => null),
pannes: {
renommage(racineId, n = Infinity, cause = 'EBUSY') {
if (n !== Infinity && !(Number.isInteger(n) && n >= 0)) {
throw new TypeError('pannes.renommage : n est un entier positif ou nul, ou Infinity');
}
refusRenommage.set(localiser({ id: racineId }, '').racine, { restants: n, cause });
},
ecriture(racineId, cause = 'EROFS') {
refusEcriture.set(localiser({ id: racineId }, '').racine, cause);
},
couperApres(n) {
if (!(Number.isInteger(n) && n >= 0)) throw new TypeError('pannes.couperApres : n est un entier positif ou nul');
restantes = n;
},
},
compteurs,
deposer(racineId, chemin, texte, modifie = maintenant()) {
exigerChaine(texte, 'le texte');
const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length - 1, maintenant);
exigerPasDossier(loc);
poserFichier(loc, texte, modifie);
},
deposerDossier(racineId, chemin = '') {
const loc = localiser({ id: racineId }, chemin);
creerDossiers(loc, loc.parties.length, maintenant);
},
contenu(racineId, chemin) {
const noeud = disque.get(localiser({ id: racineId }, chemin).cle);
return noeud?.type === 'fichier' ? noeud.texte : null;
},
};
}

View file

@ -0,0 +1,589 @@
// © 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']);
});
});