// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Épreuves de la séance (§ 2.1, § 8.1 à § 8.6, § 8.8, § 9, § 10.1), toutes sur // le système de fichiers d'épreuve, qui compte ses écritures, avec une // horloge et un planificateur factices : rien n'attend le temps réel. Une // ouverture n'écrit rien ; un geste écrit une entrée puis un état ; un refus // n'écrit rien ; le verrou se prend à l'entrée en écriture et se rend au // retour en lecture, explicite ou après l'inactivité ; le support qui rompt // se remplace par un autre dossier. Les noms d'épreuve sont inventés ; la // version vient du module engendré. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { apercevoir } from '../csv/apercu.js'; import { rechercher } from '../moteur/recherche.js'; import { serialiser } from '../stockage/canonique.js'; import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../stockage/depot.js'; import { analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { ligneEntree, ligneOuverture, lireJournal, reconstruire } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; import { VERSION } from '../version.genere.js'; import { libelle } from './libelles.js'; import { INACTIVITE_MS, 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 PORTABLE = { id: 'portable', chemin: 'E:\\soirees\\data' }; const BASE = 'Soirée des Lucioles'; const ETAT = `${BASE}${SUFFIXES.etat}`; const JOURNAL = `${BASE}${SUFFIXES.journal}`; const VERROU = `${BASE}${SUFFIXES.verrou}`; const CREATION = "Création de l'événement « Soirée des Lucioles »"; // --- Le montage ------------------------------------------------------------- const deux = (valeur) => String(valeur).padStart(2, '0'); // Horloge d'épreuve : une minute de plus à chaque lecture, à partir de 8 h le // 17 mai 2031, à l'heure de l'Est. function creerHorloge() { let n = 0; return { horodatage() { const minutes = 8 * 60 + n; n += 1; return `2031-05-17T${deux(Math.floor(minutes / 60))}:${deux(minutes % 60)}:00-04:00`; }, }; } // Identifiants d'épreuve, numérotés dans l'ordre des tirages : le premier est // celui de la séance. tirages les garde. function creerIdentifiants(prefixe) { const tirages = []; const identifiant = () => { tirages.push(`${prefixe}-${tirages.length + 1}`); return tirages.at(-1); }; identifiant.tirages = tirages; return identifiant; } // Planificateur d'épreuve : rien ne part seul. avancer(ms) déclenche les // minuteries échues, dans l'ordre de leur échéance puis de leur création, et // attend ce que chacune rend. function creerPlanificateur() { let maintenant = 0; let suivant = 1; const minuteries = new Map(); return { planifier(fn, ms) { const id = suivant; suivant += 1; minuteries.set(id, { fn, echeance: maintenant + ms }); return id; }, annuler(id) { minuteries.delete(id); }, async avancer(ms) { maintenant += ms; const echues = [...minuteries] .filter(([, { echeance }]) => echeance <= maintenant) .sort(([a, m], [b, n]) => m.echeance - n.echeance || a - b); for (const [id, { fn }] of echues) { if (!minuteries.has(id)) continue; minuteries.delete(id); await fn(); } }, get actives() { return minuteries.size; }, }; } // Une séance sur le système d'épreuve, sa racine créée : documents par // défaut. function monter({ fs = creerFichiersSimules(), racine = DOC, prefixe = 'seance', inactiviteMs } = {}) { fs.deposerDossier(racine.id); const planificateur = creerPlanificateur(); const identifiant = creerIdentifiants(prefixe); const seance = creerSeance({ fs, racine, horloge: creerHorloge(), identifiant, produitVersion: V1, planifier: planificateur.planifier, annuler: planificateur.annuler, ...(inactiviteMs === undefined ? {} : { inactiviteMs }), }); return { fs, seance, planificateur, identifiant }; } // Crée l'événement d'essai par la séance, puis passe en écriture. async function enEcriture(montage, champs = {}) { await montage.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2, ...champs }); await montage.seance.passerEnEcriture(); } const personne = (id, nom, prenom = null, appartenance = null) => ({ id, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: false, }); // Quatre personnes, deux tables de deux places, deux tours ; Iris Ombrelle // anime la table 1 : une réservation « tous » au siège 1 et le titre de ce // siège. function chargeEssai() { const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 2, tours: 2 }); charge.prochainsIds = { participant: 5, table: 3, proposition: 1 }; charge.participants = [ personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), personne(2, 'Grisaille', null, 'Club des Merles'), personne(3, 'Pervenche', 'Théo'), personne(4, 'Lacasse', 'Ondine', 'Société Alpha'), ]; charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 250 * (id - 1), y: 0 } })); charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }]; return charge; } // Pose sur le disque, par le dépôt, l'événement de la charge d'essai. async function deposerEssai(fs, charge = chargeEssai()) { fs.deposerDossier('documents'); await creerDansLeDepot(fs, DOC, charge, { libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }), nomJalon: libelle('JALON_CREATION'), horodatage: '2031-05-17T07:00:00-04:00', produitVersion: V1, }); } // Les arguments d'enregistrerGeneration : une génération courte du moteur. function generer(charge, nombre = 2, graine = 7) { const configuration = configurationDepuisCharge(charge); return { propositions: rechercher(configuration, { graine, arret: 40, nombre }), configuration, produitVersion: V1 }; } // Le journal et l'état que porte le disque. const journalDe = (fs, racine = 'documents', base = BASE) => lireJournal(fs.contenu(racine, `${base}${SUFFIXES.journal}`)); const etatDe = (fs, racine = 'documents', base = BASE) => analyser(fs.contenu(racine, `${base}${SUFFIXES.etat}`)); const libellesDe = (journal) => journal.entrees.map((entree) => entree.libelle); const verrouDe = (fs, racine = 'documents') => { const texte = fs.contenu(racine, VERROU); return texte === null ? null : JSON.parse(texte); }; const noms = (charge) => charge.participants.map(({ nom }) => nom); // Ouvre base par la séance d'un montage qui ne tient aucun verrou, et // constate qu'ouvrir n'écrit rien (§ 8.4). async function ouvrirSansEcrire({ fs, seance }, base = BASE) { const avant = fs.compteurs.ecritures; const rendu = await seance.ouvrir(base); assert.equal(fs.compteurs.ecritures, avant, 'ouvrir écrit'); assert.equal(seance.mode, 'lecture'); return rendu; } // Le refus attendu : le code, et les détails quand ils sont donnés. Rend // l'erreur. async function refus(promesse, code, details) { let erreur; await assert.rejects(promesse, (raison) => { erreur = raison; return true; }); assert.equal(erreur.code, code, `${erreur}`); if (details !== undefined) assert.deepEqual(erreur.details, details); return erreur; } // --- Les épreuves ----------------------------------------------------------- describe('séance : créer et ouvrir (§ 2.1, § 8.1, § 8.4)', () => { test("créer écrit le journal puis l'état, pose le jalon « Création », et laisse l'événement ouvert en lecture", async () => { const { fs, seance, identifiant } = monter(); const avant = fs.compteurs.ecritures; const { base } = await seance.creer({ nom: ' Soirée des Lucioles ', date: '2031-05-17', siegesParDefaut: 8, tours: 3 }); assert.equal(base, BASE); assert.equal(fs.compteurs.ecritures - avant, 2); assert.equal(seance.mode, 'lecture'); assert.equal(verrouDe(fs), null); assert.deepEqual(identifiant.tirages, ['seance-1', 'seance-2']); assert.deepEqual(seance.charge.evenement, { ...creerCharge({ id: 'seance-2', nom: BASE, date: '2031-05-17', siegesParDefaut: 8, tours: 3 }).evenement }); const journal = journalDe(fs); assert.deepEqual(libellesDe(journal), [CREATION]); assert.deepEqual(journal.jalons.map(({ revision, nom }) => [revision, nom]), [[1, 'Création']]); assert.equal(etatDe(fs).entete.revision, 1); }); test("une création refusée n'écrit rien et ne tire aucun identifiant", async () => { const { fs, seance, identifiant } = monter(); const avant = fs.compteurs.ecritures; await refus(seance.creer({ nom: ' ', date: null, siegesParDefaut: 8, tours: 3 }), 'NOM_REQUIS'); await refus(seance.creer({ nom: BASE, date: null, siegesParDefaut: 1, tours: 3 }), 'SIEGES_INVALIDES'); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual(identifiant.tirages, ['seance-1']); assert.equal(seance.ouvert, null); }); test("créer ferme l'événement en écriture : son verrou est rendu, et le nouveau s'ouvre en lecture, sans verrou", async () => { const montage = monter(); const { fs, seance, planificateur } = montage; await enEcriture(montage); assert.equal(verrouDe(fs).seance, seance.id); await seance.creer({ nom: 'Soirée des Hiboux', date: null, siegesParDefaut: 2, tours: 1 }); assert.deepEqual([seance.mode, seance.charge.evenement.nom, planificateur.actives], ['lecture', 'Soirée des Hiboux', 0]); assert.equal(verrouDe(fs), null); assert.equal(fs.contenu('documents', `Soirée des Hiboux${SUFFIXES.verrou}`), null); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE'); }); test("une création refusée laisse l'événement ouvert, son mode et son verrou, sans rien écrire", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); const avant = fs.compteurs.ecritures; await refus(seance.creer({ nom: ' ', date: null, siegesParDefaut: 2, tours: 2 }), 'NOM_REQUIS'); await refus(seance.creer({ nom: 'Soirée des Hiboux', date: '2031-02-30', siegesParDefaut: 2, tours: 2 }), 'DATE_INVALIDE'); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual([seance.mode, seance.charge.evenement.nom, verrouDe(fs)?.seance], ['ecriture', BASE, seance.id]); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie']); }); test("ouvrir n'écrit rien et laisse en lecture ; en lecture, un geste, défaire et nommer sont refusés MODE_LECTURE", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const { seance } = monter({ fs }); const avant = fs.compteurs.ecritures; const { base, avertissements } = await seance.ouvrir(BASE); assert.deepEqual([base, avertissements], [BASE, []]); assert.equal(fs.compteurs.ecritures, avant, 'ouvrir écrit'); assert.equal(seance.mode, 'lecture'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE', {}); await refus(seance.defaire(), 'MODE_LECTURE'); await refus(seance.revenir(1), 'MODE_LECTURE'); await refus(seance.nommer('Avant le dessert'), 'MODE_LECTURE'); assert.equal(fs.compteurs.ecritures, avant, 'un refus écrit'); assert.match(libelle('MODE_LECTURE'), /« Modifier »/); }); test("ouvrir un secours n'écrit rien et laisse en lecture", async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }) }); await enEcriture(premier); await premier.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); await premier.seance.fermer(); const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); const avant = second.fs.compteurs.ecritures; await second.seance.ouvrirSecours(BASE, 'precedent'); assert.equal(second.fs.compteurs.ecritures, avant, 'ouvrirSecours écrit'); assert.equal(second.seance.mode, 'lecture'); assert.deepEqual([second.seance.ouvert.revision, noms(second.seance.charge)], [1, []]); }); test("ouvrir ferme le précédent sans question : son verrou est rendu, et seul ce rendu s'écrit", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await creerDansLeDepot(fs, DOC, creerCharge({ id: 'evt-hiboux', nom: 'Soirée des Hiboux', siegesParDefaut: 2, tours: 1 }), { libelle: 'Création', nomJalon: 'Création', horodatage: '2031-05-17T07:00:00-04:00', produitVersion: V1, }); assert.notEqual(verrouDe(fs), null); const avant = fs.compteurs.ecritures; await seance.ouvrir('Soirée des Hiboux'); assert.equal(fs.compteurs.ecritures - avant, 1); assert.equal(verrouDe(fs), null); assert.deepEqual([seance.mode, seance.charge.evenement.nom], ['lecture', 'Soirée des Hiboux']); }); test("un événement qui ne s'ouvre pas laisse la séance sans événement, le précédent fermé", async () => { const montage = monter(); await enEcriture(montage); const avant = montage.fs.compteurs.ecritures; await refus(montage.seance.ouvrir('Soirée absente'), 'ABSENT'); assert.equal(montage.fs.compteurs.ecritures - avant, 1, "seul le rendu du verrou s'écrit"); assert.equal(verrouDe(montage.fs), null); assert.deepEqual([montage.seance.ouvert, montage.seance.charge, montage.seance.mode], [null, null, 'lecture']); }); test("sans événement ouvert, chaque geste est refusé AUCUN_EVENEMENT, sans écriture", async () => { const { fs, seance } = monter(); const avant = fs.compteurs.ecritures; await refus(seance.passerEnEcriture(), 'AUCUN_EVENEMENT'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'AUCUN_EVENEMENT'); await refus(seance.defaire(), 'AUCUN_EVENEMENT'); await refus(seance.debloquer(), 'AUCUN_EVENEMENT'); await refus(seance.nommer('Jalon'), 'AUCUN_EVENEMENT'); await refus(seance.ecrireAilleurs(DOC), 'AUCUN_EVENEMENT'); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual(seance.retours, { defaire: false, refaire: false }); }); }); describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => { test("passer en écriture prend le verrou de la séance ; le redemander ne refait rien", async () => { const montage = monter(); await enEcriture(montage); assert.equal(montage.seance.mode, 'ecriture'); assert.equal(verrouDe(montage.fs).seance, montage.seance.id); assert.equal(montage.seance.id, 'seance-1'); const avant = montage.fs.compteurs.ecritures; assert.deepEqual(await montage.seance.passerEnEcriture(), { avertissements: [] }); assert.equal(montage.fs.compteurs.ecritures, avant); }); test('une seconde séance sur le même disque ouvre en lecture, et son passage en écriture est refusé VERROU_PRIS', async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); const { depuis } = verrouDe(premier.fs); const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(second); const details = { seance: 'premiere-1', depuis, vivant: true }; await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', details); assert.equal(second.seance.mode, 'lecture'); assert.deepEqual(second.seance.verrouAutre, details); assert.equal(verrouDe(second.fs).seance, 'premiere-1'); await refus(second.seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'MODE_LECTURE'); }); test("le verrou d'une séance morte du même poste se reprend, et l'avertissement le dit", async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); const { depuis } = verrouDe(premier.fs); premier.fs.pannes.couperApres(0); const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(second); const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'premiere-1', depuis } }]; assert.deepEqual(await second.seance.passerEnEcriture(), { avertissements: repris }); assert.equal(second.seance.mode, 'ecriture'); assert.equal(verrouDe(second.fs).seance, 'seconde-1'); assert.deepEqual(second.seance.avertissements, repris); assert.equal(second.seance.verrouAutre, null); }); test("le verrou d'un autre poste, ou illisible, ne se reprend pas", async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); const { depuis } = verrouDe(premier.fs); const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' }); await ouvrirSansEcrire(lointain); await refus(lointain.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: null }); premier.fs.deposer('documents', VERROU, 'pas un verrou\n'); const voisin = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'voisine' }); await ouvrirSansEcrire(voisin); await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null }); assert.equal(premier.fs.contenu('documents', VERROU), 'pas un verrou\n'); }); test('sans verrou disponible, passer en écriture ne pose aucun fichier', async () => { const montage = monter({ fs: creerFichiersSimules({ verrouDisponible: false }) }); await enEcriture(montage); assert.equal(montage.seance.mode, 'ecriture'); assert.equal(verrouDe(montage.fs), null); await montage.seance.passerEnLecture(); assert.equal(montage.seance.mode, 'lecture'); }); test("fermer rend le verrou et oublie l'événement", async () => { const montage = monter(); await enEcriture(montage); await montage.seance.fermer(); assert.equal(verrouDe(montage.fs), null); assert.deepEqual([montage.seance.ouvert, montage.seance.mode, montage.planificateur.actives], [null, 'lecture', 0]); }); }); describe("séance : l'inactivité (§ 8.4)", () => { test('au terme du délai, la séance revient seule en lecture et rend son verrou', async () => { const montage = monter(); await enEcriture(montage); await montage.planificateur.avancer(INACTIVITE_MS - 1); assert.equal(montage.seance.mode, 'ecriture'); await montage.planificateur.avancer(1); assert.equal(montage.seance.mode, 'lecture'); assert.equal(verrouDe(montage.fs), null); assert.equal(montage.planificateur.actives, 0); assert.equal(INACTIVITE_MS, 10 * 60 * 1000); }); test("pendant un geste ouvert, rien ne tombe ; les gestes s'emboîtent ; le dernier fini, le compte repart", async () => { const montage = monter(); const { seance, planificateur } = montage; await enEcriture(montage); assert.equal(planificateur.actives, 1); seance.debuterGeste(); seance.debuterGeste(); assert.equal(planificateur.actives, 0, 'un geste ouvert laisse une minuterie armée'); await planificateur.avancer(3 * INACTIVITE_MS); assert.equal(seance.mode, 'ecriture'); seance.finirGeste(); assert.equal(planificateur.actives, 0, 'le geste encore ouvert laisse une minuterie armée'); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.equal(planificateur.actives, 0, 'un geste achevé arme le compte malgré le geste ouvert'); await planificateur.avancer(2 * INACTIVITE_MS); assert.equal(seance.mode, 'ecriture'); seance.finirGeste(); assert.equal(planificateur.actives, 1); await planificateur.avancer(INACTIVITE_MS - 1); assert.equal(seance.mode, 'ecriture'); await planificateur.avancer(1); assert.equal(seance.mode, 'lecture'); assert.equal(verrouDe(montage.fs), null); }); test("pendant un calcul ouvert, rien ne tombe ; fini, le compte repart ; un calcul fini de trop ne compte pas", async () => { const montage = monter(); const { seance, planificateur } = montage; await enEcriture(montage); seance.debuterCalcul(); assert.equal(seance.calculEnCours, true); assert.equal(planificateur.actives, 0, 'un calcul ouvert laisse une minuterie armée'); await planificateur.avancer(2 * INACTIVITE_MS); assert.equal(seance.mode, 'ecriture'); seance.finirCalcul(); seance.finirCalcul(); assert.equal(seance.calculEnCours, false); seance.debuterCalcul(); await planificateur.avancer(2 * INACTIVITE_MS); assert.equal(seance.mode, 'ecriture', 'le calcul fini de trop a compté contre le suivant'); seance.finirCalcul(); await planificateur.avancer(INACTIVITE_MS); assert.equal(seance.mode, 'lecture'); }); test('chaque geste achevé relance le compte ; le délai se règle', async () => { const montage = monter({ inactiviteMs: 5000 }); const { seance, planificateur } = montage; await enEcriture(montage); await planificateur.avancer(4000); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); await planificateur.avancer(4000); assert.equal(seance.mode, 'ecriture'); await refus(seance.executer('ajouterParticipant', { champs: { nom: ' ' } }), 'NOM_REQUIS'); await planificateur.avancer(4000); assert.equal(seance.mode, 'ecriture', 'un geste refusé est une activité'); await planificateur.avancer(1000); assert.equal(seance.mode, 'lecture'); }); test('passer en lecture annule le geste en cours et rend le verrou ; repassé en écriture, le compte court', async () => { const montage = monter(); const { seance, planificateur } = montage; await enEcriture(montage); seance.debuterGeste(); await seance.passerEnLecture(); assert.equal(seance.mode, 'lecture'); assert.equal(verrouDe(montage.fs), null); assert.equal(planificateur.actives, 0); await seance.passerEnEcriture(); await planificateur.avancer(INACTIVITE_MS); assert.equal(seance.mode, 'lecture'); }); }); describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)', () => { test("un geste écrit le journal puis l'état ; son libellé reste figé quand on renomme la personne", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); const avant = fs.compteurs.ecritures; const rendu = await seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } }); assert.deepEqual(rendu, { libelle: 'Ajouté Iris Ombrelle', avertissements: [] }); assert.equal(fs.compteurs.ecritures - avant, 3, "la ligne, le .precedent, l'état"); await seance.executer('modifierParticipant', { id: 1, champs: { nom: 'Parasol' } }); const journal = journalDe(fs); assert.deepEqual(libellesDe(journal), [CREATION, 'Ajouté Iris Ombrelle', 'Modifié Iris Ombrelle : nom (désormais Iris Parasol)']); assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 3)); assert.equal(etatDe(fs).entete.revision, 3); assert.deepEqual(noms(seance.charge), ['Parasol']); }); test("deux gestes lancés sans attendre écrivent deux entrées, dans l'ordre", async () => { const montage = monter(); await enEcriture(montage); const premier = montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); const second = montage.seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }); await Promise.all([premier, second]); assert.deepEqual(libellesDe(journalDe(montage.fs)), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']); assert.deepEqual(montage.seance.charge.participants.map(({ id, nom }) => [id, nom]), [[1, 'Ancolie'], [2, 'Bruyère']]); }); test("un geste sans effet, ou refusé par sa commande, n'écrit rien", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); const avant = fs.compteurs.ecritures; await refus(seance.executer('modifierParticipant', { id: 1, champs: { nom: 'Ancolie' } }), 'SANS_EFFET'); await refus(seance.executer('exclureParticipant', { id: 7 }), 'PARTICIPANT_INCONNU'); await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'AUCUN_RETENU'); await assert.rejects(seance.executer('commandeInventee', {}), TypeError); assert.equal(fs.compteurs.ecritures, avant); }); test("nommer l'instant courant pose un jalon, sans entrée", async () => { const montage = monter(); await enEcriture(montage); await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); const avant = montage.fs.compteurs.ecritures; await montage.seance.nommer(' Avant le dessert '); assert.equal(montage.fs.compteurs.ecritures - avant, 1); const journal = journalDe(montage.fs); assert.equal(journal.entrees.length, 2); assert.deepEqual(journal.jalons.at(-1), { type: 'jalon', revision: 2, nom: 'Avant le dessert', horodatage: journal.jalons.at(-1).horodatage }); await refus(montage.seance.nommer(' '), 'NOM_REQUIS'); }); test("un import est une seule entrée ; il n'avertit pas en brouillon, et porte l'avertissement de dérive en retenu (§ 10.1)", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); const csv = 'nom;prenom\nAncolie;Bastien\nBruyère;Capucine\nCerfeuil;Damien\n'; const entrees = journalDe(fs).entrees.length; const rendu = await seance.executer('importerParticipants', { apercu: apercevoir(csv, { participants: seance.charge.participants }), mode: 'ajouter' }); assert.equal(rendu.libelle, 'Importé 3 participants'); assert.deepEqual(rendu.avertissements, []); assert.equal(rendu.resume.ajoutes, 3); assert.equal(journalDe(fs).entrees.length, entrees + 1); assert.equal(seance.charge.participants.length, 7); await seance.executer('supprimerParticipant', { id: 7 }); await seance.executer('supprimerParticipant', { id: 6 }); await seance.executer('supprimerParticipant', { id: 5 }); await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('retenirProposition', { id: 1 }); const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants }); const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' }); assert.deepEqual(enRetenu.avertissements, [{ code: 'IMPORT_EN_RETENU', details: {} }]); // La séance montre aussi la dérive que l'Ouvert écrit porte désormais. assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...enRetenu.avertissements]); assert.deepEqual(seance.ouvert.avertissements.map(({ code }) => code), ['DERIVE']); }); test("importer sur un plan bloqué est refusé, et le texte du refus nomme « Débloquer »", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('changerEtat', { etat: 'bloque' }); const avant = fs.compteurs.ecritures; const apercu = apercevoir('nom\nAncolie\n', { participants: [] }); const erreur = await refus(seance.executer('importerParticipants', { apercu, mode: 'ajouter' }), 'LECTURE_SEULE', { raison: 'BLOQUE' }); assert.match(libelle(erreur.code, erreur.details), /« Débloquer »/); assert.equal(fs.compteurs.ecritures, avant); }); test("exclure une personne réservée suspend ses réservations, son titre n'est plus pourvu, et l'avertissement le dit ; la réintégrer les rend (§ 4.4)", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); const exclusion = await seance.executer('exclureParticipant', { id: 1 }); assert.deepEqual(exclusion, { libelle: 'Exclu Iris Ombrelle', avertissements: [{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } }], }); assert.deepEqual([seance.charge.participants[0].exclu, seance.charge.reservations.length], [true, 1]); assert.deepEqual(seance.avertissements, exclusion.avertissements); const retour = await seance.executer('reintegrerParticipant', { id: 1 }); assert.deepEqual(retour.avertissements, [ { code: 'RESERVATIONS_RETABLIES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } }, ]); assert.equal(seance.charge.participants[0].exclu, false); }); }); describe("séance : ce qu'un import dit, et ce que la séance montre (§ 4.4, § 9, § 10.1)", () => { // L'événement d'essai, ouvert depuis le disque, en écriture. async function essaiEnEcriture() { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); await ouvrirSansEcrire(montage); await montage.seance.passerEnEcriture(); return montage; } const mettreAJour = (seance, csv) => seance.executer('importerParticipants', { apercu: apercevoir(csv, { participants: seance.charge.participants, mode: 'mettreAJour' }), mode: 'mettreAJour', }); test("un import qui exclut l'animatrice réservée dit ses réservations suspendues et son titre non pourvu, comme exclure (§ 4.4)", async () => { const { seance } = await essaiEnEcriture(); const rendu = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;oui\n'); const suspendues = { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }; assert.equal(rendu.libelle, 'Mis à jour la liste par import : 0 ajouté, 1 modifié'); assert.deepEqual(rendu.avertissements, [{ code: 'RESERVATIONS_SUSPENDUES', details: suspendues }]); assert.equal(seance.charge.participants[0].exclu, true); const retour = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;non\n'); assert.deepEqual(retour.avertissements, [{ code: 'RESERVATIONS_RETABLIES', details: suspendues }]); }); test("un import dont les lignes valides ne changent rien, mais qui refuse des lignes, n'écrit rien et rend son résumé", async () => { const { fs, seance } = await essaiEnEcriture(); await seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine' } }); const avant = fs.compteurs.ecritures; const entrees = journalDe(fs).entrees.length; const rendu = await mettreAJour(seance, 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nPervenche;Théo;\n'); assert.equal(rendu.libelle, null); assert.deepEqual(rendu.avertissements, []); assert.deepEqual(rendu.resume.refusees.map(({ ligne, code }) => [ligne, code]), [[2, 'PLUSIEURS_CORRESPONDENT']]); assert.equal(fs.compteurs.ecritures, avant); assert.equal(journalDe(fs).entrees.length, entrees); assert.deepEqual(seance.avertissements, []); }); test("après un geste, la séance montre les avertissements de l'Ouvert écrit : la dérive paraît et s'en va (§ 9)", async () => { const { seance } = await essaiEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('retenirProposition', { id: 1 }); assert.deepEqual(seance.avertissements, []); const exclusion = await seance.executer('exclureParticipant', { id: 2 }); assert.deepEqual(exclusion.avertissements, []); assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE']); assert.deepEqual(seance.avertissements, seance.ouvert.avertissements); assert.equal(seance.avertissements[0].details.retenu, true); await seance.executer('reintegrerParticipant', { id: 2 }); assert.deepEqual(seance.avertissements, []); }); test("le journal incomplet de l'ouverture ne s'affiche plus une fois la reprise écrite", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const plus = chargeEssai(); plus.participants.push(personne(5, 'Ancolie')); plus.prochainsIds.participant = 6; fs.deposer('documents', ETAT, serialiser(plus, { revision: 4, produitVersion: V1 })); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); const incomplet = [{ code: 'JOURNAL_INCOMPLET', details: { etat: 4, journal: 1 } }]; assert.deepEqual(seance.avertissements, incomplet); await seance.passerEnEcriture(); assert.deepEqual(seance.avertissements, incomplet); await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }); assert.deepEqual(seance.avertissements, []); }); }); describe('séance : la forme nommée se lit sans rien écrire (§ 8.9, point 4)', () => { test("la forme nommée d'une proposition ou du retenu se lit en lecture, sans entrée ni écriture", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('retenirProposition', { id: 2 }); await seance.passerEnLecture(); const avant = fs.compteurs.ecritures; const entrees = journalDe(fs).entrees.length; const proposition = await seance.formeNommee({ proposition: 1 }); assert.deepEqual(proposition.fautes, []); assert.deepEqual( proposition.lignes.filter(({ tour }) => tour === 1).map(({ nom }) => nom).sort(), ['Grisaille', 'Lacasse', 'Ombrelle', 'Pervenche'], ); const retenu = await seance.formeNommee({ retenu: true }); assert.equal(retenu.lignes.length, 8); await refus(seance.formeNommee({ proposition: 7 }), 'PROPOSITION_INCONNUE'); assert.equal(fs.compteurs.ecritures, avant); assert.equal(journalDe(fs).entrees.length, entrees); assert.equal(seance.mode, 'lecture'); }); test("un retenu fautif sur le disque se lit sous forme nommée par la séance : ses lignes et ses fautes (RETENU_FAUTIF, § 8.9)", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('retenirProposition', { id: 1 }); await seance.passerEnLecture(); const { entete, charge } = etatDe(fs); const premier = charge.retenu.placement[0]; const assis = premier.sieges.find((liste) => liste.length > 0)[0]; premier.reserve = [...premier.reserve, assis].sort((a, b) => a - b); fs.deposer('documents', ETAT, serialiser(charge, { revision: entete.revision, produitVersion: V1 })); await ouvrirSansEcrire(montage); assert.equal(seance.avertissements[0].code, 'RETENU_FAUTIF'); const lue = await seance.formeNommee({ retenu: true }); assert.deepEqual(lue.fautes, [{ code: 'DOUBLON', tour: 1, participant: assis }]); assert.equal(lue.lignes.length, 9); assert.equal(lue.lignes.filter(({ tour, participant }) => tour === 1 && participant === assis).length, 2); }); test('sans événement ouvert, la forme nommée est refusée AUCUN_EVENEMENT', async () => { await refus(monter().seance.formeNommee({ retenu: true }), 'AUCUN_EVENEMENT'); }); }); describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () => { // Deux processus d'un même poste sur un même disque : la première séance // crée l'événement d'essai, puis la seconde l'ouvre en lecture. async function deuxSeances() { const disque = new Map(); const premiere = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await premiere.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }); const seconde = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(seconde); return { premiere: premiere.seance, seconde: seconde.seance, fs: seconde.fs, fsPremiere: premiere.fs, disque }; } test("passer en écriture relit l'événement : le travail de l'autre séance est adopté, EVENEMENT_CHANGE le dit, et le geste suivant s'écrit à sa suite", async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } }); await premiere.passerEnLecture(); const change = [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }]; assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change }); assert.deepEqual([seconde.mode, verrouDe(fs).seance], ['ecriture', seconde.id]); assert.deepEqual(noms(seconde.charge), ['Xavier']); assert.deepEqual(seconde.avertissements, change); await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }); const journal = journalDe(fs); assert.deepEqual(libellesDe(journal), [CREATION, 'Ajouté Xavier', 'Ajouté Yvette']); assert.equal(journal.ecartees, 0); assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']); assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 3)); }); test("sans geste de l'autre séance, rien n'avertit ; son jalon est adopté avec le journal relu", async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.nommer('Avant les invitations'); await premiere.passerEnLecture(); assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: [] }); assert.deepEqual(seconde.ouvert.journal.jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']); await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }); assert.deepEqual(journalDe(fs).jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']); }); test("un événement qui a quitté le disque entre-temps, renommé ailleurs : le passage en écriture est refusé, le verrou rendu, rien ne se recrée", async () => { const { seconde, fs, disque } = await deuxSeances(); const autre = creerFichiersSimules({ disque }); for (const fichier of [ETAT, JOURNAL]) await autre.supprimer(DOC, fichier); await refus(seconde.passerEnEcriture(), 'ABSENT'); assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]); assert.deepEqual((await fs.lister(DOC, '')).map(({ nom }) => nom), []); await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }), 'MODE_LECTURE'); }); test("un plan que l'autre séance a bloqué entre-temps : adopté, EVENEMENT_CHANGE le dit, et le passage est refusé BLOQUE, le verrou rendu", async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('changerEtat', { etat: 'bloque' }); await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'BLOQUE' }); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'bloque']); assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }]); }); test("débloquer relit l'événement sous le verrou : le déblocage s'écrit à la suite du travail de l'autre séance", async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('changerEtat', { etat: 'bloque' }); await seconde.ouvrir(BASE); await premiere.debloquer(); await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } }); await premiere.executer('changerEtat', { etat: 'bloque' }); assert.equal(premiere.mode, 'lecture'); const rendu = await seconde.debloquer(); assert.deepEqual(rendu, { libelle: 'Plan débloqué, état « Brouillon »', avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }], }); assert.equal(seconde.mode, 'ecriture'); const journal = journalDe(fs); assert.deepEqual(journal.entrees.map(({ revision }) => revision), [1, 2, 3, 4, 5, 6]); assert.deepEqual(noms(etatDe(fs).charge), ['Xavier']); assert.equal(etatDe(fs).charge.evenement.etat, 'brouillon'); }); test("débloquer un plan que l'autre séance a débloqué entre-temps : refusé PLAN_NON_BLOQUE, adopté, le verrou rendu", async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('changerEtat', { etat: 'bloque' }); await seconde.ouvrir(BASE); await premiere.debloquer(); await premiere.passerEnLecture(); const entrees = journalDe(fs).entrees.length; await refus(seconde.debloquer(), 'PLAN_NON_BLOQUE'); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'brouillon']); assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }]); assert.equal(journalDe(fs).entrees.length, entrees); }); test("un secours ouvert se relit par la même source : passer en écriture ne bute pas sur l'état illisible", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('ajouterParticipant', { champs: { nom: 'Xavier' } }); await seance.passerEnLecture(); fs.deposer('documents', ETAT, ''); await seance.ouvrirSecours(BASE, 'journal'); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); await seance.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }); assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']); await seance.passerEnLecture(); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); }); // L'événement de la première séance porte Aubépine puis Bourrache, son // état devient illisible, et chacune des deux ouvre son .precedent : la // révision 2, Aubépine seule. async function surPrecedent() { const deux = await deuxSeances(); const { premiere, seconde, fs } = deux; await premiere.passerEnEcriture(); for (const nom of ['Aubépine', 'Bourrache']) await premiere.executer('ajouterParticipant', { champs: { nom } }); await premiere.passerEnLecture(); fs.deposer('documents', ETAT, ''); for (const seance of [premiere, seconde]) await seance.ouvrirSecours(BASE, 'precedent'); assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [2, ['Aubépine']]); return deux; } for (const gestes of [['Cerfeuil'], ['Cerfeuil', 'Dictame']]) { test(`un secours .precedent se relit par l'état qu'une autre séance a écrit depuis (${gestes.length} geste·s) : adopté, EVENEMENT_CHANGE le dit, rien ne s'efface`, async () => { const { premiere, seconde, fs } = await surPrecedent(); await premiere.passerEnEcriture(); for (const nom of gestes) await premiere.executer('ajouterParticipant', { champs: { nom } }); await premiere.passerEnLecture(); const { revision } = etatDe(fs).entete; const change = [{ code: 'EVENEMENT_CHANGE', details: { revision } }]; assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change }); assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [revision, ['Aubépine', ...gestes]]); await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } }); assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', ...gestes, 'Estragon']); assert.equal(journalDe(fs).ecartees, 0); }); } test("un secours .precedent dont l'état reste illisible, mais dont le journal a reçu le geste d'une autre séance : le dernier instant du journal est adopté, EVENEMENT_CHANGE le dit", async () => { const { premiere, seconde, fs, fsPremiere } = await surPrecedent(); await premiere.passerEnEcriture(); await premiere.nommer('Point'); fsPremiere.pannes.renommage('documents'); await refus(premiere.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ETAT_NON_ECRIT'); await premiere.passerEnLecture(); assert.equal(fs.contenu('documents', ETAT), ''); assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }], }); assert.deepEqual(noms(seconde.charge), ['Aubépine', 'Cerfeuil']); await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } }); assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil', 'Estragon']); }); test("un secours .precedent dont l'état redevient lisible entre-temps se relit par l'état, sans entrée nouvelle au journal : EVENEMENT_CHANGE le dit", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); const lisible = fs.contenu('documents', ETAT); fs.deposer('documents', ETAT, ''); await seance.ouvrirSecours(BASE, 'precedent'); fs.deposer('documents', ETAT, lisible); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }], }); assert.deepEqual(noms(seance.charge), ['Aubépine', 'Bourrache']); await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }); assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Bourrache', 'Cerfeuil']); }); test("un secours .precedent ouvert devant un état lisible se relit par le .precedent tant que l'état ne change pas : le choix de l'opérateur reste (§ 8.9, point 3)", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); await seance.ouvrirSecours(BASE, 'precedent'); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [2, ['Aubépine']]); await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }); assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil']); }); test("après un geste sur un secours .precedent, la séance se relit par l'état : redevenu illisible, il refuse le passage en écriture, sans revenir au .precedent", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); fs.deposer('documents', ETAT, ''); await seance.ouvrirSecours(BASE, 'precedent'); await seance.passerEnEcriture(); await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }); await seance.passerEnLecture(); fs.deposer('documents', ETAT, ''); await refus(seance.passerEnEcriture(), 'ETAT_ILLISIBLE'); assert.deepEqual([seance.mode, verrouDe(fs), noms(seance.charge)], ['lecture', null, ['Aubépine', 'Cerfeuil']]); }); test("après avoir écrit ailleurs un secours .precedent, la séance s'y relit par l'état, que le dossier nouveau porte seul", async () => { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); fs.deposerDossier('portable'); const montage = monter({ fs }); const { seance } = montage; await enEcriture(montage); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); fs.deposer('documents', ETAT, ''); await seance.ouvrirSecours(BASE, 'precedent'); await seance.passerEnEcriture(); fs.pannes.ecriture('documents'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ECRITURE'); await seance.ecrireAilleurs(PORTABLE); await seance.passerEnLecture(); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); assert.deepEqual([seance.mode, noms(seance.charge)], ['ecriture', ['Aubépine']]); }); test("le jalon d'une séance seule sur un secours .precedent pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE ni recul de révision", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); fs.deposer('documents', ETAT, ''); await seance.ouvrirSecours(BASE, 'precedent'); await seance.passerEnEcriture(); assert.deepEqual(await seance.nommer('Point'), { revision: 4 }); await seance.passerEnLecture(); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, ['Aubépine']]); const codes = seance.avertissements.map(({ code }) => code); assert.ok(!codes.includes('JOURNAL_EN_AVANCE_DE'), codes.join()); }); test("le jalon d'une séance seule sur un état que le journal devance de deux entrées pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); const premier = fs.contenu('documents', ETAT); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); fs.deposer('documents', ETAT, premier); await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); assert.deepEqual(await seance.nommer('Point'), { revision: 4 }); await seance.passerEnLecture(); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, []]); }); // L'événement réécrit par une construction plus récente : l'état, ou la // première ligne du journal, déclarent le format 2. const plusRecent = { etat: (fs) => fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).replace('"format":1', '"format":2')), journal: (fs) => fs.deposer('documents', JOURNAL, fs.contenu('documents', JOURNAL).replace('"format":1', '"format":2')), }; for (const [fichier, designe] of [['etat', "l'état"], ['journal', 'le journal']]) { test(`${designe} qu'une construction plus récente a réécrit entre-temps : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => { const { seconde, fs } = await deuxSeances(); const entrees = journalDe(fs).entrees.length; plusRecent[fichier](fs); await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' }); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', { raison: 'FORMAT' }); if (fichier === 'etat') assert.equal(journalDe(fs).entrees.length, entrees); }); test(`un secours .precedent dont ${designe} est réécrit entre-temps par une construction plus récente : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => { const { seconde, fs } = await surPrecedent(); if (fichier === 'etat') { const precedent = fs.contenu('documents', `${BASE}${SUFFIXES.precedent}`); fs.deposer('documents', ETAT, precedent.replace('"format":1', '"format":2')); } else { plusRecent.journal(fs); } await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' }); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); }); test(`un plan bloqué dont ${designe} est réécrit entre-temps par une construction plus récente : débloquer est refusé LECTURE_SEULE FORMAT, le verrou rendu, sans entrée`, async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('changerEtat', { etat: 'bloque' }); await seconde.ouvrir(BASE); const texte = fs.contenu('documents', JOURNAL); plusRecent[fichier](fs); await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'FORMAT' }); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); if (fichier === 'etat') assert.equal(fs.contenu('documents', JOURNAL), texte); }); } // L'état réécrit en octets hors UTF-8 : son premier « é » remplacé par // l'octet E9 d'un éditeur réglé en windows-1252. Rend ces octets. function etatMalEncode(fs) { const octets = [...new TextEncoder().encode(fs.contenu('documents', ETAT))]; const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9); octets.splice(rang, 2, 0xe9); fs.deposerOctets('documents', ETAT, new Uint8Array(octets)); return new Uint8Array(octets); } // Les noms des fichiers de documents et le texte du journal : ce qu'un // refus laisse tel quel. const empreinte = async (fs) => [(await fs.lister(DOC, '')).map(({ nom }) => nom), fs.contenu('documents', JOURNAL)]; test("l'état réécrit entre-temps en octets hors UTF-8 : passer en écriture est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, comme un format plus récent, le verrou rendu et rien de l'événement écrit ; le refus a son texte", async () => { const { seconde, fs } = await deuxSeances(); const octets = etatMalEncode(fs); const avant = await empreinte(fs); const erreur = await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'ENCODAGE' }); assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]); assert.deepEqual(await empreinte(fs), avant); assert.deepEqual(fs.octets('documents', ETAT), octets); assert.match(libelle(erreur.code, erreur.details), /UTF-8/); await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'MODE_LECTURE'); }); test("un plan bloqué dont l'état est réécrit entre-temps en octets hors UTF-8 : débloquer est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, le verrou rendu, sans entrée", async () => { const { premiere, seconde, fs } = await deuxSeances(); await premiere.passerEnEcriture(); await premiere.executer('changerEtat', { etat: 'bloque' }); await premiere.passerEnLecture(); await seconde.ouvrir(BASE); const octets = etatMalEncode(fs); const avant = await empreinte(fs); await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'ENCODAGE' }); assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]); assert.deepEqual(await empreinte(fs), avant); assert.deepEqual(fs.octets('documents', ETAT), octets); }); for (const source of ['precedent', 'journal']) { test(`ouvert par son secours ${source}, un état aux octets hors UTF-8 ne fige rien : passer en écriture réussit, et le premier geste met l'état dans un dossier daté de la corbeille, ses octets intacts, avant d'écrire`, async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.passerEnLecture(); const octets = etatMalEncode(fs); const illisible = await refus(seance.ouvrir(BASE), 'ETAT_ILLISIBLE'); assert.equal(illisible.details.raison, 'ENCODAGE'); assert.match(libelle(illisible.code, illisible.details), /UTF-8/); await seance.ouvrirSecours(BASE, source); assert.equal(seance.ouvert.etatMalEncode, true); assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] }); await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }); const [dossier] = (await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom); assert.deepEqual(fs.octets('documents', `corbeille/${dossier}/${ETAT}`), octets); assert.deepEqual(noms(etatDe(fs).charge), [...noms(seance.charge)]); assert.ok(noms(seance.charge).includes('Cerfeuil')); assert.equal(seance.ouvert.etatMalEncode, false); for (const { code, details } of seance.avertissements) assert.equal(typeof libelle(code, details), 'string'); }); } }); describe('séance : revenir, défaire, refaire (§ 8.3)', () => { // Trois gestes après la création : Ancolie, Bruyère, Capucine, aux // révisions 2, 3 et 4. async function troisGestes() { const montage = monter(); await enEcriture(montage); for (const nom of ['Ancolie', 'Bruyère', 'Capucine']) await montage.seance.executer('ajouterParticipant', { champs: { nom } }); return montage; } test('défaire, refaire et revenir écrivent des entrées de retour, et le journal ne se tronque jamais', async () => { const montage = await troisGestes(); const { fs, seance } = montage; assert.deepEqual(await seance.defaire(), { libelle: 'Défait : « Ajouté Capucine »', avertissements: [] }); assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère']); await seance.refaire(); assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère', 'Capucine']); await seance.revenir(2); assert.deepEqual(noms(seance.charge), ['Ancolie']); const journal = journalDe(fs); assert.deepEqual( journal.entrees.map(({ revision, sens, retour, libelle: texte }) => [revision, sens, retour, texte]), [ [1, null, null, CREATION], [2, null, null, 'Ajouté Ancolie'], [3, null, null, 'Ajouté Bruyère'], [4, null, null, 'Ajouté Capucine'], [5, 'defaire', 3, 'Défait : « Ajouté Capucine »'], [6, 'refaire', 4, 'Refait : « Ajouté Capucine »'], [7, 'revenir', 2, 'Retour à « Ajouté Ancolie »'], ], ); // L'instant 2 revient avec son contenu, et ses compteurs restent où trois // ajouts les ont portés : aucun identifiant donné ne se redonne (§ 8.9). const instant2 = reconstruire(journal, 2); const attendu = { ...instant2, prochainsIds: { ...instant2.prochainsIds, participant: 4 } }; assert.deepEqual(etatDe(fs).charge, attendu); assert.deepEqual(reconstruire(journal, 7), attendu); await seance.executer('ajouterParticipant', { champs: { nom: 'Digitale' } }); assert.deepEqual( seance.charge.participants.map(({ id, nom }) => [id, nom]), [[1, 'Ancolie'], [4, 'Digitale']], ); }); test("refaire n'existe qu'après des retours, tant qu'aucun geste ne les suit", async () => { const montage = await troisGestes(); const { fs, seance } = montage; assert.deepEqual(seance.retours, { defaire: true, refaire: false }); const avant = fs.compteurs.ecritures; await refus(seance.refaire(), 'RIEN_A_REFAIRE'); assert.equal(fs.compteurs.ecritures, avant); await seance.defaire(); await seance.defaire(); assert.deepEqual(seance.retours, { defaire: true, refaire: true }); await seance.refaire(); assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère']); await seance.executer('ajouterParticipant', { champs: { nom: 'Dauphinelle' } }); assert.deepEqual(seance.retours, { defaire: true, refaire: false }); await refus(seance.refaire(), 'RIEN_A_REFAIRE'); }); test("rien à défaire au premier instant", async () => { const montage = monter(); await enEcriture(montage); assert.deepEqual(montage.seance.retours, { defaire: false, refaire: false }); const avant = montage.fs.compteurs.ecritures; await refus(montage.seance.defaire(), 'RIEN_A_DEFAIRE'); assert.equal(montage.fs.compteurs.ecritures, avant); }); test('après un retour de la révision 6 à la 3, défaire vise le prédécesseur de 3 sur le fil, non la ligne précédente', async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); for (const nom of ['Ancolie', 'Bruyère', 'Capucine', 'Dauphinelle', 'Églantine']) await seance.executer('ajouterParticipant', { champs: { nom } }); await seance.revenir(3); await seance.defaire(); const derniere = journalDe(fs).entrees.at(-1); assert.deepEqual([derniere.revision, derniere.sens, derniere.retour, derniere.libelle], [8, 'defaire', 2, 'Défait : « Ajouté Bruyère »']); assert.deepEqual(noms(seance.charge), ['Ancolie']); }); test("un journal qui finit avant l'état : défaire vise l'instant où il finit, par-dessus l'entrée de reprise", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); // L'état du disque, à la révision 4, porte une personne que le journal, // arrêté à la révision 1, n'a pas. const plus = chargeEssai(); plus.participants.push(personne(5, 'Ancolie')); plus.prochainsIds.participant = 6; fs.deposer('documents', ETAT, serialiser(plus, { revision: 4, produitVersion: V1 })); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); assert.deepEqual(seance.ouvert.avertissements, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 4, journal: 1 } }]); await seance.passerEnEcriture(); assert.deepEqual(seance.retours, { defaire: true, refaire: false }); const reprise = "Reprise de l'état trouvé sur le disque"; assert.deepEqual(await seance.defaire(), { libelle: `Défait : « ${reprise} »`, avertissements: [] }); assert.deepEqual( journalDe(fs).entrees.map(({ revision, sens, retour, libelle: texte }) => [revision, sens, retour, texte]), [ [1, null, null, CREATION], [2, null, null, reprise], [3, 'defaire', 1, `Défait : « ${reprise} »`], ], ); assert.deepEqual(noms(seance.charge), noms(chargeEssai())); assert.deepEqual(seance.retours, { defaire: false, refaire: true }); await seance.refaire(); assert.deepEqual(noms(seance.charge), noms(plus)); }); test("un état sans journal : rien à défaire ; le premier geste ouvre le journal par l'entrée de reprise", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', ETAT, serialiser(chargeEssai(), { revision: 3, produitVersion: V1 })); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); assert.deepEqual(seance.retours, { defaire: false, refaire: false }); await refus(seance.defaire(), 'RIEN_A_DEFAIRE'); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.deepEqual( journalDe(fs).entrees.map(({ revision, libelle: texte }) => [revision, texte]), [ [3, "Reprise de l'état trouvé sur le disque"], [4, 'Ajouté Ancolie'], ], ); assert.deepEqual(seance.retours, { defaire: true, refaire: false }); }); test("revenir à un instant que le journal ne restitue plus est refusé, sans écriture", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); const charge = chargeEssai(); // Un journal élagué : sa première entrée est la révision 10. const lignes = [ ligneOuverture(charge.evenement.id), ligneEntree({ revision: 10, libelle: 'Geste 10', horodatage: '2031-05-17T07:00:00-04:00', produitVersion: V1, apres: charge, instantane: true }), ]; fs.deposer('documents', JOURNAL, `${lignes.join('\n')}\n`); fs.deposer('documents', ETAT, serialiser(charge, { revision: 10, produitVersion: V1 })); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); const avant = fs.compteurs.ecritures; await refus(seance.revenir(3), 'INSTANT_NON_RESTITUABLE', { revision: 3 }); await refus(seance.revenir(11), 'INSTANT_NON_RESTITUABLE', { revision: 11 }); await refus(seance.defaire(), 'RIEN_A_DEFAIRE'); assert.equal(fs.compteurs.ecritures, avant); }); }); describe('séance : les jalons automatiques (§ 8.3)', () => { test("la création, chaque changement d'état, et l'instant qui précède chaque génération sont nommés", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); const { seance } = montage; await ouvrirSansEcrire(montage); await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('retenirProposition', { id: 1 }); await seance.executer('enregistrerGeneration', generer(seance.charge, 1, 9)); const journal = journalDe(fs); assert.deepEqual(libellesDe(journal), [CREATION, 'Généré 2 propositions (n° 1 à 2)', 'Retenu la proposition n° 1', 'Généré 1 proposition (n° 3)']); assert.deepEqual(journal.jalons.map(({ revision, nom }) => [revision, nom]), [ [1, 'Création'], [1, 'Avant la génération'], [2, 'État « Proposé »'], [3, 'État « Retenu »'], [3, 'Avant la génération'], ]); }); test("un retour qui change l'état du plan pose le jalon qui le nomme : défaire un déblocage rebloque, et la séance revient en lecture", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('changerEtat', { etat: 'bloque' }); await seance.debloquer(); const rendu = await seance.defaire(); assert.deepEqual(rendu, { libelle: 'Défait : « Plan débloqué, état « Brouillon » »', avertissements: [] }); const journal = journalDe(fs); const derniere = journal.entrees.at(-1); assert.deepEqual([derniere.revision, derniere.sens, derniere.retour], [4, 'defaire', 2]); assert.deepEqual(journal.jalons.map(({ revision, nom }) => [revision, nom]), [ [1, 'Création'], [2, 'État « Bloqué »'], [3, 'État « Brouillon »'], [4, 'État « Bloqué »'], ]); assert.deepEqual([seance.mode, seance.charge.evenement.etat, verrouDe(fs)], ['lecture', 'bloque', null]); }); }); describe('séance : bloquer et débloquer (§ 8.5, § 9)', () => { test("bloquer écrit l'entrée et son jalon, puis la séance revient en lecture et rend le verrou", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); const rendu = await seance.executer('changerEtat', { etat: 'bloque' }); assert.equal(rendu.libelle, "Plan passé à l'état « Bloqué »"); assert.equal(seance.mode, 'lecture'); assert.equal(verrouDe(fs), null); assert.equal(montage.planificateur.actives, 0); const journal = journalDe(fs); assert.deepEqual(journal.jalons.at(-1).nom, 'État « Bloqué »'); assert.equal(etatDe(fs).charge.evenement.etat, 'bloque'); }); test('sur un plan bloqué : passer en écriture, un geste, défaire et nommer sont refusés BLOQUE, sans écriture', async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }) }); await enEcriture(premier); await premier.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); await premier.seance.executer('changerEtat', { etat: 'bloque' }); const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(second); for (const { seance, fs } of [premier, second]) { const avant = fs.compteurs.ecritures; const bloque = { raison: 'BLOQUE' }; await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', bloque); await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', bloque); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'LECTURE_SEULE', bloque); await refus(seance.defaire(), 'LECTURE_SEULE', bloque); await refus(seance.nommer('Distribué'), 'LECTURE_SEULE', bloque); assert.equal(fs.compteurs.ecritures, avant); assert.equal(seance.mode, 'lecture'); } assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/); }); test("débloquer prend le verrou, écrit une entrée et un jalon, rend l'état que le contenu impose, et laisse la séance en écriture", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('changerEtat', { etat: 'bloque' }); const entrees = journalDe(fs).entrees.length; const rendu = await seance.debloquer(); assert.equal(rendu.libelle, 'Plan débloqué, état « Brouillon »'); assert.equal(seance.mode, 'ecriture'); assert.equal(verrouDe(fs).seance, seance.id); assert.equal(seance.charge.evenement.etat, etatDeduit(seance.charge)); const journal = journalDe(fs); assert.equal(journal.entrees.length, entrees + 1); assert.deepEqual(journal.jalons.at(-1), { ...journal.jalons.at(-1), revision: journal.entrees.at(-1).revision, nom: 'État « Brouillon »' }); await seance.executer('changerEtat', { etat: 'bloque' }); await seance.executer('debloquer', {}); assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['ecriture', 'brouillon']); }); test("débloquer devant le verrou d'un autre poste est refusé VERROU_PRIS, avant toute entrée", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('changerEtat', { etat: 'bloque' }); const depuis = '2031-05-17T09:00:00-04:00'; fs.deposer('documents', VERROU, `${JSON.stringify({ seance: 'lointaine-1', pid: 99, hote: 'autre-poste', depuis })}\n`); const journal = fs.contenu('documents', JOURNAL); const avant = fs.compteurs.ecritures; await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis, vivant: null }); assert.equal(fs.compteurs.ecritures - avant, 1, 'seule la prise du verrou se tente'); assert.equal(fs.contenu('documents', JOURNAL), journal); assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']); assert.equal(verrouDe(fs).seance, 'lointaine-1'); }); test("débloquer un plan qui ne l'est pas : en lecture MODE_LECTURE, en écriture PLAN_NON_BLOQUE", async () => { const montage = monter(); await montage.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }); await refus(montage.seance.debloquer(), 'MODE_LECTURE'); await montage.seance.passerEnEcriture(); const avant = montage.fs.compteurs.ecritures; await refus(montage.seance.debloquer(), 'PLAN_NON_BLOQUE'); assert.equal(montage.fs.compteurs.ecritures, avant); }); }); describe("séance : un fichier d'un format plus récent (§ 8.8)", () => { test("s'ouvre en lecture sans rien écrire, et tout ce qui écrirait est refusé LECTURE_SEULE FORMAT, avant BLOQUE", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); const charge = chargeEssai(); charge.evenement.etat = 'bloque'; fs.deposer('documents', ETAT, serialiser(charge, { revision: 1, produitVersion: V1 }).replace('"format":1', '"format":2')); const { seance } = monter({ fs }); const avant = fs.compteurs.ecritures; await seance.ouvrir(BASE); assert.equal(seance.ouvert.formatPlusRecent, true); const format = { raison: 'FORMAT' }; await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', format); await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', format); await refus(seance.debloquer(), 'LECTURE_SEULE', format); await refus(seance.defaire(), 'LECTURE_SEULE', format); assert.equal(fs.compteurs.ecritures, avant); assert.doesNotMatch(libelle('LECTURE_SEULE', format), /Débloquer/); }); }); describe('séance : la perte du support en cours de séance (§ 8.6)', () => { // Un événement du disque portable, en écriture, après un premier geste. async function surPortable(reglages = {}) { const fs = creerFichiersSimules({ executable: 'E:\\soirees', ...reglages }); const montage = monter({ fs, racine: PORTABLE }); await enEcriture(montage); await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); return montage; } test("une écriture refusée rompt le support : le geste est refusé, les suivants aussi, puis écrire ailleurs rétablit", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }; await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); assert.equal(seance.supportRompu, true); assert.deepEqual(seance.refusSupport, refusSupport); assert.deepEqual(noms(seance.charge), ['Ancolie']); const avant = fs.compteurs.ecritures; await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'SUPPORT_ROMPU', refusSupport); await refus(seance.defaire(), 'SUPPORT_ROMPU'); assert.equal(fs.compteurs.ecritures, avant); assert.match(libelle('SUPPORT_ROMPU', refusSupport), /Écrire ailleurs/); const { base, avertissements } = await seance.ecrireAilleurs(DOC); assert.equal(base, BASE); assert.deepEqual(avertissements, [ { code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } }, ]); assert.deepEqual([seance.supportRompu, seance.refusSupport, seance.mode], [false, null, 'ecriture']); assert.deepEqual(seance.racine, DOC); assert.equal(verrouDe(fs).seance, seance.id); await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }); assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']); assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']); }); test("un état qui ne s'écrit pas : le geste est au journal, la séance en montre la charge, et écrire ailleurs la garde", async () => { const { fs, seance } = await surPortable(); fs.pannes.renommage('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ETAT_NON_ECRIT'); assert.deepEqual([seance.supportRompu, seance.refusSupport.echec], [true, 'ETAT_NON_ECRIT']); assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère']); assert.deepEqual(libellesDe(journalDe(fs, 'portable')), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']); await seance.ecrireAilleurs(DOC); assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']); assert.equal(seance.supportRompu, false); }); test("un jalon d'état qui ne s'écrit pas laisse le geste enregistré : JALON_NON_ECRIT le dit, et le support est rompu", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); // La deuxième ligne ajoutée au journal à partir d'ici, le jalon qui suit // l'entrée, trouve le support en lecture seule. const ajouterLigne = fs.ajouterLigne; let lignes = 0; fs.ajouterLigne = async (racine, chemin, ligne) => { lignes += 1; if (lignes === 2) fs.pannes.ecriture(racine.id); return ajouterLigne(racine, chemin, ligne); }; const refusSupport = { chemin: JOURNAL, dossier: DOCUMENTS, cause: 'EROFS' }; const rendu = await seance.executer('changerEtat', { etat: 'bloque' }); assert.deepEqual(rendu, { libelle: "Plan passé à l'état « Bloqué »", avertissements: [ { code: 'JALON_NON_ECRIT', details: refusSupport }, { code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: DOCUMENTS, cause: 'EROFS' } }, ], }); assert.deepEqual([seance.supportRompu, seance.refusSupport], [true, { echec: 'JALON_NON_ECRIT', ...refusSupport }]); assert.equal(seance.mode, 'lecture'); assert.equal(etatDe(fs).charge.evenement.etat, 'bloque'); const journal = journalDe(fs); assert.equal(journal.entrees.at(-1).libelle, "Plan passé à l'état « Bloqué »"); assert.deepEqual(journal.jalons.map(({ nom }) => nom), ['Création']); }); test("une création dont l'état ne s'écrit pas reste ouverte en lecture, le support rompu, et s'écrit ailleurs", async () => { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); const montage = monter({ fs, racine: PORTABLE }); const { seance } = montage; // La deuxième écriture atomique, l'état qui suit le journal, trouve le // support en lecture seule. const ecrireAtomique = fs.ecrireAtomique; let ecritures = 0; fs.ecrireAtomique = async (racine, chemin, texte) => { ecritures += 1; if (ecritures === 2) fs.pannes.ecriture(racine.id); return ecrireAtomique(racine, chemin, texte); }; await refus(seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }), 'ETAT_NON_ECRIT'); assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport.echec], ['lecture', true, 'ETAT_NON_ECRIT']); assert.equal(seance.charge.evenement.nom, BASE); assert.equal(fs.contenu('portable', ETAT), null); const { base } = await seance.ecrireAilleurs(DOC); assert.equal(base, BASE); assert.deepEqual([seance.supportRompu, seance.mode, verrouDe(fs)], [false, 'lecture', null]); assert.deepEqual(libellesDe(journalDe(fs)), [CREATION]); assert.equal(etatDe(fs).entete.revision, 1); }); test("un verrou que le support refuse rompt le support : « Modifier » est refusé, puis écrire ailleurs rétablit l'écriture", async () => { const { fs, seance, planificateur } = await surPortable(); await planificateur.avancer(INACTIVITE_MS); assert.deepEqual([seance.mode, verrouDe(fs, 'portable')], ['lecture', null]); fs.pannes.ecriture('portable'); const details = { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' }; await refus(seance.passerEnEcriture(), 'ECRITURE', details); const refusSupport = { echec: 'VERROU_NON_ECRIT', ...details }; assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, refusSupport]); assert.deepEqual(noms(seance.charge), ['Ancolie']); const avant = fs.compteurs.ecritures; await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', refusSupport); assert.equal(fs.compteurs.ecritures, avant); const { base } = await seance.ecrireAilleurs(DOC); assert.equal(base, BASE); assert.deepEqual([seance.supportRompu, seance.mode, seance.racine], [false, 'lecture', DOC]); await seance.passerEnEcriture(); assert.equal(verrouDe(fs).seance, seance.id); await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }); assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']); }); test("débloquer devant un verrou que le support refuse rompt aussi le support, et le plan se débloque ailleurs", async () => { const { fs, seance } = await surPortable(); await seance.executer('changerEtat', { etat: 'bloque' }); assert.equal(seance.mode, 'lecture'); fs.pannes.ecriture('portable'); const details = { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' }; await refus(seance.debloquer(), 'ECRITURE', details); assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, { echec: 'VERROU_NON_ECRIT', ...details }]); assert.equal(seance.charge.evenement.etat, 'bloque'); await seance.ecrireAilleurs(DOC); const rendu = await seance.debloquer(); assert.equal(rendu.libelle, 'Plan débloqué, état « Brouillon »'); assert.deepEqual([seance.mode, verrouDe(fs)?.seance], ['ecriture', seance.id]); assert.equal(etatDe(fs).charge.evenement.etat, 'brouillon'); }); test("écrire ailleurs dont le verrou ne s'écrit pas là-bas : la paire y est, la séance y passe en lecture, son support de nouveau rompu", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); // Les Documents prennent la paire, mais refusent le verrou. const details = { chemin: VERROU, dossier: DOCUMENTS, cause: 'EACCES' }; const verrouiller = fs.verrouiller; fs.verrouiller = async (racine, chemin, jeton) => { if (racine.id === 'documents') throw new ErreurStockage('ECRITURE', details); return verrouiller(racine, chemin, jeton); }; const { base, avertissements } = await seance.ecrireAilleurs(DOC); assert.equal(base, BASE); assert.deepEqual(avertissements.map(({ code }) => code), ['VERROU_NON_RENDU', 'ECRITURE']); const refusSupport = { echec: 'VERROU_NON_ECRIT', ...details }; assert.deepEqual([seance.racine, seance.mode, seance.supportRompu, seance.refusSupport], [DOC, 'lecture', true, refusSupport]); assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie']); await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', refusSupport); }); test("écrire ailleurs refuse un dossier qui porte déjà l'événement, le sien compris : jamais deux copies d'un identifiant dans un dossier", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); fs.pannes.renommage('documents', 11); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ETAT_NON_ECRIT'); assert.equal(seance.supportRompu, true); const avant = fs.compteurs.ecritures; const id = seance.charge.evenement.id; const erreur = await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE }); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual([seance.supportRompu, seance.racine], [true, DOC]); const etats = (await fs.lister(DOC, '')).filter(({ nom }) => nom.endsWith(SUFFIXES.etat)).map(({ nom }) => nom); assert.deepEqual(etats, [ETAT]); assert.doesNotMatch(libelle(erreur.code, erreur.details), /import/); }); test("un état qu'écrire ailleurs ne pose pas y transporte la séance, avec le journal écrit : un nouvel essai n'y dérive pas une seconde base", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); // La deuxième écriture atomique dans les Documents, l'état qui suit le // journal, voit refusés tous ses renommages. const ecrireAtomique = fs.ecrireAtomique; let ecritures = 0; fs.ecrireAtomique = async (racine, chemin, texte) => { if (racine.id === 'documents') { ecritures += 1; if (ecritures === 2) fs.pannes.renommage('documents', 11); } return ecrireAtomique(racine, chemin, texte); }; const id = seance.charge.evenement.id; await refus(seance.ecrireAilleurs(DOC), 'ETAT_NON_ECRIT'); assert.deepEqual([seance.racine, seance.ouvert.racine, seance.ouvert.base], [DOC, DOC, BASE]); const refusSupport = { echec: 'ETAT_NON_ECRIT', chemin: ETAT, dossier: DOCUMENTS, cause: 'EBUSY' }; assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, refusSupport]); assert.deepEqual(seance.avertissements, [ { code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } }, ]); assert.deepEqual(noms(seance.charge), ['Ancolie']); assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie']); assert.equal(fs.contenu('documents', ETAT), null); const avant = fs.compteurs.ecritures; await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE }); await refus(seance.ecrireAilleurs(PORTABLE), 'IDENTIFIANT_PRESENT', { id, base: BASE }); assert.equal(fs.compteurs.ecritures, avant); const fichiers = (await fs.lister(DOC, '')).map(({ nom }) => nom); assert.deepEqual(fichiers, [JOURNAL]); }); // L'événement d'essai des Documents, dont l'état devient un JSON tronqué et // dont le journal reste lisible : ouvrir le refuse, la séance l'ouvre par // le secours du journal et passe en écriture, puis l'état d'un geste ne // s'écrit pas — le geste est au journal, le support est rompu. async function illisibleRompu() { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); await deposerEssai(fs); fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).slice(0, 40)); const montage = monter({ fs }); const { seance } = montage; const illisible = await refus(seance.ouvrir(BASE), 'ETAT_ILLISIBLE'); assert.deepEqual([illisible.details.raison, illisible.details.secours], ['JSON', { precedent: null, journal: 1 }]); await seance.ouvrirSecours(BASE, 'journal'); await seance.passerEnEcriture(); fs.pannes.renommage('documents', 11); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'ETAT_NON_ECRIT'); assert.deepEqual([seance.supportRompu, seance.racine], [true, DOC]); return montage; } // Les noms des fichiers d'une racine. const fichiersDe = async (fs, racine) => (await fs.lister(racine, '')).map(({ nom }) => nom); test("écrire ailleurs refuse le dossier où l'événement n'a qu'un état illisible, que son journal apparie : aucune seconde paire, et ce journal reste son secours", async () => { const { fs, seance } = await illisibleRompu(); const id = seance.charge.evenement.id; const avant = fs.compteurs.ecritures; const fichiers = await fichiersDe(fs, DOC); await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE }); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual(await fichiersDe(fs, DOC), fichiers); assert.deepEqual([seance.supportRompu, seance.racine], [true, DOC]); const illisible = await refus(ouvrirDansLeDepot(fs, DOC, BASE), 'ETAT_ILLISIBLE'); assert.deepEqual(illisible.details.secours, { precedent: null, journal: 2 }); }); test("écrire ailleurs refuse de revenir au dossier d'où l'événement est parti, quand son état y est illisible", async () => { const { fs, seance } = await illisibleRompu(); const id = seance.charge.evenement.id; await seance.ecrireAilleurs(PORTABLE); assert.deepEqual([seance.racine, seance.supportRompu, seance.mode], [PORTABLE, false, 'ecriture']); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const avant = fs.compteurs.ecritures; const fichiers = await fichiersDe(fs, DOC); await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE }); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual(await fichiersDe(fs, DOC), fichiers); assert.deepEqual([seance.supportRompu, seance.racine], [true, PORTABLE]); }); test("écrire ailleurs sur un support amovible l'annonce, comme la règle du dossier de travail au démarrage (§ 8.8)", async () => { const { fs, seance } = await surPortable({ support: { documents: 'amovible' } }); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const amovible = { code: 'SUPPORT_AMOVIBLE', details: { dossier: DOCUMENTS } }; const { avertissements } = await seance.ecrireAilleurs(DOC); assert.deepEqual(avertissements, [ { code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } }, amovible, ]); assert.ok(seance.avertissements.some(({ code }) => code === 'SUPPORT_AMOVIBLE')); assert.match(libelle(amovible.code, amovible.details), /Gestion table tournante Libre/); }); const fautesDuPont = [ ['stockage', () => new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EIO' })], ['pont', () => new TypeError('réponse du pont illisible pour typeSupport')], ]; for (const [nature, faute] of fautesDuPont) { test(`écrire ailleurs réussit quand le type du support ne se dit pas (faute ${nature}) : le support vaut inconnu, rien n'est annoncé`, async () => { const { fs, seance, planificateur } = await surPortable({ support: { documents: 'amovible' } }); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); fs.typeSupport = async () => { throw faute(); }; const { base, avertissements } = await seance.ecrireAilleurs(DOC); assert.equal(base, BASE); assert.deepEqual(avertissements, [ { code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } }, ]); assert.deepEqual(seance.avertissements, avertissements); assert.deepEqual([seance.supportRompu, seance.mode, seance.racine, planificateur.actives], [false, 'ecriture', DOC, 1]); }); } test("tant que le support est rompu, ouvrir, ouvrir un secours, créer et fermer sont refusés SUPPORT_ROMPU_NON_SAUVE, sans rien changer", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }; const avant = fs.compteurs.ecritures; await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual([seance.supportRompu, seance.refusSupport, noms(seance.charge)], [true, refusSupport, ['Ancolie']]); assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', refusSupport), /« Écrire ailleurs »/); await seance.ecrireAilleurs(DOC); await seance.fermer(); assert.equal(seance.ouvert, null); }); test("le support rompu se quitte sur abandon explicite : ouvrir, ou fermer, avec {abandonner: true}", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const rendu = await seance.ouvrir(BASE, { abandonner: true }); assert.equal(rendu.base, BASE); assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie']]); await refus(seance.passerEnEcriture(), 'ECRITURE'); assert.equal(seance.supportRompu, true); await seance.fermer({ abandonner: true }); assert.deepEqual([seance.ouvert, seance.supportRompu], [null, false]); }); test("le support rompu se quitte sur abandon explicite : créer, ou ouvrir un secours, avec {abandonner: true}", async () => { const { fs, seance } = await surPortable(); // Un état que le support refuse rompt le support ; les renommages // rétablis ensuite, ce qui suit s'écrit. const rompre = async (nom) => { await seance.passerEnEcriture(); fs.pannes.renommage('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom } }), 'ETAT_NON_ECRIT'); fs.pannes.renommage('portable', 0); assert.equal(seance.supportRompu, true); }; await rompre('Bruyère'); const grillons = 'Soirée des Grillons'; const cree = await seance.creer({ nom: grillons, date: null, siegesParDefaut: 2, tours: 2 }, { abandonner: true }); assert.equal(cree.base, grillons); assert.deepEqual([seance.supportRompu, seance.mode, seance.charge.evenement.nom], [false, 'lecture', grillons]); await rompre('Cerfeuil'); const secours = await seance.ouvrirSecours(BASE, 'journal', { abandonner: true }); assert.equal(secours.base, BASE); assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie', 'Bruyère']]); }); test("écrire ailleurs qui échoue laisse le support rompu ; sans support rompu, il est refusé SUPPORT_INTACT", async () => { const { fs, seance } = await surPortable(); const avant = fs.compteurs.ecritures; await refus(seance.ecrireAilleurs(DOC), 'SUPPORT_INTACT'); assert.equal(fs.compteurs.ecritures, avant); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); fs.pannes.ecriture('documents'); await refus(seance.ecrireAilleurs(DOC), 'ECRITURE'); assert.equal(seance.supportRompu, true); assert.deepEqual(seance.racine, PORTABLE); }); }); describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => { // Commentaires et chaînes blanchis : seul le code compte. const JETONS = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\[\s\S]|[^`\\])*`/g; const code = (texte) => texte.replace(JETONS, (jeton) => jeton.replace(/[^\n]/g, ' ')); test("ni la séance, ni les commandes, ni la table des libellés ne lisent l'horloge, un aléa ou une minuterie", () => { for (const fichier of ['seance.js', 'commandes.js', 'libelles.js']) { const texte = code(readFileSync(new URL(`./${fichier}`, import.meta.url), 'utf8')); assert.ok(texte.trim().length > 0, `${fichier} vide`); for (const motif of [/\bDate\b/, /\bsetTimeout\b/, /\bsetInterval\b/, /\bclearTimeout\b/, /\bperformance\b/, /\bMath\s*\.\s*random\b/, /\bcrypto\b/]) { assert.doesNotMatch(texte, motif, `${fichier} : ${motif}`); } } }); });