gestion_table_tournante_libre/src/application/seance.test.js

1794 lines
94 KiB
JavaScript
Raw Normal View History

[ADD] application: session, named commands, French label table The session holds one event, opens it read-only and writes only after "Modifier" takes the lock (§ 8.4, § 8.8); calls run one after another, so a double click writes two entries in order. Every change is a pure named command with a frozen French label (§ 8.2); undo, redo and return write entries and never truncate the journal (§ 8.3). A failed write — a gesture, or the lock on a removed key — breaks the medium until the event is written elsewhere (§ 8.6). The label table covers every code storage, CSV and the session emit, and its test sweeps the sources so a new code needs a text. Checked: 1416 node tests; depot.js at 100 % with the identifier lister keeps. --- FR --- [ADD] application : séance, commandes nommées, table des libellés La séance tient un événement, l'ouvre en lecture et n'écrit qu'après que « Modifier » a pris le verrou (§ 8.4, § 8.8) ; les appels passent l'un après l'autre : un double clic écrit deux entrées, dans l'ordre. Toute modification est une commande nommée pure au libellé français figé (§ 8.2) ; défaire, refaire et revenir écrivent des entrées sans tronquer le journal (§ 8.3). Une écriture refusée — un geste, ou le verrou sur une clé retirée — rompt le support jusqu'à l'écriture ailleurs (§ 8.6). La table des libellés couvre chaque code du stockage, du CSV et de la séance ; son épreuve balaie les sources, et un code neuf exige un texte. Vérifié : 1416 épreuves node ; depot.js à 100 %, lister gardant l'identifiant. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:36 -04:00
// © 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: {} }]);
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions 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
2026-10-07 01:20:42 -04:00
// 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']);
[ADD] application: session, named commands, French label table The session holds one event, opens it read-only and writes only after "Modifier" takes the lock (§ 8.4, § 8.8); calls run one after another, so a double click writes two entries in order. Every change is a pure named command with a frozen French label (§ 8.2); undo, redo and return write entries and never truncate the journal (§ 8.3). A failed write — a gesture, or the lock on a removed key — breaks the medium until the event is written elsewhere (§ 8.6). The label table covers every code storage, CSV and the session emit, and its test sweeps the sources so a new code needs a text. Checked: 1416 node tests; depot.js at 100 % with the identifier lister keeps. --- FR --- [ADD] application : séance, commandes nommées, table des libellés La séance tient un événement, l'ouvre en lecture et n'écrit qu'après que « Modifier » a pris le verrou (§ 8.4, § 8.8) ; les appels passent l'un après l'autre : un double clic écrit deux entrées, dans l'ordre. Toute modification est une commande nommée pure au libellé français figé (§ 8.2) ; défaire, refaire et revenir écrivent des entrées sans tronquer le journal (§ 8.3). Une écriture refusée — un geste, ou le verrou sur une clé retirée — rompt le support jusqu'à l'écriture ailleurs (§ 8.6). La table des libellés couvre chaque code du stockage, du CSV et de la séance ; son épreuve balaie les sources, et un code neuf exige un texte. Vérifié : 1416 épreuves node ; depot.js à 100 %, lister gardant l'identifiant. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:36 -04:00
});
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);
});
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions 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
2026-10-07 01:20:42 -04:00
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');
});
}
});
[ADD] application: session, named commands, French label table The session holds one event, opens it read-only and writes only after "Modifier" takes the lock (§ 8.4, § 8.8); calls run one after another, so a double click writes two entries in order. Every change is a pure named command with a frozen French label (§ 8.2); undo, redo and return write entries and never truncate the journal (§ 8.3). A failed write — a gesture, or the lock on a removed key — breaks the medium until the event is written elsewhere (§ 8.6). The label table covers every code storage, CSV and the session emit, and its test sweeps the sources so a new code needs a text. Checked: 1416 node tests; depot.js at 100 % with the identifier lister keeps. --- FR --- [ADD] application : séance, commandes nommées, table des libellés La séance tient un événement, l'ouvre en lecture et n'écrit qu'après que « Modifier » a pris le verrou (§ 8.4, § 8.8) ; les appels passent l'un après l'autre : un double clic écrit deux entrées, dans l'ordre. Toute modification est une commande nommée pure au libellé français figé (§ 8.2) ; défaire, refaire et revenir écrivent des entrées sans tronquer le journal (§ 8.3). Une écriture refusée — un geste, ou le verrou sur une clé retirée — rompt le support jusqu'à l'écriture ailleurs (§ 8.6). La table des libellés couvre chaque code du stockage, du CSV et de la séance ; son épreuve balaie les sources, et un code neuf exige un texte. Vérifié : 1416 épreuves node ; depot.js à 100 %, lister gardant l'identifiant. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:36 -04:00
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']],
);
[ADD] application: session, named commands, French label table The session holds one event, opens it read-only and writes only after "Modifier" takes the lock (§ 8.4, § 8.8); calls run one after another, so a double click writes two entries in order. Every change is a pure named command with a frozen French label (§ 8.2); undo, redo and return write entries and never truncate the journal (§ 8.3). A failed write — a gesture, or the lock on a removed key — breaks the medium until the event is written elsewhere (§ 8.6). The label table covers every code storage, CSV and the session emit, and its test sweeps the sources so a new code needs a text. Checked: 1416 node tests; depot.js at 100 % with the identifier lister keeps. --- FR --- [ADD] application : séance, commandes nommées, table des libellés La séance tient un événement, l'ouvre en lecture et n'écrit qu'après que « Modifier » a pris le verrou (§ 8.4, § 8.8) ; les appels passent l'un après l'autre : un double clic écrit deux entrées, dans l'ordre. Toute modification est une commande nommée pure au libellé français figé (§ 8.2) ; défaire, refaire et revenir écrivent des entrées sans tronquer le journal (§ 8.3). Une écriture refusée — un geste, ou le verrou sur une clé retirée — rompt le support jusqu'à l'écriture ailleurs (§ 8.6). La table des libellés couvre chaque code du stockage, du CSV et de la séance ; son épreuve balaie les sources, et un code neuf exige un texte. Vérifié : 1416 épreuves node ; depot.js à 100 %, lister gardant l'identifiant. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:36 -04:00
});
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.
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions 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
2026-10-07 01:20:42 -04:00
async function surPortable(reglages = {}) {
const fs = creerFichiersSimules({ executable: 'E:\\soirees', ...reglages });
[ADD] application: session, named commands, French label table The session holds one event, opens it read-only and writes only after "Modifier" takes the lock (§ 8.4, § 8.8); calls run one after another, so a double click writes two entries in order. Every change is a pure named command with a frozen French label (§ 8.2); undo, redo and return write entries and never truncate the journal (§ 8.3). A failed write — a gesture, or the lock on a removed key — breaks the medium until the event is written elsewhere (§ 8.6). The label table covers every code storage, CSV and the session emit, and its test sweeps the sources so a new code needs a text. Checked: 1416 node tests; depot.js at 100 % with the identifier lister keeps. --- FR --- [ADD] application : séance, commandes nommées, table des libellés La séance tient un événement, l'ouvre en lecture et n'écrit qu'après que « Modifier » a pris le verrou (§ 8.4, § 8.8) ; les appels passent l'un après l'autre : un double clic écrit deux entrées, dans l'ordre. Toute modification est une commande nommée pure au libellé français figé (§ 8.2) ; défaire, refaire et revenir écrivent des entrées sans tronquer le journal (§ 8.3). Une écriture refusée — un geste, ou le verrou sur une clé retirée — rompt le support jusqu'à l'écriture ailleurs (§ 8.6). La table des libellés couvre chaque code du stockage, du CSV et de la séance ; son épreuve balaie les sources, et un code neuf exige un texte. Vérifié : 1416 épreuves node ; depot.js à 100 %, lister gardant l'identifiant. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:36 -04:00
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]);
});
[FIX] storage, session: strict UTF-8, lost journal, lock, exclusions 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
2026-10-07 01:20:42 -04:00
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']]);
});
[ADD] application: session, named commands, French label table The session holds one event, opens it read-only and writes only after "Modifier" takes the lock (§ 8.4, § 8.8); calls run one after another, so a double click writes two entries in order. Every change is a pure named command with a frozen French label (§ 8.2); undo, redo and return write entries and never truncate the journal (§ 8.3). A failed write — a gesture, or the lock on a removed key — breaks the medium until the event is written elsewhere (§ 8.6). The label table covers every code storage, CSV and the session emit, and its test sweeps the sources so a new code needs a text. Checked: 1416 node tests; depot.js at 100 % with the identifier lister keeps. --- FR --- [ADD] application : séance, commandes nommées, table des libellés La séance tient un événement, l'ouvre en lecture et n'écrit qu'après que « Modifier » a pris le verrou (§ 8.4, § 8.8) ; les appels passent l'un après l'autre : un double clic écrit deux entrées, dans l'ordre. Toute modification est une commande nommée pure au libellé français figé (§ 8.2) ; défaire, refaire et revenir écrivent des entrées sans tronquer le journal (§ 8.3). Une écriture refusée — un geste, ou le verrou sur une clé retirée — rompt le support jusqu'à l'écriture ailleurs (§ 8.6). La table des libellés couvre chaque code du stockage, du CSV et de la séance ; son épreuve balaie les sources, et un code neuf exige un texte. Vérifié : 1416 épreuves node ; depot.js à 100 %, lister gardant l'identifiant. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 23:17:36 -04:00
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}`);
}
}
});
});