// © 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'); }); }); }