[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:
parent
f24896c4a1
commit
fdaa79ed32
5 changed files with 1665 additions and 0 deletions
161
src/stockage/systeme_fichiers.js
Normal file
161
src/stockage/systeme_fichiers.js
Normal 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;
|
||||
}
|
||||
84
src/stockage/systeme_fichiers.test.js
Normal file
84
src/stockage/systeme_fichiers.test.js
Normal 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
314
test/contrat_fichiers.js
Normal 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
517
test/fichiers_simules.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
589
test/fichiers_simules.test.js
Normal file
589
test/fichiers_simules.test.js
Normal 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue