diff --git a/src/stockage/fichiers_web.js b/src/stockage/fichiers_web.js new file mode 100644 index 0000000..8376b4c --- /dev/null +++ b/src/stockage/fichiers_web.js @@ -0,0 +1,441 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Système de fichiers de la plateforme web (§ 13.1, § 13.4) : l'interface de +// systeme_fichiers.js portée par l'OPFS du navigateur, que +// navigator.storage.getDirectory() ouvre. Avec fichiers_electron.js, ce module +// est l'une des deux exceptions nommées de la frontière des couches : il +// touche navigator pour l'OPFS, et document pour le champ de fichier et le +// lien de téléchargement. Il n'y touche qu'à l'appel d'une primitive : le +// charger hors d'un navigateur ne lève rien. +// +// Une seule racine, documents : le dossier gestion_table_tournante_libre de +// l'OPFS, affiché « /gestion_table_tournante_libre ». Ni exécutable publié, +// ni données applicatives, ni racine portable ; le support est inconnu. +// +// La plateforme n'offre ni renommage atomique ni verrou (§ 8.8) et le dit : +// renommageAtomique et verrouDisponible sont faux, et l'application +// l'annonce au démarrage. ecrireAtomique et ajouterLigne écrivent par un flux +// de l'OPFS ouvert sur la cible (createWritable) : le contenu n'atteint la +// cible qu'à la fermeture du flux ; un échec avant elle abandonne le flux et +// retire le fichier que l'appel a créé, si bien que la cible reste telle +// qu'avant l'appel. Rien ne garantit ce que devient une fermeture +// interrompue. deplacer copie les octets de la source sous la cible, puis +// retire la source ; un échec du retrait retire la copie. verrouiller rend +// { pris: true } sans rien écrire, et deverrouiller n'efface rien. +// +// L'OPFS compare les noms à la lettre : la casse et la forme de normalisation +// distinguent deux fichiers, d'où insensibleCasse faux. Il ne date pas un +// dossier, que lister date de 0. Pendant l'écriture d'un flux, Chromium peut +// poser à côté de la cible un fichier d'échange, .crswap, que lister +// rend comme toute entrée ; un fichier qui disparaît entre la liste et sa +// lecture n'est pas rendu. +// +// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit +// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou +// TypeMismatchError pour une entrée d'un autre genre que celui demandé — +// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une +// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les +// deux portent {chemin, dossier, cause}, dont la cause est le nom de +// l'exception : SecurityError pour un OPFS que le navigateur refuse à +// l'origine, NotReadableError pour un fichier qui change pendant qu'on le +// lit, NotFoundError pour une écriture dans un dossier absent, +// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine +// elle-même, un dossier. +// +// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et +// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter +// passe par un champ de fichier, que le navigateur n'ouvre que dans le +// prolongement d'un geste de l'opérateur, et qui ne dit du fichier choisi +// que son nom : l'échec de sa lecture le nomme, dossier null. +// enregistrerSous passe par un lien de téléchargement : le navigateur range +// le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend +// le nom proposé. +import { ErreurStockage } from './erreurs.js'; +import { exigerCheminRelatif } from './systeme_fichiers.js'; + +// Le dossier du produit dans l'OPFS, et la racine documents qui le désigne. +const DOSSIER_PRODUIT = 'gestion_table_tournante_libre'; +const DOCUMENTS = 'documents'; +const CHEMIN_DOCUMENTS = `/${DOSSIER_PRODUIT}`; + +// Nom du témoin de la sonde (§ 8.6), qui porte aussi ce nom pour texte. +const TEMOIN = '.gtt-temoin'; + +// Cause d'une écriture qui vise un dossier — la racine elle-même, ou un +// dossier là où un fichier est attendu —, celle que lève l'OPFS. +const TYPE_DIFFERENT = 'TypeMismatchError'; + +// Cause d'une sonde dont le témoin relu diffère du texte écrit. +const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE'; + +// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans +// retirer celle qu'il porte, que Blob.text retirerait. Un octet qui n'est pas +// de l'UTF-8 se lit U+FFFD. +const UTF8 = new TextEncoder(); +const DECODEUR = new TextDecoder('utf-8', { ignoreBOM: true }); + +// Un nettoyage qui échoue ne masque pas l'échec qu'il suit. +const ignorer = () => {}; + +// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou +// d'un autre genre que celui demandé. +const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT; + +// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son +// texte quand elle n'en porte pas. +const causeDe = (erreur) => (typeof erreur?.name === 'string' && erreur.name !== '' ? erreur.name : String(erreur)); + +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"); +} + +// Ce que désigne un chemin relatif à la racine : ses segments, ceux du +// dossier qui le porte, son nom — null pour '', la racine elle-même —, et le +// chemin affichable de ce dossier, celui de la racine pour ''. CHEMIN_REFUSE +// pour un chemin refusé ou une racine inconnue, reconnue par son seul +// identifiant. +function localiser(racine, chemin) { + const segments = exigerCheminRelatif(chemin); + if (racine?.id !== DOCUMENTS) throw new ErreurStockage('CHEMIN_REFUSE', { chemin }); + const parent = segments.slice(0, -1); + return { + chemin, + segments, + parent, + nom: segments.length > 0 ? segments[segments.length - 1] : null, + dossier: [CHEMIN_DOCUMENTS, ...parent].join('/'), + }; +} + +const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause }); + +const absent = (chemin) => new ErreurStockage('ABSENT', { chemin }); + +// Ce que devient l'exception d'une lecture de loc : ABSENT quand l'entrée +// manque, LECTURE sinon. +const echecLecture = (loc, erreur) => + estAbsence(erreur) + ? absent(loc.chemin) + : new ErreurStockage('LECTURE', { chemin: loc.chemin, dossier: loc.dossier, cause: causeDe(erreur) }); + +// Exécute travail, une écriture sur loc, et rend ce qu'il rend : une +// ErreurStockage passe telle quelle, toute autre exception devient ECRITURE. +async function ecrire(loc, travail) { + try { + return await travail(); + } catch (erreur) { + if (erreur instanceof ErreurStockage) throw erreur; + throw refus(loc, causeDe(erreur)); + } +} + +// Poignée du dossier que désignent les segments sous le dossier du produit ; +// creer crée ce qui manque, dossier du produit compris. Lève l'exception de +// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un +// fichier à sa place. +async function dossierDe(segments, creer = false) { + const options = { create: creer }; + let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, options); + for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options); + return dossier; +} + +// Le dossier que désignent les segments, ou null quand il n'existe pas. +async function dossierExistant(segments) { + try { + return await dossierDe(segments); + } catch (erreur) { + if (estAbsence(erreur)) return null; + throw erreur; + } +} + +// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle +// n'existe pas. +async function genreDe(dossier, nom) { + try { + await dossier.getFileHandle(nom); + return 'fichier'; + } catch (erreur) { + if (erreur?.name === TYPE_DIFFERENT) return 'dossier'; + if (erreur?.name === 'NotFoundError') return null; + throw erreur; + } +} + +// Retire l'entrée nom du dossier ; une entrée absente n'est pas une faute. +async function retirer(dossier, nom) { + try { + await dossier.removeEntry(nom); + } catch (erreur) { + if (erreur?.name !== 'NotFoundError') throw erreur; + } +} + +// Texte d'un fichier : ses octets décodés tels quels. +const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer()); + +// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux, +// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur +// un contenu vide. Le contenu n'atteint le fichier qu'à la fermeture du flux. +// Un échec abandonne le flux, retire le fichier quand l'appel l'a créé, puis +// se relance : le fichier reste tel qu'avant l'appel. +async function ecrireParFlux(dossier, nom, garder, remplir) { + let poignee; + let cree = false; + try { + poignee = await dossier.getFileHandle(nom); + } catch (erreur) { + if (erreur?.name !== 'NotFoundError') throw erreur; + poignee = await dossier.getFileHandle(nom, { create: true }); + cree = true; + } + let flux = null; + try { + flux = await poignee.createWritable({ keepExistingData: garder }); + await remplir(flux, poignee); + await flux.close(); + } catch (erreur) { + if (flux !== null) await flux.abort().catch(ignorer); + if (cree) await dossier.removeEntry(nom).catch(ignorer); + throw erreur; + } +} + +// Description d'une entrée d'un dossier, ou null pour un fichier disparu +// entre la liste et sa lecture. +async function decrire(poignee) { + if (poignee.kind === 'directory') return { nom: poignee.name, type: 'dossier', taille: 0, modifie: 0 }; + try { + const fichier = await poignee.getFile(); + return { nom: poignee.name, type: 'fichier', taille: fichier.size, modifie: fichier.lastModified }; + } catch (erreur) { + if (erreur?.name === 'NotFoundError') return null; + throw erreur; + } +} + +// Ordre des entrées : leurs noms comparés par unités UTF-16, une à une. +const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0); + +// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand +// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier. +async function lireSource(loc) { + if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); + const dossier = await dossierExistant(loc.parent); + const genre = dossier === null ? null : await genreDe(dossier, loc.nom); + if (genre === null) throw absent(loc.chemin); + if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT); + const fichier = await (await dossier.getFileHandle(loc.nom)).getFile(); + return { dossier, octets: await fichier.arrayBuffer() }; +} + +// Écrit les octets sous la cible de deplacer, et rend le dossier qui la +// porte. EXISTE quand une entrée y porte déjà ce nom, quel qu'en soit le +// genre, et pour '', la racine elle-même. +async function copierVers(loc, octets) { + if (loc.nom === null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin }); + const dossier = await dossierDe(loc.parent); + if ((await genreDe(dossier, loc.nom)) !== null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin }); + await ecrireParFlux(dossier, loc.nom, false, (flux) => flux.write(octets)); + return dossier; +} + +// Ouvre le dialogue du navigateur par un champ de fichier, hors du document, +// et rend le nom et les octets du fichier choisi ; null quand l'opérateur +// annule — l'événement cancel — ou que le champ revient sans fichier. Un +// fichier choisi qui ne se lit pas lève ABSENT ou LECTURE sous son nom. +function choisirFichier() { + return new Promise((resoudre, rejeter) => { + const champ = document.createElement('input'); + champ.type = 'file'; + champ.addEventListener('cancel', () => resoudre(null), { once: true }); + champ.addEventListener( + 'change', + () => { + const fichier = champ.files[0]; + if (fichier === undefined) { + resoudre(null); + return; + } + fichier.arrayBuffer().then( + (tampon) => resoudre({ nom: fichier.name, octets: new Uint8Array(tampon) }), + (erreur) => rejeter(echecLecture({ chemin: fichier.name, dossier: null }, erreur)), + ); + }, + { once: true }, + ); + champ.click(); + }); +} + +/** + * Système de fichiers de la plateforme web, sur l'OPFS. Chaque appel rend un + * système neuf ; tous partagent l'OPFS de l'origine. + * + * @returns {import('./systeme_fichiers.js').SystemeFichiers} + */ +export function creerFichiersWeb() { + return { + nature: 'web', + renommageAtomique: false, + verrouDisponible: false, + + emplacements: async () => ({ executable: null, donneesApplicatives: [], insensibleCasse: false, separateur: '/' }), + + racines: async () => ({ portable: null, documents: { id: DOCUMENTS, chemin: CHEMIN_DOCUMENTS } }), + + choisirDossier: async () => { + throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'choisirDossier' }); + }, + + // Crée le dossier du produit, efface un témoin resté, écrit le témoin, le + // relit, l'efface. Un échec rend sa cause et efface le témoin qu'il + // laisse ; seule une racine inconnue lève. + sonder: async (racine) => { + localiser(racine, ''); + let dossier = null; + try { + dossier = await dossierDe([], true); + await retirer(dossier, TEMOIN); + await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN))); + const relu = await lire(await dossier.getFileHandle(TEMOIN)); + await retirer(dossier, TEMOIN); + return relu === TEMOIN + ? { inscriptible: true, cause: null } + : { inscriptible: false, cause: RELECTURE_DIFFERENTE }; + } catch (erreur) { + if (dossier !== null) await retirer(dossier, TEMOIN).catch(ignorer); + return { inscriptible: false, cause: causeDe(erreur) }; + } + }, + + typeSupport: async (racine) => { + localiser(racine, ''); + return 'inconnu'; + }, + + lireTexte: async (racine, chemin) => { + const loc = localiser(racine, chemin); + if (loc.nom === null) throw absent(chemin); + try { + return await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom)); + } catch (erreur) { + throw echecLecture(loc, erreur); + } + }, + + ecrireAtomique: async (racine, chemin, texte) => { + exigerChaine(texte, 'le texte'); + const loc = localiser(racine, chemin); + await ecrire(loc, async () => { + if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); + const octets = UTF8.encode(texte); + await ecrireParFlux(await dossierDe(loc.parent), loc.nom, false, (flux) => flux.write(octets)); + }); + }, + + // Le flux garde le contenu présent et écrit la ligne à sa fin. + ajouterLigne: async (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); + await ecrire(loc, async () => { + if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); + const octets = UTF8.encode(`${ligne}\n`); + await ecrireParFlux(await dossierDe(loc.parent), loc.nom, true, async (flux, poignee) => { + const { size } = await poignee.getFile(); + await flux.write({ type: 'write', position: size, data: octets }); + }); + }); + }, + + lister: async (racine, chemin) => { + const loc = localiser(racine, chemin); + const entrees = []; + try { + for await (const poignee of (await dossierDe(loc.segments)).values()) { + const entree = await decrire(poignee); + if (entree !== null) entrees.push(entree); + } + } catch (erreur) { + throw echecLecture(loc, erreur); + } + return entrees.sort(parNom); + }, + + creerDossier: async (racine, chemin) => { + const loc = localiser(racine, chemin); + await ecrire(loc, () => dossierDe(loc.segments, true)); + }, + + // Un échec de la lecture de la source ou de son retrait nomme la source ; + // un échec de la copie nomme la cible. + deplacer: async (racine, de, vers) => { + const source = localiser(racine, de); + const cible = localiser(racine, vers); + const { dossier: dossierSource, octets } = await ecrire(source, () => lireSource(source)); + const dossierCible = await ecrire(cible, () => copierVers(cible, octets)); + await ecrire(source, async () => { + try { + await dossierSource.removeEntry(source.nom); + } catch (erreur) { + await dossierCible.removeEntry(cible.nom).catch(ignorer); + throw erreur; + } + }); + }, + + // Retire un fichier ; un dossier, la racine comprise, lève ECRITURE + // TypeMismatchError et reste. + supprimer: async (racine, chemin) => { + const loc = localiser(racine, chemin); + await ecrire(loc, async () => { + if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT); + const dossier = await dossierExistant(loc.parent); + const genre = dossier === null ? null : await genreDe(dossier, loc.nom); + if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT); + if (genre === 'fichier') await retirer(dossier, loc.nom); + }); + }, + + verrouiller: async (racine, chemin, seance) => { + exigerSeance(seance); + localiser(racine, chemin); + return { pris: true }; + }, + + deverrouiller: async (racine, chemin, seance) => { + exigerSeance(seance); + localiser(racine, chemin); + }, + + ouvrirDansExplorateur: async (racine) => { + localiser(racine, ''); + throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' }); + }, + + choisirFichierAImporter: () => choisirFichier(), + + // Le clic remet au navigateur l'adresse du contenu, qui se libère à la + // tâche suivante. + enregistrerSous: async (nomPropose, octets) => { + if (typeof nomPropose !== 'string' || nomPropose === '') { + throw new TypeError("le nom proposé n'est pas une chaîne non vide"); + } + if (!(octets instanceof Uint8Array)) throw new TypeError("les octets ne sont pas un Uint8Array"); + const adresse = URL.createObjectURL(new Blob([octets], { type: 'application/octet-stream' })); + const lien = document.createElement('a'); + lien.href = adresse; + lien.download = nomPropose; + lien.click(); + setTimeout(() => URL.revokeObjectURL(adresse), 0); + return nomPropose; + }, + }; +} diff --git a/src/stockage/fichiers_web.navigateur.test.js b/src/stockage/fichiers_web.navigateur.test.js new file mode 100644 index 0000000..0525123 --- /dev/null +++ b/src/stockage/fichiers_web.navigateur.test.js @@ -0,0 +1,498 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du système de fichiers de la plateforme web (§ 8.8, § 13.1, +// § 13.4), dans Chromium, sur son OPFS réel, vidé avant chaque épreuve. La +// suite de contrat test/contrat_fichiers.js s'y joue avec expect ; suit ce +// qui n'appartient qu'à la plateforme web : sa nature, ses emplacements, sa +// racine unique — le dossier gestion_table_tournante_libre de l'OPFS —, la +// correspondance des refus de l'OPFS avec les codes du stockage, le verrou +// qui n'en est pas un, les primitives qui n'existent pas sous web, le choix +// d'un fichier à importer et l'enregistrement par téléchargement. +// +// Une panne de l'OPFS se provoque en remplaçant, le temps d'une épreuve, une +// méthode de ses prototypes (vi.spyOn), que afterEach rétablit ; un dialogue, +// en remplaçant le clic du champ de fichier ou du lien. Les mains de +// l'épreuve sur l'OPFS passent par navigator.storage, jamais par le module +// éprouvé. Le projet navigateur n'importe pas node:assert/strict : il emploie +// expect, et prend test, describe et les crochets chez Vitest. Les noms +// d'épreuve sont inventés. +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { eprouverContrat } from '../../test/contrat_fichiers.js'; +import { ErreurStockage } from './erreurs.js'; +import { creerFichiersWeb } from './fichiers_web.js'; + +// Le dossier du produit dans l'OPFS, et la racine qui le désigne. +const DOSSIER = 'gestion_table_tournante_libre'; +const CHEMIN_DOCUMENTS = '/gestion_table_tournante_libre'; +const DOCUMENTS = { id: 'documents', chemin: CHEMIN_DOCUMENTS }; + +// Octets d'un fichier venu d'ailleurs : marque d'ordre d'octets, CRLF, et un +// octet qui n'est pas de l'UTF-8. +const BRUTS = new Uint8Array([0xef, 0xbb, 0xbf, 0x6e, 0x6f, 0x6d, 0x3b, 0x0d, 0x0a, 0xff]); + +const opfs = () => navigator.storage.getDirectory(); + +// Retire toute entrée de la racine de l'OPFS. +async function viderOpfs() { + const racine = await opfs(); + const noms = []; + for await (const nom of racine.keys()) noms.push(nom); + for (const nom of noms) await racine.removeEntry(nom, { recursive: true }); +} + +// Noms des entrées de la racine de l'OPFS, triés. +async function nomsOpfs() { + const noms = []; + for await (const nom of (await opfs()).keys()) noms.push(nom); + return noms.sort(); +} + +// Le dossier du produit, lu dans l'OPFS ; NotFoundError quand il manque. +const dossierProduit = async () => (await opfs()).getDirectoryHandle(DOSSIER); + +// Octets du fichier que désignent les segments sous le dossier du produit. +async function octetsOpfs(...segments) { + let dossier = await dossierProduit(); + for (const segment of segments.slice(0, -1)) dossier = await dossier.getDirectoryHandle(segment); + const fichier = await (await dossier.getFileHandle(segments.at(-1))).getFile(); + return new Uint8Array(await fichier.arrayBuffer()); +} + +// Pose des octets sous un nom, à la racine du dossier du produit. +async function poserOpfs(nom, octets) { + const flux = await (await (await dossierProduit()).getFileHandle(nom, { create: true })).createWritable(); + await flux.write(octets); + await flux.close(); +} + +// Un système neuf sur un dossier du produit qui existe et ne porte rien. +async function preparer() { + await (await opfs()).getDirectoryHandle(DOSSIER, { create: true }); + return creerFichiersWeb(); +} + +// Rend la raison du rejet de la promesse ; échoue quand elle se résout. +async function rejette(promesse, message) { + try { + await promesse; + } catch (raison) { + return raison; + } + return expect.unreachable(`${message} : la promesse se résout`); +} + +// Attend le rejet d'une ErreurStockage de ce code et de ces détails. +async function echoue(promesse, code, details, message = code) { + const erreur = await rejette(promesse, message); + expect(erreur, message).toBeInstanceOf(ErreurStockage); + expect(erreur.code, message).toBe(code); + expect(erreur.details, message).toStrictEqual(details); +} + +const noms = (entrees) => entrees.map((entree) => entree.nom); + +beforeEach(viderOpfs); +afterEach(() => { + vi.restoreAllMocks(); +}); + +eprouverContrat("web, sur l'OPFS de Chromium", async () => { + const fs = await preparer(); + return { fs, racine: (await fs.racines()).documents }; +}, { + describe, + test, + egal: (reel, attendu, message) => expect(reel, message).toStrictEqual(attendu), + vrai: (valeur, message) => expect(valeur, message).toBeTruthy(), + rejette, +}); + +describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => { + test("nature web, ni renommage atomique ni verrou ; ni exécutable ni données applicatives ; noms comparés à la lettre sous « / » ; support inconnu", async () => { + const fs = creerFichiersWeb(); + expect(fs.nature).toBe('web'); + expect(fs.renommageAtomique).toBe(false); + expect(fs.verrouDisponible).toBe(false); + expect(await fs.emplacements()).toStrictEqual({ + executable: null, + donneesApplicatives: [], + insensibleCasse: false, + separateur: '/', + }); + expect(await fs.racines()).toStrictEqual({ portable: null, documents: DOCUMENTS }); + expect(await fs.typeSupport(DOCUMENTS)).toBe('inconnu'); + }); + + test("la racine documents est le dossier gestion_table_tournante_libre de l'OPFS : ce qui s'y écrit s'y lit en UTF-8 sans marque d'ordre d'octets, et ce qui s'y pose se lit et se liste", async () => { + const fs = await preparer(); + await fs.creerDossier(DOCUMENTS, 'corbeille'); + await fs.ecrireAtomique(DOCUMENTS, 'corbeille/\u{E9}t\u{E9}.txt', '\u{E9}t\u{E9}'); + await fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', '\u{AB} a \u{BB}'); + expect(await nomsOpfs()).toStrictEqual([DOSSIER]); + expect(await octetsOpfs('corbeille', '\u{E9}t\u{E9}.txt')).toStrictEqual( + new Uint8Array([0xc3, 0xa9, 0x74, 0xc3, 0xa9]), + ); + expect(await octetsOpfs('journal.jsonl')).toStrictEqual( + new Uint8Array([0xc2, 0xab, 0x20, 0x61, 0x20, 0xc2, 0xbb, 0x0a]), + ); + + // Un octet qui n'est pas de l'UTF-8 se lit U+FFFD ; la marque d'ordre + // d'octets et CRLF restent. + await poserOpfs('pose.csv', BRUTS); + expect(await fs.lireTexte(DOCUMENTS, 'pose.csv')).toBe('\u{FEFF}nom;\r\n\u{FFFD}'); + expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille', 'journal.jsonl', 'pose.csv']); + }); + + test("sonder crée le dossier du produit dans un OPFS vide, et n'y laisse aucun témoin", async () => { + const fs = creerFichiersWeb(); + expect(await nomsOpfs()).toStrictEqual([]); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: true, cause: null }); + expect(await nomsOpfs()).toStrictEqual([DOSSIER]); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + }); + + test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien ; creerDossier le crée", async () => { + const fs = creerFichiersWeb(); + const refus = (chemin) => ({ chemin, dossier: CHEMIN_DOCUMENTS, cause: 'NotFoundError' }); + await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json')); + await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl')); + await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' }); + await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' }); + await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' }); + await fs.supprimer(DOCUMENTS, 'etat.json'); + expect(await nomsOpfs()).toStrictEqual([]); + + await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05'); + expect(await nomsOpfs()).toStrictEqual([DOSSIER]); + expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']); + }); +}); + +describe("fichiers web : ce que l'OPFS refuse", () => { + test("une entrée d'un autre genre que celui demandé : lire ou lister lèvent ABSENT, écrire ECRITURE TypeMismatchError, qui nomme le dossier affichable de l'entrée", async () => { + const fs = await preparer(); + await fs.ecrireAtomique(DOCUMENTS, 'note.txt', 'note'); + await fs.creerDossier(DOCUMENTS, 'corbeille'); + const refus = (chemin, dossier) => ({ chemin, dossier, cause: 'TypeMismatchError' }); + + // Un dossier là où un fichier est attendu, et la racine elle-même. + await echoue(fs.lireTexte(DOCUMENTS, 'corbeille'), 'ABSENT', { chemin: 'corbeille' }); + await echoue(fs.lireTexte(DOCUMENTS, ''), 'ABSENT', { chemin: '' }); + await echoue(fs.ecrireAtomique(DOCUMENTS, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS)); + await echoue(fs.ecrireAtomique(DOCUMENTS, '', 'x'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS)); + await echoue(fs.ajouterLigne(DOCUMENTS, 'corbeille', 'x'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS)); + await echoue(fs.ajouterLigne(DOCUMENTS, '', 'x'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS)); + await echoue(fs.supprimer(DOCUMENTS, 'corbeille'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS)); + await echoue(fs.supprimer(DOCUMENTS, ''), 'ECRITURE', refus('', CHEMIN_DOCUMENTS)); + await echoue(fs.deplacer(DOCUMENTS, 'corbeille', 'ailleurs'), 'ECRITURE', refus('corbeille', CHEMIN_DOCUMENTS)); + await echoue(fs.deplacer(DOCUMENTS, '', 'ailleurs'), 'ECRITURE', refus('', CHEMIN_DOCUMENTS)); + await echoue(fs.deplacer(DOCUMENTS, 'note.txt', 'corbeille'), 'EXISTE', { chemin: 'corbeille' }); + await echoue(fs.deplacer(DOCUMENTS, 'note.txt', ''), 'EXISTE', { chemin: '' }); + + // Un fichier là où un dossier est attendu. + const sousNote = `${CHEMIN_DOCUMENTS}/note.txt`; + await echoue(fs.lister(DOCUMENTS, 'note.txt'), 'ABSENT', { chemin: 'note.txt' }); + await echoue(fs.lireTexte(DOCUMENTS, 'note.txt/x.txt'), 'ABSENT', { chemin: 'note.txt/x.txt' }); + await echoue(fs.deplacer(DOCUMENTS, 'note.txt/x.txt', 'x.txt'), 'ABSENT', { chemin: 'note.txt/x.txt' }); + await echoue(fs.ecrireAtomique(DOCUMENTS, 'note.txt/x.txt', 'x'), 'ECRITURE', refus('note.txt/x.txt', sousNote)); + await echoue(fs.ajouterLigne(DOCUMENTS, 'note.txt/x.txt', 'x'), 'ECRITURE', refus('note.txt/x.txt', sousNote)); + await echoue(fs.creerDossier(DOCUMENTS, 'note.txt/sous'), 'ECRITURE', refus('note.txt/sous', sousNote)); + await echoue(fs.deplacer(DOCUMENTS, 'note.txt', 'note.txt/x.txt'), 'ECRITURE', refus('note.txt/x.txt', sousNote)); + await fs.supprimer(DOCUMENTS, 'note.txt/x.txt'); + + expect(await fs.lister(DOCUMENTS, '')).toMatchObject([ + { nom: 'corbeille', type: 'dossier' }, + { nom: 'note.txt', type: 'fichier' }, + ]); + expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]); + expect(await fs.lireTexte(DOCUMENTS, 'note.txt')).toBe('note'); + }); + + // Chaque panne frappe un temps de l'écriture par flux : son ouverture, + // l'écriture, la fermeture qui porte le contenu sur la cible. + const PANNES = [ + ['ouverture du flux', () => FileSystemFileHandle.prototype, 'createWritable', 'NoModificationAllowedError'], + ["écriture dans le flux", () => FileSystemWritableFileStream.prototype, 'write', 'QuotaExceededError'], + ['fermeture du flux', () => FileSystemWritableFileStream.prototype, 'close', 'InvalidStateError'], + ]; + + test("une panne de l'OPFS lève ECRITURE avec le nom de son exception, laisse la cible intacte et ne laisse aucun fichier neuf ni fichier d'échange", async () => { + const fs = await preparer(); + await fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'avant'); + await fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'premiere'); + for (const [temps, prototype, methode, cause] of PANNES) { + vi.spyOn(prototype(), methode).mockRejectedValue(new DOMException(temps, cause)); + const appels = [ + ['etat.json', () => fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'apres')], + ['neuf.json', () => fs.ecrireAtomique(DOCUMENTS, 'neuf.json', 'apres')], + ['journal.jsonl', () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'seconde')], + ['neuf.jsonl', () => fs.ajouterLigne(DOCUMENTS, 'neuf.jsonl', 'seconde')], + ]; + for (const [chemin, appel] of appels) { + await echoue(appel(), 'ECRITURE', { chemin, dossier: CHEMIN_DOCUMENTS, cause }, `${temps} : ${chemin}`); + } + vi.restoreAllMocks(); + expect(await fs.lireTexte(DOCUMENTS, 'etat.json'), temps).toBe('avant'); + expect(await fs.lireTexte(DOCUMENTS, 'journal.jsonl'), temps).toBe('premiere\n'); + expect(noms(await fs.lister(DOCUMENTS, '')), temps).toStrictEqual(['etat.json', 'journal.jsonl']); + } + + // Un rejet qui n'est pas une exception nommée donne son texte pour cause. + vi.spyOn(FileSystemWritableFileStream.prototype, 'write').mockRejectedValueOnce('flux rompu'); + await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'apres'), 'ECRITURE', { + chemin: 'etat.json', + dossier: CHEMIN_DOCUMENTS, + cause: 'flux rompu', + }); + expect(await fs.lireTexte(DOCUMENTS, 'etat.json')).toBe('avant'); + }); + + test('deplacer copie les octets de la source puis la retire ; un échec de la copie ou du retrait laisse la source seule', async () => { + const fs = await preparer(); + await fs.creerDossier(DOCUMENTS, 'corbeille'); + await poserOpfs('source.csv', BRUTS); + + vi.spyOn(FileSystemWritableFileStream.prototype, 'write').mockRejectedValue( + new DOMException('copie', 'QuotaExceededError'), + ); + await echoue(fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'), 'ECRITURE', { + chemin: 'corbeille/source.csv', + dossier: `${CHEMIN_DOCUMENTS}/corbeille`, + cause: 'QuotaExceededError', + }); + vi.restoreAllMocks(); + expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]); + expect(await octetsOpfs('source.csv')).toStrictEqual(BRUTS); + + vi.spyOn(FileSystemDirectoryHandle.prototype, 'removeEntry').mockRejectedValueOnce( + new DOMException('retrait', 'NoModificationAllowedError'), + ); + await echoue(fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'), 'ECRITURE', { + chemin: 'source.csv', + dossier: CHEMIN_DOCUMENTS, + cause: 'NoModificationAllowedError', + }); + vi.restoreAllMocks(); + expect(await fs.lister(DOCUMENTS, 'corbeille')).toStrictEqual([]); + expect(await octetsOpfs('source.csv')).toStrictEqual(BRUTS); + + await fs.deplacer(DOCUMENTS, 'source.csv', 'corbeille/source.csv'); + expect(await octetsOpfs('corbeille', 'source.csv')).toStrictEqual(BRUTS); + expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['corbeille']); + }); + + // Une lecture tombe en panne à l'un de ses temps : l'OPFS, que le + // navigateur refuse à l'origine ; la description d'un fichier ; ses octets, + // quand il change pendant qu'on le lit. + test("une lecture qui échoue autrement qu'en ne trouvant pas l'entrée lève LECTURE avec le nom de son exception, qui nomme le dossier affichable de l'entrée : une panne n'est pas une absence", async () => { + const fs = await preparer(); + await fs.creerDossier(DOCUMENTS, 'corbeille'); + await fs.ecrireAtomique(DOCUMENTS, 'corbeille/etat.json', 'etat'); + const corbeille = `${CHEMIN_DOCUMENTS}/corbeille`; + const lecture = (chemin, dossier, cause) => ({ chemin, dossier, cause }); + const etat = 'corbeille/etat.json'; + + vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce(new DOMException('change', 'NotReadableError')); + await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'NotReadableError'), 'octets'); + vi.spyOn(FileSystemFileHandle.prototype, 'getFile').mockRejectedValueOnce( + new DOMException('illisible', 'NotReadableError'), + ); + await echoue( + fs.lister(DOCUMENTS, 'corbeille'), + 'LECTURE', + lecture('corbeille', CHEMIN_DOCUMENTS, 'NotReadableError'), + 'description', + ); + + // Un rejet qui n'est pas une exception nommée donne son texte pour cause. + vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce('flux rompu'); + await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'flux rompu'), 'rejet sans nom'); + + vi.spyOn(StorageManager.prototype, 'getDirectory').mockRejectedValue(new DOMException('refus', 'SecurityError')); + await echoue(fs.lireTexte(DOCUMENTS, etat), 'LECTURE', lecture(etat, corbeille, 'SecurityError'), 'lireTexte'); + await echoue(fs.lister(DOCUMENTS, ''), 'LECTURE', lecture('', CHEMIN_DOCUMENTS, 'SecurityError'), 'lister'); + vi.restoreAllMocks(); + expect(await fs.lireTexte(DOCUMENTS, etat)).toBe('etat'); + }); + + test("lister omet un fichier qui disparaît pendant la liste, prend la date d'un fichier dans l'OPFS et date un dossier de 0", async () => { + const fs = await preparer(); + await fs.creerDossier(DOCUMENTS, 'corbeille'); + await fs.ecrireAtomique(DOCUMENTS, 'reste.txt', 'reste'); + await fs.ecrireAtomique(DOCUMENTS, 'fugace.txt', 'fugace'); + const modifie = (await (await (await dossierProduit()).getFileHandle('reste.txt')).getFile()).lastModified; + + const lire = FileSystemFileHandle.prototype.getFile; + vi.spyOn(FileSystemFileHandle.prototype, 'getFile').mockImplementation(function getFile() { + return this.name === 'fugace.txt' ? Promise.reject(new DOMException('disparu', 'NotFoundError')) : lire.call(this); + }); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([ + { nom: 'corbeille', type: 'dossier', taille: 0, modifie: 0 }, + { nom: 'reste.txt', type: 'fichier', taille: 5, modifie }, + ]); + }); + + test("sonder rend la cause que lève l'OPFS, ou RELECTURE_DIFFERENTE, sans lever, et ne laisse aucun témoin", async () => { + const fs = creerFichiersWeb(); + vi.spyOn(StorageManager.prototype, 'getDirectory').mockRejectedValueOnce(new DOMException('refus', 'SecurityError')); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'SecurityError' }); + expect(await nomsOpfs()).toStrictEqual([]); + + vi.spyOn(FileSystemFileHandle.prototype, 'createWritable').mockRejectedValueOnce( + new DOMException('tenu', 'NoModificationAllowedError'), + ); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NoModificationAllowedError' }); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + + vi.spyOn(Blob.prototype, 'arrayBuffer').mockResolvedValueOnce(new TextEncoder().encode('autre').buffer); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'RELECTURE_DIFFERENTE' }); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + + // Une relecture qui échoue suit l'écriture du témoin, que la sonde efface. + vi.spyOn(Blob.prototype, 'arrayBuffer').mockRejectedValueOnce(new DOMException('illisible', 'NotReadableError')); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: false, cause: 'NotReadableError' }); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + + vi.restoreAllMocks(); + expect(await fs.sonder(DOCUMENTS)).toStrictEqual({ inscriptible: true, cause: null }); + }); +}); + +describe('fichiers web : ni verrou, ni explorateur, ni dossier choisi (§ 8.8)', () => { + test("verrouiller rend { pris: true } sans rien écrire, et deverrouiller n'efface aucun verrou", async () => { + const fs = await preparer(); + expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true }); + expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-a')).toStrictEqual({ pris: true }); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + + // Un verrou venu d'un dossier de la coquille reste où il est. + const verrou = '{"seance":"seance-b","pid":4321,"hote":"poste-voisin","depuis":"jamais"}\n'; + await fs.ecrireAtomique(DOCUMENTS, 'soiree.gtt.verrou', verrou); + await fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-b'); + expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou); + expect(await fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', 'seance-c')).toStrictEqual({ pris: true }); + expect(await fs.lireTexte(DOCUMENTS, 'soiree.gtt.verrou')).toBe(verrou); + }); + + test("ouvrirDansExplorateur et choisirDossier lèvent NON_DISPONIBLE {primitive} ; une racine inconnue lève d'abord CHEMIN_REFUSE", async () => { + const fs = await preparer(); + await echoue(fs.ouvrirDansExplorateur(DOCUMENTS), 'NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' }); + await echoue(fs.choisirDossier(), 'NON_DISPONIBLE', { primitive: 'choisirDossier' }); + const inconnue = { id: 'portable', chemin: CHEMIN_DOCUMENTS }; + await echoue(fs.ouvrirDansExplorateur(inconnue), 'CHEMIN_REFUSE', { chemin: '' }); + await echoue(fs.typeSupport(inconnue), 'CHEMIN_REFUSE', { chemin: '' }); + }); + + test("deux casses ou deux formes de normalisation d'un nom sont deux fichiers, et deplacer vers une autre casse ne trouve pas de cible existante", async () => { + const fs = await preparer(); + await fs.ecrireAtomique(DOCUMENTS, 'casse.txt', 'minuscules'); + await fs.ecrireAtomique(DOCUMENTS, 'CASSE.txt', 'capitales'); + await fs.ecrireAtomique(DOCUMENTS, '\u{E9}.txt', 'NFC'); + await fs.ecrireAtomique(DOCUMENTS, 'e\u{301}.txt', 'NFD'); + expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['CASSE.txt', 'casse.txt', 'e\u{301}.txt', '\u{E9}.txt']); + expect(await fs.lireTexte(DOCUMENTS, 'casse.txt')).toBe('minuscules'); + expect(await fs.lireTexte(DOCUMENTS, '\u{E9}.txt')).toBe('NFC'); + + await fs.deplacer(DOCUMENTS, 'casse.txt', 'Casse.txt'); + expect(noms(await fs.lister(DOCUMENTS, ''))).toStrictEqual(['CASSE.txt', 'Casse.txt', 'e\u{301}.txt', '\u{E9}.txt']); + expect(await fs.lireTexte(DOCUMENTS, 'Casse.txt')).toBe('minuscules'); + expect(await fs.lireTexte(DOCUMENTS, 'CASSE.txt')).toBe('capitales'); + }); +}); + +describe('fichiers web : importer et enregistrer', () => { + test('choisirFichierAImporter ouvre un champ de fichier unique, hors du document, et rend le nom et les octets choisis tels quels', async () => { + const fs = creerFichiersWeb(); + const champs = []; + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function cliquer() { + champs.push({ type: this.type, multiple: this.multiple, dansLeDocument: this.isConnected }); + const transfert = new DataTransfer(); + transfert.items.add(new File([BRUTS], 'invit\u{E9}s.csv')); + this.files = transfert.files; + this.dispatchEvent(new Event('change')); + }); + const choisi = await fs.choisirFichierAImporter(); + expect(champs).toStrictEqual([{ type: 'file', multiple: false, dansLeDocument: false }]); + expect(choisi).toStrictEqual({ nom: 'invit\u{E9}s.csv', octets: BRUTS }); + }); + + test("choisirFichierAImporter rend null quand l'opérateur annule, ou que le champ revient sans fichier", async () => { + const fs = creerFichiersWeb(); + const clic = vi.spyOn(HTMLInputElement.prototype, 'click'); + clic.mockImplementationOnce(function annuler() { + this.dispatchEvent(new Event('cancel')); + }); + expect(await fs.choisirFichierAImporter()).toBeNull(); + clic.mockImplementationOnce(function revenirVide() { + this.dispatchEvent(new Event('change')); + }); + expect(await fs.choisirFichierAImporter()).toBeNull(); + }); + + test("un fichier choisi qui ne se lit pas lève LECTURE, ou ABSENT quand il a disparu, et se nomme par son seul nom : le navigateur tait son dossier", async () => { + const fs = creerFichiersWeb(); + vi.spyOn(Blob.prototype, 'arrayBuffer') + .mockRejectedValueOnce(new DOMException('change', 'NotReadableError')) + .mockRejectedValueOnce(new DOMException('disparu', 'NotFoundError')); + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function cliquer() { + const transfert = new DataTransfer(); + transfert.items.add(new File([BRUTS], 'invit\u{E9}s.csv')); + this.files = transfert.files; + this.dispatchEvent(new Event('change')); + }); + await echoue(fs.choisirFichierAImporter(), 'LECTURE', { + chemin: 'invit\u{E9}s.csv', + dossier: null, + cause: 'NotReadableError', + }); + await echoue(fs.choisirFichierAImporter(), 'ABSENT', { chemin: 'invit\u{E9}s.csv' }); + }); + + test("enregistrerSous remet au navigateur un lien de téléchargement des octets sous le nom proposé, rend ce nom, puis libère l'adresse", async () => { + const fs = creerFichiersWeb(); + const creer = vi.spyOn(URL, 'createObjectURL'); + const liberer = vi.spyOn(URL, 'revokeObjectURL'); + const liens = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function cliquer() { + liens.push({ + href: this.href, + download: this.download, + dansLeDocument: this.isConnected, + liberee: liberer.mock.calls.length > 0, + }); + }); + expect(await fs.enregistrerSous('invit\u{E9}s.csv', BRUTS)).toBe('invit\u{E9}s.csv'); + expect(creer).toHaveBeenCalledTimes(1); + const [contenu] = creer.mock.calls[0]; + const adresse = creer.mock.results[0].value; + expect(contenu).toBeInstanceOf(Blob); + expect(contenu.type).toBe('application/octet-stream'); + expect(new Uint8Array(await contenu.arrayBuffer())).toStrictEqual(BRUTS); + expect(liens).toStrictEqual([ + { href: adresse, download: 'invit\u{E9}s.csv', dansLeDocument: false, liberee: false }, + ]); + await vi.waitFor(() => expect(liberer).toHaveBeenCalledWith(adresse)); + expect(liberer).toHaveBeenCalledTimes(1); + }); +}); + +describe('fichiers web : fautes de programmation', () => { + test('un texte, une ligne, une séance, un nom ou des octets mal formés lèvent TypeError, sans rien écrire ni rien proposer', async () => { + const fs = await preparer(); + const creer = vi.spyOn(URL, 'createObjectURL'); + const fautes = [ + () => fs.ecrireAtomique(DOCUMENTS, 'etat.json', 42), + () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 42), + () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'deux\nlignes'), + () => fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'retour\rchariot'), + () => fs.verrouiller(DOCUMENTS, 'soiree.gtt.verrou', ''), + () => fs.deverrouiller(DOCUMENTS, 'soiree.gtt.verrou', null), + () => fs.enregistrerSous('', BRUTS), + () => fs.enregistrerSous('liste.csv', 'nom;prenom'), + ]; + for (const faute of fautes) await expect(faute(), String(faute)).rejects.toThrow(TypeError); + expect(creer).not.toHaveBeenCalled(); + expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]); + }); +});