// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Épreuves de la façade des événements (§ 8.1, § 8.6, § 8.7, § 8.8, § 15, // § 19.4), toutes sur le système de fichiers d'épreuve, qui compte ses // lectures et ses écritures, avec l'horloge pilotée. La façade se branche sur // une séance réelle, que le montage crée sur la racine que reçoit // changerRacine, comme App la branche ; chaque appel que la façade fait à la // séance s'inscrit au relevé du montage. Les noms d'épreuve sont inventés ; la // version vient du module engendré. import assert from 'node:assert/strict'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE } from '../demo/catalogue.js'; import { LIVREES, texteLivre } from '../demo/livrees.js'; import { serialiser } from '../stockage/canonique.js'; import { creer as creerDansLeDepot } from '../stockage/depot.js'; import { analyser, creerCharge } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { lireJournal } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; import { VERSION } from '../version.genere.js'; import { creerFacade } from './evenements.js'; import { creerHorlogePilotee } from './horloge.js'; import { libelle } from './libelles.js'; import { creerSeance } from './seance.js'; const V1 = VERSION.affichee; const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; const DOC = { id: 'documents', chemin: DOCUMENTS }; const PUBLIE = 'E:\\soirees'; const PORTABLE = { id: 'portable', chemin: 'E:\\soirees\\data' }; const SOUS_APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programmes\\Tablee'; const PORTABLE_APPDATA = { id: 'portable', chemin: `${SOUS_APPDATA}\\data` }; // L'instant de départ de l'horloge pilotée, et le dossier de corbeille qu'il // date. const DEPART = '2026-10-06T19:00:00-04:00'; const CORBEILLE_DU_DEPART = 'corbeille/2026-10-06_19-00-00'; const LUCIOLES = 'Soirée des Lucioles'; const CERFS = 'Soirée des Cerfs'; const DEMONSTRATIONS = LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((entree) => entree.cle === cle).nom, texte, })); const UTF8 = new TextEncoder(); // --- Le montage ------------------------------------------------------------- // Identifiants d'épreuve, numérotés dans l'ordre des tirages. function creerIdentifiants(prefixe) { let n = 0; return () => { n += 1; return `${prefixe}-${n}`; }; } // La façade sur le système fs, branchée comme App la branche : changerRacine // crée la séance sur la racine reçue. appels relève, dans l'ordre, ce que la // façade demande à la séance, et ce que l'épreuve y ajoute. function monter({ fs = creerFichiersSimules(), horloge = creerHorlogePilotee({ depart: DEPART }), demonstrations = DEMONSTRATIONS } = {}) { const identifiant = creerIdentifiants('id'); const appels = []; let seance = null; let racine = null; const facade = creerFacade({ fs, horloge, identifiant, produitVersion: V1, demonstrations, ouvert: () => seance?.ouvert?.base ?? null, ouvrir: async (base) => { appels.push(['ouvrir', base]); return seance.ouvrir(base); }, fermer: async () => { appels.push(['fermer']); return seance.fermer(); }, creer: async (champs) => { appels.push(['creer', champs]); return seance.creer(champs); }, racine: () => racine, changerRacine: async (nouvelle) => { appels.push(['changerRacine', nouvelle]); racine = nouvelle; seance = creerSeance({ fs, racine: nouvelle, horloge, identifiant, produitVersion: V1, planifier: horloge.planifier, annuler: horloge.annuler, }); }, }); return { fs, horloge, facade, appels, get seance() { return seance; }, }; } // Un événement posé par le dépôt, hors de la façade : sa paire, journal // d'une entrée et son jalon, puis l'état. async function deposerEvenement(fs, racine, { id, nom, etat = null }) { const charge = creerCharge({ id, nom, siegesParDefaut: 8, tours: 2 }); if (etat !== null) charge.evenement.etat = etat; const ouvert = await creerDansLeDepot(fs, racine, charge, { libelle: libelle('ENTREE_CREATION', { nom }), nomJalon: libelle('JALON_CREATION'), horodatage: '2031-05-17T08:00:00-04:00', produitVersion: V1, }); return ouvert.base; } // Le texte d'état d'un événement neuf, que l'épreuve dépose tel quel. const texteEtat = ({ id, nom }) => serialiser(creerCharge({ id, nom, siegesParDefaut: 8, tours: 2 }), { revision: 1, produitVersion: V1 }); // Le refus qu'une promesse rejette. async function refusDe(promesse) { try { await promesse; } catch (erreur) { return erreur; } assert.fail('la promesse devait rejeter'); } // Les textes de chaque fichier d'une racine, par nom, sous le dossier donné. async function photographier(fs, racine, dossier = '') { const entrees = await fs.lister(racine, dossier); const photo = {}; for (const { nom, type } of entrees) { if (type === 'fichier') photo[nom] = fs.contenu(racine.id, dossier === '' ? nom : `${dossier}/${nom}`); } return photo; } const ecritures = (fs) => fs.compteurs.ecritures; // Le nom en NFD que copier et renommer reçoivent, et sa forme NFC, distinctes. const NFD = 'Soire\u{0065}\u{0301}e des Hiboux'.normalize('NFD'); const NFC = NFD.normalize('NFC'); // Des Documents sous un chemin de 250 caractères : aucun nom long n'y tient // sous la borne d'un chemin, que lève deriverBase. const PROFOND = `C:\\${'p'.repeat(247)}`; // --- La détermination du dossier de travail (§ 8.6) -------------------------- describe('demarrer : une détermination par séance (§ 8.6)', () => { test('le second demarrer ne lit ni n’écrit rien, et rend le même résultat', async () => { const { fs, facade, appels } = monter(); const premier = await facade.demarrer(); const { ecritures: e, lectures: l } = fs.compteurs; const second = await facade.demarrer(); assert.equal(second, premier); assert.equal(fs.compteurs.ecritures, e); assert.equal(fs.compteurs.lectures, l); assert.deepEqual(premier.dossier.racine, DOC); assert.deepEqual(appels, [['changerRacine', DOC]]); }); test('les cinq issues de S8 rendent leurs avis, dans l’ordre d’Avis', async () => { const cas = [ [{}, [{ code: 'NON_PUBLIE', details: {} }]], [{ executable: SOUS_APPDATA }, [{ code: 'DONNEES_APPLICATIVES', details: {} }]], [{ executable: PUBLIE, sonde: { portable: 'EACCES' } }, [{ code: 'SONDE_ECHOUEE', details: { cause: 'EACCES' } }]], [{ nature: 'web' }, [{ code: 'PLATEFORME_WEB', details: {} }]], [{ executable: PUBLIE, support: { portable: 'amovible' } }, [{ code: 'SUPPORT_AMOVIBLE', details: {} }]], [ { nature: 'web', support: { documents: 'amovible' } }, [ { code: 'PLATEFORME_WEB', details: {} }, { code: 'SUPPORT_AMOVIBLE', details: {} }, ], ], [{ executable: PUBLIE }, []], ]; assert.equal(cas.length, 7); for (const [systeme, attendus] of cas) { const { facade } = monter({ fs: creerFichiersSimules(systeme) }); const { avis } = await facade.demarrer(); assert.deepEqual(avis, attendus, JSON.stringify(systeme)); } }); test('changerRacine reçoit la racine retenue : data/ en mode portable', async () => { const { facade, appels } = monter({ fs: creerFichiersSimules({ executable: PUBLIE }) }); const { dossier } = await facade.demarrer(); assert.equal(dossier.mode, 'portable'); assert.deepEqual(appels, [['changerRacine', PORTABLE]]); assert.deepEqual(facade.explorateur(), { disponible: true, raison: null }); }); test('des réglages locaux corrompus ajoutent REGLAGES_ILLISIBLES après les avis de S8', async () => { const fs = creerFichiersSimules(); fs.deposer('documents', 'reglages_locaux.json', '{ pas du JSON'); const { facade } = monter({ fs }); const { avis } = await facade.demarrer(); assert.deepEqual(avis, [ { code: 'NON_PUBLIE', details: {} }, { code: 'REGLAGES_ILLISIBLES', details: { chemin: 'reglages_locaux.json', raison: 'JSON' } }, ]); }); test('une lecture refusée rejette demarrer sans rien retenir, et le suivant refait la détermination', async () => { const fs = creerFichiersSimules(); const lister = fs.lister; let pannes = 1; fs.lister = async (...parametres) => { if (pannes > 0) { pannes -= 1; throw new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EIO' }); } return lister(...parametres); }; const { facade, appels } = monter({ fs }); const refus = await refusDe(facade.demarrer()); assert.equal(refus.code, 'LECTURE'); assert.deepEqual(appels, []); const { dossier } = await facade.demarrer(); assert.deepEqual(dossier.racine, DOC); assert.deepEqual(appels, [['changerRacine', DOC]]); }); }); // --- Les questions (§ 8.6) --------------------------------------------------- describe('les questions du dossier de travail (§ 8.6)', () => { // data/ porte deux événements, posés par un premier processus ; le second, // monté sur le même disque, voit data/ refuser l'écriture. async function dataQuiRefuse() { const disque = new Map(); const avant = creerFichiersSimules({ disque, executable: PUBLIE }); avant.deposerDossier('portable'); await deposerEvenement(avant, PORTABLE, { id: 'evt-lucioles', nom: LUCIOLES }); await deposerEvenement(avant, PORTABLE, { id: 'evt-cerfs', nom: CERFS }); const fs = creerFichiersSimules({ disque, executable: PUBLIE, sonde: { portable: 'EACCES' } }); const sondes = []; const sonder = fs.sonder; fs.sonder = async (racine) => { sondes.push(racine.id); return sonder(racine); }; return { fs, sondes }; } test('PORTABLE_NON_INSCRIPTIBLE : question au compte de 2, liste refusée tant qu’elle attend', async () => { const { fs } = await dataQuiRefuse(); const { facade, appels } = monter({ fs }); const { dossier, avis } = await facade.demarrer(); assert.deepEqual(dossier.question, { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: 2, cause: 'EACCES', }); assert.deepEqual(avis, [{ code: 'SONDE_ECHOUEE', details: { cause: 'EACCES' } }]); assert.deepEqual(appels, []); const refus = await refusDe(facade.lister()); assert.equal(refus.code, 'QUESTION_EN_ATTENTE'); assert.deepEqual(refus.details, { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }); assert.equal(typeof libelle(refus.code, refus.details), 'string'); }); test('« reessayer » refait la détermination : deux sondes de plus, la même question', async () => { const { fs, sondes } = await dataQuiRefuse(); const { facade, appels } = monter({ fs }); await facade.demarrer(); assert.deepEqual(sondes, ['portable', 'documents']); const avant = ecritures(fs); const { dossier } = await facade.repondre('reessayer'); assert.deepEqual(sondes, ['portable', 'documents', 'portable', 'documents']); // Les deux sondes, et rien d'autre. assert.equal(ecritures(fs), avant + 2); assert.equal(dossier.question.code, 'PORTABLE_NON_INSCRIPTIBLE'); assert.deepEqual(appels, []); assert.equal((await refusDe(facade.lister())).code, 'QUESTION_EN_ATTENTE'); }); test('« documents » copie les deux paires dans les Documents, originaux intacts', async () => { const { fs } = await dataQuiRefuse(); const { facade, appels } = monter({ fs }); await facade.demarrer(); const originaux = await photographier(fs, PORTABLE); assert.equal(Object.keys(originaux).length, 4); const avant = ecritures(fs); const { dossier } = await facade.repondre('documents'); // Chaque paire : son journal, puis son état. assert.equal(ecritures(fs), avant + 4); assert.equal(dossier.question, null); assert.equal(dossier.autre, null); assert.deepEqual(dossier.racine, DOC); assert.deepEqual(appels, [['changerRacine', DOC]]); assert.deepEqual(await photographier(fs, PORTABLE), originaux); const lignes = await facade.lister(); assert.equal(lignes.length, 2); assert.deepEqual( lignes.map(({ id, nom }) => [id, nom]), [ ['evt-cerfs', CERFS], ['evt-lucioles', LUCIOLES], ], ); for (const base of [LUCIOLES, CERFS]) { assert.equal(fs.contenu('documents', base + SUFFIXES.etat), originaux[base + SUFFIXES.etat]); assert.equal(fs.contenu('documents', base + SUFFIXES.journal), originaux[base + SUFFIXES.journal]); } assert.equal((await facade.demarrer()).dossier, dossier); }); test('un data/ qui ne se lit pas, compte null : « documents » n’a rien à copier, ni à relire', async () => { const { fs } = await dataQuiRefuse(); const lister = fs.lister; const listes = []; fs.lister = async (racine, dossier) => { listes.push(racine.id); if (racine.id === 'portable') throw new ErreurStockage('LECTURE', { chemin: dossier, dossier: PORTABLE.chemin, cause: 'EIO' }); return lister(racine, dossier); }; const { facade, appels } = monter({ fs }); const { dossier } = await facade.demarrer(); assert.deepEqual(dossier.question, { code: 'PORTABLE_NON_INSCRIPTIBLE', racine: PORTABLE, evenements: null, cause: 'EACCES', }); const lus = listes.length; const avant = ecritures(fs); const reponse = await facade.repondre('documents'); assert.deepEqual(reponse.importe, { importes: [], refuses: [] }); assert.deepEqual(reponse.dossier.racine, DOC); assert.deepEqual(appels, [['changerRacine', DOC]]); assert.deepEqual(listes.slice(lus), []); assert.equal(ecritures(fs), avant); }); test('« continuer » ne répond pas à PORTABLE_NON_INSCRIPTIBLE : RangeError', async () => { const { fs } = await dataQuiRefuse(); const { facade, appels } = monter({ fs }); await facade.demarrer(); await assert.rejects(facade.repondre('continuer'), RangeError); await assert.rejects(facade.repondre('ignorer'), RangeError); assert.deepEqual(appels, []); }); test('DOCUMENTS_NON_INSCRIPTIBLE nomme le dossier, la cause et le compte ; « continuer » ouvre la liste sans écrire', async () => { const disque = new Map(); const avant = creerFichiersSimules({ disque }); avant.deposerDossier('documents'); await deposerEvenement(avant, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const fs = creerFichiersSimules({ disque, sonde: { documents: 'EACCES' } }); const { facade, appels } = monter({ fs }); const { dossier } = await facade.demarrer(); assert.deepEqual(dossier.question, { code: 'DOCUMENTS_NON_INSCRIPTIBLE', racine: DOC, evenements: 1, cause: 'EACCES', }); assert.equal((await refusDe(facade.lister())).code, 'QUESTION_EN_ATTENTE'); await assert.rejects(facade.repondre('documents'), RangeError); assert.deepEqual(appels, []); const avant2 = ecritures(fs); const reponse = await facade.repondre('continuer'); assert.equal(reponse.dossier.question, null); assert.deepEqual(appels, [['changerRacine', DOC]]); const lignes = await facade.lister(); assert.deepEqual( lignes.map(({ base }) => base), [LUCIOLES], ); assert.equal(ecritures(fs), avant2); }); }); // --- La liste (§ 8.1, § 19.4) ----------------------------------------------- describe('lister (§ 8.1, § 19.4)', () => { // Publié en E:\soirees, data/ inscriptible : la petite démonstration // déposée sans journal, et un événement renommé à la main. async function deuxEvenements() { const fs = creerFichiersSimules({ executable: PUBLIE }); const montage = monter({ fs }); await montage.facade.demarrer(); fs.deposer('portable', `petite${SUFFIXES.etat}`, texteLivre('petite')); const base = await deposerEvenement(fs, PORTABLE, { id: 'evt-gala', nom: 'Gala du printemps' }); await fs.deplacer(PORTABLE, base + SUFFIXES.etat, `soiree-a${SUFFIXES.etat}`); await fs.deplacer(PORTABLE, base + SUFFIXES.journal, `soiree-a${SUFFIXES.journal}`); return montage; } test('six informations par ligne, le nom et le compte lus dans le fichier, rien d’écrit', async () => { const { fs, facade } = await deuxEvenements(); const avant = ecritures(fs); const lignes = await facade.lister(); assert.equal(ecritures(fs), avant); const petite = analyser(texteLivre('petite')).charge; assert.deepEqual(lignes, [ { base: 'soiree-a', id: 'evt-gala', nom: 'Gala du printemps', date: null, etat: 'brouillon', participants: 0, derniereModification: '2031-05-17T08:00:00-04:00', chemin: `E:\\soirees\\data\\soiree-a${SUFFIXES.etat}`, illisible: null, journalSeul: false, doublonAvec: [], verrou: null, }, { base: 'petite', id: petite.evenement.id, nom: petite.evenement.nom, date: petite.evenement.date, etat: petite.evenement.etat, participants: petite.participants.length, derniereModification: null, chemin: `E:\\soirees\\data\\petite${SUFFIXES.etat}`, illisible: null, journalSeul: false, doublonAvec: [], verrou: null, }, ]); assert.ok(petite.participants.length > 0); }); test('deux exécutions rendent la même liste ; la date des fichiers n’y change rien', async () => { const { fs, facade } = await deuxEvenements(); const avant = ecritures(fs); const premiere = await facade.lister(); assert.equal(premiere.length, 2); assert.deepEqual(await facade.lister(), premiere); fs.deposer('portable', `petite${SUFFIXES.etat}`, texteLivre('petite'), 1); fs.deposer('portable', `soiree-a${SUFFIXES.journal}`, fs.contenu('portable', `soiree-a${SUFFIXES.journal}`), 2); assert.deepEqual(await facade.lister(), premiere); assert.equal(ecritures(fs), avant); }); test('la dernière modification est l’horodatage de la dernière entrée du journal', async () => { const { fs, horloge, facade } = monter(); await facade.demarrer(); await facade.creer({ nom: LUCIOLES }); horloge.avancer(3_600_000); await facade.renommer(LUCIOLES, { nom: CERFS }); const journal = lireJournal(fs.contenu('documents', CERFS + SUFFIXES.journal)); assert.deepEqual( journal.entrees.map(({ horodatage }) => horodatage), [DEPART, '2026-10-06T20:00:00-04:00'], ); const avant = ecritures(fs); const [ligne] = await facade.lister(); assert.equal(ligne.derniereModification, '2026-10-06T20:00:00-04:00'); assert.equal(ecritures(fs), avant); }); test('un .gtt.verrou présent se lit sans écrire ; illisible, sa séance et son instant sont null', async () => { const { fs, facade } = await deuxEvenements(); const contenu = { seance: 'seance-voisine', pid: 7, hote: 'poste-voisin', depuis: '2031-05-17T09:30:00-04:00' }; fs.deposer('portable', `soiree-a${SUFFIXES.verrou}`, `${JSON.stringify(contenu)}\n`); fs.deposer('portable', `petite${SUFFIXES.verrou}`, 'pas un verrou'); const avant = ecritures(fs); const lignes = await facade.lister(); assert.equal(ecritures(fs), avant); assert.deepEqual( lignes.map(({ base, verrou }) => [base, verrou]), [ ['soiree-a', { seance: 'seance-voisine', depuis: '2031-05-17T09:30:00-04:00' }], ['petite', { seance: null, depuis: null }], ], ); }); test('signalements : état vide, journal seul, doublons ; rien pour les autres fichiers', async () => { const fs = creerFichiersSimules(); const { facade } = monter({ fs }); await facade.demarrer(); fs.deposer('documents', `Soirée vide${SUFFIXES.etat}`, ''); const hiboux = await deposerEvenement(fs, DOC, { id: 'evt-hiboux', nom: 'Soirée des Hiboux' }); await fs.supprimer(DOC, hiboux + SUFFIXES.etat); const merles = texteEtat({ id: 'evt-merles', nom: 'Club des Merles' }); fs.deposer('documents', `Merles A${SUFFIXES.etat}`, merles); fs.deposer('documents', `Merles B${SUFFIXES.etat}`, merles); fs.deposer('documents', 'reglages_locaux.json', '{}'); fs.deposer('documents', `corbeille/2031-01-01_00-00-00/Ancienne${SUFFIXES.etat}`, merles); fs.deposer('documents', 'planche.pdf', '%PDF'); const avant = ecritures(fs); const lignes = await facade.lister(); assert.equal(ecritures(fs), avant); assert.equal(lignes.length, 4); const signalees = lignes.filter((l) => l.illisible !== null || l.journalSeul || l.doublonAvec.length > 0); assert.equal(signalees.length, 4); const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); assert.deepEqual(Object.keys(par).sort(), ['Merles A', 'Merles B', 'Soirée des Hiboux', 'Soirée vide']); assert.equal(par['Soirée vide'].illisible.raison, 'VIDE'); assert.deepEqual(par['Soirée vide'].illisible.secours, { precedent: null, journal: null }); assert.equal(par['Soirée vide'].journalSeul, false); assert.equal(typeof libelle('ETAT_ILLISIBLE', par['Soirée vide'].illisible), 'string'); assert.equal(par['Soirée des Hiboux'].journalSeul, true); assert.equal(par['Soirée des Hiboux'].id, 'evt-hiboux'); assert.deepEqual(par['Soirée des Hiboux'].illisible.secours, { precedent: null, journal: 1 }); assert.equal(par['Soirée des Hiboux'].derniereModification, '2031-05-17T08:00:00-04:00'); assert.deepEqual(par['Merles A'].doublonAvec, ['Merles B']); assert.deepEqual(par['Merles B'].doublonAvec, ['Merles A']); assert.equal(par['Merles A'].illisible, null); assert.equal(par['Soirée vide'].doublonAvec.length, 0); }); }); // --- Créer (§ 2.1, § 8.3) ---------------------------------------------------- describe('creer (§ 2.1, § 8.3)', () => { test('« Soirée d’essai » : creer de la séance, une entrée, le jalon « Création », ouvert en lecture', async () => { const montage = monter(); const { fs, facade, appels } = montage; await facade.demarrer(); const avant = ecritures(fs); const { base } = await facade.creer({ nom: "Soirée d'essai", date: null }); assert.equal(ecritures(fs), avant + 2); assert.deepEqual(appels.at(-1), ['creer', { nom: "Soirée d'essai", date: null, siegesParDefaut: 8, tours: 4 }]); const journal = lireJournal(fs.contenu('documents', base + SUFFIXES.journal)); assert.equal(journal.entrees.length, 1); assert.deepEqual( journal.jalons.map(({ nom }) => nom), ['Création'], ); assert.equal(montage.seance.ouvert.base, base); assert.equal(montage.seance.mode, 'lecture'); const { charge } = analyser(fs.contenu('documents', base + SUFFIXES.etat)); assert.equal(charge.evenement.siegesParDefaut, 8); assert.equal(charge.evenement.tours, 4); }); test('une coupure après la première écriture laisse le journal sans l’état : le journal d’abord', async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque }); const { facade } = monter({ fs }); await facade.demarrer(); fs.pannes.couperApres(1); await assert.rejects(facade.creer({ nom: "Soirée d'essai" })); const relu = creerFichiersSimules({ disque }); assert.notEqual(relu.contenu('documents', `Soirée d'essai${SUFFIXES.journal}`), null); assert.equal(relu.contenu('documents', `Soirée d'essai${SUFFIXES.etat}`), null); }); test('un nom en NFD est inscrit en NFC', async () => { const { fs, facade } = monter(); await facade.demarrer(); const nfd = 'Soire\u{0065}\u{0301}e des Lucioles'.normalize('NFD'); assert.notEqual(nfd, nfd.normalize('NFC')); const { base } = await facade.creer({ nom: nfd }); assert.equal(analyser(fs.contenu('documents', base + SUFFIXES.etat)).charge.evenement.nom, nfd.normalize('NFC')); }); test('nom blanc, date hors du calendrier ou mal écrite : refus, rien d’écrit', async () => { const { fs, facade } = monter(); await facade.demarrer(); const avant = ecritures(fs); assert.equal((await refusDe(facade.creer({ nom: ' ' }))).code, 'NOM_REQUIS'); for (const date of ['2026-02-30', '2026-2-3']) { const refus = await refusDe(facade.creer({ nom: LUCIOLES, date })); assert.equal(refus.code, 'DATE_INVALIDE'); assert.equal(refus.details.date, date); } assert.equal(ecritures(fs), avant); }); test('des Documents sous un chemin de 250 caractères : CHEMIN_TROP_LONG, rien d’écrit', async () => { assert.equal(PROFOND.length, 250); const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); await facade.demarrer(); const avant = ecritures(fs); assert.equal((await refusDe(facade.creer({ nom: LUCIOLES }))).code, 'CHEMIN_TROP_LONG'); assert.equal(ecritures(fs), avant); }); }); // --- Charger une démonstration (§ 15) --------------------------------------- describe('chargerDemonstration (§ 15)', () => { test('deux chargements : « ‹nom› 1 » puis « ‹nom› 2 », identités distinctes, ouverts en lecture', async () => { const montage = monter(); const { fs, facade } = montage; await facade.demarrer(); const { base: courant } = await facade.creer({ nom: LUCIOLES }); const courantAvant = await photographier(fs, DOC); const courants = [courant + SUFFIXES.etat, courant + SUFFIXES.journal]; assert.deepEqual(Object.keys(courantAvant).sort(), [...courants].sort()); const livre = texteLivre('petite'); const nom = CATALOGUE.find((entree) => entree.cle === 'petite').nom; const avant = ecritures(fs); const premier = await facade.chargerDemonstration('petite'); // Le journal, puis l'état. assert.equal(ecritures(fs), avant + 2); assert.equal(montage.seance.ouvert.base, premier.base); assert.equal(montage.seance.mode, 'lecture'); const second = await facade.chargerDemonstration('petite'); assert.equal(ecritures(fs), avant + 4); assert.equal(montage.seance.ouvert.base, second.base); assert.equal(montage.seance.mode, 'lecture'); const lire = (base) => analyser(fs.contenu('documents', base + SUFFIXES.etat)).charge.evenement; assert.equal(lire(premier.base).nom, `${nom} 1`); assert.equal(lire(second.base).nom, `${nom} 2`); assert.notEqual(lire(premier.base).id, lire(second.base).id); const journal = lireJournal(fs.contenu('documents', premier.base + SUFFIXES.journal)); assert.deepEqual( journal.entrees.map(({ libelle: texte }) => texte), [`Chargement de la démonstration « ${nom} »`], ); assert.equal(texteLivre('petite'), livre); const photo = await photographier(fs, DOC); for (const fichier of courants) { assert.equal(typeof courantAvant[fichier], 'string'); assert.equal(photo[fichier], courantAvant[fichier]); } }); test('une clé inconnue lève RangeError, sans rien écrire', async () => { const { fs, facade } = monter(); await facade.demarrer(); const avant = ecritures(fs); await assert.rejects(facade.chargerDemonstration('inconnue'), RangeError); assert.equal(ecritures(fs), avant); }); }); // --- Copier (§ 8.7) ---------------------------------------------------------- describe('copier (§ 8.7)', () => { test('depuis un plan bloqué : nouvel identifiant, une entrée, non bloquée, original intact', async () => { const montage = monter(); const { fs, facade } = montage; await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES, etat: 'bloque' }); const original = await photographier(fs, DOC); assert.equal(Object.keys(original).length, 2); const avant = ecritures(fs); const { base } = await facade.copier(LUCIOLES, { nom: ' Variante des Lucioles ' }); // Le journal de la copie, puis son état. assert.equal(ecritures(fs), avant + 2); assert.equal(base, 'Variante des Lucioles'); assert.equal(montage.seance.ouvert.base, base); assert.equal(montage.seance.mode, 'lecture'); const copie = analyser(fs.contenu('documents', base + SUFFIXES.etat)).charge.evenement; assert.notEqual(copie.id, 'evt-lucioles'); assert.notEqual(copie.etat, 'bloque'); assert.equal(copie.nom, 'Variante des Lucioles'); const creation = libelle('ENTREE_CREATION', { nom: LUCIOLES }); const journal = lireJournal(fs.contenu('documents', base + SUFFIXES.journal)); assert.deepEqual( journal.entrees.map(({ libelle: texte }) => texte), [`Créée par copie de « ${LUCIOLES} », à l'instant « ${creation} »`], ); // L'entrée nomme l'instant que la filiation inscrit. assert.equal(copie.filiation.instant.libelle, creation); const apres = await photographier(fs, DOC); for (const [fichier, texte] of Object.entries(original)) assert.equal(apres[fichier], texte); }); test('un nom vide est refusé, sans écrire, et aucun « (copie) » ne paraît', async () => { const { fs, facade } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const avant = ecritures(fs); for (const nom of ['', ' \t ']) { const refus = await refusDe(facade.copier(LUCIOLES, { nom })); assert.equal(refus.code, 'NOM_REQUIS'); assert.equal(refus.details.remede, null); } assert.equal(ecritures(fs), avant); const lignes = await facade.lister(); assert.equal(lignes.length, 1); assert.ok(lignes.every(({ nom }) => !nom.includes('(copie)'))); }); test('un nom en NFD : la copie porte le nom et la base en NFC', async () => { const { fs, facade } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); assert.notEqual(NFD, NFC); const { base } = await facade.copier(LUCIOLES, { nom: NFD }); assert.equal(base, NFC); assert.equal(analyser(fs.contenu('documents', NFC + SUFFIXES.etat)).charge.evenement.nom, NFC); assert.equal(fs.contenu('documents', NFD + SUFFIXES.etat), null); }); test('des Documents trop profonds pour le nom : CHEMIN_TROP_LONG, rien d’écrit', async () => { const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); await facade.demarrer(); fs.deposer('documents', `Court${SUFFIXES.etat}`, texteEtat({ id: 'evt-court', nom: 'Court' })); const avant = ecritures(fs); const refus = await refusDe(facade.copier('Court', { nom: 'x'.repeat(120) })); assert.equal(refus.code, 'CHEMIN_TROP_LONG'); assert.equal(ecritures(fs), avant); assert.deepEqual(Object.keys(await photographier(fs, { id: 'documents', chemin: PROFOND })), [`Court${SUFFIXES.etat}`]); }); }); // --- Renommer (§ 8.7, § 8.8) ------------------------------------------------- describe('renommer (§ 8.7, § 8.8)', () => { test('nouveau nom, nouveau chemin, une entrée du renommage, aucun verrou restant', async () => { const { fs, facade, appels } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const avant = ecritures(fs); const { base } = await facade.renommer(LUCIOLES, { nom: CERFS }); // La sonde qui prend le verrou ; la nouvelle paire — .precedent, journal, // état — ; l'effacement de l'ancienne — état, .precedent, journal — ; le // verrou rendu. assert.equal(ecritures(fs), avant + 1 + 3 + 3 + 1); assert.equal(base, CERFS); const lignes = await facade.lister(); assert.deepEqual( lignes.map(({ base: b, nom, chemin }) => [b, nom, chemin]), [[CERFS, CERFS, `${DOCUMENTS}\\${CERFS}${SUFFIXES.etat}`]], ); assert.equal(fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null); assert.equal(fs.contenu('documents', CERFS + SUFFIXES.verrou), null); const journal = lireJournal(fs.contenu('documents', CERFS + SUFFIXES.journal)); assert.equal(journal.entrees.at(-1).libelle, `Renommé de « ${LUCIOLES} » en « ${CERFS} »`); assert.deepEqual( appels.filter(([nom]) => nom !== 'changerRacine'), [], ); }); // Coupe le processus dès que l'écriture atomique a posé ce fichier. function couperApresEcriture(fs, chemin) { const ecrire = fs.ecrireAtomique; fs.ecrireAtomique = async (racine, cible, texte) => { await ecrire(racine, cible, texte); if (cible === chemin) fs.pannes.couperApres(0); }; } test('une coupure après le nouvel état laisse deux paires, signalées doublons l’une de l’autre', async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque }); const { facade } = monter({ fs }); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); couperApresEcriture(fs, CERFS + SUFFIXES.etat); await assert.rejects(facade.renommer(LUCIOLES, { nom: CERFS })); const relu = monter({ fs: creerFichiersSimules({ disque }) }); await relu.facade.demarrer(); const lignes = await relu.facade.lister(); assert.deepEqual( lignes.map(({ base, doublonAvec }) => [base, doublonAvec]), [ [CERFS, [LUCIOLES]], [LUCIOLES, [CERFS]], ], ); }); test('une coupure après le seul nouveau journal laisse une ligne, sous l’ancienne base', async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque }); const { facade } = monter({ fs }); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); couperApresEcriture(fs, CERFS + SUFFIXES.journal); await assert.rejects(facade.renommer(LUCIOLES, { nom: CERFS })); const relu = monter({ fs: creerFichiersSimules({ disque }) }); await relu.facade.demarrer(); const lignes = await relu.facade.lister(); assert.deepEqual( lignes.map(({ base, nom }) => [base, nom]), [[LUCIOLES, LUCIOLES]], ); }); test('plan bloqué : LECTURE_SEULE {raison: BLOQUE}, remède « débloquer », rien d’écrit', async () => { const { fs, facade } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES, etat: 'bloque' }); const avant = ecritures(fs); const refus = await refusDe(facade.renommer(LUCIOLES, { nom: CERFS })); assert.equal(refus.code, 'LECTURE_SEULE'); assert.deepEqual(refus.details, { raison: 'BLOQUE', remede: { geste: 'debloquer' } }); assert.equal(ecritures(fs), avant); }); test('un nom vide : NOM_REQUIS avant toute sonde, rien d’écrit', async () => { const { fs, facade } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const avant = ecritures(fs); assert.equal((await refusDe(facade.renommer(LUCIOLES, { nom: ' ' }))).code, 'NOM_REQUIS'); assert.equal(ecritures(fs), avant); }); test('un nom en NFD : le nom et la base renommés sont en NFC', async () => { const { fs, facade } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); assert.notEqual(NFD, NFC); const { base } = await facade.renommer(LUCIOLES, { nom: NFD }); assert.equal(base, NFC); assert.equal(analyser(fs.contenu('documents', NFC + SUFFIXES.etat)).charge.evenement.nom, NFC); assert.equal(fs.contenu('documents', NFD + SUFFIXES.etat), null); }); test('CHEMIN_TROP_LONG, levé sous le verrou : le verrou se rend, le disque reste tel qu’il était', async () => { const { fs, facade } = monter({ fs: creerFichiersSimules({ documents: PROFOND }) }); await facade.demarrer(); const profond = { id: 'documents', chemin: PROFOND }; fs.deposer('documents', `Court${SUFFIXES.etat}`, texteEtat({ id: 'evt-court', nom: 'Court' })); const photo = await photographier(fs, profond); assert.deepEqual(Object.keys(photo), [`Court${SUFFIXES.etat}`]); const avant = ecritures(fs); const refus = await refusDe(facade.renommer('Court', { nom: 'x'.repeat(120) })); assert.equal(refus.code, 'CHEMIN_TROP_LONG'); // La sonde qui prend le verrou, puis le verrou rendu. assert.equal(ecritures(fs), avant + 2); assert.equal(fs.contenu('documents', `Court${SUFFIXES.verrou}`), null); assert.deepEqual(await photographier(fs, profond), photo); }); test('l’événement ouvert en écriture : fermé d’abord, aucun VERROU_PRIS, rouvert sous la nouvelle base', async () => { const montage = monter(); const { facade, appels } = montage; await facade.demarrer(); await facade.creer({ nom: LUCIOLES }); await montage.seance.passerEnEcriture(); assert.equal(montage.seance.mode, 'ecriture'); const debut = appels.length; const { base } = await facade.renommer(LUCIOLES, { nom: CERFS }); assert.deepEqual(appels.slice(debut), [['fermer'], ['ouvrir', CERFS]]); assert.equal(base, CERFS); assert.equal(montage.seance.ouvert.base, CERFS); assert.equal(montage.seance.mode, 'lecture'); assert.equal(montage.fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null); }); test('le verrou d’une séance vivante : VERROU_PRIS, rien ne bouge ; celui d’une séance morte s’efface', async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque }); const { facade } = monter({ fs }); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const voisin = creerFichiersSimules({ disque }); await voisin.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-voisine'); const avant = await photographier(fs, DOC); assert.equal(Object.keys(avant).length, 3); const ecrituresAvant = ecritures(fs); const refus = await refusDe(facade.renommer(LUCIOLES, { nom: CERFS })); assert.equal(refus.code, 'VERROU_PRIS'); assert.equal(refus.details.seance, 'seance-voisine'); assert.equal(refus.details.vivant, true); // La seule sonde, refusée. assert.equal(ecritures(fs), ecrituresAvant + 1); assert.deepEqual(await photographier(fs, DOC), avant); voisin.pannes.couperApres(0); const avantReprise = ecritures(fs); const { base } = await facade.renommer(LUCIOLES, { nom: CERFS }); // La sonde refusée, le verrou mort effacé, la sonde qui le prend ; puis // le renommage : nouvelle paire, ancienne effacée, verrou rendu. assert.equal(ecritures(fs), avantReprise + 3 + 3 + 3 + 1); assert.equal(base, CERFS); assert.equal(fs.contenu('documents', LUCIOLES + SUFFIXES.verrou), null); }); }); // --- Supprimer (§ 8.7, § 8.8) ------------------------------------------------ describe('supprimer (§ 8.7, § 8.8)', () => { test('les fichiers partent dans la corbeille datée par l’horloge, l’ouvert fermé d’abord', async () => { const { fs, facade, appels } = monter(); await facade.demarrer(); await facade.creer({ nom: LUCIOLES }); const deplacer = fs.deplacer; fs.deplacer = async (...parametres) => { appels.push(['deplacer', parametres[1]]); return deplacer(...parametres); }; const avant = ecritures(fs); const { dossier } = await facade.supprimer(LUCIOLES); // La sonde qui prend le verrou, le dossier daté, puis trois déplacements : // le verrou, le journal, l'état. assert.equal(ecritures(fs), avant + 1 + 1 + 3); assert.equal(dossier, CORBEILLE_DU_DEPART); const fermer = appels.findIndex(([nom]) => nom === 'fermer'); const premierDeplacement = appels.findIndex(([nom]) => nom === 'deplacer'); assert.ok(fermer >= 0 && premierDeplacement > fermer); assert.deepEqual(await facade.lister(), []); assert.deepEqual(await facade.corbeille(), [ { dossier: CORBEILLE_DU_DEPART, chemin: `${DOCUMENTS}\\corbeille\\2026-10-06_19-00-00`, fichiers: [LUCIOLES + SUFFIXES.journal, LUCIOLES + SUFFIXES.etat, LUCIOLES + SUFFIXES.verrou], }, ]); }); test('le verrou d’une séance morte ne retient rien : il part avec l’événement', async () => { const disque = new Map(); const fs = creerFichiersSimules({ disque }); const { facade } = monter({ fs }); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const mort = creerFichiersSimules({ disque }); await mort.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-morte'); mort.pannes.couperApres(0); const avant = ecritures(fs); await facade.supprimer(LUCIOLES); // La sonde, que le verrou mort n'arrête pas, le dossier daté, trois // déplacements. assert.equal(ecritures(fs), avant + 1 + 1 + 3); const [{ fichiers }] = await facade.corbeille(); assert.ok(fichiers.includes(LUCIOLES + SUFFIXES.verrou)); assert.deepEqual(await facade.lister(), []); }); test('un plan bloqué se supprime : rien ne se détruit, ses fichiers passent dans la corbeille', async () => { const { fs, facade } = monter(); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES, etat: 'bloque' }); const original = await photographier(fs, DOC); assert.equal(Object.keys(original).length, 2); const avant = ecritures(fs); const { dossier } = await facade.supprimer(LUCIOLES); assert.equal(ecritures(fs), avant + 1 + 1 + 3); assert.equal(dossier, CORBEILLE_DU_DEPART); assert.deepEqual(await facade.lister(), []); const [{ fichiers }] = await facade.corbeille(); assert.deepEqual(fichiers, [LUCIOLES + SUFFIXES.journal, LUCIOLES + SUFFIXES.etat, LUCIOLES + SUFFIXES.verrou]); for (const [fichier, texte] of Object.entries(original)) { assert.equal(fs.contenu('documents', `${CORBEILLE_DU_DEPART}/${fichier}`), texte); } }); test('le verrou d’une séance vivante, puis d’un autre hôte : VERROU_PRIS, fichiers en place', async () => { const cas = [ [{}, true], [{ hote: 'poste-voisin' }, null], ]; assert.equal(cas.length, 2); for (const [reglages, vivant] of cas) { const disque = new Map(); const fs = creerFichiersSimules({ disque }); const { facade } = monter({ fs }); await facade.demarrer(); await deposerEvenement(fs, DOC, { id: 'evt-lucioles', nom: LUCIOLES }); const autre = creerFichiersSimules({ disque, ...reglages }); await autre.verrouiller(DOC, LUCIOLES + SUFFIXES.verrou, 'seance-autre'); const avant = await photographier(fs, DOC); assert.equal(Object.keys(avant).length, 3); const ecrituresAvant = ecritures(fs); const refus = await refusDe(facade.supprimer(LUCIOLES)); assert.equal(refus.code, 'VERROU_PRIS'); assert.equal(refus.details.vivant, vivant); // La seule sonde, refusée. assert.equal(ecritures(fs), ecrituresAvant + 1); assert.deepEqual(await photographier(fs, DOC), avant); assert.deepEqual(await facade.corbeille(), []); } }); }); // --- Importer (§ 8.6, § 8.7) ------------------------------------------------- describe('importer (§ 8.6, § 8.7)', () => { const MERLES = { id: 'evt-merles', nom: 'Club des Merles' }; test('un fichier choisi : ses octets sous une base dérivée de son nom ; une seconde fois, IDENTIFIANT_PRESENT', async () => { const { fs, facade } = monter(); await facade.demarrer(); const octets = UTF8.encode(texteEtat(MERLES)); fs.choisirFichierAImporter = async () => ({ nom: 'recu-par-courriel.gtt.json', octets }); const avant = ecritures(fs); const { base } = await facade.importerFichier(); // L'état seul, aucun verrou. assert.equal(ecritures(fs), avant + 1); assert.equal(base, MERLES.nom); assert.deepEqual(fs.octets('documents', base + SUFFIXES.etat), octets); const photo = await photographier(fs, DOC); assert.equal(Object.keys(photo).length, 1); const avantRefus = ecritures(fs); const refus = await refusDe(facade.importerFichier()); assert.equal(refus.code, 'IDENTIFIANT_PRESENT'); assert.deepEqual(refus.details, { id: MERLES.id, base: MERLES.nom }); assert.equal(ecritures(fs), avantRefus); assert.deepEqual(await photographier(fs, DOC), photo); }); test('un fichier d’un format plus récent s’importe tel quel', async () => { const { fs, facade } = monter(); await facade.demarrer(); const texte = texteEtat(MERLES).replace('"format":1', '"format":2'); assert.ok(texte.includes('"format":2')); const octets = UTF8.encode(texte); fs.choisirFichierAImporter = async () => ({ nom: 'futur.gtt.json', octets }); const avant = ecritures(fs); const { base } = await facade.importerFichier(); assert.equal(ecritures(fs), avant + 1); assert.deepEqual(fs.octets('documents', base + SUFFIXES.etat), octets); }); test('le dialogue annulé rend null, sans rien écrire', async () => { const { fs, facade } = monter(); await facade.demarrer(); const avant = ecritures(fs); assert.equal(await facade.importerFichier(), null); assert.equal(ecritures(fs), avant); }); test('l’autre emplacement : Documents neufs, data/ en porte deux → copiés, originaux intacts', async () => { const disque = new Map(); const avant = creerFichiersSimules({ disque, executable: SOUS_APPDATA }); avant.deposerDossier('portable'); await deposerEvenement(avant, PORTABLE_APPDATA, { id: 'evt-lucioles', nom: LUCIOLES }); await deposerEvenement(avant, PORTABLE_APPDATA, { id: 'evt-cerfs', nom: CERFS }); const fs = creerFichiersSimules({ disque, executable: SOUS_APPDATA }); const { facade } = monter({ fs }); const { dossier } = await facade.demarrer(); assert.deepEqual(dossier.autre, { racine: PORTABLE_APPDATA, evenements: 2 }); const originaux = await photographier(fs, PORTABLE_APPDATA); assert.equal(Object.keys(originaux).length, 4); const ecrituresAvant = ecritures(fs); const { importes, refuses } = await facade.importerDepuisAutre(); // Chaque paire : son journal, puis son état. assert.equal(ecritures(fs), ecrituresAvant + 4); assert.equal(importes.length, 2); assert.deepEqual(refuses, []); assert.deepEqual(await photographier(fs, PORTABLE_APPDATA), originaux); assert.deepEqual( (await facade.lister()).map(({ id }) => id), ['evt-cerfs', 'evt-lucioles'], ); assert.equal((await facade.demarrer()).dossier.autre, null); }); }); // --- Les dialogues de fichiers, relayés (contrat de données) ----------------- describe('les dialogues de fichiers, lus sur fs à l’appel', () => { test('choisirCsv rend tels quels {nom, octets} et null', async () => { const { fs, facade } = monter(); assert.equal(await facade.choisirCsv(), null); const choisi = { nom: 'liste.csv', octets: UTF8.encode('nom\nOmbrelle\n') }; fs.choisirFichierAImporter = async () => choisi; assert.equal(await facade.choisirCsv(), choisi); }); test('enregistrerSous remet nom et octets tels quels, et rend le chemin ou null', async () => { const { fs, facade } = monter(); assert.equal(await facade.enregistrerSous('a.csv', new Uint8Array([1])), null); const recus = []; fs.enregistrerSous = async (nom, octets) => { recus.push([nom, octets]); return 'C:\\Users\\Exemple\\Documents\\liste-refus.csv'; }; const octets = UTF8.encode('motif\n'); assert.equal(await facade.enregistrerSous('liste-refus.csv', octets), 'C:\\Users\\Exemple\\Documents\\liste-refus.csv'); assert.equal(recus.length, 1); assert.equal(recus[0][0], 'liste-refus.csv'); assert.equal(recus[0][1], octets); }); }); // --- L'explorateur (§ 8.6) --------------------------------------------------- describe('explorateur (§ 8.6)', () => { test('sous web : indisponible, EXPLORATEUR_ABSENT, aucun appel', async () => { const { fs, facade } = monter({ fs: creerFichiersSimules({ nature: 'web' }) }); await facade.demarrer(); const recus = []; fs.ouvrirDansExplorateur = async (racine) => recus.push(racine); assert.deepEqual(facade.explorateur(), { disponible: false, raison: 'EXPLORATEUR_ABSENT' }); assert.equal(typeof libelle('EXPLORATEUR_ABSENT'), 'string'); const refus = await refusDe(facade.ouvrirExplorateur()); assert.equal(refus.code, 'EXPLORATEUR_ABSENT'); assert.deepEqual(recus, []); }); test('ailleurs : ouvrirDansExplorateur reçoit la racine', async () => { const { fs, facade } = monter(); await facade.demarrer(); const recus = []; fs.ouvrirDansExplorateur = async (racine) => recus.push(racine); assert.deepEqual(facade.explorateur(), { disponible: true, raison: null }); await facade.ouvrirExplorateur(); assert.deepEqual(recus, [DOC]); }); });