// © 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 { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js'; import { H_RETENU, personne as personneBadge, soiree, table as tableBadge } from '../../test/fixtures/badges/cas.js'; import { describe, test } from '../../test/lanceur.js'; import { lirePolices } from '../../test/polices.js'; import { apercevoir } from '../csv/apercu.js'; import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.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 { FORMAT, 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, nomPlanche } from '../stockage/noms.js'; import { FICHIER_REGLAGES } from '../stockage/reglages_locaux.js'; import { VERSION } from '../version.genere.js'; import { libelle } from './libelles.js'; import { DELAI_REGLAGES_MS, 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, décalée de debut minutes. function creerHorloge(debut = 0) { let n = debut; 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. // options : les paramètres de creerSeance qu'une épreuve règle en plus — // formatCourant, delaiReglagesMs. Une racine null crée la séance sans // dossier de travail. function monter({ fs = creerFichiersSimules(), racine = DOC, prefixe = 'seance', inactiviteMs, ...options } = {}) { if (racine !== null) 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 }), ...options, }); 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 refus de lecture seule de la séance, remède compris. const FORMAT_SEUL = { raison: 'FORMAT', remede: null }; const BLOQUE_SEUL = { raison: 'BLOQUE', remede: { geste: 'debloquer' } }; const ENCODAGE_SEUL = { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } }; // Le remède d'un support rompu : écrire ailleurs, dans la racine proposée. const versRacine = (racine) => ({ geste: 'ecrireAilleurs', racine }); // --- 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', { remede: { geste: 'modifier' } }); 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, remede: null }); 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 porte l'instant de l'horloge de la séance : VERROU_PRIS le rend à une seconde séance", async () => { const disque = new Map(); const pilote = '2031-05-17T19:42:00-04:00'; const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere', horloge: { horodatage: () => pilote } }); await enEcriture(premier); assert.equal(verrouDe(premier.fs).depuis, pilote); const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(second); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis: pilote, vivant: true, remede: null }); }); 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, remede: 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, remede: 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 : reprendre le verrou que la séance ne reprend pas seule (§ 8.8)', () => { // Un verrou posé sur le disque partagé par une séance d'un autre poste, à // l'instant depuis. const verrouLointain = (seance, depuis) => `${JSON.stringify({ seance, pid: 41, hote: 'poste-lointain', depuis })}\n`; // Une séance d'épreuve qui ouvre en lecture l'événement d'essai, créé par // une première séance, déjà fermée, sur le même disque. async function secondeSeance(disque, hote = 'autre-poste') { const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'createur' }); await premier.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }); await premier.seance.fermer(); const second = monter({ fs: creerFichiersSimules({ disque, hote }), prefixe: 'reprise' }); await ouvrirSansEcrire(second); return second; } test("le verrou d'un autre poste, refusé puis repris : il s'efface, la séance prend le sien et passe en écriture, VERROU_REPRIS le dit", 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, remede: null }); assert.deepEqual(lointain.seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null }); const journal = lointain.fs.contenu('documents', JOURNAL); const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'premiere-1', depuis } }]; assert.deepEqual(await lointain.seance.reprendreVerrou(), { avertissements: repris }); assert.equal(lointain.seance.mode, 'ecriture'); assert.equal(verrouDe(lointain.fs).seance, 'lointaine-1'); assert.equal(lointain.fs.contenu('documents', JOURNAL), journal, 'reprendre n\u{2019}écrit aucune entrée'); const etat = lointain.seance.etat(); assert.equal(etat.verrouAutre, null); assert.deepEqual(etat.avertissements, repris); assert.equal(etat.refus, null); await lointain.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } }); assert.deepEqual(noms(etatDe(lointain.fs).charge), ['Ombrelle']); }); test('un verrou illisible se reprend de même, sous VERROU_REPRIS {seance: null, depuis: null}', async () => { const disque = new Map(); const second = await secondeSeance(disque, 'poste-epreuve'); second.fs.deposer('documents', VERROU, 'pas un verrou\n'); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null }); const repris = [{ code: 'VERROU_REPRIS', details: { seance: null, depuis: null } }]; assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris }); assert.equal(second.seance.mode, 'ecriture'); assert.equal(verrouDe(second.fs).seance, 'reprise-1'); }); test("un verrou illisible remplacé par un valide entre sa relecture et son effacement : le valide reste, et VERROU_PRIS le nomme", async () => { const disque = new Map(); const second = await secondeSeance(disque, 'poste-epreuve'); second.fs.deposer('documents', VERROU, 'pas un verrou\n'); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null }); // Le système de la séance pose un verrou valide juste après avoir rendu // l'illisible à la reprise, comme une autre séance qui le prendrait // entre deux appels. const pose = '2031-05-17T09:15:00-04:00'; const valide = verrouLointain('lointaine-6', pose); const verrouiller = second.fs.verrouiller; let substituer = true; second.fs.verrouiller = async (...parametres) => { const rendu = await verrouiller(...parametres); if (substituer && rendu.pris === false && rendu.seance === null) { substituer = false; second.fs.deposer('documents', VERROU, valide); } return rendu; }; const details = { seance: 'lointaine-6', depuis: pose, vivant: null }; await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); assert.equal(second.fs.contenu('documents', VERROU), valide); assert.equal(second.seance.mode, 'lecture'); assert.deepEqual(second.seance.etat().verrouAutre, details); }); test('un verrou illisible disparu entre le refus et sa relecture : la relecture prend le verrou, la séance le reconnaît comme le sien, VERROU_REPRIS puis écriture', async () => { const disque = new Map(); const second = await secondeSeance(disque, 'poste-epreuve'); second.fs.deposer('documents', VERROU, 'pas un verrou\n'); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null }); // Le fichier disparaît juste avant le deuxième appel de la reprise, la // relecture d'effacerVerrou, qui pose alors le verrou de la séance. const verrouiller = second.fs.verrouiller; let appels = 0; second.fs.verrouiller = async (racine, chemin, ...reste) => { appels += 1; if (appels === 2) await second.fs.supprimer(racine, chemin); return verrouiller(racine, chemin, ...reste); }; const repris = [{ code: 'VERROU_REPRIS', details: { seance: null, depuis: null } }]; assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris }); assert.equal(second.seance.mode, 'ecriture'); assert.equal(verrouDe(second.fs).seance, 'reprise-1'); assert.equal(second.seance.etat().verrouAutre, null); await second.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } }); assert.deepEqual(noms(etatDe(second.fs).charge), ['Ombrelle']); }); test("un verrou qui a changé depuis le refus ne s'efface pas : VERROU_PRIS nomme le nouveau, que verrouAutre garde", async () => { const disque = new Map(); const second = await secondeSeance(disque); const vu = '2031-05-17T09:00:00-04:00'; second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu)); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null }); const pose = '2031-05-17T09:30:00-04:00'; const nouveau = verrouLointain('lointaine-2', pose); second.fs.deposer('documents', VERROU, nouveau); const details = { seance: 'lointaine-2', depuis: pose, vivant: null }; await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); assert.equal(second.fs.contenu('documents', VERROU), nouveau); assert.equal(second.seance.mode, 'lecture'); assert.deepEqual(second.seance.etat().verrouAutre, details); const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-2', depuis: pose } }]; assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris }); assert.equal(verrouDe(second.fs).seance, 'reprise-1'); }); // Le verrou vu, puis remplacé par celui que décrit change : la reprise // le refuse VERROU_PRIS avec les détails du nouveau, ne touche pas au // fichier, et verrouAutre garde le nouveau. async function repriseRefusee(seance, depuis) { const second = await secondeSeance(new Map()); const vu = '2031-05-17T09:00:00-04:00'; second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu)); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null }); const nouveau = verrouLointain(seance, depuis); second.fs.deposer('documents', VERROU, nouveau); const details = { seance, depuis, vivant: null }; await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); assert.equal(second.fs.contenu('documents', VERROU), nouveau); assert.equal(second.seance.mode, 'lecture'); assert.deepEqual(second.seance.etat().verrouAutre, details); } test("la même séance reprise à un instant plus tard : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => { await repriseRefusee('lointaine-1', '2031-05-17T09:45:00-04:00'); }); test("une autre séance au même instant : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => { await repriseRefusee('lointaine-5', '2031-05-17T09:00:00-04:00'); }); test("le verrou d'une séance vivante de ce poste ne se reprend jamais", async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); const { depuis } = verrouDe(premier.fs); const voisin = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'voisine' }); await ouvrirSansEcrire(voisin); const details = { seance: 'premiere-1', depuis, vivant: true }; await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { ...details, remede: null }); await refus(voisin.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); assert.equal(verrouDe(voisin.fs).seance, 'premiere-1'); assert.equal(voisin.seance.mode, 'lecture'); }); test("sans verrou refusé, reprendre est passer en écriture : un verrou posé depuis n'est pas repris", async () => { const second = await secondeSeance(new Map()); const pose = '2031-05-17T10:00:00-04:00'; second.fs.deposer('documents', VERROU, verrouLointain('lointaine-3', pose)); await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { seance: 'lointaine-3', depuis: pose, vivant: null, remede: null }); assert.equal(verrouDe(second.fs).seance, 'lointaine-3'); const libre = await secondeSeance(new Map()); assert.deepEqual(await libre.seance.reprendreVerrou(), { avertissements: [] }); assert.deepEqual([libre.seance.mode, verrouDe(libre.fs).seance], ['ecriture', 'reprise-1']); }); test('sans verrou refusé, reprendre sur un plan bloqué lève LECTURE_SEULE BLOQUE comme passer en écriture, sans rien écrire', async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('changerEtat', { etat: 'bloque' }); await seance.passerEnLecture(); assert.equal(seance.etat().verrouAutre, null); const avant = fs.compteurs.ecritures; await refus(seance.reprendreVerrou(), 'LECTURE_SEULE', BLOQUE_SEUL); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual([seance.mode, seance.charge.evenement.etat, verrouDe(fs)], ['lecture', 'bloque', null]); }); test('sur un plan bloqué, la reprise rend aussitôt le verrou : la séance reste en lecture, et débloquer passe ensuite', async () => { const montage = monter({ prefixe: 'bloqueuse' }); const { fs, seance } = montage; await enEcriture(montage); await seance.executer('changerEtat', { etat: 'bloque' }); const depuis = '2031-05-17T11:00:00-04:00'; fs.deposer('documents', VERROU, verrouLointain('lointaine-4', depuis)); await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-4', depuis, vivant: null, remede: null }); const journal = fs.contenu('documents', JOURNAL); const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-4', depuis } }]; assert.deepEqual(await seance.reprendreVerrou(), { avertissements: repris }); assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']); assert.equal(verrouDe(fs), null); assert.equal(fs.contenu('documents', JOURNAL), journal); assert.deepEqual(seance.etat().avertissements, repris); assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: BLOQUE_SEUL }); await seance.debloquer(); assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['ecriture', 'brouillon']); }); test("en écriture, reprendre ne fait rien ; un format plus récent et un support rompu le refusent sans rien écrire", async () => { const montage = monter(); await enEcriture(montage); const avant = montage.fs.compteurs.ecritures; assert.deepEqual(await montage.seance.reprendreVerrou(), { avertissements: [] }); assert.equal(montage.fs.compteurs.ecritures, avant); montage.fs.pannes.ecriture('documents'); await refus(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), 'ECRITURE'); await montage.seance.passerEnLecture(); const rompu = montage.fs.compteurs.ecritures; await refus(montage.seance.reprendreVerrou(), 'SUPPORT_ROMPU'); assert.equal(montage.fs.compteurs.ecritures, rompu); const fsRecent = creerFichiersSimules(); deposerAuFormat(fsRecent, FORMAT + 1); const recent = monter({ fs: fsRecent }); await ouvrirSansEcrire(recent); await refus(recent.seance.reprendreVerrou(), 'LECTURE_SEULE', FORMAT_SEUL); }); }); 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' } }); // L'événement neuf n'a aucune table : la première personne manque d'une place. const manque = [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]; assert.deepEqual(rendu, { libelle: 'Ajouté Iris Ombrelle', avertissements: manque }); 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 ; en brouillon, il ne dit que le manque de places, et en retenu l'import et la dérive (§ 10.1, § 5.9)", 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, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 3 } }]); 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' }); const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8, personne: 'Epicéa' }]; assert.deepEqual(enRetenu.avertissements, [ { code: 'IMPORT_EN_RETENU', details: {} }, { code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }, { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nonPlace }, { id: 2, raisons: nonPlace }, { id: 'retenu', raisons: nonPlace }] }, }, ]); // 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', BLOQUE_SEUL); 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(codesDe(exclusion.avertissements), ['DERIVE_NOUVELLE']); assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE', 'DERIVE_NOUVELLE']); assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...exclusion.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' } }); // Cinq personnes déjà pour quatre places : seul le manque, qui croît, se dit. assert.deepEqual(seance.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } }]); }); }); 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', BLOQUE_SEUL); 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 qui suit FORMAT. const versPlusRecent = (texte) => texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`); const plusRecent = { etat: (fs) => fs.deposer('documents', ETAT, versPlusRecent(fs.contenu('documents', ETAT))), journal: (fs) => fs.deposer('documents', JOURNAL, versPlusRecent(fs.contenu('documents', JOURNAL))), }; 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', FORMAT_SEUL); assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]); await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', FORMAT_SEUL); 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, versPlusRecent(precedent)); } else { plusRecent.journal(fs); } await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', FORMAT_SEUL); 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', FORMAT_SEUL); 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', ENCODAGE_SEUL); 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', ENCODAGE_SEUL); 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, remede: null }); await refus(seance.revenir(11), 'INSTANT_NON_RESTITUABLE', { revision: 11, remede: null }); 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 = BLOQUE_SEUL; 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, remede: 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":${FORMAT}`, `"format":${FORMAT + 1}`), ); const { seance } = monter({ fs }); const avant = fs.compteurs.ecritures; await seance.ouvrir(BASE); assert.equal(seance.ouvert.formatPlusRecent, true); const format = FORMAT_SEUL; 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, remede: versRacine(DOC) }); 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("le cadrage en attente quand le support rompt s'écrit dans les réglages de la racine nouvelle, après écrire ailleurs", async () => { const { fs, seance, planificateur } = await surPortable(); fs.pannes.ecriture('portable'); seance.demanderCadrage({ k: 1.25, tx: 30, ty: -12 }); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); await seance.ecrireAilleurs(DOC); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); const evenement = seance.charge.evenement.id; assert.deepEqual(JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).cadrages, [{ evenement, k: 1.25, tx: 30, ty: -12 }]); assert.equal(fs.contenu('portable', FICHIER_REGLAGES), null); }); 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, remede: versRacine(DOC) }); 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, depuis) => { if (racine.id === 'documents') throw new ErreurStockage('ECRITURE', details); return verrouiller(racine, chemin, jeton, depuis); }; 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']); // Le support rompu est celui des Documents : aucune racine à proposer. await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', { ...refusSupport, remede: versRacine(null) }); }); 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 nonSauve = { ...refusSupport, remede: versRacine(DOC) }; const avant = fs.compteurs.ecritures; await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); 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', 'avertissements.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}`); } } }); }); // --- La séance observable ---------------------------------------------------- // Laisse passer les tâches en attente : l'écrivain différé des réglages // écrit à la suite de sa minuterie, sans que celle-ci rende sa promesse. // setImmediate rappelle une fois vidées les microtâches, où s'enchaînent // les écritures du système de fichiers d'épreuve ; il n'attend aucune durée. const laisser = () => new Promise((resolve) => setImmediate(resolve)); // Compte les objets atteints depuis valeur, chacun une fois, en affirmant // que chacun est figé. function compterFiges(valeur, vus = new Set()) { if (valeur === null || typeof valeur !== 'object' || vus.has(valeur)) return 0; vus.add(valeur); assert.ok(Object.isFrozen(valeur), `objet non figé : ${JSON.stringify(valeur).slice(0, 80)}`); return 1 + Object.keys(valeur).reduce((n, cle) => n + compterFiges(valeur[cle], vus), 0); } const codesDe = (avertissements) => avertissements.map(({ code }) => code); // La charge d'essai, d'un plan bloqué. const chargeBloquee = () => { const charge = chargeEssai(); charge.evenement.etat = 'bloque'; return charge; }; // Le retenu de la proposition 1 posé sur la charge d'essai : Iris et // Grisaille à la table 1 au premier tour, Iris et Théo au second. function avecRetenu(charge) { charge.retenu = { proposition: 1, siegesAttribues: false, tables: [1, 2], capacites: [2, 2], tours: 2, participants: [1, 2, 3, 4], placement: [ { sieges: [[1, 2], [3, 4]], reserve: [] }, { sieges: [[1, 3], [2, 4]], reserve: [] }, ], }; charge.prochainsIds.proposition = 2; charge.evenement.etat = 'retenu'; return charge; } // Grisaille réservée à la table 2 au premier tour, où le retenu l'assoit à // la table 1. const reserverGrisaille = (charge) => { charge.reservations.push({ participant: 2, table: 2, siege: null, portee: 'tour', tour: 1 }); return charge; }; // Un montage dont l'événement d'essai, de cette charge, est ouvert. async function ouvertSur(charge = chargeEssai(), options = {}) { const fs = options.fs ?? creerFichiersSimules(); await deposerEssai(fs, charge); const montage = monter({ ...options, fs }); await montage.seance.ouvrir(BASE); return montage; } // Un fichier d'état de la charge d'essai, au format donné. function deposerAuFormat(fs, format, charge = chargeEssai()) { fs.deposerDossier('documents'); const texte = serialiser(charge, { revision: 1, produitVersion: V1 }); fs.deposer('documents', ETAT, texte.replace(`"format":${FORMAT}`, `"format":${format}`)); } const MODIFIER = { geste: 'modifier' }; const DEBLOQUER = { geste: 'debloquer' }; describe('séance observable : etat() et subscribe (§ 8.4)', () => { test('subscribe appelle aussitôt, puis après ouvrir, passer en écriture, un geste et passer en lecture ; désabonné, plus rien', async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const { seance } = monter({ fs }); const recus = []; const desabonner = seance.subscribe((etat) => recus.push(etat)); assert.equal(recus.length, 1); assert.equal(recus[0], seance.etat()); assert.equal(recus[0].evenement, null); await seance.ouvrir(BASE); assert.deepEqual([recus.at(-1).evenement.base, recus.at(-1).mode], [BASE, 'lecture']); await seance.passerEnEcriture(); assert.equal(recus.at(-1).mode, 'ecriture'); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.deepEqual(noms(recus.at(-1).charge), ['Ombrelle', 'Grisaille', 'Pervenche', 'Lacasse', 'Ancolie']); await seance.passerEnLecture(); assert.equal(recus.at(-1).mode, 'lecture'); assert.ok(recus.length >= 5, `${recus.length} appels`); assert.equal(new Set(recus.slice(1)).size, recus.length - 1, 'un état rendu deux fois'); const n = recus.length; desabonner(); await seance.passerEnEcriture(); seance.debuterCalcul(); assert.equal(recus.length, n); }); test("l'état rendu n'est jamais muté : figé en profondeur, et une copie d'avant compare égale après les gestes suivants", async () => { const { seance } = await ouvertSur(); await seance.passerEnEcriture(); const etat = seance.etat(); const copie = structuredClone(etat); assert.ok(compterFiges(etat) > 20); assert.throws(() => etat.charge.participants.push(personne(9, 'Ancolie')), TypeError); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); await seance.executer('exclureParticipant', { id: 3 }); await seance.defaire(); assert.deepEqual(etat, copie); assert.notEqual(seance.etat(), etat); assert.equal(seance.etat(), seance.etat(), "deux lectures sans changement rendent le même état"); compterFiges(seance.etat()); }); test("la charge de l'état garde son identité tant que l'événement ne change pas, et chaque geste en rend une neuve, figée", async () => { const { seance } = await ouvertSur(); await seance.passerEnEcriture(); const etat = seance.etat(); const { charge } = etat; seance.debuterCalcul(); seance.finirCalcul(); assert.notEqual(seance.etat(), etat); assert.equal(seance.etat().charge, charge); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); const apres = seance.etat().charge; assert.notEqual(apres, charge); compterFiges(apres); await seance.executer('exclureParticipant', { id: 3 }); assert.notEqual(seance.etat().charge, apres); compterFiges(seance.etat().charge); }); test("l'état porte l'événement, la charge, l'examen, les retours, le calcul en cours ; sans événement, rien", async () => { const vide = monter().seance.etat(); assert.deepEqual( [vide.evenement, vide.charge, vide.examen, vide.fautives, vide.avertissements, vide.refus, vide.supportRompu, vide.verrouAutre], [null, null, null, [], [], null, null, null], ); assert.deepEqual([vide.mode, vide.retours, vide.calculEnCours, vide.racine], ['lecture', { defaire: false, refaire: false }, false, DOC]); const { seance } = await ouvertSur(); await seance.passerEnEcriture(); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); const etat = seance.etat(); assert.deepEqual(etat.evenement, { base: BASE, id: 'evt-lucioles', nom: BASE, etat: 'brouillon', revision: 2, annonceFormat: null }); assert.deepEqual(etat.charge, seance.charge); assert.deepEqual(etat.examen, seance.ouvert.examen); assert.deepEqual(etat.retours, { defaire: true, refaire: false }); const recus = []; seance.subscribe((suivant) => recus.push(suivant)); seance.debuterCalcul(); assert.deepEqual([seance.etat().calculEnCours, recus.at(-1).calculEnCours], [true, true]); seance.finirCalcul(); assert.deepEqual([seance.etat().calculEnCours, recus.at(-1).calculEnCours], [false, false]); }); }); describe('séance observable : la racine (§ 8.6)', () => { test("une séance créée sans racine n'en a pas ; changerRacine la pose sans rien écrire", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); const { seance } = monter({ fs, racine: null }); assert.equal(seance.etat().racine, null); const avant = fs.compteurs.ecritures; const recus = []; seance.subscribe((etat) => recus.push(etat)); await seance.changerRacine(DOC); assert.deepEqual([seance.etat().racine, recus.at(-1).racine], [DOC, DOC]); assert.equal(fs.compteurs.ecritures, avant); await seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }); assert.equal(seance.etat().evenement.base, BASE); }); test("changerRacine ferme l'événement ouvert en écriture : son verrou quitte le disque, et la séance ouvre désormais là", async () => { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); fs.deposerDossier('portable'); const montage = monter({ fs }); await enEcriture(montage); assert.equal(verrouDe(fs).seance, montage.seance.id); await montage.seance.changerRacine(PORTABLE); assert.equal(verrouDe(fs), null); assert.deepEqual([montage.seance.etat().evenement, montage.seance.etat().racine, montage.seance.etat().mode], [null, PORTABLE, 'lecture']); await refus(montage.seance.ouvrir(BASE), 'ABSENT'); }); }); describe('séance observable : la version de format (§ 8.8, § 18.6)', () => { test("un fichier d'un format plus récent s'annonce 'plusRecent' ; passer en écriture et un geste sont refusés LECTURE_SEULE FORMAT, remède null, jamais MODE_LECTURE", async () => { const fs = creerFichiersSimules(); deposerAuFormat(fs, FORMAT + 1); const { seance } = monter({ fs }); const avant = fs.compteurs.ecritures; await seance.ouvrir(BASE); assert.equal(seance.etat().evenement.annonceFormat, 'plusRecent'); const format = { raison: 'FORMAT', remede: null }; await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', format); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'LECTURE_SEULE', format); assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: format }); assert.equal(fs.compteurs.ecritures, avant); }); test("au format courant, aucune annonce ; formatCourant au-delà, 'plusAncien' sans rien écrire, puis le premier geste écrit au format FORMAT et l'annonce tombe", async () => { const courant = await ouvertSur(); assert.equal(courant.seance.etat().evenement.annonceFormat, null); const fs = creerFichiersSimules(); await deposerEssai(fs); const { seance } = monter({ fs, formatCourant: FORMAT + 1 }); const avant = fs.compteurs.ecritures; await seance.ouvrir(BASE); assert.equal(seance.etat().evenement.annonceFormat, 'plusAncien'); assert.equal(fs.compteurs.ecritures, avant); await seance.passerEnEcriture(); assert.equal(seance.etat().evenement.annonceFormat, 'plusAncien', 'le passage en écriture ne convertit rien'); const entrees = journalDe(fs).entrees.length; await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.equal(journalDe(fs).entrees.length, entrees + 1); assert.equal(etatDe(fs).entete.format, FORMAT); assert.equal(seance.etat().evenement.annonceFormat, null); }); }); describe('séance observable : bloquer et débloquer (§ 8.5, § 9)', () => { test("sur un plan bloqué, passer en écriture, changer l'état et défaire sont refusés BLOQUE, remède debloquer, jamais MODE_LECTURE, sans écriture", async () => { const montage = await ouvertSur(chargeBloquee()); const { fs, seance } = montage; const avant = fs.compteurs.ecritures; const bloque = { raison: 'BLOQUE', remede: DEBLOQUER }; await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', bloque); await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', bloque); await refus(seance.defaire(), 'LECTURE_SEULE', bloque); assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: bloque }); assert.equal(fs.compteurs.ecritures, avant); }); test("débloquer prend le verrou, écrit une entrée et un jalon, rend l'état que le contenu impose, et laisse en écriture", async () => { const { fs, seance } = await ouvertSur(chargeBloquee()); const entrees = journalDe(fs).entrees.length; await seance.debloquer(); assert.equal(seance.etat().mode, 'ecriture'); assert.equal(verrouDe(fs).seance, seance.id); const journal = journalDe(fs); assert.equal(journal.entrees.length, entrees + 1); assert.equal(journal.jalons.at(-1).revision, journal.entrees.at(-1).revision); assert.equal(seance.etat().evenement.etat, etatDeduit(seance.charge)); assert.equal(seance.etat().refus, null); }); test('sur un plan non bloqué ouvert en lecture, un geste est refusé MODE_LECTURE, remède modifier', async () => { const { fs, seance } = await ouvertSur(); const avant = fs.compteurs.ecritures; await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'MODE_LECTURE', { remede: MODIFIER }); await refus(seance.nommer('Avant le dessert'), 'MODE_LECTURE', { remede: MODIFIER }); assert.equal(fs.compteurs.ecritures, avant); }); test('bloquer écrit une entrée et un jalon, revient en lecture, rend le verrou ; une seconde séance du même disque ouvre un plan bloqué', async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); const entrees = journalDe(premier.fs).entrees.length; await premier.seance.executer('changerEtat', { etat: 'bloque' }); assert.equal(journalDe(premier.fs).entrees.length, entrees + 1); assert.equal(journalDe(premier.fs).jalons.at(-1).nom, 'État « Bloqué »'); assert.deepEqual([premier.seance.etat().mode, verrouDe(premier.fs)], ['lecture', null]); const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' }); await ouvrirSansEcrire(second); assert.equal(second.seance.etat().evenement.etat, 'bloque'); assert.deepEqual(second.seance.etat().refus, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: DEBLOQUER } }); }); }); describe("séance observable : l'annulation et le retour automatique (§ 8.4)", () => { test('surAnnulation est appelé au passage en lecture explicite pendant un geste, au retour automatique et à la fermeture ; désabonné, plus rien', async () => { const montage = monter(); const { seance, planificateur } = montage; await enEcriture(montage); let appels = 0; const desabonner = seance.surAnnulation(() => { appels += 1; }); seance.debuterGeste(); await seance.passerEnLecture(); assert.equal(appels, 1); await seance.passerEnEcriture(); await planificateur.avancer(INACTIVITE_MS); assert.deepEqual([seance.mode, appels], ['lecture', 2]); await seance.fermer(); assert.equal(appels, 3); desabonner(); await seance.ouvrir(BASE); await seance.passerEnEcriture(); await seance.passerEnLecture(); await seance.fermer(); assert.equal(appels, 3); }); test("le retour automatique ne tombe jamais pendant un geste ouvert ; il pose RETOUR_AUTOMATIQUE à la suite des avertissements, que le passage en écriture suivant retire", async () => { const montage = monter(); const { seance, planificateur } = montage; await enEcriture(montage); const ajout = await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.deepEqual(codesDe(ajout.avertissements), ['PLACES_MANQUANTES']); let appels = 0; seance.surAnnulation(() => { appels += 1; }); seance.debuterGeste(); await planificateur.avancer(3 * INACTIVITE_MS); assert.deepEqual([seance.mode, appels], ['ecriture', 0]); assert.ok(!codesDe(seance.etat().avertissements).includes('RETOUR_AUTOMATIQUE')); seance.finirGeste(); await planificateur.avancer(INACTIVITE_MS); assert.deepEqual([seance.mode, appels], ['lecture', 1]); const retour = { code: 'RETOUR_AUTOMATIQUE', details: {} }; assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, ...ajout.avertissements, retour]); await seance.passerEnEcriture(); assert.deepEqual(codesDe(seance.etat().avertissements), []); await seance.passerEnLecture(); assert.ok(!codesDe(seance.etat().avertissements).includes('RETOUR_AUTOMATIQUE'), 'le retour explicite se tait'); }); }); describe('séance observable : le cadrage (§ 8.5)', () => { const REGLAGES = `{"cadrages":[{"evenement":"evt-lucioles","k":2,"tx":10,"ty":-5}],"format":1,"papier":null}\n`; const lireCadrages = (fs) => JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).cadrages; for (const [cas, charge, ecriture] of [ ['en lecture', chargeEssai, false], ['sur un plan bloqué', chargeBloquee, false], ['en écriture', chargeEssai, true], ]) { test(`${cas} : ouvrir relit le cadrage ; vingt demandes en 500 ms font une seule écriture des réglages, ${DELAI_REGLAGES_MS} ms après la première, le dernier cadrage, sans toucher l'état ni le journal`, async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, charge()); fs.deposer('documents', FICHIER_REGLAGES, REGLAGES); const montage = monter({ fs }); const { seance, planificateur } = montage; await seance.ouvrir(BASE); if (ecriture) await seance.passerEnEcriture(); assert.deepEqual(seance.cadrage(), { k: 2, tx: 10, ty: -5 }); const avant = fs.compteurs.ecritures; const revision = seance.etat().evenement.revision; const textes = [fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)]; for (let rang = 0; rang < 20; rang += 1) { seance.demanderCadrage({ k: 1 + rang / 10, tx: rang, ty: -rang }); await planificateur.avancer(25); } assert.deepEqual(seance.cadrage(), { k: 2.9, tx: 19, ty: -19 }); await planificateur.avancer(DELAI_REGLAGES_MS - 500 - 1); await laisser(); assert.equal(fs.compteurs.ecritures, avant); await planificateur.avancer(1); await laisser(); assert.equal(fs.compteurs.ecritures, avant + 1); assert.deepEqual(lireCadrages(fs), [{ evenement: 'evt-lucioles', k: 2.9, tx: 19, ty: -19 }]); assert.deepEqual([fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)], textes); assert.equal(seance.etat().evenement.revision, revision); }); } test("une vue de k nul ou non fini n'est pas demandée ; une écriture refusée se tait", async () => { const { fs, seance, planificateur } = await ouvertSur(); const avant = fs.compteurs.ecritures; for (const vue of [{ k: 0, tx: 0, ty: 0 }, { k: Number.NaN, tx: 0, ty: 0 }, { k: Infinity, tx: 0, ty: 0 }, { k: 1, tx: Number.NaN, ty: 0 }]) { seance.demanderCadrage(vue); } await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); assert.equal(fs.compteurs.ecritures, avant); assert.equal(seance.cadrage(), null); fs.pannes.ecriture('documents'); seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), null); assert.deepEqual(seance.cadrage(), { k: 1.5, tx: 3, ty: 4 }); await seance.fermer(); }); test("fermer écrit aussitôt une demande en attente ; rouvrir la rend ; défaire ne la change pas", async () => { const montage = monter(); const { fs, seance, planificateur } = montage; await enEcriture(montage); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); seance.demanderCadrage({ k: 0.5, tx: -40, ty: 12 }); await seance.fermer(); assert.equal(planificateur.actives, 0); const { id } = etatDe(fs).charge.evenement; assert.deepEqual(lireCadrages(fs), [{ evenement: id, k: 0.5, tx: -40, ty: 12 }]); await seance.ouvrir(BASE); assert.deepEqual(seance.cadrage(), { k: 0.5, tx: -40, ty: 12 }); await seance.passerEnEcriture(); await seance.defaire(); assert.deepEqual(seance.cadrage(), { k: 0.5, tx: -40, ty: 12 }); const ailleurs = monter({ fs: creerFichiersSimules({ disque: new Map() }) }); assert.equal(ailleurs.seance.cadrage(), null, 'sans événement, aucun cadrage'); }); test("une lecture des réglages qui lève ne retient pas l'ouverture : cadrage() null", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const lireTexte = fs.lireTexte; fs.lireTexte = async (racine, chemin) => { if (chemin === FICHIER_REGLAGES) throw new TypeError('réponse illisible du système'); return lireTexte(racine, chemin); }; const { seance } = monter({ fs }); await seance.ouvrir(BASE); assert.deepEqual([seance.etat().evenement.base, seance.cadrage()], [BASE, null]); }); test("des réglages que la lecture ne prend pas ne s'écrasent pas : le cadrage attend, le papier et les autres cadrages restent", async () => { const AUTRES = `{"cadrages":[{"evenement":"evt-voisin","k":3,"tx":1,"ty":2}],"format":1,"papier":"A4"}\n`; const fs = creerFichiersSimules(); await deposerEssai(fs); fs.deposer('documents', FICHIER_REGLAGES, AUTRES); let occupe = false; const lireTexte = fs.lireTexte; fs.lireTexte = async (racine, chemin) => { if (occupe && chemin === FICHIER_REGLAGES) throw new ErreurStockage('LECTURE', { chemin, cause: 'EBUSY' }); return lireTexte(racine, chemin); }; const montage = monter({ fs }); const { seance, planificateur } = montage; await seance.ouvrir(BASE); occupe = true; seance.demanderCadrage({ k: 1, tx: 0, ty: 0 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), AUTRES, 'une lecture refusée laisse le fichier'); assert.deepEqual(seance.cadrage(), { k: 1, tx: 0, ty: 0 }); occupe = false; await seance.fermer(); const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); assert.equal(relus.papier, 'A4'); assert.deepEqual(relus.cadrages, [ { evenement: 'evt-lucioles', k: 1, tx: 0, ty: 0 }, { evenement: 'evt-voisin', k: 3, tx: 1, ty: 2 }, ]); for (const corrompu of ['pas du json\n', '{"cadrages":[],"format":0,"papier":"A4"}\n']) { fs.deposer('documents', FICHIER_REGLAGES, corrompu); await seance.ouvrir(BASE); seance.demanderCadrage({ k: 2, tx: 5, ty: 5 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); await seance.fermer(); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), corrompu, corrompu); } }); test("des réglages corrompus : cadrage() null, et aucun avertissement de la séance ne le dit", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); fs.deposer('documents', FICHIER_REGLAGES, 'pas du json\n'); const { seance } = monter({ fs }); await seance.ouvrir(BASE); assert.equal(seance.cadrage(), null); assert.ok(!codesDe(seance.etat().avertissements).includes('REGLAGES_ILLISIBLES')); }); }); describe("séance observable : les avertissements d'un geste (§ 5.9, § 9)", () => { // Deux tables de huit et seize convives : la salle est pleine. function sallePleine() { const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 8, tours: 2 }); charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } })); charge.participants = Array.from({ length: 16 }, (_, rang) => personne(rang + 1, `Convive ${rang + 1}`)); charge.prochainsIds = { participant: 17, table: 3, proposition: 1 }; return charge; } const manqueDe = (charge) => nombrePlacesManquantes(normaliser(configurationDepuisCharge(charge))); test("la dix-septième personne avertit PLACES_MANQUANTES {1}, trois de plus {4}, le nombre du moteur ; un geste qui ne fait pas croître le manque n'en dit rien", async () => { const { seance } = await ouvertSur(sallePleine()); await seance.passerEnEcriture(); const ajout = await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.deepEqual(ajout.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]); assert.equal(manqueDe(seance.charge), 1); const apercu = apercevoir('nom\nBruyère\nCerfeuil\nDahlia\n', { participants: seance.charge.participants }); const import3 = await seance.executer('importerParticipants', { apercu, mode: 'ajouter' }); assert.deepEqual(import3.avertissements, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 4 } }]); assert.equal(manqueDe(seance.charge), 4); assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, ...import3.avertissements]); const renomme = await seance.executer('modifierParticipant', { id: 1, champs: { nom: 'Parasol' } }); assert.deepEqual(renomme.avertissements, []); const retire = await seance.executer('supprimerParticipant', { id: 2 }); assert.deepEqual(retire.avertissements, []); }); test("après la retenue, exclure une personne : DERIVE_NOUVELLE nomme le retenu et PARTICIPANT_EXCLU, sans lever ; le geste suivant passe sans redire cette raison", async () => { const { seance } = await ouvertSur(); await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); await seance.executer('retenirProposition', { id: 1 }); const exclusion = await seance.executer('exclureParticipant', { id: 3 }); const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' }]; assert.deepEqual(exclusion.avertissements, [ { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } }, ]); const suivant = await seance.executer('modifierParticipant', { id: 4, champs: { nom: 'Lacasse-Ombrelle' } }); assert.deepEqual(suivant.avertissements, []); const autre = await seance.executer('exclureParticipant', { id: 2 }); const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2, personne: 'Grisaille' }]; assert.deepEqual(autre.avertissements, [ { code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } }, ]); }); test("un fichier au retenu fautif : le geste passe, et ni DERIVE_NOUVELLE ni RESERVATION_NON_HONOREE ne vient du retenu", async () => { const fs = creerFichiersSimules(); const charge = avecRetenu(chargeEssai()); charge.retenu.placement[0].reserve = [2]; await deposerEssai(fs, reserverGrisaille(charge)); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const { seance } = montage; assert.equal(seance.etat().avertissements[0].code, 'RETENU_FAUTIF'); await seance.passerEnEcriture(); const exclusion = await seance.executer('exclureParticipant', { id: 3 }); assert.deepEqual(exclusion.avertissements, []); assert.ok(!codesDe(seance.etat().avertissements).includes('INVARIANT_VIOLE')); }); }); describe("séance observable : le verrou d'une autre séance (§ 8.8)", () => { test('la seconde séance ouvre en lecture sans refus ; son passage en écriture est refusé VERROU_PRIS, remède null, et verrouAutre porte la séance et depuis', 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); assert.equal(second.seance.etat().verrouAutre, null); await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: true, remede: null }); assert.deepEqual(second.seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: true }); assert.equal(verrouDe(second.fs).seance, 'premiere-1'); }); test("le verrou d'une séance morte se reprend seul, et VERROU_REPRIS 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.deepEqual(second.seance.etat().avertissements, repris); }); test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste ne le reprend seul : seul reprendreVerrou, que l'opérateur confirme, le reprend", async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); const { depuis } = verrouDe(premier.fs); const texte = premier.fs.contenu('documents', VERROU); const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' }); await ouvrirSansEcrire(lointain); const { seance } = lointain; const pris = { seance: 'premiere-1', depuis, vivant: null, remede: null }; await refus(seance.passerEnEcriture(), 'VERROU_PRIS', pris); await refus(seance.passerEnEcriture(), 'VERROU_PRIS', pris); assert.equal(premier.fs.contenu('documents', VERROU), texte); assert.deepEqual(seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null }); assert.equal(seance.etat().mode, 'lecture'); }); test("un verrou illisible n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); fs.deposer('documents', VERROU, 'pas un verrou\n'); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const illisible = { seance: null, depuis: null, vivant: null, remede: null }; await refus(montage.seance.passerEnEcriture(), 'VERROU_PRIS', illisible); assert.equal(fs.contenu('documents', VERROU), 'pas un verrou\n'); }); test("sur un plan bloqué, débloquer devant le verrou d'un autre hôte est refusé VERROU_PRIS, remède null, sans entrée, le verrou inchangé", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, chargeBloquee()); const depuis = '2031-05-17T09:00:00-04:00'; const texte = `${JSON.stringify({ seance: 'lointaine-1', pid: 99, hote: 'autre-poste', depuis })}\n`; fs.deposer('documents', VERROU, texte); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const { seance } = montage; const entrees = journalDe(fs).entrees.length; await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis, vivant: null, remede: null }); assert.equal(journalDe(fs).entrees.length, entrees); assert.deepEqual([seance.etat().mode, seance.etat().evenement.etat], ['lecture', 'bloque']); assert.equal(fs.contenu('documents', VERROU), texte); }); }); describe('séance observable : le support rompu (§ 8.6, § 8.2)', () => { // Un événement du disque portable, en écriture, après un premier geste. async function surPortable() { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); const montage = monter({ fs, racine: PORTABLE }); await enEcriture(montage); await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); return montage; } const rompu = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }; const versDocuments = { geste: 'ecrireAilleurs', racine: DOC }; test("une écriture refusée remonte ECRITURE ; supportRompu en garde les champs et la racine documents ; le refus courant et le geste suivant sont SUPPORT_ROMPU, remède écrire ailleurs ; écrire ailleurs rétablit", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); const erreur = await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }); assert.deepEqual(seance.etat().supportRompu, { ...rompu, ailleurs: DOC }); const attendu = { code: 'SUPPORT_ROMPU', details: { ...rompu, remede: versDocuments } }; assert.deepEqual(seance.etat().refus, attendu); const avant = fs.compteurs.ecritures; await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'SUPPORT_ROMPU', attendu.details); assert.equal(fs.compteurs.ecritures, avant); await seance.ecrireAilleurs(DOC); assert.deepEqual([seance.etat().supportRompu, seance.etat().racine, seance.etat().refus], [null, DOC, null]); assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie']); assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie']); }); test("un état qui ne s'écrit pas : ETAT_NON_ECRIT, le geste au journal, l'état du disque intact ; écrire ailleurs écrit là-bas l'état de ce geste", async () => { const { fs, seance } = await surPortable(); fs.pannes.renommage('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ETAT_NON_ECRIT'); assert.equal(seance.etat().supportRompu.echec, 'ETAT_NON_ECRIT'); assert.deepEqual(libellesDe(journalDe(fs, 'portable')), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']); assert.deepEqual(noms(etatDe(fs, 'portable').charge), ['Ancolie']); await seance.ecrireAilleurs(DOC); assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']); }); test('sous un support rompu, ouvrir, créer, fermer et changerRacine sont refusés SUPPORT_ROMPU_NON_SAUVE, remède écrire ailleurs, sans écriture ; fermer({abandonner: true}) ferme', async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const nonSauve = { ...rompu, remede: versDocuments }; const avant = fs.compteurs.ecritures; await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); await refus(seance.changerRacine(DOC), 'SUPPORT_ROMPU_NON_SAUVE', nonSauve); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual(seance.etat().racine, PORTABLE); await seance.fermer({ abandonner: true }); assert.deepEqual([seance.etat().evenement, seance.etat().supportRompu], [null, null]); }); test("écrire ailleurs sans racine demande le dossier par fs.choisirDossier : son annulation ne change rien, son choix y écrit", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); let appels = 0; fs.choisirDossier = async () => { appels += 1; return null; }; const avant = fs.compteurs.ecritures; assert.equal(await seance.ecrireAilleurs(null), null); assert.equal(appels, 1); assert.equal(fs.compteurs.ecritures, avant); assert.deepEqual([seance.etat().supportRompu, seance.etat().racine], [{ ...rompu, ailleurs: DOC }, PORTABLE]); fs.deposerDossier('documents'); fs.choisirDossier = async () => DOC; const { base } = await seance.ecrireAilleurs(null); assert.equal(base, BASE); assert.deepEqual([seance.etat().supportRompu, seance.etat().racine], [null, DOC]); }); test("un système qui ne dit pas ses racines : aucun ailleurs n'est proposé", async () => { const { fs, seance } = await surPortable(); fs.pannes.ecriture('portable'); fs.racines = async () => { throw new TypeError('réponse illisible du système'); }; await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); assert.deepEqual(seance.etat().supportRompu, { ...rompu, ailleurs: null }); }); test("le support rompu de la racine documents n'a pas d'ailleurs : le remède laisse l'opérateur désigner le dossier", async () => { const montage = monter(); const { fs, seance } = montage; await enEcriture(montage); fs.pannes.ecriture('documents'); await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); assert.equal(seance.etat().supportRompu.ailleurs, null); assert.deepEqual(seance.etat().refus.details.remede, { geste: 'ecrireAilleurs', racine: null }); }); }); describe('séance observable : le refus courant (§ 2.1, garantie 3)', () => { test("null sans événement et en écriture ; sinon, en profondeur, le code et les détails qu'executer lève, remède compris", async () => { assert.equal(monter().seance.etat().refus, null); const enEcritureMontage = await ouvertSur(); await enEcritureMontage.seance.passerEnEcriture(); assert.equal(enEcritureMontage.seance.etat().refus, null); const lecture = await ouvertSur(); const bloque = await ouvertSur(chargeBloquee()); const fsFormat = creerFichiersSimules(); deposerAuFormat(fsFormat, FORMAT + 1); const format = monter({ fs: fsFormat }); await format.seance.ouvrir(BASE); const portable = monter({ fs: creerFichiersSimules({ executable: 'E:\\soirees' }), racine: PORTABLE }); await enEcriture(portable); portable.fs.pannes.ecriture('portable'); await refus(portable.seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE'); const cas = [ [lecture, 'MODE_LECTURE', { remede: MODIFIER }], [bloque, 'LECTURE_SEULE', { raison: 'BLOQUE', remede: DEBLOQUER }], [format, 'LECTURE_SEULE', { raison: 'FORMAT', remede: null }], [portable, 'SUPPORT_ROMPU', { ...{ echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' }, remede: { geste: 'ecrireAilleurs', racine: DOC } }], ]; assert.equal(cas.length, 4); for (const [{ seance }, code, details] of cas) { assert.deepEqual(seance.etat().refus, { code, details }, code); const erreur = await refus(seance.executer('changerEtat', { etat: 'brouillon' }), code); assert.deepEqual(seance.etat().refus, { code: erreur.code, details: erreur.details }, code); } }); test("les autres refus de la séance portent un remède null", async () => { await refus(monter().seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'AUCUN_EVENEMENT', { remede: null }); const montage = monter(); await enEcriture(montage); await refus(montage.seance.defaire(), 'RIEN_A_DEFAIRE', { remede: null }); await refus(montage.seance.refaire(), 'RIEN_A_REFAIRE', { remede: null }); await refus(montage.seance.nommer(' '), 'NOM_REQUIS', { remede: null }); await refus(montage.seance.revenir(99), 'INSTANT_NON_RESTITUABLE', { revision: 99, remede: null }); await refus(montage.seance.ecrireAilleurs(DOC), 'SUPPORT_INTACT', { remede: null }); }); }); describe('séance observable : fautives et examen (§ 8.9)', () => { test("les propositions que l'ouverture écarte restent dans fautives après un geste, et partent à la fermeture ; l'examen d'après un geste n'en porte aucune", async () => { const charge = chargeEssai(); charge.prochainsIds.proposition = 2; charge.propositions = [ { id: 1, graine: 7, arret: 40, historique: 1, produitVersion: V1, siegesAttribues: false, tables: [1, 2], capacites: [2, 2], tours: 2, participants: [1, 2, 3, 4], placement: [ { sieges: [[1, 2, 3], [4]], reserve: [] }, { sieges: [[1, 3], [2, 4]], reserve: [] }, ], }, ]; const { seance } = await ouvertSur(charge); const { fautives } = seance.etat(); assert.deepEqual(fautives.map(({ rang, id }) => [rang, id]), [[0, 1]]); assert.equal(fautives[0].fautes[0].code, 'LONGUEUR'); assert.deepEqual(seance.etat().examen.fautives, fautives); await seance.passerEnEcriture(); await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); assert.deepEqual(seance.etat().fautives, fautives); assert.deepEqual(seance.etat().examen.fautives, []); await seance.fermer(); assert.deepEqual(seance.etat().fautives, []); }); }); describe('séance : attendre la file (§ 5.10, § 8.4)', () => { test("attendre se résout quand les appels déjà en file ont fini, réussis ou refusés, sans rien écrire ni publier de plus", async () => { const { seance, fs } = await ouvertSur(); await seance.passerEnEcriture(); const ecrit = seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }); const refuse = seance.executer('ajouterParticipant', { champs: { nom: '' } }).catch((erreur) => erreur); const avant = seance.charge.participants.length; const ecritures = fs.compteurs.ecritures; let publications = 0; seance.subscribe(() => { publications += 1; }); publications = 0; await seance.attendre(); assert.equal(seance.charge.participants.length, avant + 1); assert.equal((await refuse).code !== undefined, true); await ecrit; const apres = publications; const ecrituresApres = fs.compteurs.ecritures; await seance.attendre(); assert.equal(publications, apres, 'attendre ne publie rien'); assert.equal(fs.compteurs.ecritures, ecrituresApres, 'attendre n’écrit rien'); assert.ok(ecrituresApres > ecritures); }); }); describe('séance observable : la dérive nommée (§ 9)', () => { test("l'examen de l'état nomme chaque raison de dérive — la personne par son nom, la table par son numéro —, quand celui de l'Ouvert garde les identifiants", async () => { const charge = avecRetenu(chargeEssai()); charge.participants[2].exclu = true; charge.tables[1].numero = 7; charge.tables[1].sieges = 3; const { seance } = await ouvertSur(charge); const { raisons } = seance.etat().examen.retenu; assert.deepEqual(raisons, [ { code: 'CAPACITE_CHANGEE', table: 2, avant: 2, maintenant: 3, numero: 7 }, { code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' }, ]); assert.deepEqual( seance.ouvert.examen.retenu.raisons, raisons.map(({ personne, numero, ...nue }) => nue), ); }); }); describe('séance observable : les invariants du retenu (§ 14.12, § 14.15)', () => { test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => { const fs = creerFichiersSimules(); const charge = reserverGrisaille(avecRetenu(chargeEssai())); charge.participants[3].exclu = true; await deposerEssai(fs, charge); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const { seance } = montage; const viole = { code: 'INVARIANT_VIOLE', details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }] }, }; assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, viole]); assert.deepEqual(codesDe(seance.ouvert.avertissements), ['DERIVE']); await seance.passerEnEcriture(); const renomme = await seance.executer('modifierParticipant', { id: 3, champs: { nom: 'Pervenche-Ombrelle' } }); assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, viole, ...renomme.avertissements]); for (const retour of [() => seance.defaire(), () => seance.refaire(), () => seance.revenir(1)]) { await retour(); assert.ok(seance.etat().avertissements.some((a) => a.code === 'INVARIANT_VIOLE'), String(retour)); assert.deepEqual(seance.etat().avertissements.find((a) => a.code === 'INVARIANT_VIOLE'), viole); } const exclusion = await seance.executer('exclureParticipant', { id: 1 }); assert.deepEqual(codesDe(exclusion.avertissements), ['RESERVATIONS_SUSPENDUES', 'DERIVE_NOUVELLE']); assert.deepEqual(seance.etat().avertissements, [...seance.ouvert.avertissements, viole, ...exclusion.avertissements]); }); test("un retenu aux surnuméraires versés en réserve s'ouvre sans INVARIANT_VIOLE", async () => { const charge = avecRetenu(chargeEssai()); charge.participants.push(personne(5, 'Ancolie')); charge.prochainsIds.participant = 6; charge.retenu.participants = [1, 2, 3, 4, 5]; for (const tour of charge.retenu.placement) tour.reserve = [5]; const { seance } = await ouvertSur(charge); assert.deepEqual(codesDe(seance.etat().avertissements), []); }); }); // Les textes des codes que la séance ajoute se composent, sans trace de // valeur absente, sous les détails d'exemple de la table que // libelles.test.js partage pour chaque code ; les épreuves qui suivent // lisent ce que chaque texte nomme. describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6)", () => { test('le manque dit son nombre ; la surréservation nomme la table, le tour, les réservées et la capacité', () => { assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 1 }), /\b1 place\b/); assert.match(libelle('PLACES_MANQUANTES', { placesManquantes: 4 }), /\b4 places\b/); const texte = libelle('SURRESERVATION', { table: 3, tour: 2, reservees: 9, capacite: 8 }); for (const motif of [/identifiant 3\b/, /tour 2\b/, /\b9 personnes\b/, /\b8 places\b/]) assert.match(texte, motif); }); test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou la dit d'une autre séance, d'un poste ou d'un autre", () => { const derive = libelle('DERIVE_NOUVELLE', { touchees: [ { id: 2, raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 3 }] }, { id: 'retenu', raisons: [{ code: 'TABLE_AJOUTEE', table: 5 }] }, ], }); assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive); assert.match(derive, /n° 2/); assert.match(derive, /retenu/); const viole = libelle('INVARIANT_VIOLE', { proposition: 3, violations: [ { code: 'RESERVATION', participant: 2, table: 2, tour: 1 }, { code: 'DOUBLE_PLACE', participant: 4, tour: 2 }, ], }); assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole); assert.match(viole, /n° 3/); for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) { const repris = libelle('VERROU_REPRIS', details); assert.match(repris, /une autre séance/); assert.doesNotMatch(repris, /interrompue|ce poste/); } assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/); }); }); describe('séance : le placement daté (§ 8.9, § 11.8, Q2)', () => { // L'horodatage de la dernière entrée du journal sur le disque. const derniereDate = (fs) => journalDe(fs).entrees.at(-1).horodatage; test("chaque geste date le placement de l'instant de son entrée, une lecture d'horloge ; corriger un nom ne date rien ; défaire restitue l'ancien", async () => { const { fs, seance } = await ouvertSur(); await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); const generation = derniereDate(fs); assert.deepEqual(seance.charge.propositions.map((p) => p.horodatage), [generation, generation]); await seance.executer('retenirProposition', { id: 1 }); const premier = derniereDate(fs); assert.notEqual(premier, generation); assert.equal(seance.charge.retenu.horodatage, premier); assert.equal(etatDe(fs).charge.retenu.horodatage, premier); await seance.executer('modifierParticipant', { id: 3, champs: { nom: 'Pervenche-Lacasse' } }); assert.equal(seance.charge.retenu.horodatage, premier); await seance.executer('retenirProposition', { id: 2 }); const second = derniereDate(fs); assert.notEqual(second, premier); assert.deepEqual([seance.charge.retenu.proposition, seance.charge.retenu.horodatage], [2, second]); assert.deepEqual(seance.charge.propositions.map((p) => p.horodatage), [generation, generation]); await seance.defaire(); assert.deepEqual([seance.charge.retenu.proposition, seance.charge.retenu.horodatage], [1, premier]); assert.equal(etatDe(fs).charge.retenu.horodatage, premier); }); test('débloquer, depuis la lecture, garde le placement daté de son instant', async () => { const charge = avecRetenu(chargeEssai()); charge.retenu.horodatage = '2031-05-17T07:30:00-04:00'; charge.evenement.etat = 'bloque'; const { seance } = await ouvertSur(charge); await seance.debloquer(); assert.deepEqual([seance.charge.evenement.etat, seance.charge.retenu.horodatage], ['retenu', '2031-05-17T07:30:00-04:00']); }); }); describe('séance : le papier de la machine (§ 8.5, § 11.2, Q1)', () => { const papierLu = (fs) => JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).papier; // Le texte de l'état et du journal sur le disque, null pour un absent. const evenementSurDisque = (fs) => [fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)]; test("null au départ ; choisirPapier écrit les seuls réglages, et une séance neuve sur la même racine le lit, jamais redemandé", async () => { const { fs, seance } = await ouvertSur(); assert.equal(seance.etat().papier, null); const avant = fs.compteurs.ecritures; const textes = evenementSurDisque(fs); await seance.choisirPapier('Lettre'); assert.equal(fs.compteurs.ecritures, avant + 1); assert.deepEqual(evenementSurDisque(fs), textes); assert.equal(papierLu(fs), 'Lettre'); assert.equal(seance.etat().papier, 'Lettre'); const neuve = monter({ fs, racine: null, prefixe: 'neuve' }); assert.equal(neuve.seance.etat().papier, null, 'sans racine'); await neuve.seance.changerRacine(DOC); assert.equal(neuve.seance.etat().papier, 'Lettre'); const surRacine = monter({ fs, prefixe: 'sur-racine' }); await surRacine.seance.attendre(); assert.equal(surRacine.seance.etat().papier, 'Lettre'); }); test("vivant en lecture, en écriture, sur un plan bloqué et sans événement ; jamais l'état ni le journal", async () => { const bloque = chargeEssai(); bloque.evenement.etat = 'bloque'; const cas = [ ['lecture', async () => ouvertSur()], ['écriture', async () => { const montage = await ouvertSur(); await montage.seance.passerEnEcriture(); return montage; }], ['bloqué', async () => ouvertSur(bloque)], ['sans événement', async () => monter()], ]; assert.equal(cas.length, 4); for (const [libelleCas, monterCas] of cas) { const { fs, seance } = await monterCas(); const textes = evenementSurDisque(fs); const avant = fs.compteurs.ecritures; for (const [rang, papier] of ['A4', 'Lettre'].entries()) { await seance.choisirPapier(papier); assert.equal(fs.compteurs.ecritures, avant + rang + 1, libelleCas); assert.deepEqual([papierLu(fs), seance.etat().papier], [papier, papier], libelleCas); } assert.deepEqual(evenementSurDisque(fs), textes, libelleCas); } }); test('un papier hors du domaine est une faute du code, et rien ne s’écrit', async () => { const { fs, seance } = await ouvertSur(); const avant = fs.compteurs.ecritures; for (const faux of ['A3', 'a4', 'lettre', null, undefined, 4]) { await assert.rejects(seance.choisirPapier(faux), TypeError, String(faux)); } assert.equal(fs.compteurs.ecritures, avant); assert.equal(seance.etat().papier, null); }); // Des réglages que la lecture ne prend pas : un format plus récent qui // porte un papier inconnu de cette construction, et un texte tronqué. const ILLISIBLES = [ ['FORME', '{"format":2,"papier":"Legal","cadrages":[]}'], ['JSON', '{"format":1,"papier":"A4","cadr'], ]; // Une soirée retenue de deux personnes à une table, un tour : une planche // se prépare dès que le papier est connu. const PLAN_RETENU = [{ sieges: [[1, 2]] }]; const chargeRetenue = () => soiree({ personnes: [personneBadge(1, 'Iris', 'Ombrelle'), personneBadge(2, 'Théo', 'Pervenche')], tables: [tableBadge(1, 1, 2)], plan: PLAN_RETENU, propositions: [[1, PLAN_RETENU]], }); const avecPlanche = { polices: async () => lirePolices(), provenance: 'production' }; test('des réglages illisibles : le papier choisi vaut pour la séance, en mémoire, le fichier intact octet pour octet, et la planche se prépare sans PAPIER_REQUIS', async () => { for (const [raison, texte] of ILLISIBLES) { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, texte); const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); const avant = fs.compteurs.ecritures; const textes = evenementSurDisque(fs); const avis = { code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } }; assert.deepEqual(await seance.choisirPapier('A4'), { avertissements: [avis] }, raison); assert.equal(seance.etat().papier, 'A4', raison); assert.deepEqual(seance.etat().avertissements.filter(({ code }) => code === avis.code), [avis], raison); const annonce = await seance.preparerPlanche(); assert.deepEqual([annonce.refus, annonce.papier], [null, 'A4'], raison); await seance.choisirPapier('Lettre'); assert.equal(seance.etat().papier, 'Lettre', raison); assert.equal((await seance.preparerPlanche()).papier, 'Lettre', raison); assert.deepEqual(seance.etat().avertissements.filter(({ code }) => code === avis.code), [avis], `${raison} : un avis, pas deux`); assert.deepEqual(fs.octets('documents', FICHIER_REGLAGES), new TextEncoder().encode(texte), raison); assert.equal(fs.compteurs.ecritures, avant, raison); assert.deepEqual(evenementSurDisque(fs), textes, raison); } }); test('des réglages illisibles : la planche se produit sur le papier de la séance, et seul le PDF s’écrit', async () => { const [, texte] = ILLISIBLES[1]; const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, texte); const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); await refus(seance.produirePlanche(), 'PAPIER_REQUIS'); await seance.choisirPapier('A4'); const textes = evenementSurDisque(fs); const { nom } = await seance.produirePlanche(); assert.match(nom, /_A4_/); assert.ok(fs.octets('documents', nom).length > 0); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte); assert.deepEqual(evenementSurDisque(fs), textes); }); test("le papier de la séance n'est pas retenu sur la machine : une séance neuve sur la même racine le demande, et un changement de racine le garde", async () => { const [, texte] = ILLISIBLES[0]; const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, texte); const { seance } = await ouvertSur(chargeEssai(), { fs }); await seance.choisirPapier('A4'); const neuve = monter({ fs, prefixe: 'neuve' }); await neuve.seance.attendre(); assert.equal(neuve.seance.etat().papier, null); await seance.changerRacine(DOC); assert.equal(seance.etat().papier, 'A4', 'relu sur la même racine illisible'); fs.deposerDossier(PORTABLE.id); await seance.changerRacine(PORTABLE); assert.equal(seance.etat().papier, 'A4', 'une racine sans réglages : le choix de la séance'); fs.deposer(PORTABLE.id, FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"Lettre"}\n'); await seance.changerRacine(DOC); await seance.changerRacine(PORTABLE); assert.equal(seance.etat().papier, 'Lettre', 'un papier écrit sur la racine l’emporte'); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte); }); test('des réglages illisibles : le cadrage différé attend, rien ne s’écrit, et le choix du papier ne le change pas', async () => { const [, texte] = ILLISIBLES[1]; const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, texte); const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); await planificateur.avancer(DELAI_REGLAGES_MS); await seance.choisirPapier('Lettre'); await laisser(); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte); assert.equal(seance.etat().papier, 'Lettre'); }); test('un cadrage écrit après des réglages illisibles réparés écrit avec lui le papier de la séance, qui devient retenu, et l’avis se retire', async () => { const [, texte] = ILLISIBLES[1]; const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, texte); const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); await seance.choisirPapier('A4'); fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"Lettre"}\n'); seance.demanderCadrage({ k: 2, tx: 0, ty: 0 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); assert.deepEqual([relus.papier, relus.cadrages.length], ['A4', 1]); assert.equal(seance.etat().papier, 'A4'); assert.equal(seance.etat().avertissements.some(({ code }) => code === 'REGLAGES_ILLISIBLES'), false); const neuve = monter({ fs, prefixe: 'neuve' }); await neuve.seance.attendre(); assert.equal(neuve.seance.etat().papier, 'A4', 'retenu sur la machine'); }); test('un papier écrit après des réglages illisibles réparés : le fichier le porte, la séance suit le fichier, et le papier de la séance s’efface', async () => { const [, texte] = ILLISIBLES[1]; const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, texte); const { seance } = await ouvertSur(chargeEssai(), { fs }); await seance.choisirPapier('A4'); fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":null}\n'); assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [] }); assert.deepEqual([papierLu(fs), seance.etat().papier], ['Lettre', 'Lettre']); assert.equal(seance.etat().avertissements.some(({ code }) => code === 'REGLAGES_ILLISIBLES'), false, 'l’avis se retire'); fs.deposerDossier(PORTABLE.id); await seance.changerRacine(PORTABLE); assert.equal(seance.etat().papier, null, 'le choix retenu reste à sa racine ; une racine sans réglages le demande'); }); // Un dossier de travail qui refuse toute écriture (EROFS) après // l'ouverture : les réglages s'y lisent, ils ne s'y écrivent pas. const avisNonEcrits = { code: 'REGLAGES_NON_ECRITS', details: { chemin: FICHIER_REGLAGES, dossier: DOCUMENTS, cause: 'EROFS' } }; test("un dossier qui refuse l'écriture des réglages : le papier choisi vaut pour la séance, un avis le dit, et la planche se prépare sans PAPIER_REQUIS", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":null}\n'); const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); fs.pannes.ecriture('documents'); const textes = evenementSurDisque(fs); const reglages = fs.contenu('documents', FICHIER_REGLAGES); assert.deepEqual(await seance.choisirPapier('A4'), { avertissements: [avisNonEcrits] }); assert.equal(seance.etat().papier, 'A4'); const annonce = await seance.preparerPlanche(); assert.deepEqual([annonce.refus, annonce.papier], [null, 'A4']); assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [avisNonEcrits] }); assert.equal((await seance.preparerPlanche()).papier, 'Lettre'); assert.deepEqual( seance.etat().avertissements.filter(({ code }) => code === avisNonEcrits.code), [avisNonEcrits], 'un avis, pas deux', ); assert.equal(seance.etat().avertissements.some(({ code }) => code === 'REGLAGES_ILLISIBLES'), false); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), reglages); assert.deepEqual(evenementSurDisque(fs), textes); }); test("un dossier qui refuse l'écriture des réglages : la production ne demande plus le papier, seule l'écriture du PDF est refusée", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); const { seance } = await ouvertSur(chargeRetenue(), { fs, ...avecPlanche }); fs.pannes.ecriture('documents'); await refus(seance.produirePlanche(), 'PAPIER_REQUIS'); const textes = evenementSurDisque(fs); await seance.choisirPapier('A4'); const erreur = await refus(seance.produirePlanche(), 'PLANCHE_NON_ECRITE'); assert.match(erreur.details.chemin, /_A4_/); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), null); assert.deepEqual(evenementSurDisque(fs), textes); }); test("le papier d'une écriture refusée vaut au changement de racine, là où les réglages n'en portent pas", async () => { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); const { seance } = await ouvertSur(chargeEssai(), { fs }); fs.pannes.ecriture('documents'); await seance.choisirPapier('Lettre'); fs.deposerDossier(PORTABLE.id); await seance.changerRacine(PORTABLE); assert.equal(seance.etat().papier, 'Lettre'); }); // Des réglages lisibles qui portent déjà un papier, sur la racine des // documents. const REGLAGES_A4 = '{"cadrages":[],"format":1,"papier":"A4"}\n'; const sansReglages = (seance) => seance.etat().avertissements.filter(({ code }) => code === 'REGLAGES_NON_ECRITS' || code === 'REGLAGES_ILLISIBLES'); test("une écriture refusée une fois : le papier de la séance l'emporte sur celui du fichier, le cadrage écrit ensuite l'écrit avec lui, et l'avis se retire", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, REGLAGES_A4); const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); fs.pannes.renommage('documents', 11); const avis = { code: 'REGLAGES_NON_ECRITS', details: { chemin: FICHIER_REGLAGES, dossier: DOCUMENTS, cause: 'EBUSY' } }; assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [avis] }); assert.deepEqual([papierLu(fs), seance.etat().papier], ['A4', 'Lettre']); seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); assert.deepEqual([relus.papier, relus.cadrages.length], ['Lettre', 1]); assert.equal(seance.etat().papier, 'Lettre'); assert.deepEqual(sansReglages(seance), [], 'le choix est retenu : l’avis se retire'); await seance.changerRacine(DOC); assert.equal(seance.etat().papier, 'Lettre'); }); test("une écriture refusée une fois, puis un papier qui s'écrit : l'avis se retire de l'état", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, REGLAGES_A4); const { seance } = await ouvertSur(chargeEssai(), { fs }); fs.pannes.renommage('documents', 11); await seance.choisirPapier('Lettre'); assert.deepEqual(sansReglages(seance).map(({ code }) => code), ['REGLAGES_NON_ECRITS']); assert.deepEqual(await seance.choisirPapier('Lettre'), { avertissements: [] }); assert.deepEqual([papierLu(fs), seance.etat().papier], ['Lettre', 'Lettre']); assert.deepEqual(sansReglages(seance), []); }); test("un dossier qui refuse l'écriture : revenir sur la même racine garde le papier de la séance, que le fichier en porte un autre ou non ; une autre racine lit le sien", async () => { const fs = creerFichiersSimules({ executable: 'E:\\soirees' }); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, REGLAGES_A4); const { seance, planificateur } = await ouvertSur(chargeEssai(), { fs }); fs.pannes.ecriture('documents'); await seance.choisirPapier('Lettre'); await seance.changerRacine(DOC); assert.equal(seance.etat().papier, 'Lettre', 'la même racine'); fs.deposerDossier(PORTABLE.id); fs.deposer(PORTABLE.id, FICHIER_REGLAGES, REGLAGES_A4); await seance.changerRacine(PORTABLE); assert.equal(seance.etat().papier, 'A4', 'une autre racine : son papier l’emporte'); await seance.changerRacine(DOC); assert.equal(seance.etat().papier, 'Lettre', 'de retour sur la racine du choix'); await seance.ouvrir(BASE); seance.demanderCadrage({ k: 2, tx: 0, ty: 0 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); assert.equal(seance.etat().papier, 'Lettre', 'un cadrage refusé ne le change pas'); assert.equal(fs.contenu('documents', FICHIER_REGLAGES), REGLAGES_A4); }); test('une écriture des réglages qui lève autre chose qu’un refus du stockage remonte de choisirPapier, sans poser de papier ni d’avis', async () => { const { fs, seance } = await ouvertSur(); // Les réglages se lisent ; leur écriture lève une panne du processus. const ecrireAtomique = fs.ecrireAtomique; fs.ecrireAtomique = async (racine, chemin, texte) => { if (chemin === FICHIER_REGLAGES) throw new PanneSimulee(); return ecrireAtomique(racine, chemin, texte); }; await assert.rejects(seance.choisirPapier('A4'), PanneSimulee); assert.equal(seance.etat().papier, null); assert.deepEqual(sansReglages(seance), []); }); test("un papier écrit pour l'ancienne racine, dans la file pendant un changement de racine, ne se publie jamais sur la nouvelle", async () => { const { fs, seance } = monter({ fs: creerFichiersSimules({ executable: 'E:\\soirees' }) }); await seance.attendre(); // Un fichier présent : sa lecture se retient, celle d'un absent non. fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":null}\n'); fs.deposerDossier(PORTABLE.id); const publies = []; seance.subscribe(({ racine, papier }) => publies.push([racine?.id ?? null, papier])); const lecture = retenirLectureReglages(fs); const choix = seance.choisirPapier('Lettre'); await laisser(); assert.equal(lecture.prise(), true, 'la lecture du choix est partie'); const changement = seance.changerRacine(PORTABLE); await laisser(); assert.equal(seance.racine.id, PORTABLE.id, 'la racine a changé, le choix attend'); lecture.liberer(); await choix; await changement; await seance.attendre(); assert.equal(papierLu(fs), 'Lettre', 'écrit à sa racine'); assert.deepEqual(publies.filter(([racine, papier]) => racine === PORTABLE.id && papier !== null), []); assert.equal(seance.etat().papier, null); }); test("un cadrage différé et le choix du papier écrits ensemble : les deux restent, l'un après l'autre", async () => { const { fs, seance, planificateur } = await ouvertSur(); seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); const cadrage = planificateur.avancer(DELAI_REGLAGES_MS); const papier = seance.choisirPapier('Lettre'); await cadrage; await papier; await seance.fermer(); await laisser(); const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); assert.equal(relus.papier, 'Lettre'); assert.deepEqual(relus.cadrages, [{ evenement: 'evt-lucioles', k: 1.5, tx: 3, ty: 4 }]); }); test("le papier qu'une autre séance a choisi se lit après l'écriture suivante de cette séance, cadrage compris", async () => { const { fs, seance, planificateur } = await ouvertSur(); const autre = monter({ fs, prefixe: 'autre' }); await autre.seance.choisirPapier('A4'); assert.equal(seance.etat().papier, null); seance.demanderCadrage({ k: 2, tx: 0, ty: 0 }); await planificateur.avancer(DELAI_REGLAGES_MS); await laisser(); assert.equal(seance.etat().papier, 'A4'); assert.equal(papierLu(fs), 'A4'); }); // Retient la réponse de la première lecture de reglages_locaux.json qui // suit : la lecture se fait aussitôt, sa réponse attend liberer(). function retenirLectureReglages(fs) { const lireTexte = fs.lireTexte; let liberer = null; const retenue = new Promise((resolve) => { liberer = resolve; }); let prise = false; fs.lireTexte = async (racine, chemin) => { if (prise || chemin !== FICHIER_REGLAGES) return lireTexte(racine, chemin); prise = true; const reponse = await lireTexte(racine, chemin); await retenue; return reponse; }; return { liberer: () => liberer(), prise: () => prise }; } test("une relecture du papier partie avant un choix ne l'écrase pas : à la création, l'état suit le fichier", async () => { const fs = creerFichiersSimules(); fs.deposerDossier('documents'); fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"A4"}\n'); const lecture = retenirLectureReglages(fs); const { seance } = monter({ fs }); await laisser(); assert.equal(lecture.prise(), true, 'la relecture de la création est partie'); const choix = seance.choisirPapier('Lettre'); await laisser(); lecture.liberer(); await choix; await seance.attendre(); assert.deepEqual([papierLu(fs), seance.etat().papier], ['Lettre', 'Lettre']); }); test("une relecture du papier partie avant un choix ne l'écrase pas : au changement de racine, l'état suit le fichier", async () => { const { fs, seance } = monter({ fs: creerFichiersSimules({ executable: 'E:\\soirees' }) }); await seance.attendre(); fs.deposerDossier(PORTABLE.id); fs.deposer(PORTABLE.id, FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"A4"}\n'); const lecture = retenirLectureReglages(fs); const changement = seance.changerRacine(PORTABLE); await laisser(); assert.equal(lecture.prise(), true, 'la relecture du changement de racine est partie'); const choix = seance.choisirPapier('Lettre'); await laisser(); lecture.liberer(); await changement; await choix; await seance.attendre(); assert.equal(JSON.parse(fs.contenu(PORTABLE.id, FICHIER_REGLAGES)).papier, 'Lettre'); assert.equal(seance.etat().papier, 'Lettre'); }); }); describe('séance : la planche, câblée dans la file des appels (§ 11.6, § 13.4)', () => { // Une soirée retenue de quatre personnes sur deux tables de deux places, // deux tours, le retenu daté de H_RETENU. const PLAN = [{ sieges: [[1, 2], [3, 4]] }, { sieges: [[1, 3], [2, 4]] }]; const chargeRetenue = () => soiree({ personnes: [ personneBadge(1, 'Iris', 'Ombrelle'), personneBadge(2, 'Théo', 'Pervenche'), personneBadge(3, 'Ondine', 'Lacasse'), personneBadge(4, null, 'Grisaille'), ], tables: [tableBadge(1, 1, 2), tableBadge(2, 2, 2)], plan: PLAN, propositions: [[1, PLAN]], }); test('preparerPlanche lancé derrière un geste lit la charge que le geste laisse ; la police se charge une fois par séance', async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, chargeRetenue()); let chargements = 0; const polices = async () => { chargements += 1; return lirePolices(); }; const { seance } = monter({ fs, polices, provenance: 'production' }); await seance.choisirPapier('A4'); await seance.ouvrir(BASE); await seance.passerEnEcriture(); const geste = seance.executer('echangerDansRetenu', { tour: 1, a: 1, b: 3 }); const annonce = seance.preparerPlanche(); await geste; const horodatage = seance.charge.retenu.horodatage; assert.notEqual(horodatage, H_RETENU); const attendu = nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage, reedition: null }); assert.equal((await annonce).nom, attendu); assert.equal((await seance.preparerPlanche()).nom, attendu); assert.equal(chargements, 1); }); test('un papier choisi sans être attendu compte pour la planche demandée juste après : la planche attend les écritures de réglages déjà demandées', async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, chargeRetenue()); const { seance } = monter({ fs, polices: async () => lirePolices(), provenance: 'production' }); await seance.ouvrir(BASE); const choix = seance.choisirPapier('Lettre'); const annonce = await seance.preparerPlanche(); await choix; assert.deepEqual([annonce.refus, annonce.papier], [null, 'Lettre']); }); test('sans police ni provenance, préparer lève TypeError ; un chargement refusé remonte, et le suivant réessaie', async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, chargeRetenue()); const sansPolices = monter({ fs, provenance: 'production' }).seance; await sansPolices.ouvrir(BASE); await assert.rejects(sansPolices.preparerPlanche(), (erreur) => erreur instanceof TypeError && /polices/.test(erreur.message)); const sansProvenance = monter({ fs, prefixe: 'sans-provenance', polices: async () => lirePolices() }).seance; await sansProvenance.ouvrir(BASE); await assert.rejects(sansProvenance.preparerPlanche(), TypeError); let essais = 0; const polices = async () => { essais += 1; if (essais === 1) throw new Error('police illisible'); return lirePolices(); }; const { seance } = monter({ fs, prefixe: 'reessai', polices, provenance: 'production' }); await seance.ouvrir(BASE); await assert.rejects(seance.preparerPlanche(), /police illisible/); const annonce = await seance.preparerPlanche(); assert.equal(annonce.refus.code, 'PAPIER_REQUIS'); assert.equal(essais, 2); }); }); describe('séance : la course de la recherche dans son compagnon (§ 5.10, § 8.9, B31)', () => { const COURSES = `${BASE}${SUFFIXES.courses}`; // Les identifiants des propositions dont le compagnon sur le disque porte // la course, dans son ordre ; null sans compagnon. const dansLeCompagnon = (fs) => { const texte = fs.contenu('documents', COURSES); return texte === null ? null : JSON.parse(texte).courses.map(({ id }) => id); }; test("une génération écrit le compagnon, que courseDe lit ; un geste qui garde les propositions ne le réécrit pas ; défaire et refaire la génération le réécrivent, la course gardée par la séance, même relue au passage en écriture", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const { seance } = montage; await seance.passerEnEcriture(); assert.equal(seance.courseDe(1), null); const args = generer(seance.charge); await seance.executer('enregistrerGeneration', args); assert.deepEqual(dansLeCompagnon(fs), [1, 2]); assert.equal(seance.courseDe(2).evalues, args.propositions[1].evalues); assert.equal(etatDe(fs).charge.propositions.some((p) => Object.hasOwn(p, 'course')), false); const texte = fs.contenu('documents', COURSES); const ecrire = fs.ecrireAtomique; const ecrits = []; fs.ecrireAtomique = (racine, chemin, contenu) => { ecrits.push(chemin); return ecrire(racine, chemin, contenu); }; await seance.executer('retenirProposition', { id: 1 }); await seance.executer('exclureParticipant', { id: 2 }); assert.equal(ecrits.includes(COURSES), false); assert.equal(fs.contenu('documents', COURSES), texte); await seance.defaire(); await seance.defaire(); await seance.defaire(); assert.deepEqual(seance.charge.propositions, []); assert.deepEqual(dansLeCompagnon(fs), []); assert.equal(seance.courseDe(1), null); await seance.passerEnLecture(); await seance.passerEnEcriture(); await seance.refaire(); assert.deepEqual(dansLeCompagnon(fs), [1, 2]); assert.equal(seance.courseDe(2).evalues, args.propositions[1].evalues); assert.equal(fs.contenu('documents', COURSES), texte); }); // Le compteur des propositions ne recule jamais sous défaire ni revenir ; // il recule quand l'opérateur reprend un état d'avant la génération, ici // son .precedent. La génération suivante, sous la même graine, porte // alors les mêmes id, graine, arret et historique que celles du journal, // et après un changement qui laisse la déclaration intacte — ici une // appartenance — un autre placement. Le retour à la première génération // ne rend jamais la course de la seconde, et réécrit le compagnon. test("une génération refaite sous la même graine après un changement d'appartenance ne prête jamais sa course à la première ; le retour à la première réécrit le compagnon", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const { seance } = montage; await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generer(seance.charge)); const revision = seance.ouvert.revision; const premieres = seance.charge.propositions; assert.notEqual(seance.courseDe(1), null); await seance.passerEnLecture(); await seance.ouvrirSecours(BASE, 'precedent'); await seance.passerEnEcriture(); assert.deepEqual(seance.charge.propositions, []); await seance.executer('modifierParticipant', { id: 2, champs: { appartenance: 'Société Alpha' } }); await seance.executer('enregistrerGeneration', generer(seance.charge)); const identite = ({ id, graine, arret, historique }) => [id, graine, arret, historique]; assert.deepEqual(seance.charge.propositions.map(identite), premieres.map(identite)); assert.notDeepEqual(seance.charge.propositions[0].placement, premieres[0].placement); assert.notEqual(seance.courseDe(1), null); const texteSeconde = fs.contenu('documents', COURSES); await seance.revenir(revision); const placements = (propositions) => propositions.map(({ placement }) => placement); assert.deepEqual(placements(seance.charge.propositions), placements(premieres)); // La séance ne connaît plus la course de la première génération. assert.equal(seance.courseDe(1), null); assert.notEqual(fs.contenu('documents', COURSES), texteSeconde); assert.deepEqual(dansLeCompagnon(fs), []); // Relu par une autre séance, le compagnon ne rend aucune course. await seance.fermer(); const relecture = monter({ fs, prefixe: 'relecture' }); await ouvrirSansEcrire(relecture); assert.equal(relecture.seance.courseDe(1), null); }); // Douze personnes, la sixième seule du Club des Merles, trois tables de // quatre, trois tours. function chargeDouze() { const charge = creerCharge({ id: 'evt-lucioles', nom: BASE, siegesParDefaut: 4, tours: 3 }); const prenoms = ['Iris', 'Théo', 'Ondine', 'Basile', 'Clémence', 'Gaspard', 'Lison', 'Marius', 'Nora', 'Octave', 'Perle', 'Rémi']; charge.participants = prenoms.map((prenom, i) => personne(i + 1, 'Lucane', prenom, i === 5 ? 'Club des Merles' : null)); charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 250 * (id - 1), y: 0 } })); charge.prochainsIds = { participant: 13, table: 4, proposition: 1 }; return charge; } // Les arguments d'enregistrerGeneration : graine 1, arrêt à 400 mouvements. const generationDouze = (charge) => { const configuration = configurationDepuisCharge(charge); return { propositions: rechercher(configuration, { graine: 1, arret: 400, nombre: 2 }), configuration, produitVersion: V1 }; }; // Une deuxième personne au Club des Merles ne change pas le placement // final de la recherche sous la même graine, mais sa course : la seconde // génération porte alors la même identité de recherche et la même // empreinte que la première. Seul l'horodatage de la génération les // départage. test("une génération refaite sous la même graine qui finit sur le même placement ne prête jamais sa course à la première", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, chargeDouze()); const montage = monter({ fs }); await ouvrirSansEcrire(montage); const { seance } = montage; await seance.passerEnEcriture(); await seance.executer('enregistrerGeneration', generationDouze(seance.charge)); const revision = seance.ouvert.revision; const premieres = seance.charge.propositions; const premiereCourse = seance.courseDe(1); assert.notEqual(premiereCourse, null); await seance.passerEnLecture(); await seance.ouvrirSecours(BASE, 'precedent'); await seance.passerEnEcriture(); await seance.executer('modifierParticipant', { id: 2, champs: { appartenance: 'Club des Merles' } }); await seance.executer('enregistrerGeneration', generationDouze(seance.charge)); const [p1] = premieres; const [q1] = seance.charge.propositions; const identite = ({ id, graine, arret, historique, placement }) => ({ id, graine, arret, historique, placement }); assert.deepEqual(identite(q1), identite(p1)); assert.notEqual(q1.horodatage, p1.horodatage); const secondeCourse = seance.courseDe(1); assert.notDeepEqual(secondeCourse, premiereCourse); await seance.revenir(revision); assert.deepEqual(seance.charge.propositions, premieres); // La séance a quitté la course de la première en ouvrant le .precedent, // et celle de la seconde n'est pas la sienne. assert.equal(seance.courseDe(1), null); assert.deepEqual(dansLeCompagnon(fs), []); await seance.fermer(); const relecture = monter({ fs, prefixe: 'relecture' }); await ouvrirSansEcrire(relecture); assert.equal(relecture.seance.courseDe(1), null); }); // La première séance garde en mémoire la course de sa génération ; une // autre, sur une horloge plus tardive, reprend le .precedent et régénère // sur le même placement. Le passage en écriture de la première relit le // compagnon de la seconde sans perdre sa course. test("la course qu'une séance garde ne cède pas à celle qu'une autre séance écrit pour une génération qui finit sur le même placement", async () => { const fs = creerFichiersSimules(); await deposerEssai(fs, chargeDouze()); const premiere = monter({ fs }); await ouvrirSansEcrire(premiere); await premiere.seance.passerEnEcriture(); await premiere.seance.executer('enregistrerGeneration', generationDouze(premiere.seance.charge)); const revision = premiere.seance.ouvert.revision; const premieres = premiere.seance.charge.propositions; const premiereCourse = premiere.seance.courseDe(1); assert.notEqual(premiereCourse, null); await premiere.seance.passerEnLecture(); const seconde = monter({ fs, prefixe: 'seconde', horloge: creerHorloge(60) }); await ouvrirSansEcrire(seconde); await seconde.seance.ouvrirSecours(BASE, 'precedent'); await seconde.seance.passerEnEcriture(); await seconde.seance.executer('modifierParticipant', { id: 2, champs: { appartenance: 'Club des Merles' } }); await seconde.seance.executer('enregistrerGeneration', generationDouze(seconde.seance.charge)); assert.deepEqual(seconde.seance.charge.propositions[0].placement, premieres[0].placement); assert.notDeepEqual(seconde.seance.courseDe(1), premiereCourse); await seconde.seance.passerEnLecture(); await premiere.seance.passerEnEcriture(); await premiere.seance.revenir(revision); assert.deepEqual(premiere.seance.charge.propositions, premieres); assert.deepEqual(premiere.seance.courseDe(1), premiereCourse); assert.deepEqual(dansLeCompagnon(fs), [1, 2]); await premiere.seance.fermer(); const relecture = monter({ fs, prefixe: 'relecture' }); await ouvrirSansEcrire(relecture); assert.deepEqual(relecture.seance.courseDe(1), premiereCourse); }); test("la séance sans événement n'a aucune course", () => { const { seance } = monter(); assert.equal(seance.courseDe(1), null); }); });