The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
1793 lines
94 KiB
JavaScript
1793 lines
94 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: {} }]);
|
|
// La séance montre aussi la dérive que l'Ouvert écrit porte désormais.
|
|
assert.deepEqual(seance.avertissements, [...seance.ouvert.avertissements, ...enRetenu.avertissements]);
|
|
assert.deepEqual(seance.ouvert.avertissements.map(({ code }) => code), ['DERIVE']);
|
|
});
|
|
|
|
test("importer sur un plan bloqué est refusé, et le texte du refus nomme « Débloquer »", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
|
const avant = fs.compteurs.ecritures;
|
|
const apercu = apercevoir('nom\nAncolie\n', { participants: [] });
|
|
const erreur = await refus(seance.executer('importerParticipants', { apercu, mode: 'ajouter' }), 'LECTURE_SEULE', { raison: 'BLOQUE' });
|
|
assert.match(libelle(erreur.code, erreur.details), /« Débloquer »/);
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
});
|
|
|
|
test("exclure une personne réservée suspend ses réservations, son titre n'est plus pourvu, et l'avertissement le dit ; la réintégrer les rend (§ 4.4)", async () => {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
const exclusion = await seance.executer('exclureParticipant', { id: 1 });
|
|
assert.deepEqual(exclusion, {
|
|
libelle: 'Exclu Iris Ombrelle',
|
|
avertissements: [{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } }],
|
|
});
|
|
assert.deepEqual([seance.charge.participants[0].exclu, seance.charge.reservations.length], [true, 1]);
|
|
assert.deepEqual(seance.avertissements, exclusion.avertissements);
|
|
const retour = await seance.executer('reintegrerParticipant', { id: 1 });
|
|
assert.deepEqual(retour.avertissements, [
|
|
{ code: 'RESERVATIONS_RETABLIES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
|
|
]);
|
|
assert.equal(seance.charge.participants[0].exclu, false);
|
|
});
|
|
});
|
|
|
|
describe("séance : ce qu'un import dit, et ce que la séance montre (§ 4.4, § 9, § 10.1)", () => {
|
|
// L'événement d'essai, ouvert depuis le disque, en écriture.
|
|
async function essaiEnEcriture() {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
const montage = monter({ fs });
|
|
await ouvrirSansEcrire(montage);
|
|
await montage.seance.passerEnEcriture();
|
|
return montage;
|
|
}
|
|
const mettreAJour = (seance, csv) =>
|
|
seance.executer('importerParticipants', {
|
|
apercu: apercevoir(csv, { participants: seance.charge.participants, mode: 'mettreAJour' }),
|
|
mode: 'mettreAJour',
|
|
});
|
|
|
|
test("un import qui exclut l'animatrice réservée dit ses réservations suspendues et son titre non pourvu, comme exclure (§ 4.4)", async () => {
|
|
const { seance } = await essaiEnEcriture();
|
|
const rendu = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;oui\n');
|
|
const suspendues = { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 };
|
|
assert.equal(rendu.libelle, 'Mis à jour la liste par import : 0 ajouté, 1 modifié');
|
|
assert.deepEqual(rendu.avertissements, [{ code: 'RESERVATIONS_SUSPENDUES', details: suspendues }]);
|
|
assert.equal(seance.charge.participants[0].exclu, true);
|
|
const retour = await mettreAJour(seance, 'nom;prenom;exclu\nOmbrelle;Iris;non\n');
|
|
assert.deepEqual(retour.avertissements, [{ code: 'RESERVATIONS_RETABLIES', details: suspendues }]);
|
|
});
|
|
|
|
test("un import dont les lignes valides ne changent rien, mais qui refuse des lignes, n'écrit rien et rend son résumé", async () => {
|
|
const { fs, seance } = await essaiEnEcriture();
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine' } });
|
|
const avant = fs.compteurs.ecritures;
|
|
const entrees = journalDe(fs).entrees.length;
|
|
const rendu = await mettreAJour(seance, 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nPervenche;Théo;\n');
|
|
assert.equal(rendu.libelle, null);
|
|
assert.deepEqual(rendu.avertissements, []);
|
|
assert.deepEqual(rendu.resume.refusees.map(({ ligne, code }) => [ligne, code]), [[2, 'PLUSIEURS_CORRESPONDENT']]);
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.equal(journalDe(fs).entrees.length, entrees);
|
|
assert.deepEqual(seance.avertissements, []);
|
|
});
|
|
|
|
test("après un geste, la séance montre les avertissements de l'Ouvert écrit : la dérive paraît et s'en va (§ 9)", async () => {
|
|
const { seance } = await essaiEnEcriture();
|
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
|
await seance.executer('retenirProposition', { id: 1 });
|
|
assert.deepEqual(seance.avertissements, []);
|
|
const exclusion = await seance.executer('exclureParticipant', { id: 2 });
|
|
assert.deepEqual(exclusion.avertissements, []);
|
|
assert.deepEqual(seance.avertissements.map(({ code }) => code), ['DERIVE']);
|
|
assert.deepEqual(seance.avertissements, seance.ouvert.avertissements);
|
|
assert.equal(seance.avertissements[0].details.retenu, true);
|
|
await seance.executer('reintegrerParticipant', { id: 2 });
|
|
assert.deepEqual(seance.avertissements, []);
|
|
});
|
|
|
|
test("le journal incomplet de l'ouverture ne s'affiche plus une fois la reprise écrite", async () => {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
const plus = chargeEssai();
|
|
plus.participants.push(personne(5, 'Ancolie'));
|
|
plus.prochainsIds.participant = 6;
|
|
fs.deposer('documents', ETAT, serialiser(plus, { revision: 4, produitVersion: V1 }));
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
const incomplet = [{ code: 'JOURNAL_INCOMPLET', details: { etat: 4, journal: 1 } }];
|
|
assert.deepEqual(seance.avertissements, incomplet);
|
|
await seance.passerEnEcriture();
|
|
assert.deepEqual(seance.avertissements, incomplet);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } });
|
|
assert.deepEqual(seance.avertissements, []);
|
|
});
|
|
});
|
|
|
|
describe('séance : la forme nommée se lit sans rien écrire (§ 8.9, point 4)', () => {
|
|
test("la forme nommée d'une proposition ou du retenu se lit en lecture, sans entrée ni écriture", async () => {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
|
await seance.executer('retenirProposition', { id: 2 });
|
|
await seance.passerEnLecture();
|
|
const avant = fs.compteurs.ecritures;
|
|
const entrees = journalDe(fs).entrees.length;
|
|
const proposition = await seance.formeNommee({ proposition: 1 });
|
|
assert.deepEqual(proposition.fautes, []);
|
|
assert.deepEqual(
|
|
proposition.lignes.filter(({ tour }) => tour === 1).map(({ nom }) => nom).sort(),
|
|
['Grisaille', 'Lacasse', 'Ombrelle', 'Pervenche'],
|
|
);
|
|
const retenu = await seance.formeNommee({ retenu: true });
|
|
assert.equal(retenu.lignes.length, 8);
|
|
await refus(seance.formeNommee({ proposition: 7 }), 'PROPOSITION_INCONNUE');
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.equal(journalDe(fs).entrees.length, entrees);
|
|
assert.equal(seance.mode, 'lecture');
|
|
});
|
|
|
|
test("un retenu fautif sur le disque se lit sous forme nommée par la séance : ses lignes et ses fautes (RETENU_FAUTIF, § 8.9)", async () => {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
|
await seance.executer('retenirProposition', { id: 1 });
|
|
await seance.passerEnLecture();
|
|
const { entete, charge } = etatDe(fs);
|
|
const premier = charge.retenu.placement[0];
|
|
const assis = premier.sieges.find((liste) => liste.length > 0)[0];
|
|
premier.reserve = [...premier.reserve, assis].sort((a, b) => a - b);
|
|
fs.deposer('documents', ETAT, serialiser(charge, { revision: entete.revision, produitVersion: V1 }));
|
|
await ouvrirSansEcrire(montage);
|
|
assert.equal(seance.avertissements[0].code, 'RETENU_FAUTIF');
|
|
const lue = await seance.formeNommee({ retenu: true });
|
|
assert.deepEqual(lue.fautes, [{ code: 'DOUBLON', tour: 1, participant: assis }]);
|
|
assert.equal(lue.lignes.length, 9);
|
|
assert.equal(lue.lignes.filter(({ tour, participant }) => tour === 1 && participant === assis).length, 2);
|
|
});
|
|
|
|
test('sans événement ouvert, la forme nommée est refusée AUCUN_EVENEMENT', async () => {
|
|
await refus(monter().seance.formeNommee({ retenu: true }), 'AUCUN_EVENEMENT');
|
|
});
|
|
});
|
|
|
|
describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", () => {
|
|
// Deux processus d'un même poste sur un même disque : la première séance
|
|
// crée l'événement d'essai, puis la seconde l'ouvre en lecture.
|
|
async function deuxSeances() {
|
|
const disque = new Map();
|
|
const premiere = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' });
|
|
await premiere.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 });
|
|
const seconde = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' });
|
|
await ouvrirSansEcrire(seconde);
|
|
return { premiere: premiere.seance, seconde: seconde.seance, fs: seconde.fs, fsPremiere: premiere.fs, disque };
|
|
}
|
|
|
|
test("passer en écriture relit l'événement : le travail de l'autre séance est adopté, EVENEMENT_CHANGE le dit, et le geste suivant s'écrit à sa suite", async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
|
|
await premiere.passerEnLecture();
|
|
const change = [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }];
|
|
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs).seance], ['ecriture', seconde.id]);
|
|
assert.deepEqual(noms(seconde.charge), ['Xavier']);
|
|
assert.deepEqual(seconde.avertissements, change);
|
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
|
|
const journal = journalDe(fs);
|
|
assert.deepEqual(libellesDe(journal), [CREATION, 'Ajouté Xavier', 'Ajouté Yvette']);
|
|
assert.equal(journal.ecartees, 0);
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']);
|
|
assert.deepEqual(etatDe(fs).charge, reconstruire(journal, 3));
|
|
});
|
|
|
|
test("sans geste de l'autre séance, rien n'avertit ; son jalon est adopté avec le journal relu", async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.nommer('Avant les invitations');
|
|
await premiere.passerEnLecture();
|
|
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: [] });
|
|
assert.deepEqual(seconde.ouvert.journal.jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']);
|
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
|
|
assert.deepEqual(journalDe(fs).jalons.map(({ nom }) => nom), ['Création', 'Avant les invitations']);
|
|
});
|
|
|
|
test("un événement qui a quitté le disque entre-temps, renommé ailleurs : le passage en écriture est refusé, le verrou rendu, rien ne se recrée", async () => {
|
|
const { seconde, fs, disque } = await deuxSeances();
|
|
const autre = creerFichiersSimules({ disque });
|
|
for (const fichier of [ETAT, JOURNAL]) await autre.supprimer(DOC, fichier);
|
|
await refus(seconde.passerEnEcriture(), 'ABSENT');
|
|
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
|
|
assert.deepEqual((await fs.lister(DOC, '')).map(({ nom }) => nom), []);
|
|
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Yvette' } }), 'MODE_LECTURE');
|
|
});
|
|
|
|
test("un plan que l'autre séance a bloqué entre-temps : adopté, EVENEMENT_CHANGE le dit, et le passage est refusé BLOQUE, le verrou rendu", async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
|
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'BLOQUE' });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'bloque']);
|
|
assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 2 } }]);
|
|
});
|
|
|
|
test("débloquer relit l'événement sous le verrou : le déblocage s'écrit à la suite du travail de l'autre séance", async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
|
await seconde.ouvrir(BASE);
|
|
await premiere.debloquer();
|
|
await premiere.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
|
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
|
assert.equal(premiere.mode, 'lecture');
|
|
const rendu = await seconde.debloquer();
|
|
assert.deepEqual(rendu, {
|
|
libelle: 'Plan débloqué, état « Brouillon »',
|
|
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }],
|
|
});
|
|
assert.equal(seconde.mode, 'ecriture');
|
|
const journal = journalDe(fs);
|
|
assert.deepEqual(journal.entrees.map(({ revision }) => revision), [1, 2, 3, 4, 5, 6]);
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier']);
|
|
assert.equal(etatDe(fs).charge.evenement.etat, 'brouillon');
|
|
});
|
|
|
|
test("débloquer un plan que l'autre séance a débloqué entre-temps : refusé PLAN_NON_BLOQUE, adopté, le verrou rendu", async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
|
await seconde.ouvrir(BASE);
|
|
await premiere.debloquer();
|
|
await premiere.passerEnLecture();
|
|
const entrees = journalDe(fs).entrees.length;
|
|
await refus(seconde.debloquer(), 'PLAN_NON_BLOQUE');
|
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.charge.evenement.etat], ['lecture', null, 'brouillon']);
|
|
assert.deepEqual(seconde.avertissements, [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }]);
|
|
assert.equal(journalDe(fs).entrees.length, entrees);
|
|
});
|
|
|
|
test("un secours ouvert se relit par la même source : passer en écriture ne bute pas sur l'état illisible", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Xavier' } });
|
|
await seance.passerEnLecture();
|
|
fs.deposer('documents', ETAT, '');
|
|
await seance.ouvrirSecours(BASE, 'journal');
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Yvette' } });
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Xavier', 'Yvette']);
|
|
await seance.passerEnLecture();
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
});
|
|
|
|
// L'événement de la première séance porte Aubépine puis Bourrache, son
|
|
// état devient illisible, et chacune des deux ouvre son .precedent : la
|
|
// révision 2, Aubépine seule.
|
|
async function surPrecedent() {
|
|
const deux = await deuxSeances();
|
|
const { premiere, seconde, fs } = deux;
|
|
await premiere.passerEnEcriture();
|
|
for (const nom of ['Aubépine', 'Bourrache']) await premiere.executer('ajouterParticipant', { champs: { nom } });
|
|
await premiere.passerEnLecture();
|
|
fs.deposer('documents', ETAT, '');
|
|
for (const seance of [premiere, seconde]) await seance.ouvrirSecours(BASE, 'precedent');
|
|
assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [2, ['Aubépine']]);
|
|
return deux;
|
|
}
|
|
|
|
for (const gestes of [['Cerfeuil'], ['Cerfeuil', 'Dictame']]) {
|
|
test(`un secours .precedent se relit par l'état qu'une autre séance a écrit depuis (${gestes.length} geste·s) : adopté, EVENEMENT_CHANGE le dit, rien ne s'efface`, async () => {
|
|
const { premiere, seconde, fs } = await surPrecedent();
|
|
await premiere.passerEnEcriture();
|
|
for (const nom of gestes) await premiere.executer('ajouterParticipant', { champs: { nom } });
|
|
await premiere.passerEnLecture();
|
|
const { revision } = etatDe(fs).entete;
|
|
const change = [{ code: 'EVENEMENT_CHANGE', details: { revision } }];
|
|
assert.deepEqual(await seconde.passerEnEcriture(), { avertissements: change });
|
|
assert.deepEqual([seconde.ouvert.revision, noms(seconde.charge)], [revision, ['Aubépine', ...gestes]]);
|
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } });
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', ...gestes, 'Estragon']);
|
|
assert.equal(journalDe(fs).ecartees, 0);
|
|
});
|
|
}
|
|
|
|
test("un secours .precedent dont l'état reste illisible, mais dont le journal a reçu le geste d'une autre séance : le dernier instant du journal est adopté, EVENEMENT_CHANGE le dit", async () => {
|
|
const { premiere, seconde, fs, fsPremiere } = await surPrecedent();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.nommer('Point');
|
|
fsPremiere.pannes.renommage('documents');
|
|
await refus(premiere.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ETAT_NON_ECRIT');
|
|
await premiere.passerEnLecture();
|
|
assert.equal(fs.contenu('documents', ETAT), '');
|
|
assert.deepEqual(await seconde.passerEnEcriture(), {
|
|
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 5 } }],
|
|
});
|
|
assert.deepEqual(noms(seconde.charge), ['Aubépine', 'Cerfeuil']);
|
|
await seconde.executer('ajouterParticipant', { champs: { nom: 'Estragon' } });
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil', 'Estragon']);
|
|
});
|
|
|
|
test("un secours .precedent dont l'état redevient lisible entre-temps se relit par l'état, sans entrée nouvelle au journal : EVENEMENT_CHANGE le dit", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
const lisible = fs.contenu('documents', ETAT);
|
|
fs.deposer('documents', ETAT, '');
|
|
await seance.ouvrirSecours(BASE, 'precedent');
|
|
fs.deposer('documents', ETAT, lisible);
|
|
assert.deepEqual(await seance.passerEnEcriture(), {
|
|
avertissements: [{ code: 'EVENEMENT_CHANGE', details: { revision: 3 } }],
|
|
});
|
|
assert.deepEqual(noms(seance.charge), ['Aubépine', 'Bourrache']);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Bourrache', 'Cerfeuil']);
|
|
});
|
|
|
|
test("un secours .precedent ouvert devant un état lisible se relit par le .precedent tant que l'état ne change pas : le choix de l'opérateur reste (§ 8.9, point 3)", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
await seance.ouvrirSecours(BASE, 'precedent');
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [2, ['Aubépine']]);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Aubépine', 'Cerfeuil']);
|
|
});
|
|
|
|
test("après un geste sur un secours .precedent, la séance se relit par l'état : redevenu illisible, il refuse le passage en écriture, sans revenir au .precedent", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
fs.deposer('documents', ETAT, '');
|
|
await seance.ouvrirSecours(BASE, 'precedent');
|
|
await seance.passerEnEcriture();
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
|
await seance.passerEnLecture();
|
|
fs.deposer('documents', ETAT, '');
|
|
await refus(seance.passerEnEcriture(), 'ETAT_ILLISIBLE');
|
|
assert.deepEqual([seance.mode, verrouDe(fs), noms(seance.charge)], ['lecture', null, ['Aubépine', 'Cerfeuil']]);
|
|
});
|
|
|
|
test("après avoir écrit ailleurs un secours .precedent, la séance s'y relit par l'état, que le dossier nouveau porte seul", async () => {
|
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
|
fs.deposerDossier('portable');
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
fs.deposer('documents', ETAT, '');
|
|
await seance.ouvrirSecours(BASE, 'precedent');
|
|
await seance.passerEnEcriture();
|
|
fs.pannes.ecriture('documents');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'ECRITURE');
|
|
await seance.ecrireAilleurs(PORTABLE);
|
|
await seance.passerEnLecture();
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
assert.deepEqual([seance.mode, noms(seance.charge)], ['ecriture', ['Aubépine']]);
|
|
});
|
|
|
|
test("le jalon d'une séance seule sur un secours .precedent pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE ni recul de révision", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
fs.deposer('documents', ETAT, '');
|
|
await seance.ouvrirSecours(BASE, 'precedent');
|
|
await seance.passerEnEcriture();
|
|
assert.deepEqual(await seance.nommer('Point'), { revision: 4 });
|
|
await seance.passerEnLecture();
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, ['Aubépine']]);
|
|
const codes = seance.avertissements.map(({ code }) => code);
|
|
assert.ok(!codes.includes('JOURNAL_EN_AVANCE_DE'), codes.join());
|
|
});
|
|
|
|
test("le jalon d'une séance seule sur un état que le journal devance de deux entrées pose la reprise : la relecture la retrouve, sans EVENEMENT_CHANGE", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
const premier = fs.contenu('documents', ETAT);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
fs.deposer('documents', ETAT, premier);
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
assert.deepEqual(await seance.nommer('Point'), { revision: 4 });
|
|
await seance.passerEnLecture();
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
assert.deepEqual([seance.ouvert.revision, noms(seance.charge)], [4, []]);
|
|
});
|
|
|
|
// L'événement réécrit par une construction plus récente : l'état, ou la
|
|
// première ligne du journal, déclarent le format 2.
|
|
const plusRecent = {
|
|
etat: (fs) => fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).replace('"format":1', '"format":2')),
|
|
journal: (fs) => fs.deposer('documents', JOURNAL, fs.contenu('documents', JOURNAL).replace('"format":1', '"format":2')),
|
|
};
|
|
|
|
for (const [fichier, designe] of [['etat', "l'état"], ['journal', 'le journal']]) {
|
|
test(`${designe} qu'une construction plus récente a réécrit entre-temps : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => {
|
|
const { seconde, fs } = await deuxSeances();
|
|
const entrees = journalDe(fs).entrees.length;
|
|
plusRecent[fichier](fs);
|
|
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
|
|
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
|
if (fichier === 'etat') assert.equal(journalDe(fs).entrees.length, entrees);
|
|
});
|
|
|
|
test(`un secours .precedent dont ${designe} est réécrit entre-temps par une construction plus récente : passer en écriture est refusé LECTURE_SEULE FORMAT, le verrou rendu`, async () => {
|
|
const { seconde, fs } = await surPrecedent();
|
|
if (fichier === 'etat') {
|
|
const precedent = fs.contenu('documents', `${BASE}${SUFFIXES.precedent}`);
|
|
fs.deposer('documents', ETAT, precedent.replace('"format":1', '"format":2'));
|
|
} else {
|
|
plusRecent.journal(fs);
|
|
}
|
|
await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
|
|
});
|
|
|
|
test(`un plan bloqué dont ${designe} est réécrit entre-temps par une construction plus récente : débloquer est refusé LECTURE_SEULE FORMAT, le verrou rendu, sans entrée`, async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
|
await seconde.ouvrir(BASE);
|
|
const texte = fs.contenu('documents', JOURNAL);
|
|
plusRecent[fichier](fs);
|
|
await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'FORMAT' });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs), seconde.ouvert.formatPlusRecent], ['lecture', null, true]);
|
|
if (fichier === 'etat') assert.equal(fs.contenu('documents', JOURNAL), texte);
|
|
});
|
|
}
|
|
|
|
// L'état réécrit en octets hors UTF-8 : son premier « é » remplacé par
|
|
// l'octet E9 d'un éditeur réglé en windows-1252. Rend ces octets.
|
|
function etatMalEncode(fs) {
|
|
const octets = [...new TextEncoder().encode(fs.contenu('documents', ETAT))];
|
|
const rang = octets.findIndex((octet, i) => octet === 0xc3 && octets[i + 1] === 0xa9);
|
|
octets.splice(rang, 2, 0xe9);
|
|
fs.deposerOctets('documents', ETAT, new Uint8Array(octets));
|
|
return new Uint8Array(octets);
|
|
}
|
|
|
|
// Les noms des fichiers de documents et le texte du journal : ce qu'un
|
|
// refus laisse tel quel.
|
|
const empreinte = async (fs) => [(await fs.lister(DOC, '')).map(({ nom }) => nom), fs.contenu('documents', JOURNAL)];
|
|
|
|
test("l'état réécrit entre-temps en octets hors UTF-8 : passer en écriture est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, comme un format plus récent, le verrou rendu et rien de l'événement écrit ; le refus a son texte", async () => {
|
|
const { seconde, fs } = await deuxSeances();
|
|
const octets = etatMalEncode(fs);
|
|
const avant = await empreinte(fs);
|
|
const erreur = await refus(seconde.passerEnEcriture(), 'LECTURE_SEULE', { raison: 'ENCODAGE' });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
|
|
assert.deepEqual(await empreinte(fs), avant);
|
|
assert.deepEqual(fs.octets('documents', ETAT), octets);
|
|
assert.match(libelle(erreur.code, erreur.details), /UTF-8/);
|
|
await refus(seconde.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } }), 'MODE_LECTURE');
|
|
});
|
|
|
|
test("un plan bloqué dont l'état est réécrit entre-temps en octets hors UTF-8 : débloquer est refusé LECTURE_SEULE {raison: 'ENCODAGE'}, le verrou rendu, sans entrée", async () => {
|
|
const { premiere, seconde, fs } = await deuxSeances();
|
|
await premiere.passerEnEcriture();
|
|
await premiere.executer('changerEtat', { etat: 'bloque' });
|
|
await premiere.passerEnLecture();
|
|
await seconde.ouvrir(BASE);
|
|
const octets = etatMalEncode(fs);
|
|
const avant = await empreinte(fs);
|
|
await refus(seconde.debloquer(), 'LECTURE_SEULE', { raison: 'ENCODAGE' });
|
|
assert.deepEqual([seconde.mode, verrouDe(fs)], ['lecture', null]);
|
|
assert.deepEqual(await empreinte(fs), avant);
|
|
assert.deepEqual(fs.octets('documents', ETAT), octets);
|
|
});
|
|
|
|
for (const source of ['precedent', 'journal']) {
|
|
test(`ouvert par son secours ${source}, un état aux octets hors UTF-8 ne fige rien : passer en écriture réussit, et le premier geste met l'état dans un dossier daté de la corbeille, ses octets intacts, avant d'écrire`, async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Aubépine', 'Bourrache']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.passerEnLecture();
|
|
const octets = etatMalEncode(fs);
|
|
const illisible = await refus(seance.ouvrir(BASE), 'ETAT_ILLISIBLE');
|
|
assert.equal(illisible.details.raison, 'ENCODAGE');
|
|
assert.match(libelle(illisible.code, illisible.details), /UTF-8/);
|
|
await seance.ouvrirSecours(BASE, source);
|
|
assert.equal(seance.ouvert.etatMalEncode, true);
|
|
assert.deepEqual(await seance.passerEnEcriture(), { avertissements: [] });
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Cerfeuil' } });
|
|
const [dossier] = (await fs.lister(DOC, 'corbeille')).map(({ nom }) => nom);
|
|
assert.deepEqual(fs.octets('documents', `corbeille/${dossier}/${ETAT}`), octets);
|
|
assert.deepEqual(noms(etatDe(fs).charge), [...noms(seance.charge)]);
|
|
assert.ok(noms(seance.charge).includes('Cerfeuil'));
|
|
assert.equal(seance.ouvert.etatMalEncode, false);
|
|
for (const { code, details } of seance.avertissements) assert.equal(typeof libelle(code, details), 'string');
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
|
|
// Trois gestes après la création : Ancolie, Bruyère, Capucine, aux
|
|
// révisions 2, 3 et 4.
|
|
async function troisGestes() {
|
|
const montage = monter();
|
|
await enEcriture(montage);
|
|
for (const nom of ['Ancolie', 'Bruyère', 'Capucine']) await montage.seance.executer('ajouterParticipant', { champs: { nom } });
|
|
return montage;
|
|
}
|
|
|
|
test('défaire, refaire et revenir écrivent des entrées de retour, et le journal ne se tronque jamais', async () => {
|
|
const montage = await troisGestes();
|
|
const { fs, seance } = montage;
|
|
assert.deepEqual(await seance.defaire(), { libelle: 'Défait : « Ajouté Capucine »', avertissements: [] });
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère']);
|
|
await seance.refaire();
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère', 'Capucine']);
|
|
await seance.revenir(2);
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie']);
|
|
const journal = journalDe(fs);
|
|
assert.deepEqual(
|
|
journal.entrees.map(({ revision, sens, retour, libelle: texte }) => [revision, sens, retour, texte]),
|
|
[
|
|
[1, null, null, CREATION],
|
|
[2, null, null, 'Ajouté Ancolie'],
|
|
[3, null, null, 'Ajouté Bruyère'],
|
|
[4, null, null, 'Ajouté Capucine'],
|
|
[5, 'defaire', 3, 'Défait : « Ajouté Capucine »'],
|
|
[6, 'refaire', 4, 'Refait : « Ajouté Capucine »'],
|
|
[7, 'revenir', 2, 'Retour à « Ajouté Ancolie »'],
|
|
],
|
|
);
|
|
// L'instant 2 revient avec son contenu, et ses compteurs restent où trois
|
|
// ajouts les ont portés : aucun identifiant donné ne se redonne (§ 8.9).
|
|
const instant2 = reconstruire(journal, 2);
|
|
const attendu = { ...instant2, prochainsIds: { ...instant2.prochainsIds, participant: 4 } };
|
|
assert.deepEqual(etatDe(fs).charge, attendu);
|
|
assert.deepEqual(reconstruire(journal, 7), attendu);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Digitale' } });
|
|
assert.deepEqual(
|
|
seance.charge.participants.map(({ id, nom }) => [id, nom]),
|
|
[[1, 'Ancolie'], [4, 'Digitale']],
|
|
);
|
|
});
|
|
|
|
test("refaire n'existe qu'après des retours, tant qu'aucun geste ne les suit", async () => {
|
|
const montage = await troisGestes();
|
|
const { fs, seance } = montage;
|
|
assert.deepEqual(seance.retours, { defaire: true, refaire: false });
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.refaire(), 'RIEN_A_REFAIRE');
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
await seance.defaire();
|
|
await seance.defaire();
|
|
assert.deepEqual(seance.retours, { defaire: true, refaire: true });
|
|
await seance.refaire();
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère']);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Dauphinelle' } });
|
|
assert.deepEqual(seance.retours, { defaire: true, refaire: false });
|
|
await refus(seance.refaire(), 'RIEN_A_REFAIRE');
|
|
});
|
|
|
|
test("rien à défaire au premier instant", async () => {
|
|
const montage = monter();
|
|
await enEcriture(montage);
|
|
assert.deepEqual(montage.seance.retours, { defaire: false, refaire: false });
|
|
const avant = montage.fs.compteurs.ecritures;
|
|
await refus(montage.seance.defaire(), 'RIEN_A_DEFAIRE');
|
|
assert.equal(montage.fs.compteurs.ecritures, avant);
|
|
});
|
|
|
|
test('après un retour de la révision 6 à la 3, défaire vise le prédécesseur de 3 sur le fil, non la ligne précédente', async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
for (const nom of ['Ancolie', 'Bruyère', 'Capucine', 'Dauphinelle', 'Églantine']) await seance.executer('ajouterParticipant', { champs: { nom } });
|
|
await seance.revenir(3);
|
|
await seance.defaire();
|
|
const derniere = journalDe(fs).entrees.at(-1);
|
|
assert.deepEqual([derniere.revision, derniere.sens, derniere.retour, derniere.libelle], [8, 'defaire', 2, 'Défait : « Ajouté Bruyère »']);
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie']);
|
|
});
|
|
|
|
test("un journal qui finit avant l'état : défaire vise l'instant où il finit, par-dessus l'entrée de reprise", async () => {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
// L'état du disque, à la révision 4, porte une personne que le journal,
|
|
// arrêté à la révision 1, n'a pas.
|
|
const plus = chargeEssai();
|
|
plus.participants.push(personne(5, 'Ancolie'));
|
|
plus.prochainsIds.participant = 6;
|
|
fs.deposer('documents', ETAT, serialiser(plus, { revision: 4, produitVersion: V1 }));
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
assert.deepEqual(seance.ouvert.avertissements, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 4, journal: 1 } }]);
|
|
await seance.passerEnEcriture();
|
|
assert.deepEqual(seance.retours, { defaire: true, refaire: false });
|
|
const reprise = "Reprise de l'état trouvé sur le disque";
|
|
assert.deepEqual(await seance.defaire(), { libelle: `Défait : « ${reprise} »`, avertissements: [] });
|
|
assert.deepEqual(
|
|
journalDe(fs).entrees.map(({ revision, sens, retour, libelle: texte }) => [revision, sens, retour, texte]),
|
|
[
|
|
[1, null, null, CREATION],
|
|
[2, null, null, reprise],
|
|
[3, 'defaire', 1, `Défait : « ${reprise} »`],
|
|
],
|
|
);
|
|
assert.deepEqual(noms(seance.charge), noms(chargeEssai()));
|
|
assert.deepEqual(seance.retours, { defaire: false, refaire: true });
|
|
await seance.refaire();
|
|
assert.deepEqual(noms(seance.charge), noms(plus));
|
|
});
|
|
|
|
test("un état sans journal : rien à défaire ; le premier geste ouvre le journal par l'entrée de reprise", async () => {
|
|
const fs = creerFichiersSimules();
|
|
fs.deposerDossier('documents');
|
|
fs.deposer('documents', ETAT, serialiser(chargeEssai(), { revision: 3, produitVersion: V1 }));
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
assert.deepEqual(seance.retours, { defaire: false, refaire: false });
|
|
await refus(seance.defaire(), 'RIEN_A_DEFAIRE');
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
|
assert.deepEqual(
|
|
journalDe(fs).entrees.map(({ revision, libelle: texte }) => [revision, texte]),
|
|
[
|
|
[3, "Reprise de l'état trouvé sur le disque"],
|
|
[4, 'Ajouté Ancolie'],
|
|
],
|
|
);
|
|
assert.deepEqual(seance.retours, { defaire: true, refaire: false });
|
|
});
|
|
|
|
test("revenir à un instant que le journal ne restitue plus est refusé, sans écriture", async () => {
|
|
const fs = creerFichiersSimules();
|
|
fs.deposerDossier('documents');
|
|
const charge = chargeEssai();
|
|
// Un journal élagué : sa première entrée est la révision 10.
|
|
const lignes = [
|
|
ligneOuverture(charge.evenement.id),
|
|
ligneEntree({ revision: 10, libelle: 'Geste 10', horodatage: '2031-05-17T07:00:00-04:00', produitVersion: V1, apres: charge, instantane: true }),
|
|
];
|
|
fs.deposer('documents', JOURNAL, `${lignes.join('\n')}\n`);
|
|
fs.deposer('documents', ETAT, serialiser(charge, { revision: 10, produitVersion: V1 }));
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.revenir(3), 'INSTANT_NON_RESTITUABLE', { revision: 3 });
|
|
await refus(seance.revenir(11), 'INSTANT_NON_RESTITUABLE', { revision: 11 });
|
|
await refus(seance.defaire(), 'RIEN_A_DEFAIRE');
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
});
|
|
});
|
|
|
|
describe('séance : les jalons automatiques (§ 8.3)', () => {
|
|
test("la création, chaque changement d'état, et l'instant qui précède chaque génération sont nommés", async () => {
|
|
const fs = creerFichiersSimules();
|
|
await deposerEssai(fs);
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
await ouvrirSansEcrire(montage);
|
|
await seance.passerEnEcriture();
|
|
await seance.executer('enregistrerGeneration', generer(seance.charge));
|
|
await seance.executer('retenirProposition', { id: 1 });
|
|
await seance.executer('enregistrerGeneration', generer(seance.charge, 1, 9));
|
|
const journal = journalDe(fs);
|
|
assert.deepEqual(libellesDe(journal), [CREATION, 'Généré 2 propositions (n° 1 à 2)', 'Retenu la proposition n° 1', 'Généré 1 proposition (n° 3)']);
|
|
assert.deepEqual(journal.jalons.map(({ revision, nom }) => [revision, nom]), [
|
|
[1, 'Création'],
|
|
[1, 'Avant la génération'],
|
|
[2, 'État « Proposé »'],
|
|
[3, 'État « Retenu »'],
|
|
[3, 'Avant la génération'],
|
|
]);
|
|
});
|
|
|
|
test("un retour qui change l'état du plan pose le jalon qui le nomme : défaire un déblocage rebloque, et la séance revient en lecture", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
|
await seance.debloquer();
|
|
const rendu = await seance.defaire();
|
|
assert.deepEqual(rendu, { libelle: 'Défait : « Plan débloqué, état « Brouillon » »', avertissements: [] });
|
|
const journal = journalDe(fs);
|
|
const derniere = journal.entrees.at(-1);
|
|
assert.deepEqual([derniere.revision, derniere.sens, derniere.retour], [4, 'defaire', 2]);
|
|
assert.deepEqual(journal.jalons.map(({ revision, nom }) => [revision, nom]), [
|
|
[1, 'Création'],
|
|
[2, 'État « Bloqué »'],
|
|
[3, 'État « Brouillon »'],
|
|
[4, 'État « Bloqué »'],
|
|
]);
|
|
assert.deepEqual([seance.mode, seance.charge.evenement.etat, verrouDe(fs)], ['lecture', 'bloque', null]);
|
|
});
|
|
});
|
|
|
|
describe('séance : bloquer et débloquer (§ 8.5, § 9)', () => {
|
|
test("bloquer écrit l'entrée et son jalon, puis la séance revient en lecture et rend le verrou", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
const rendu = await seance.executer('changerEtat', { etat: 'bloque' });
|
|
assert.equal(rendu.libelle, "Plan passé à l'état « Bloqué »");
|
|
assert.equal(seance.mode, 'lecture');
|
|
assert.equal(verrouDe(fs), null);
|
|
assert.equal(montage.planificateur.actives, 0);
|
|
const journal = journalDe(fs);
|
|
assert.deepEqual(journal.jalons.at(-1).nom, 'État « Bloqué »');
|
|
assert.equal(etatDe(fs).charge.evenement.etat, 'bloque');
|
|
});
|
|
|
|
test('sur un plan bloqué : passer en écriture, un geste, défaire et nommer sont refusés BLOQUE, sans écriture', async () => {
|
|
const disque = new Map();
|
|
const premier = monter({ fs: creerFichiersSimules({ disque }) });
|
|
await enEcriture(premier);
|
|
await premier.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
|
await premier.seance.executer('changerEtat', { etat: 'bloque' });
|
|
const second = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'seconde' });
|
|
await ouvrirSansEcrire(second);
|
|
for (const { seance, fs } of [premier, second]) {
|
|
const avant = fs.compteurs.ecritures;
|
|
const bloque = { raison: 'BLOQUE' };
|
|
await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', bloque);
|
|
await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', bloque);
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'LECTURE_SEULE', bloque);
|
|
await refus(seance.defaire(), 'LECTURE_SEULE', bloque);
|
|
await refus(seance.nommer('Distribué'), 'LECTURE_SEULE', bloque);
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.equal(seance.mode, 'lecture');
|
|
}
|
|
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/);
|
|
});
|
|
|
|
test("débloquer prend le verrou, écrit une entrée et un jalon, rend l'état que le contenu impose, et laisse la séance en écriture", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
|
const entrees = journalDe(fs).entrees.length;
|
|
const rendu = await seance.debloquer();
|
|
assert.equal(rendu.libelle, 'Plan débloqué, état « Brouillon »');
|
|
assert.equal(seance.mode, 'ecriture');
|
|
assert.equal(verrouDe(fs).seance, seance.id);
|
|
assert.equal(seance.charge.evenement.etat, etatDeduit(seance.charge));
|
|
const journal = journalDe(fs);
|
|
assert.equal(journal.entrees.length, entrees + 1);
|
|
assert.deepEqual(journal.jalons.at(-1), { ...journal.jalons.at(-1), revision: journal.entrees.at(-1).revision, nom: 'État « Brouillon »' });
|
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
|
await seance.executer('debloquer', {});
|
|
assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['ecriture', 'brouillon']);
|
|
});
|
|
|
|
test("débloquer devant le verrou d'un autre poste est refusé VERROU_PRIS, avant toute entrée", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
|
const depuis = '2031-05-17T09:00:00-04:00';
|
|
fs.deposer('documents', VERROU, `${JSON.stringify({ seance: 'lointaine-1', pid: 99, hote: 'autre-poste', depuis })}\n`);
|
|
const journal = fs.contenu('documents', JOURNAL);
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis, vivant: null });
|
|
assert.equal(fs.compteurs.ecritures - avant, 1, 'seule la prise du verrou se tente');
|
|
assert.equal(fs.contenu('documents', JOURNAL), journal);
|
|
assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']);
|
|
assert.equal(verrouDe(fs).seance, 'lointaine-1');
|
|
});
|
|
|
|
test("débloquer un plan qui ne l'est pas : en lecture MODE_LECTURE, en écriture PLAN_NON_BLOQUE", async () => {
|
|
const montage = monter();
|
|
await montage.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 });
|
|
await refus(montage.seance.debloquer(), 'MODE_LECTURE');
|
|
await montage.seance.passerEnEcriture();
|
|
const avant = montage.fs.compteurs.ecritures;
|
|
await refus(montage.seance.debloquer(), 'PLAN_NON_BLOQUE');
|
|
assert.equal(montage.fs.compteurs.ecritures, avant);
|
|
});
|
|
});
|
|
|
|
describe("séance : un fichier d'un format plus récent (§ 8.8)", () => {
|
|
test("s'ouvre en lecture sans rien écrire, et tout ce qui écrirait est refusé LECTURE_SEULE FORMAT, avant BLOQUE", async () => {
|
|
const fs = creerFichiersSimules();
|
|
fs.deposerDossier('documents');
|
|
const charge = chargeEssai();
|
|
charge.evenement.etat = 'bloque';
|
|
fs.deposer('documents', ETAT, serialiser(charge, { revision: 1, produitVersion: V1 }).replace('"format":1', '"format":2'));
|
|
const { seance } = monter({ fs });
|
|
const avant = fs.compteurs.ecritures;
|
|
await seance.ouvrir(BASE);
|
|
assert.equal(seance.ouvert.formatPlusRecent, true);
|
|
const format = { raison: 'FORMAT' };
|
|
await refus(seance.passerEnEcriture(), 'LECTURE_SEULE', format);
|
|
await refus(seance.executer('changerEtat', { etat: 'retenu' }), 'LECTURE_SEULE', format);
|
|
await refus(seance.debloquer(), 'LECTURE_SEULE', format);
|
|
await refus(seance.defaire(), 'LECTURE_SEULE', format);
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.doesNotMatch(libelle('LECTURE_SEULE', format), /Débloquer/);
|
|
});
|
|
});
|
|
|
|
describe('séance : la perte du support en cours de séance (§ 8.6)', () => {
|
|
// Un événement du disque portable, en écriture, après un premier geste.
|
|
async function surPortable(reglages = {}) {
|
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees', ...reglages });
|
|
const montage = monter({ fs, racine: PORTABLE });
|
|
await enEcriture(montage);
|
|
await montage.seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
|
return montage;
|
|
}
|
|
|
|
test("une écriture refusée rompt le support : le geste est refusé, les suivants aussi, puis écrire ailleurs rétablit", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
fs.pannes.ecriture('portable');
|
|
const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' };
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
assert.equal(seance.supportRompu, true);
|
|
assert.deepEqual(seance.refusSupport, refusSupport);
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie']);
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'SUPPORT_ROMPU', refusSupport);
|
|
await refus(seance.defaire(), 'SUPPORT_ROMPU');
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.match(libelle('SUPPORT_ROMPU', refusSupport), /Écrire ailleurs/);
|
|
|
|
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
|
|
assert.equal(base, BASE);
|
|
assert.deepEqual(avertissements, [
|
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
|
|
]);
|
|
assert.deepEqual([seance.supportRompu, seance.refusSupport, seance.mode], [false, null, 'ecriture']);
|
|
assert.deepEqual(seance.racine, DOC);
|
|
assert.equal(verrouDe(fs).seance, seance.id);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } });
|
|
assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']);
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']);
|
|
});
|
|
|
|
test("un état qui ne s'écrit pas : le geste est au journal, la séance en montre la charge, et écrire ailleurs la garde", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
fs.pannes.renommage('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ETAT_NON_ECRIT');
|
|
assert.deepEqual([seance.supportRompu, seance.refusSupport.echec], [true, 'ETAT_NON_ECRIT']);
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie', 'Bruyère']);
|
|
assert.deepEqual(libellesDe(journalDe(fs, 'portable')), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']);
|
|
await seance.ecrireAilleurs(DOC);
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie', 'Bruyère']);
|
|
assert.equal(seance.supportRompu, false);
|
|
});
|
|
|
|
test("un jalon d'état qui ne s'écrit pas laisse le geste enregistré : JALON_NON_ECRIT le dit, et le support est rompu", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
// La deuxième ligne ajoutée au journal à partir d'ici, le jalon qui suit
|
|
// l'entrée, trouve le support en lecture seule.
|
|
const ajouterLigne = fs.ajouterLigne;
|
|
let lignes = 0;
|
|
fs.ajouterLigne = async (racine, chemin, ligne) => {
|
|
lignes += 1;
|
|
if (lignes === 2) fs.pannes.ecriture(racine.id);
|
|
return ajouterLigne(racine, chemin, ligne);
|
|
};
|
|
const refusSupport = { chemin: JOURNAL, dossier: DOCUMENTS, cause: 'EROFS' };
|
|
const rendu = await seance.executer('changerEtat', { etat: 'bloque' });
|
|
assert.deepEqual(rendu, {
|
|
libelle: "Plan passé à l'état « Bloqué »",
|
|
avertissements: [
|
|
{ code: 'JALON_NON_ECRIT', details: refusSupport },
|
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: DOCUMENTS, cause: 'EROFS' } },
|
|
],
|
|
});
|
|
assert.deepEqual([seance.supportRompu, seance.refusSupport], [true, { echec: 'JALON_NON_ECRIT', ...refusSupport }]);
|
|
assert.equal(seance.mode, 'lecture');
|
|
assert.equal(etatDe(fs).charge.evenement.etat, 'bloque');
|
|
const journal = journalDe(fs);
|
|
assert.equal(journal.entrees.at(-1).libelle, "Plan passé à l'état « Bloqué »");
|
|
assert.deepEqual(journal.jalons.map(({ nom }) => nom), ['Création']);
|
|
});
|
|
|
|
test("une création dont l'état ne s'écrit pas reste ouverte en lecture, le support rompu, et s'écrit ailleurs", async () => {
|
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
|
const montage = monter({ fs, racine: PORTABLE });
|
|
const { seance } = montage;
|
|
// La deuxième écriture atomique, l'état qui suit le journal, trouve le
|
|
// support en lecture seule.
|
|
const ecrireAtomique = fs.ecrireAtomique;
|
|
let ecritures = 0;
|
|
fs.ecrireAtomique = async (racine, chemin, texte) => {
|
|
ecritures += 1;
|
|
if (ecritures === 2) fs.pannes.ecriture(racine.id);
|
|
return ecrireAtomique(racine, chemin, texte);
|
|
};
|
|
await refus(seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }), 'ETAT_NON_ECRIT');
|
|
assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport.echec], ['lecture', true, 'ETAT_NON_ECRIT']);
|
|
assert.equal(seance.charge.evenement.nom, BASE);
|
|
assert.equal(fs.contenu('portable', ETAT), null);
|
|
const { base } = await seance.ecrireAilleurs(DOC);
|
|
assert.equal(base, BASE);
|
|
assert.deepEqual([seance.supportRompu, seance.mode, verrouDe(fs)], [false, 'lecture', null]);
|
|
assert.deepEqual(libellesDe(journalDe(fs)), [CREATION]);
|
|
assert.equal(etatDe(fs).entete.revision, 1);
|
|
});
|
|
|
|
test("un verrou que le support refuse rompt le support : « Modifier » est refusé, puis écrire ailleurs rétablit l'écriture", async () => {
|
|
const { fs, seance, planificateur } = await surPortable();
|
|
await planificateur.avancer(INACTIVITE_MS);
|
|
assert.deepEqual([seance.mode, verrouDe(fs, 'portable')], ['lecture', null]);
|
|
fs.pannes.ecriture('portable');
|
|
const details = { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' };
|
|
await refus(seance.passerEnEcriture(), 'ECRITURE', details);
|
|
const refusSupport = { echec: 'VERROU_NON_ECRIT', ...details };
|
|
assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, refusSupport]);
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie']);
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', refusSupport);
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
const { base } = await seance.ecrireAilleurs(DOC);
|
|
assert.equal(base, BASE);
|
|
assert.deepEqual([seance.supportRompu, seance.mode, seance.racine], [false, 'lecture', DOC]);
|
|
await seance.passerEnEcriture();
|
|
assert.equal(verrouDe(fs).seance, seance.id);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } });
|
|
assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie', 'Ajouté Bruyère']);
|
|
});
|
|
|
|
test("débloquer devant un verrou que le support refuse rompt aussi le support, et le plan se débloque ailleurs", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
await seance.executer('changerEtat', { etat: 'bloque' });
|
|
assert.equal(seance.mode, 'lecture');
|
|
fs.pannes.ecriture('portable');
|
|
const details = { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' };
|
|
await refus(seance.debloquer(), 'ECRITURE', details);
|
|
assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, { echec: 'VERROU_NON_ECRIT', ...details }]);
|
|
assert.equal(seance.charge.evenement.etat, 'bloque');
|
|
await seance.ecrireAilleurs(DOC);
|
|
const rendu = await seance.debloquer();
|
|
assert.equal(rendu.libelle, 'Plan débloqué, état « Brouillon »');
|
|
assert.deepEqual([seance.mode, verrouDe(fs)?.seance], ['ecriture', seance.id]);
|
|
assert.equal(etatDe(fs).charge.evenement.etat, 'brouillon');
|
|
});
|
|
|
|
test("écrire ailleurs dont le verrou ne s'écrit pas là-bas : la paire y est, la séance y passe en lecture, son support de nouveau rompu", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
// Les Documents prennent la paire, mais refusent le verrou.
|
|
const details = { chemin: VERROU, dossier: DOCUMENTS, cause: 'EACCES' };
|
|
const verrouiller = fs.verrouiller;
|
|
fs.verrouiller = async (racine, chemin, jeton) => {
|
|
if (racine.id === 'documents') throw new ErreurStockage('ECRITURE', details);
|
|
return verrouiller(racine, chemin, jeton);
|
|
};
|
|
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
|
|
assert.equal(base, BASE);
|
|
assert.deepEqual(avertissements.map(({ code }) => code), ['VERROU_NON_RENDU', 'ECRITURE']);
|
|
const refusSupport = { echec: 'VERROU_NON_ECRIT', ...details };
|
|
assert.deepEqual([seance.racine, seance.mode, seance.supportRompu, seance.refusSupport], [DOC, 'lecture', true, refusSupport]);
|
|
assert.deepEqual(noms(etatDe(fs).charge), ['Ancolie']);
|
|
await refus(seance.passerEnEcriture(), 'SUPPORT_ROMPU', refusSupport);
|
|
});
|
|
|
|
test("écrire ailleurs refuse un dossier qui porte déjà l'événement, le sien compris : jamais deux copies d'un identifiant dans un dossier", async () => {
|
|
const montage = monter();
|
|
const { fs, seance } = montage;
|
|
await enEcriture(montage);
|
|
await seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
|
|
fs.pannes.renommage('documents', 11);
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ETAT_NON_ECRIT');
|
|
assert.equal(seance.supportRompu, true);
|
|
const avant = fs.compteurs.ecritures;
|
|
const id = seance.charge.evenement.id;
|
|
const erreur = await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE });
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.deepEqual([seance.supportRompu, seance.racine], [true, DOC]);
|
|
const etats = (await fs.lister(DOC, '')).filter(({ nom }) => nom.endsWith(SUFFIXES.etat)).map(({ nom }) => nom);
|
|
assert.deepEqual(etats, [ETAT]);
|
|
assert.doesNotMatch(libelle(erreur.code, erreur.details), /import/);
|
|
});
|
|
|
|
test("un état qu'écrire ailleurs ne pose pas y transporte la séance, avec le journal écrit : un nouvel essai n'y dérive pas une seconde base", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
// La deuxième écriture atomique dans les Documents, l'état qui suit le
|
|
// journal, voit refusés tous ses renommages.
|
|
const ecrireAtomique = fs.ecrireAtomique;
|
|
let ecritures = 0;
|
|
fs.ecrireAtomique = async (racine, chemin, texte) => {
|
|
if (racine.id === 'documents') {
|
|
ecritures += 1;
|
|
if (ecritures === 2) fs.pannes.renommage('documents', 11);
|
|
}
|
|
return ecrireAtomique(racine, chemin, texte);
|
|
};
|
|
const id = seance.charge.evenement.id;
|
|
await refus(seance.ecrireAilleurs(DOC), 'ETAT_NON_ECRIT');
|
|
assert.deepEqual([seance.racine, seance.ouvert.racine, seance.ouvert.base], [DOC, DOC, BASE]);
|
|
const refusSupport = { echec: 'ETAT_NON_ECRIT', chemin: ETAT, dossier: DOCUMENTS, cause: 'EBUSY' };
|
|
assert.deepEqual([seance.mode, seance.supportRompu, seance.refusSupport], ['lecture', true, refusSupport]);
|
|
assert.deepEqual(seance.avertissements, [
|
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
|
|
]);
|
|
assert.deepEqual(noms(seance.charge), ['Ancolie']);
|
|
assert.deepEqual(libellesDe(journalDe(fs)), [CREATION, 'Ajouté Ancolie']);
|
|
assert.equal(fs.contenu('documents', ETAT), null);
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE });
|
|
await refus(seance.ecrireAilleurs(PORTABLE), 'IDENTIFIANT_PRESENT', { id, base: BASE });
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
const fichiers = (await fs.lister(DOC, '')).map(({ nom }) => nom);
|
|
assert.deepEqual(fichiers, [JOURNAL]);
|
|
});
|
|
|
|
// L'événement d'essai des Documents, dont l'état devient un JSON tronqué et
|
|
// dont le journal reste lisible : ouvrir le refuse, la séance l'ouvre par
|
|
// le secours du journal et passe en écriture, puis l'état d'un geste ne
|
|
// s'écrit pas — le geste est au journal, le support est rompu.
|
|
async function illisibleRompu() {
|
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
|
await deposerEssai(fs);
|
|
fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).slice(0, 40));
|
|
const montage = monter({ fs });
|
|
const { seance } = montage;
|
|
const illisible = await refus(seance.ouvrir(BASE), 'ETAT_ILLISIBLE');
|
|
assert.deepEqual([illisible.details.raison, illisible.details.secours], ['JSON', { precedent: null, journal: 1 }]);
|
|
await seance.ouvrirSecours(BASE, 'journal');
|
|
await seance.passerEnEcriture();
|
|
fs.pannes.renommage('documents', 11);
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } }), 'ETAT_NON_ECRIT');
|
|
assert.deepEqual([seance.supportRompu, seance.racine], [true, DOC]);
|
|
return montage;
|
|
}
|
|
|
|
// Les noms des fichiers d'une racine.
|
|
const fichiersDe = async (fs, racine) => (await fs.lister(racine, '')).map(({ nom }) => nom);
|
|
|
|
test("écrire ailleurs refuse le dossier où l'événement n'a qu'un état illisible, que son journal apparie : aucune seconde paire, et ce journal reste son secours", async () => {
|
|
const { fs, seance } = await illisibleRompu();
|
|
const id = seance.charge.evenement.id;
|
|
const avant = fs.compteurs.ecritures;
|
|
const fichiers = await fichiersDe(fs, DOC);
|
|
await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE });
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.deepEqual(await fichiersDe(fs, DOC), fichiers);
|
|
assert.deepEqual([seance.supportRompu, seance.racine], [true, DOC]);
|
|
const illisible = await refus(ouvrirDansLeDepot(fs, DOC, BASE), 'ETAT_ILLISIBLE');
|
|
assert.deepEqual(illisible.details.secours, { precedent: null, journal: 2 });
|
|
});
|
|
|
|
test("écrire ailleurs refuse de revenir au dossier d'où l'événement est parti, quand son état y est illisible", async () => {
|
|
const { fs, seance } = await illisibleRompu();
|
|
const id = seance.charge.evenement.id;
|
|
await seance.ecrireAilleurs(PORTABLE);
|
|
assert.deepEqual([seance.racine, seance.supportRompu, seance.mode], [PORTABLE, false, 'ecriture']);
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
const avant = fs.compteurs.ecritures;
|
|
const fichiers = await fichiersDe(fs, DOC);
|
|
await refus(seance.ecrireAilleurs(DOC), 'IDENTIFIANT_PRESENT', { id, base: BASE });
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.deepEqual(await fichiersDe(fs, DOC), fichiers);
|
|
assert.deepEqual([seance.supportRompu, seance.racine], [true, PORTABLE]);
|
|
});
|
|
|
|
test("écrire ailleurs sur un support amovible l'annonce, comme la règle du dossier de travail au démarrage (§ 8.8)", async () => {
|
|
const { fs, seance } = await surPortable({ support: { documents: 'amovible' } });
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
const amovible = { code: 'SUPPORT_AMOVIBLE', details: { dossier: DOCUMENTS } };
|
|
const { avertissements } = await seance.ecrireAilleurs(DOC);
|
|
assert.deepEqual(avertissements, [
|
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
|
|
amovible,
|
|
]);
|
|
assert.ok(seance.avertissements.some(({ code }) => code === 'SUPPORT_AMOVIBLE'));
|
|
assert.match(libelle(amovible.code, amovible.details), /Gestion table tournante Libre/);
|
|
});
|
|
|
|
const fautesDuPont = [
|
|
['stockage', () => new ErreurStockage('LECTURE', { chemin: '', dossier: DOCUMENTS, cause: 'EIO' })],
|
|
['pont', () => new TypeError('réponse du pont illisible pour typeSupport')],
|
|
];
|
|
for (const [nature, faute] of fautesDuPont) {
|
|
test(`écrire ailleurs réussit quand le type du support ne se dit pas (faute ${nature}) : le support vaut inconnu, rien n'est annoncé`, async () => {
|
|
const { fs, seance, planificateur } = await surPortable({ support: { documents: 'amovible' } });
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
fs.typeSupport = async () => {
|
|
throw faute();
|
|
};
|
|
const { base, avertissements } = await seance.ecrireAilleurs(DOC);
|
|
assert.equal(base, BASE);
|
|
assert.deepEqual(avertissements, [
|
|
{ code: 'VERROU_NON_RENDU', details: { chemin: VERROU, dossier: PORTABLE.chemin, cause: 'EROFS' } },
|
|
]);
|
|
assert.deepEqual(seance.avertissements, avertissements);
|
|
assert.deepEqual([seance.supportRompu, seance.mode, seance.racine, planificateur.actives], [false, 'ecriture', DOC, 1]);
|
|
});
|
|
}
|
|
|
|
test("tant que le support est rompu, ouvrir, ouvrir un secours, créer et fermer sont refusés SUPPORT_ROMPU_NON_SAUVE, sans rien changer", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
const refusSupport = { echec: 'ECRITURE', chemin: JOURNAL, dossier: PORTABLE.chemin, cause: 'EROFS' };
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.ouvrir(BASE), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
|
await refus(seance.ouvrirSecours(BASE, 'journal'), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
|
await refus(seance.creer({ nom: 'Soirée des Grillons', date: null, siegesParDefaut: 2, tours: 2 }), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
|
await refus(seance.fermer(), 'SUPPORT_ROMPU_NON_SAUVE', refusSupport);
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
assert.deepEqual([seance.supportRompu, seance.refusSupport, noms(seance.charge)], [true, refusSupport, ['Ancolie']]);
|
|
assert.match(libelle('SUPPORT_ROMPU_NON_SAUVE', refusSupport), /« Écrire ailleurs »/);
|
|
await seance.ecrireAilleurs(DOC);
|
|
await seance.fermer();
|
|
assert.equal(seance.ouvert, null);
|
|
});
|
|
|
|
test("le support rompu se quitte sur abandon explicite : ouvrir, ou fermer, avec {abandonner: true}", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
const rendu = await seance.ouvrir(BASE, { abandonner: true });
|
|
assert.equal(rendu.base, BASE);
|
|
assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie']]);
|
|
await refus(seance.passerEnEcriture(), 'ECRITURE');
|
|
assert.equal(seance.supportRompu, true);
|
|
await seance.fermer({ abandonner: true });
|
|
assert.deepEqual([seance.ouvert, seance.supportRompu], [null, false]);
|
|
});
|
|
|
|
test("le support rompu se quitte sur abandon explicite : créer, ou ouvrir un secours, avec {abandonner: true}", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
// Un état que le support refuse rompt le support ; les renommages
|
|
// rétablis ensuite, ce qui suit s'écrit.
|
|
const rompre = async (nom) => {
|
|
await seance.passerEnEcriture();
|
|
fs.pannes.renommage('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom } }), 'ETAT_NON_ECRIT');
|
|
fs.pannes.renommage('portable', 0);
|
|
assert.equal(seance.supportRompu, true);
|
|
};
|
|
await rompre('Bruyère');
|
|
const grillons = 'Soirée des Grillons';
|
|
const cree = await seance.creer({ nom: grillons, date: null, siegesParDefaut: 2, tours: 2 }, { abandonner: true });
|
|
assert.equal(cree.base, grillons);
|
|
assert.deepEqual([seance.supportRompu, seance.mode, seance.charge.evenement.nom], [false, 'lecture', grillons]);
|
|
await rompre('Cerfeuil');
|
|
const secours = await seance.ouvrirSecours(BASE, 'journal', { abandonner: true });
|
|
assert.equal(secours.base, BASE);
|
|
assert.deepEqual([seance.supportRompu, seance.mode, noms(seance.charge)], [false, 'lecture', ['Ancolie', 'Bruyère']]);
|
|
});
|
|
|
|
test("écrire ailleurs qui échoue laisse le support rompu ; sans support rompu, il est refusé SUPPORT_INTACT", async () => {
|
|
const { fs, seance } = await surPortable();
|
|
const avant = fs.compteurs.ecritures;
|
|
await refus(seance.ecrireAilleurs(DOC), 'SUPPORT_INTACT');
|
|
assert.equal(fs.compteurs.ecritures, avant);
|
|
fs.pannes.ecriture('portable');
|
|
await refus(seance.executer('ajouterParticipant', { champs: { nom: 'Bruyère' } }), 'ECRITURE');
|
|
fs.pannes.ecriture('documents');
|
|
await refus(seance.ecrireAilleurs(DOC), 'ECRITURE');
|
|
assert.equal(seance.supportRompu, true);
|
|
assert.deepEqual(seance.racine, PORTABLE);
|
|
});
|
|
});
|
|
|
|
describe("séance : ni horloge ni minuterie lues en direct (§ 14.7)", () => {
|
|
// Commentaires et chaînes blanchis : seul le code compte.
|
|
const JETONS = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\[\s\S]|[^`\\])*`/g;
|
|
const code = (texte) => texte.replace(JETONS, (jeton) => jeton.replace(/[^\n]/g, ' '));
|
|
|
|
test("ni la séance, ni les commandes, ni la table des libellés ne lisent l'horloge, un aléa ou une minuterie", () => {
|
|
for (const fichier of ['seance.js', 'commandes.js', 'libelles.js']) {
|
|
const texte = code(readFileSync(new URL(`./${fichier}`, import.meta.url), 'utf8'));
|
|
assert.ok(texte.trim().length > 0, `${fichier} vide`);
|
|
for (const motif of [/\bDate\b/, /\bsetTimeout\b/, /\bsetInterval\b/, /\bclearTimeout\b/, /\bperformance\b/, /\bMath\s*\.\s*random\b/, /\bcrypto\b/]) {
|
|
assert.doesNotMatch(texte, motif, `${fichier} : ${motif}`);
|
|
}
|
|
}
|
|
});
|
|
});
|