gestion_table_tournante_libre/src/application/seance.test.js
Mathieu Benoit a1cac443bb [FIX] session: undo never moves id counters back
Undoing back before a generation restored the whole charge, its
prochainsIds included: the next generation numbered its propositions 1 to 3
again, and a label of the abandoned thread such as "retained proposition 2"
named another proposition. § 8.9 forbids it: a counter never goes back. A
retour now restores the content of the instant and keeps each counter at
the larger of its restored and current value, so the next id is fresh.
Checked: 1416 node tests; the retour test is red on the previous seance.js.

--- FR ---

[FIX] séance : un retour ne fait jamais reculer les compteurs

Défaire jusqu'avant une génération restituait la charge entière, prochainsIds
compris : la génération suivante renumérotait ses propositions de 1 à 3, et
un libellé du fil abandonné, comme « retenu la proposition 2 », désignait
une autre proposition. Le § 8.9 l'interdit : un compteur ne recule jamais.
Un retour restitue désormais le contenu de l'instant et porte chaque
compteur au plus grand de sa valeur restituée et de sa valeur courante.
Vérifié : 1416 épreuves node ; l'épreuve du retour est rouge sur l'ancien code.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:40:40 -04:00

1209 lines
61 KiB
JavaScript

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