[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
This commit is contained in:
Mathieu Benoit 2026-10-06 23:17:36 -04:00
parent a3d7badf6d
commit 9530eca507
8 changed files with 3917 additions and 8 deletions

View file

@ -0,0 +1,461 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les commandes nommées (§ 8.2) : toute modification d'un événement passe par
// l'une d'elles. Une commande est une fonction pure (charge, arguments) →
// { charge, libelle, avertissements } : la charge rendue est neuve, celle
// reçue ne change jamais ; le libellé est le texte français que le journal
// fige (libelles.js) ; les avertissements, { code, details }, disent ce que le
// geste emporte de plus que son effet, sans rien empêcher. Un refus lève
// ErreurCommande(code, details), où details.remede nomme le geste qui le lève,
// null quand il n'y en a pas ; une faute du code — un argument de forme
// fausse, un état ou un champ inconnus — lève TypeError. Aucune commande
// n'écrit, ne lit l'horloge ni ne tire un aléa.
//
// La saisie entre en NFC, ses blancs de bord retirés, une chaîne vide valant
// null (§ 10.2) ; la règle d'un champ est celle du schéma du fichier d'état,
// que document.js tient seul. Les identifiants viennent de prochainsIds, qui
// ne recule jamais : un identifiant retiré ne revient pas (§ 4, § 5.7). Un
// plan bloqué refuse toute commande, débloquer excepté (§ 9).
//
// Les états du plan (§ 9) : brouillon, proposé et retenu se posent par
// changerEtat sans rien détruire, proposé quand une proposition existe,
// retenu quand un placement est retenu ; bloqué se pose de chacun d'eux, et
// seul debloquer le lève, vers l'état que le contenu impose (etatDeduit). Une
// génération fait passer un brouillon à proposé ; retenir pose retenu ;
// effacer les propositions ramène un plan proposé sans retenu à brouillon.
import { ErreurCsv } from '../csv/erreurs.js';
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { versFichier } from '../stockage/placements.js';
import { libelle } from './libelles.js';
/**
* Le refus d'une commande : son code, et des détails que l'appelant lit pour
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ;
* le texte affiché vient de la table des libellés (§ 14.6).
*/
export class ErreurCommande extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurCommande.prototype.name = 'ErreurCommande';
// Le refus de code donné : ses détails, puis le remède, null sans lui.
const refus = (code, details = {}, remede = null) => new ErreurCommande(code, { ...details, remede });
// Un avertissement : il n'empêche rien.
const avertissement = (code, details) => ({ code, details });
// Règles du schéma : celle d'un champ d'objet, puis celles de l'événement et
// d'un participant.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const EVENEMENT = regleDuChamp(CHARGE, 'evenement');
const PARTICIPANT = regleDuChamp(CHARGE, 'participants').element;
// Le plus grand identifiant qu'admet la règle : le compteur, qui la suit,
// n'aurait plus de valeur au-delà de lui.
const IDENTIFIANT_MAX = regleDuChamp(PARTICIPANT, 'id').max;
const ETATS = ['brouillon', 'propose', 'retenu', 'bloque'];
// Les champs de texte d'un participant que la saisie pose, dans l'ordre du
// fichier.
const CHAMPS_DE_SAISIE = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
// Une saisie de texte : en NFC, ses blancs de bord retirés, null quand il ne
// reste rien ; null et l'absence valent null.
function saisie(valeur, champ) {
if (valeur === undefined || valeur === null) return null;
if (typeof valeur !== 'string') {
throw new TypeError(`${champ} : chaîne ou null attendue, reçu ${JSON.stringify(valeur)}`);
}
const texte = valeur.normalize('NFC').trim();
return texte === '' ? null : texte;
}
// Les champs d'une saisie de participant : chaque champ de texte présent, lu
// par saisie, et exclu quand admisExclu, un booléen. Un champ inconnu, ou
// d'une autre sorte, est une faute du code.
function lireChamps(champs, { admisExclu }) {
if (champs === null || typeof champs !== 'object' || Array.isArray(champs)) {
throw new TypeError(`champs : objet attendu, reçu ${JSON.stringify(champs)}`);
}
const permis = admisExclu ? [...CHAMPS_DE_SAISIE, 'exclu'] : CHAMPS_DE_SAISIE;
const inconnu = clesRangees(champs).find((cle) => !permis.includes(cle));
if (inconnu !== undefined) throw new TypeError(`champs : ${inconnu} n'est pas un champ de cette saisie`);
const lus = {};
for (const champ of CHAMPS_DE_SAISIE) if (Object.hasOwn(champs, champ)) lus[champ] = saisie(champs[champ], champ);
if (Object.hasOwn(champs, 'exclu')) {
if (typeof champs.exclu !== 'boolean') {
throw new TypeError(`exclu : booléen attendu, reçu ${JSON.stringify(champs.exclu)}`);
}
lus.exclu = champs.exclu;
}
return lus;
}
// Un participant tel que la saisie le pose : nom requis (NOM_REQUIS) ; toute
// autre valeur hors de sa règle est une faute du code.
function exigerParticipant(participant) {
if (premiereFaute(participant.nom, regleDuChamp(PARTICIPANT, 'nom')) !== null) throw refus('NOM_REQUIS');
const faute = premiereFaute(participant, PARTICIPANT);
if (faute !== null) throw new TypeError(`participant : ${faute} hors de sa règle`);
}
// Le participant d'identifiant id, ou PARTICIPANT_INCONNU.
function participantDe(charge, id) {
if (!Number.isSafeInteger(id)) throw new TypeError(`id : entier attendu, reçu ${JSON.stringify(id)}`);
const participant = charge.participants.find((candidat) => candidat.id === id);
if (participant === undefined) throw refus('PARTICIPANT_INCONNU', { id });
return participant;
}
// Le nom d'une personne tel que les libellés l'écrivent : prénom, puis nom.
const personneDe = ({ nom, prenom }) => (prenom === null ? nom : `${prenom} ${nom}`);
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
}
// Titres de place pourvus (§ 4.1, § 4.4) : le compte que bilanRemplacement
// tient pour l'import, seule implémentation de la règle (§ 13.2).
const titresPourvus = (charge) => bilanRemplacement(charge).titresPourvus;
// La charge reçue, copiée, son participant id remplacé par ce que rend
// modifier de sa copie.
function avecParticipant(charge, id, modifier) {
const suivante = structuredClone(charge);
suivante.participants = suivante.participants.map((participant) =>
participant.id === id ? modifier(participant) : participant,
);
return suivante;
}
// La charge reçue, copiée, à l'état donné.
function avecEtat(charge, etat) {
const suivante = structuredClone(charge);
suivante.evenement.etat = etat;
return suivante;
}
/** ajouterParticipant {champs} : seul le nom est requis ; exclu vaut faux par défaut. */
function ajouterParticipant(charge, { champs }) {
exigerModifiable(charge);
const lus = lireChamps(champs, { admisExclu: true });
const id = charge.prochainsIds.participant;
const participant = {
id,
nom: lus.nom ?? null,
prenom: lus.prenom ?? null,
appartenance: lus.appartenance ?? null,
courriel: lus.courriel ?? null,
titrePressenti: lus.titrePressenti ?? null,
notes: lus.notes ?? null,
exclu: lus.exclu ?? false,
};
exigerParticipant(participant);
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'participant' });
const suivante = structuredClone(charge);
suivante.participants.push(participant);
suivante.prochainsIds.participant = id + 1;
return {
charge: suivante,
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }),
avertissements: [],
};
}
/**
* modifierParticipant {id, champs} : les champs de texte donnés ; l'exclusion
* a ses deux commandes. Le libellé nomme la personne d'avant et chaque champ
* changé, puis le nouveau nom quand il change.
*/
function modifierParticipant(charge, { id, champs }) {
exigerModifiable(charge);
const avant = participantDe(charge, id);
const apres = { ...avant, ...lireChamps(champs, { admisExclu: false }) };
exigerParticipant(apres);
const changes = CHAMPS_DE_SAISIE.filter((champ) => apres[champ] !== avant[champ]);
if (changes.length === 0) throw refus('SANS_EFFET', { commande: 'modifierParticipant' });
const renomme = apres.nom !== avant.nom || apres.prenom !== avant.prenom;
return {
charge: avecParticipant(charge, id, () => apres),
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
personne: personneDe(avant),
champs: changes,
devenu: renomme ? personneDe(apres) : null,
}),
avertissements: [],
};
}
// Les comptes d'une exclusion ou d'une réintégration (§ 4.4) : les
// réservations de la personne, que l'exclusion suspend sans les retirer, et
// les titres que le geste cesse de pourvoir, ou pourvoit de nouveau.
function bilanExclusion(avant, apres, participant) {
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
return { participant: participant.id, personne: personneDe(participant), reservations, titres };
}
/**
* exclureParticipant {id} (§ 4.4) : ses réservations restent, suspendues ; ses
* places reviennent aux autres et ses titres ne sont plus pourvus, ce que
* RESERVATIONS_SUSPENDUES dit quand il en porte.
*/
function exclureParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
if (participant.exclu) throw refus('SANS_EFFET', { commande: 'exclureParticipant' });
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: true }));
const bilan = bilanExclusion(charge, suivante, participant);
return {
charge: suivante,
libelle: libelle('ENTREE_EXCLURE_PARTICIPANT', { personne: bilan.personne }),
avertissements: bilan.reservations > 0 ? [avertissement('RESERVATIONS_SUSPENDUES', bilan)] : [],
};
}
/** reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres reviennent, RESERVATIONS_RETABLIES. */
function reintegrerParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
const bilan = bilanExclusion(charge, suivante, participant);
return {
charge: suivante,
libelle: libelle('ENTREE_REINTEGRER_PARTICIPANT', { personne: bilan.personne }),
avertissements: bilan.reservations > 0 ? [avertissement('RESERVATIONS_RETABLIES', bilan)] : [],
};
}
/**
* supprimerParticipant {id} : la personne et ses réservations partent dans la
* même entrée ; les titres restent à leurs places, les propositions et le
* retenu restent, en dérive (§ 9) ; le compteur ne recule pas.
*/
function supprimerParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
const suivante = structuredClone(charge);
suivante.participants = suivante.participants.filter((candidat) => candidat.id !== id);
suivante.reservations = suivante.reservations.filter((reservation) => reservation.participant !== id);
const reservations = charge.reservations.length - suivante.reservations.length;
const details = {
participant: id,
personne: personneDe(participant),
reservations,
titres: titresPourvus(charge) - titresPourvus(suivante),
};
return {
charge: suivante,
libelle: libelle('ENTREE_SUPPRIMER_PARTICIPANT', { personne: details.personne }),
avertissements: reservations > 0 ? [avertissement('RESERVATIONS_RETIREES', details)] : [],
};
}
/**
* importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le
* mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu
* refusé ou ambigu — devient le refus de la commande, sous le même code. Le
* résumé suit le rendu, pour le rapport d'import et le CSV des refus. Selon
* l'état : sans avertissement en brouillon et proposé ; en retenu,
* l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
* signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE.
*/
function importerParticipants(charge, { apercu, mode }) {
exigerModifiable(charge);
let resultat;
try {
resultat = appliquerImport(charge, apercu, mode);
} catch (erreur) {
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
throw erreur;
}
const { ajoutes, misAJour, retires, derive } = resultat.resume;
return {
charge: resultat.charge,
libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }),
avertissements: derive ? [avertissement('IMPORT_EN_RETENU', {})] : [],
resume: resultat.resume,
};
}
/** changerEtat {etat} (§ 9) : un état du plan, sans rien détruire ; bloque se pose de chacun. */
function changerEtat(charge, { etat }) {
if (!ETATS.includes(etat)) throw new TypeError(`etat : ${ETATS.join(', ')} attendu, reçu ${JSON.stringify(etat)}`);
exigerModifiable(charge);
if (etat === charge.evenement.etat) throw refus('SANS_EFFET', { commande: 'changerEtat' });
if (etat === 'propose' && charge.propositions.length === 0) {
throw refus('AUCUNE_PROPOSITION', {}, { geste: 'ouvrirSection', section: 'generation' });
}
if (etat === 'retenu' && charge.retenu === null) {
throw refus('AUCUN_RETENU', {}, { geste: 'ouvrirSection', section: 'propositions' });
}
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_CHANGER_ETAT', { etat }), avertissements: [] };
}
/** debloquer {} (§ 8.5, § 9) : l'état revient à celui que le contenu impose. */
function debloquer(charge) {
if (charge.evenement.etat !== 'bloque') throw refus('PLAN_NON_BLOQUE');
const etat = etatDeduit(charge);
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_DEBLOQUER', { etat }), avertissements: [] };
}
/**
* enregistrerGeneration {propositions, configuration, produitVersion} (§ 5.7,
* § 8.9) : les propositions du moteur, mises en forme de fichier
* (versFichier) sous les identifiants qui suivent prochainsIds.proposition —
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
* dernière. Un brouillon passe à proposé.
*/
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
exigerModifiable(charge);
if (!Array.isArray(propositions)) throw new TypeError('propositions : liste attendue');
if (propositions.length === 0) throw refus('SANS_EFFET', { commande: 'enregistrerGeneration' });
const rangs = propositions.map(({ id }) => id);
if (!rangs.every((id) => Number.isSafeInteger(id) && id >= 1) || new Set(rangs).size !== rangs.length) {
throw new TypeError('propositions : identifiants du moteur entiers, ≥ 1 et distincts attendus');
}
const decalage = charge.prochainsIds.proposition - 1;
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
let fichiers;
try {
fichiers = propositions.map((proposition) => versFichier(proposition, configuration, options));
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
throw refus('COMPTEUR_SATURE', erreur.details);
}
throw erreur;
}
const ids = fichiers.map(({ id }) => id);
const premier = Math.min(...ids);
const dernier = Math.max(...ids);
const suivante = structuredClone(charge);
suivante.propositions.push(...fichiers);
suivante.prochainsIds.proposition = dernier + 1;
if (suivante.evenement.etat === 'brouillon') suivante.evenement.etat = 'propose';
return {
charge: suivante,
libelle: libelle('ENTREE_ENREGISTRER_GENERATION', { n: fichiers.length, premier, dernier }),
avertissements: [],
};
}
/**
* retenirProposition {id} (§ 5.7, § 9) : le placement de la proposition,
* copié, devient le retenu, que les retouches modifieront sans toucher la
* proposition ; le plan passe à retenu.
*/
function retenirProposition(charge, { id }) {
exigerModifiable(charge);
const proposition = charge.propositions.find((candidate) => candidate.id === id);
if (proposition === undefined) throw refus('PROPOSITION_INCONNUE', { id });
const { siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(proposition);
const suivante = avecEtat(charge, 'retenu');
suivante.retenu = { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
return { charge: suivante, libelle: libelle('ENTREE_RETENIR_PROPOSITION', { id }), avertissements: [] };
}
/**
* effacerPropositions {} (§ 5.7) : la liste se vide, la proposition d'où vient
* le retenu exceptée ; le compteur ne recule pas. Un plan proposé sans retenu
* revient à brouillon.
*/
function effacerPropositions(charge) {
exigerModifiable(charge);
const origine = charge.retenu?.proposition ?? null;
const gardees = charge.propositions.filter(({ id }) => id === origine);
const n = charge.propositions.length - gardees.length;
if (n === 0) throw refus('SANS_EFFET', { commande: 'effacerPropositions' });
const suivante = structuredClone(charge);
suivante.propositions = suivante.propositions.filter(({ id }) => id === origine);
if (suivante.retenu === null && suivante.evenement.etat === 'propose') suivante.evenement.etat = 'brouillon';
return {
charge: suivante,
libelle: libelle('ENTREE_EFFACER_PROPOSITIONS', { n, gardee: gardees.length > 0 ? origine : null }),
avertissements: [],
};
}
/**
* Le registre des commandes, par nom : chacune (charge, arguments) →
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
* résumé de l'import.
*
* @typedef {{code: string, details: Object}} Avertissement
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
*/
export const COMMANDES = new Map([
['ajouterParticipant', ajouterParticipant],
['modifierParticipant', modifierParticipant],
['exclureParticipant', exclureParticipant],
['reintegrerParticipant', reintegrerParticipant],
['supprimerParticipant', supprimerParticipant],
['importerParticipants', importerParticipants],
['changerEtat', changerEtat],
['debloquer', debloquer],
['enregistrerGeneration', enregistrerGeneration],
['retenirProposition', retenirProposition],
['effacerPropositions', effacerPropositions],
]);
/**
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
* quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la
* charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET.
*
* @param {string} nom
* @param {Object} charge
* @param {Object} [args]
* @returns {{charge: Object, libelle: string, avertissements: Array<{code: string, details: Object}>}}
* @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET
* @throws {TypeError} une commande inconnue, un argument de forme fausse
*/
export function appliquerCommande(nom, charge, args = {}) {
const commande = COMMANDES.get(nom);
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
const rendu = commande(charge, args ?? {});
if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
return { ...rendu, avertissements: rendu.avertissements ?? [] };
}
/**
* La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque
* champ se contrôle selon sa règle avant que l'identifiant ne se tire :
* NOM_REQUIS, DATE_INVALIDE {date}, SIEGES_INVALIDES {sieges},
* TOURS_INVALIDES {tours}. Le nom entre en NFC, ses blancs de bord retirés ;
* une date vide vaut null.
*
* @param {{nom: string, date?: string|null, siegesParDefaut: number, tours: number}} evenement
* @param {() => string} identifiant tiré une fois, après les contrôles
* @returns {{charge: Object, libelle: string}}
* @throws {ErreurCommande}
*/
export function chargeNeuve({ nom, date = null, siegesParDefaut, tours }, identifiant) {
const regle = (cle) => regleDuChamp(EVENEMENT, cle);
const nomLu = saisie(nom, 'nom');
const dateLue = saisie(date, 'date');
if (premiereFaute(nomLu, regle('nom')) !== null) throw refus('NOM_REQUIS');
if (premiereFaute(dateLue, regle('date')) !== null) throw refus('DATE_INVALIDE', { date });
if (premiereFaute(siegesParDefaut, regle('siegesParDefaut')) !== null) {
throw refus('SIEGES_INVALIDES', { sieges: siegesParDefaut });
}
if (premiereFaute(tours, regle('tours')) !== null) throw refus('TOURS_INVALIDES', { tours });
const charge = creerCharge({ id: identifiant(), nom: nomLu, date: dateLue, siegesParDefaut, tours });
return { charge, libelle: libelle('ENTREE_CREATION', { nom: nomLu }) };
}

View file

@ -0,0 +1,504 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des commandes nommées (§ 4, § 4.4, § 5.7, § 8.2, § 9, § 10.1) :
// chacune rend une charge neuve et un libellé figé, ne touche pas la charge
// reçue, tire ses identifiants de prochainsIds sans jamais en réutiliser un,
// et refuse par un code. Les noms d'épreuve sont inventés ; la version vient
// du module engendré.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { apercevoir } from '../csv/apercu.js';
import { bilanRemplacement } from '../csv/import.js';
import { rechercher } from '../moteur/recherche.js';
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { derive, planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve } from './commandes.js';
const V1 = VERSION.affichee;
const personne = (id, nom, prenom = null, appartenance = null) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
const table = (id, x) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x, y: 0 } });
// 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: 'Soirée des Lucioles', 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 = [table(1, 0), table(2, 250)];
charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }];
charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }];
return charge;
}
// Les arguments d'enregistrerGeneration : une génération courte du moteur
// sur la configuration de la charge.
function generer(charge, nombre = 3, graine = 7) {
const configuration = configurationDepuisCharge(charge);
return { propositions: rechercher(configuration, { graine, arret: 40, nombre }), configuration, produitVersion: V1 };
}
// La charge d'essai, une cinquième personne exclue, deux propositions et la
// première retenue.
function chargeRiche() {
const charge = chargeEssai();
charge.participants.push({ ...personne(5, 'Ancolie', 'Amandine'), exclu: true });
charge.prochainsIds.participant = 6;
const generee = COMMANDES.get('enregistrerGeneration')(charge, generer(charge, 2)).charge;
return COMMANDES.get('retenirProposition')(generee, { id: 1 }).charge;
}
// Gèle une valeur en profondeur : une commande qui la modifierait lèverait.
function figer(valeur) {
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
Object.freeze(valeur);
for (const element of Object.values(valeur)) figer(element);
}
return valeur;
}
// Le refus attendu : une ErreurCommande de ce code, et de ces détails quand
// ils sont donnés.
function refuse(appel, code, details) {
assert.throws(appel, (erreur) => {
assert.ok(erreur instanceof ErreurCommande, `ErreurCommande attendue, reçu ${erreur}`);
assert.equal(erreur.code, code);
if (details !== undefined) assert.deepEqual(erreur.details, details);
return true;
});
}
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
describe('commandes : le registre (§ 8.2)', () => {
test('le registre porte les onze commandes du plan, chacune une fonction', () => {
assert.deepEqual([...COMMANDES.keys()].sort(), [
'ajouterParticipant',
'changerEtat',
'debloquer',
'effacerPropositions',
'enregistrerGeneration',
'exclureParticipant',
'importerParticipants',
'modifierParticipant',
'reintegrerParticipant',
'retenirProposition',
'supprimerParticipant',
]);
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
});
test('appliquerCommande rend charge, libellé et avertissements ; un nom inconnu est une faute du code', () => {
const rendu = appliquerCommande('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère' } });
assert.equal(rendu.libelle, 'Ajouté Bruyère');
assert.deepEqual(rendu.avertissements, []);
assert.equal(rendu.charge.participants.length, 5);
assert.throws(() => appliquerCommande('commandeInventee', chargeEssai(), {}), TypeError);
});
test('un geste qui ne change pas la charge est refusé SANS_EFFET', () => {
const charge = chargeEssai();
const apercu = apercevoir('nom;prenom\nLacasse;Ondine\n', { participants: charge.participants });
refuse(() => appliquerCommande('importerParticipants', charge, { apercu, mode: 'mettreAJour' }), 'SANS_EFFET', {
commande: 'importerParticipants',
remede: null,
});
});
});
describe('commandes : les participants (§ 4, § 4.4, § 10.2)', () => {
test('ajouter : seul le nom est requis ; les autres champs valent null, exclu faux ; le compteur avance', () => {
const { charge, libelle, avertissements } = executer('ajouterParticipant', chargeEssai(), { champs: { nom: ' Pervenche ' } });
assert.deepEqual(charge.participants.at(-1), personne(5, 'Pervenche'));
assert.equal(charge.prochainsIds.participant, 6);
assert.equal(libelle, 'Ajouté Pervenche');
assert.deepEqual(avertissements, []);
const complet = executer('ajouterParticipant', chargeEssai(), {
champs: { nom: 'Parasol', prenom: 'Iris', appartenance: 'Club des Merles', courriel: '', notes: ' arrive tard ', exclu: true },
});
assert.deepEqual(complet.charge.participants.at(-1), {
...personne(5, 'Parasol', 'Iris', 'Club des Merles'),
notes: 'arrive tard',
exclu: true,
});
assert.equal(complet.libelle, 'Ajouté Iris Parasol');
});
test('ajouter : un nom saisi en NFD est enregistré en NFC', () => {
const { charge, libelle } = executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Pervenche', prenom: 'The\u{301}o' } });
assert.equal(charge.participants.at(-1).prenom, 'Th\u{E9}o');
assert.equal(libelle, 'Ajouté Th\u{E9}o Pervenche');
});
test('ajouter : un nom vide ou blanc est refusé NOM_REQUIS ; un champ inconnu ou de forme fausse est une faute du code', () => {
refuse(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: ' ' } }), 'NOM_REQUIS', { remede: null });
refuse(() => executer('ajouterParticipant', chargeEssai(), { champs: { prenom: 'Iris' } }), 'NOM_REQUIS');
assert.throws(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère', age: 3 } }), TypeError);
assert.throws(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère', exclu: 'oui' } }), TypeError);
assert.throws(() => executer('ajouterParticipant', chargeEssai(), { champs: { nom: 42 } }), TypeError);
});
test("un identifiant ne se réutilise jamais : supprimer ne fait pas reculer le compteur (§ 4)", () => {
const ajoutee = executer('ajouterParticipant', chargeEssai(), { champs: { nom: 'Bruyère' } }).charge;
const supprimee = executer('supprimerParticipant', ajoutee, { id: 5 }).charge;
assert.equal(supprimee.prochainsIds.participant, 6);
const rajoutee = executer('ajouterParticipant', supprimee, { champs: { nom: 'Bruyère' } }).charge;
assert.deepEqual(rajoutee.participants.map(({ id }) => id), [1, 2, 3, 4, 6]);
});
test('modifier : le libellé nomme la personne et chaque champ changé', () => {
const { charge, libelle } = executer('modifierParticipant', chargeEssai(), { id: 4, champs: { courriel: ' o@exemple.test ' } });
assert.equal(charge.participants[3].courriel, 'o@exemple.test');
assert.equal(libelle, 'Modifié Ondine Lacasse : courriel');
const deux = executer('modifierParticipant', chargeEssai(), { id: 3, champs: { notes: 'à jeun', titrePressenti: 'hôte', courriel: null } });
assert.equal(deux.libelle, 'Modifié Théo Pervenche : titre pressenti, notes');
});
test("modifier : renommer garde l'ancien nom au libellé et nomme le nouveau", () => {
const { charge, libelle } = executer('modifierParticipant', chargeEssai(), { id: 1, champs: { nom: 'Parasol' } });
assert.equal(charge.participants[0].nom, 'Parasol');
assert.equal(libelle, 'Modifié Iris Ombrelle : nom (désormais Iris Parasol)');
});
test('modifier : sans changement SANS_EFFET ; personne inconnue ; nom vidé ; exclu passe par sa commande', () => {
refuse(() => executer('modifierParticipant', chargeEssai(), { id: 4, champs: { nom: 'Lacasse', courriel: '' } }), 'SANS_EFFET');
refuse(() => executer('modifierParticipant', chargeEssai(), { id: 9, champs: { nom: 'Lacasse' } }), 'PARTICIPANT_INCONNU', {
id: 9,
remede: null,
});
refuse(() => executer('modifierParticipant', chargeEssai(), { id: 4, champs: { nom: ' ' } }), 'NOM_REQUIS');
assert.throws(() => executer('modifierParticipant', chargeEssai(), { id: 4, champs: { exclu: true } }), TypeError);
});
test("exclure : les réservations restent, suspendues ; le titre n'est plus pourvu ; l'avertissement le dit (§ 4.4)", () => {
const avant = chargeEssai();
assert.equal(bilanRemplacement(avant).titresPourvus, 1);
const { charge, libelle, avertissements } = executer('exclureParticipant', avant, { id: 1 });
assert.equal(charge.participants[0].exclu, true);
assert.deepEqual(charge.reservations, avant.reservations);
assert.deepEqual(charge.titres, avant.titres);
assert.equal(bilanRemplacement(charge).titresPourvus, 0);
assert.equal(libelle, 'Exclu Iris Ombrelle');
assert.deepEqual(avertissements, [
{ code: 'RESERVATIONS_SUSPENDUES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
]);
});
test('réintégrer rend les réservations et le titre, et le dit', () => {
const exclue = executer('exclureParticipant', chargeEssai(), { id: 1 }).charge;
const { charge, libelle, avertissements } = executer('reintegrerParticipant', exclue, { id: 1 });
assert.equal(charge.participants[0].exclu, false);
assert.equal(bilanRemplacement(charge).titresPourvus, 1);
assert.equal(libelle, 'Réintégré Iris Ombrelle');
assert.deepEqual(avertissements, [
{ code: 'RESERVATIONS_RETABLIES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
]);
});
test("exclure sans réservation n'avertit pas ; exclure deux fois, réintégrer une présente : SANS_EFFET", () => {
assert.deepEqual(executer('exclureParticipant', chargeEssai(), { id: 3 }).avertissements, []);
const exclue = executer('exclureParticipant', chargeEssai(), { id: 3 }).charge;
refuse(() => executer('exclureParticipant', exclue, { id: 3 }), 'SANS_EFFET');
refuse(() => executer('reintegrerParticipant', chargeEssai(), { id: 3 }), 'SANS_EFFET');
refuse(() => executer('exclureParticipant', chargeEssai(), { id: 9 }), 'PARTICIPANT_INCONNU');
});
test('supprimer : ses réservations partent, les titres et les propositions restent, signalées en dérive', () => {
const avant = chargeRiche();
const { charge, libelle, avertissements } = executer('supprimerParticipant', avant, { id: 1 });
assert.deepEqual(charge.participants.map(({ id }) => id), [2, 3, 4, 5]);
assert.deepEqual(charge.reservations, []);
assert.deepEqual(charge.titres, avant.titres);
assert.deepEqual(charge.prochainsIds, avant.prochainsIds);
assert.deepEqual(charge.propositions, avant.propositions);
assert.ok(derive(charge.propositions[0], charge).some(({ code, participant }) => code === 'PARTICIPANT_SUPPRIME' && participant === 1));
assert.equal(libelle, 'Supprimé Iris Ombrelle');
assert.deepEqual(avertissements, [
{ code: 'RESERVATIONS_RETIREES', details: { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 } },
]);
refuse(() => executer('supprimerParticipant', chargeEssai(), { id: 9 }), 'PARTICIPANT_INCONNU');
});
});
describe("commandes : l'import (§ 10.1)", () => {
test('ajouter : chaque ligne valide devient une personne, sous le compteur, en une seule commande', () => {
const avant = chargeEssai();
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
const { charge, libelle, resume } = executer('importerParticipants', avant, { apercu, mode: 'ajouter' });
assert.deepEqual(charge.participants.slice(4).map(({ id, nom, prenom, appartenance }) => [id, nom, prenom, appartenance]), [
[5, 'Ancolie', 'Bastien', 'Club des Merles'],
[6, 'Bruyère', 'Capucine', null],
]);
assert.equal(charge.prochainsIds.participant, 7);
assert.equal(libelle, 'Importé 2 participants');
assert.equal(resume.ajoutes, 2);
});
test('remplacer et mettre à jour se nomment au libellé, chaque compte à sa place', () => {
const avant = chargeEssai();
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
const remplace = executer('importerParticipants', avant, { apercu, mode: 'remplacer' });
assert.equal(remplace.libelle, 'Remplacé la liste par import : 4 retirés, 2 ajoutés');
assert.deepEqual(remplace.charge.reservations, []);
// Deux ajoutés, un modifié : des comptes distincts, qu'un libellé qui les
// intervertirait ne rendrait pas.
const csv = 'nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\nAncolie;Bastien;\nBruyère;Capucine;\n';
const misAJour = executer('importerParticipants', avant, {
apercu: apercevoir(csv, { participants: avant.participants }),
mode: 'mettreAJour',
});
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [2, 1]);
assert.equal(misAJour.libelle, 'Mis à jour la liste par import : 2 ajoutés, 1 modifié');
});
test("importer selon l'état : rien en brouillon et proposé, l'avertissement de dérive en retenu, refusé en bloqué", () => {
const enRetenu = [{ code: 'IMPORT_EN_RETENU', details: {} }];
const brouillon = chargeEssai();
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
const retenue = chargeRiche();
assert.deepEqual([brouillon, proposee, retenue].map(({ evenement }) => evenement.etat), ['brouillon', 'propose', 'retenu']);
const ajout = (charge, mode = 'ajouter') => ({ apercu: apercevoir('nom\nBruyère\n', { participants: charge.participants }), mode });
assert.deepEqual(executer('importerParticipants', brouillon, ajout(brouillon)).avertissements, []);
assert.deepEqual(executer('importerParticipants', proposee, ajout(proposee)).avertissements, []);
for (const mode of ['ajouter', 'mettreAJour', 'remplacer']) {
assert.deepEqual(executer('importerParticipants', retenue, ajout(retenue, mode)).avertissements, enRetenu, mode);
}
// Une mise à jour sans personne nouvelle avertit aussi : le placement
// retenu date de la liste d'avant.
const courriel = apercevoir('nom;prenom;courriel\nLacasse;Ondine;o@exemple.test\n', { participants: retenue.participants });
const misAJour = executer('importerParticipants', retenue, { apercu: courriel, mode: 'mettreAJour' });
assert.deepEqual([misAJour.resume.ajoutes, misAJour.resume.misAJour], [0, 1]);
assert.deepEqual(misAJour.avertissements, enRetenu);
const bloquee = { ...retenue, evenement: { ...retenue.evenement, etat: 'bloque' } };
refuse(() => executer('importerParticipants', bloquee, ajout(bloquee)), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
});
test("un aperçu refusé ou ambigu est refusé sous son code ; un plan bloqué nomme le remède « Débloquer »", () => {
const avant = chargeEssai();
const sansNom = apercevoir('prenom;courriel\nIris;i@exemple.test\n', { participants: avant.participants });
refuse(() => executer('importerParticipants', avant, { apercu: sansNom, mode: 'ajouter' }), 'NOM_NON_ASSOCIE', {
ligne: null,
remede: null,
});
const ambigu = apercevoir('nom;organisation;entreprise\nAncolie;A;B\n', { participants: avant.participants });
refuse(() => executer('importerParticipants', avant, { apercu: ambigu, mode: 'ajouter' }), 'AMBIGUITE', {
champs: ['appartenance'],
remede: null,
});
const bloquee = { ...avant, evenement: { ...avant.evenement, etat: 'bloque' } };
const apercu = apercevoir(CSV_DEUX, { participants: avant.participants });
refuse(() => executer('importerParticipants', bloquee, { apercu, mode: 'ajouter' }), 'PLAN_BLOQUE', {
remede: { geste: 'debloquer' },
});
});
});
describe('commandes : les états du plan (§ 9)', () => {
// Une charge à l'état donné : vide, avec des propositions, ou avec un retenu.
function chargeA(etat, contenu) {
const charge = contenu === 'vide' ? chargeEssai() : chargeRiche();
if (contenu === 'propositions') charge.retenu = null;
charge.evenement.etat = etat;
return charge;
}
test('les transitions admises et refusées, chacune sans rien détruire', () => {
// [de, contenu, vers, refus attendu ou null]
const cas = [
['brouillon', 'vide', 'propose', 'AUCUNE_PROPOSITION'],
['brouillon', 'vide', 'retenu', 'AUCUN_RETENU'],
['brouillon', 'vide', 'bloque', null],
['brouillon', 'propositions', 'propose', null],
['brouillon', 'propositions', 'retenu', 'AUCUN_RETENU'],
['propose', 'propositions', 'brouillon', null],
['propose', 'retenu', 'retenu', null],
['retenu', 'retenu', 'propose', null],
['retenu', 'retenu', 'brouillon', null],
['retenu', 'retenu', 'bloque', null],
['retenu', 'retenu', 'retenu', 'SANS_EFFET'],
['bloque', 'retenu', 'retenu', 'PLAN_BLOQUE'],
['bloque', 'retenu', 'brouillon', 'PLAN_BLOQUE'],
];
for (const [de, contenu, vers, refus] of cas) {
const avant = chargeA(de, contenu);
if (refus !== null) {
refuse(() => executer('changerEtat', avant, { etat: vers }), refus);
continue;
}
const { charge, libelle } = executer('changerEtat', avant, { etat: vers });
assert.equal(charge.evenement.etat, vers, `${de} → ${vers}`);
assert.deepEqual({ ...charge, evenement: { ...charge.evenement, etat: de } }, avant, `${de} → ${vers} : rien d'autre ne change`);
assert.match(libelle, /^Plan passé à l'état « /);
}
});
test('un refus de transition nomme le remède ; un état inconnu est une faute du code', () => {
refuse(() => executer('changerEtat', chargeA('brouillon', 'vide'), { etat: 'propose' }), 'AUCUNE_PROPOSITION', {
remede: { geste: 'ouvrirSection', section: 'generation' },
});
refuse(() => executer('changerEtat', chargeA('propose', 'propositions'), { etat: 'retenu' }), 'AUCUN_RETENU', {
remede: { geste: 'ouvrirSection', section: 'propositions' },
});
assert.throws(() => executer('changerEtat', chargeA('brouillon', 'vide'), { etat: 'archive' }), TypeError);
});
test("débloquer rend l'état que le contenu impose ; un plan qui n'est pas bloqué est refusé", () => {
for (const contenu of ['vide', 'propositions', 'retenu']) {
const avant = chargeA('bloque', contenu);
const { charge, libelle } = executer('debloquer', avant, {});
assert.equal(charge.evenement.etat, etatDeduit(avant));
assert.match(libelle, /^Plan débloqué/);
}
refuse(() => executer('debloquer', chargeA('retenu', 'retenu'), {}), 'PLAN_NON_BLOQUE', { remede: null });
});
});
describe('commandes : générer, retenir, effacer (§ 5.7, § 8.9)', () => {
test('les propositions prennent leurs identifiants à partir du compteur, et le relèvent ; brouillon passe à proposé', () => {
const avant = chargeEssai();
avant.prochainsIds.proposition = 4;
const args = generer(avant, 3);
const { charge, libelle } = executer('enregistrerGeneration', avant, args);
assert.deepEqual(charge.propositions.map(({ id }) => id), [4, 5, 6]);
assert.equal(charge.prochainsIds.proposition, 7);
assert.equal(charge.evenement.etat, 'propose');
assert.equal(libelle, 'Généré 3 propositions (n° 4 à 6)');
charge.propositions.forEach((p, rang) => {
const source = args.propositions[rang];
assert.deepEqual([p.graine, p.arret, p.historique, p.produitVersion], [source.graine, source.arret, source.historique, V1]);
assert.deepEqual(planDepuisFichier(p), source.plan);
assert.equal(p.siegesAttribues, avant.reglages.attribuerSieges);
});
});
test("une seconde génération continue la numérotation ; après un effacement, aucun identifiant ne revient", () => {
const premiere = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 3)).charge;
const seconde = executer('enregistrerGeneration', premiere, generer(premiere, 2, 11)).charge;
assert.deepEqual(seconde.propositions.map(({ id }) => id), [1, 2, 3, 4, 5]);
assert.equal(seconde.evenement.etat, 'propose');
const effacee = executer('effacerPropositions', seconde, {}).charge;
const troisieme = executer('enregistrerGeneration', effacee, generer(effacee, 1, 13)).charge;
assert.deepEqual(troisieme.propositions.map(({ id }) => id), [6]);
});
test("une liste vide est sans effet ; des identifiants du moteur en double sont une faute du code", () => {
refuse(() => executer('enregistrerGeneration', chargeEssai(), { ...generer(chargeEssai(), 1), propositions: [] }), 'SANS_EFFET');
const args = generer(chargeEssai(), 2);
args.propositions[1] = { ...args.propositions[1], id: 1 };
assert.throws(() => executer('enregistrerGeneration', chargeEssai(), args), TypeError);
});
test("retenir pose le placement en vigueur, copié de la proposition, et l'état Retenu", () => {
const generee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
const { charge, libelle } = executer('retenirProposition', generee, { id: 2 });
const { id, graine, arret, historique, produitVersion, ...plan } = generee.propositions[1];
assert.deepEqual(charge.retenu, { proposition: 2, ...plan });
assert.equal(charge.evenement.etat, 'retenu');
assert.deepEqual(charge.propositions, generee.propositions);
assert.equal(libelle, 'Retenu la proposition n° 2');
assert.deepEqual([id, typeof graine, typeof arret, typeof historique, produitVersion], [2, 'number', 'number', 'number', V1]);
refuse(() => executer('retenirProposition', generee, { id: 9 }), 'PROPOSITION_INCONNUE', { id: 9, remede: null });
});
test("effacer épargne la proposition retenue ; sans retenu, le plan revient en brouillon ; rien à effacer : SANS_EFFET", () => {
const riche = chargeRiche();
const { charge, libelle } = executer('effacerPropositions', riche, {});
assert.deepEqual(charge.propositions.map(({ id }) => id), [1]);
assert.equal(charge.prochainsIds.proposition, riche.prochainsIds.proposition);
assert.equal(charge.evenement.etat, 'retenu');
assert.equal(libelle, 'Effacé 1 proposition, gardé la retenue (n° 1)');
refuse(() => executer('effacerPropositions', charge, {}), 'SANS_EFFET');
const proposee = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge;
const videe = executer('effacerPropositions', proposee, {});
assert.deepEqual(videe.charge.propositions, []);
assert.equal(videe.charge.evenement.etat, 'brouillon');
assert.equal(videe.libelle, 'Effacé 2 propositions');
});
});
describe("commandes : la charge d'un événement neuf (§ 8.1)", () => {
test("chargeNeuve contrôle chaque champ avant de tirer l'identifiant, et convertit la saisie en NFC", () => {
let tirages = 0;
const identifiant = () => {
tirages += 1;
return `evt-${tirages}`;
};
const base = { nom: 'Soirée des Lucioles', date: null, siegesParDefaut: 8, tours: 3 };
refuse(() => chargeNeuve({ ...base, nom: ' ' }, identifiant), 'NOM_REQUIS');
refuse(() => chargeNeuve({ ...base, date: '2031-02-30' }, identifiant), 'DATE_INVALIDE', { date: '2031-02-30', remede: null });
refuse(() => chargeNeuve({ ...base, siegesParDefaut: 1 }, identifiant), 'SIEGES_INVALIDES', { sieges: 1, remede: null });
refuse(() => chargeNeuve({ ...base, tours: 0 }, identifiant), 'TOURS_INVALIDES', { tours: 0, remede: null });
assert.equal(tirages, 0);
const { charge, libelle } = chargeNeuve({ ...base, nom: ' Soire\u{301}e des Lucioles ', date: '2031-05-17' }, identifiant);
assert.equal(tirages, 1);
assert.deepEqual(charge, {
...creerCharge({ id: 'evt-1', nom: 'Soir\u{E9}e des Lucioles', date: '2031-05-17', siegesParDefaut: 8, tours: 3 }),
});
assert.equal(libelle, "Création de l'événement « Soir\u{E9}e des Lucioles »");
});
});
describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
// Arguments d'exemple de chaque commande sur la charge riche ; debloquer
// reçoit la même charge, bloquée.
function exemples(charge) {
return new Map([
['ajouterParticipant', { champs: { nom: 'Bruyère' } }],
['modifierParticipant', { id: 4, champs: { courriel: 'o@exemple.test' } }],
['exclureParticipant', { id: 1 }],
['reintegrerParticipant', { id: 5 }],
['supprimerParticipant', { id: 2 }],
['importerParticipants', { apercu: apercevoir(CSV_DEUX, { participants: charge.participants }), mode: 'ajouter' }],
['changerEtat', { etat: 'bloque' }],
['debloquer', {}],
['enregistrerGeneration', generer(charge, 1, 5)],
['retenirProposition', { id: 2 }],
['effacerPropositions', {}],
]);
}
test("aucune commande ne modifie la charge qu'elle reçoit", () => {
const charge = chargeRiche();
const args = exemples(charge);
assert.deepEqual([...args.keys()].sort(), [...COMMANDES.keys()].sort());
for (const [nom, commande] of COMMANDES) {
const recue = nom === 'debloquer' ? { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } } : charge;
const copie = structuredClone(recue);
const rendu = commande(figer(recue), args.get(nom));
assert.deepEqual(recue, copie, nom);
assert.notEqual(rendu.charge, recue, nom);
assert.equal(typeof rendu.libelle, 'string', nom);
assert.ok(Array.isArray(rendu.avertissements), nom);
}
});
test('sur un plan bloqué, chaque commande sauf debloquer est refusée PLAN_BLOQUE, remède « Débloquer »', () => {
const charge = chargeRiche();
const args = exemples(charge);
const bloquee = figer({ ...charge, evenement: { ...charge.evenement, etat: 'bloque' } });
for (const nom of COMMANDES.keys()) {
if (nom === 'debloquer') continue;
refuse(() => executer(nom, bloquee, args.get(nom)), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
}
});
});

587
src/application/libelles.js Normal file
View file

@ -0,0 +1,587 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La table des textes de l'application (§ 14.6) : chaque code que lèvent ou
// rendent src/stockage, src/csv et src/application — refus, avertissements,
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
// un code brut. Une valeur codée d'un détail — raison, état, champ, mode,
// échec — que la table ne connaît pas lève aussi, au lieu de se taire. Une
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
// un code de l'application : les textes la citent telle quelle, entre
// parenthèses ; une cause que la table porte s'y lit par son texte.
//
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
// change aucun libellé déjà écrit.
//
// Les textes ne lisent ni l'horloge ni la langue du système : un horodatage
// se met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const NOMS_DES_ETATS = new Map([
['brouillon', 'Brouillon'],
['propose', 'Proposé'],
['retenu', 'Retenu'],
['bloque', 'Bloqué'],
]);
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
const NOMS_DES_CHAMPS = new Map([
['nom', 'nom'],
['prenom', 'prénom'],
['appartenance', 'appartenance'],
['courriel', 'courriel'],
['titrePressenti', 'titre pressenti'],
['notes', 'notes'],
]);
const MOIS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
// Valeur d'une table de noms, ou RangeError qui la nomme.
function nomDans(table, valeur, quoi) {
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
return table.get(valeur);
}
const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
// Un texte cité entre guillemets français.
const cite = (texte) => `« ${texte} »`;
// Une cause entre parenthèses, rien quand elle manque : son texte quand la
// table en porte un, sinon la cause telle que le système la nomme.
function causeEntreParentheses(cause) {
if (cause === null || cause === undefined || cause === '') return '';
return ` (${TEXTES.has(cause) ? texteDe(cause) : cause})`;
}
// Le chemin de l'élément fautif, rien quand il manque ou désigne la racine.
const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
// Un compte et son nom, au singulier jusqu'à un, comme le veut le français.
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
// Une énumération : « a », « a et b », « a, b et c ».
function enumeration(termes) {
if (termes.length <= 1) return termes.join('');
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
}
// Des numéros de proposition, un illisible nommé comme tel.
const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
// La première lettre en capitale.
const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
// Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à 8 h 05 »,
// à l'heure qu'il porte ; une autre forme se cite telle quelle.
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
function moment(horodatage) {
const morceaux = HORODATAGE.exec(String(horodatage));
if (morceaux === null) return String(horodatage);
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
if (mois < 1 || mois > 12) return String(horodatage);
const quantieme = jour === 1 ? '1er' : String(jour);
return `le ${quantieme} ${MOIS[mois - 1]} ${annee} à ${heure} h ${String(minute).padStart(2, '0')}`;
}
const depuisQuand = (depuis) => (depuis === null || depuis === undefined ? '' : ` depuis ${moment(depuis)}`);
// Le chemin affichable d'une racine, reçue entière ou par son seul chemin.
const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
// Le remède commun aux refus d'encodage (§ 10.1).
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
// La raison d'un fichier d'état illisible, en une phrase.
function raisonIllisible({ raison, ...details }) {
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
return texteDe(raison, details);
}
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
function secoursDe(secours) {
if (secours === undefined || secours === null) return '';
const offerts = [];
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
}
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
function touchees({ ids, retenu }) {
const parties = [];
if (retenu) parties.push('le placement retenu');
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
}
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
const titresDe = (n, singulier, pluriel) =>
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
// Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause.
const ouEcrire = ({ chemin, dossier, cause }) =>
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}`;
const TEXTES = new Map([
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ---------
[
'ABSENT',
({ chemin }) =>
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
],
['ECRITURE', (details) => `L'écriture de ${ouEcrire(details)} a échoué ; le fichier d'avant reste intact.`],
['LECTURE', (details) => `La lecture de ${ouEcrire(details)} est refusée.`],
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
[
'ETAT_ILLISIBLE',
(details) => {
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details)}${secoursDe(details.secours)}`;
},
],
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
[
'CHEMIN_TROP_LONG',
({ racine }) =>
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
'sous la longueur de chemin que Windows accepte.',
],
[
'IDENTIFIANT_PRESENT',
({ base }) =>
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
],
[
'VERROU_PRIS',
({ depuis, vivant }) => {
if (vivant === true) {
return `Une autre séance modifie cet événement${depuisQuand(depuis)} : il reste en lecture ici.`;
}
if (vivant === false) {
return (
`Une séance interrompue a laissé son verrou${depuisQuand(depuis)}, et il n'a pas pu se reprendre : ` +
"l'événement reste en lecture."
);
}
return (
`Un verrou d'un autre poste, ou illisible, tient cet événement${depuisQuand(depuis)} : ` +
'il reste en lecture ici, et rien ne le reprend seul.'
);
},
],
[
'LECTURE_SEULE',
({ raison }) => {
if (raison === 'BLOQUE') {
return `${texteDe('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
}
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
}
throw new RangeError(`libelle : raison de lecture seule inconnue ${JSON.stringify(raison)}`);
},
],
[
'ETAT_NON_ECRIT',
(details) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details)} n'a pas pu s'écrire.`,
],
[
'CORBEILLE_SATUREE',
({ dossier }) =>
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
'la suppression se refait une seconde plus tard.',
],
[
'COMPTEUR_SATURE',
({ compteur }) =>
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
'rien de neuf ne se numérote plus dans cet événement.',
],
['NON_DISPONIBLE', ({ cause }) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause)}.`],
// --- Les raisons d'un fichier illisible ----------------------------------
['VIDE', () => 'Le fichier est vide.'],
['JSON', () => "Le texte n'est pas du JSON valide."],
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
[
'FORMAT_PLUS_RECENT',
({ format }) => {
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
},
],
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
[
'LONGUEUR',
({ tour, table, declare, ecrit }) => {
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
if (table === null) {
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
}
return (
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
`pour ${compte(declare, 'place', 'places')}.`
);
},
],
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
[
'INCONNU',
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
],
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
[
'PARTICIPANT_NON_PLACE',
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
],
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
[
'CAPACITE_CHANGEE',
({ table, avant, maintenant }) =>
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
],
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) ---------------
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
[
'JOURNAL_EN_AVANCE_DE',
({ n }) =>
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
],
[
'JOURNAL_INCOMPLET',
({ etat, journal }) =>
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
'le prochain geste inscrit une reprise.',
],
[
'JOURNAL_ECARTEES',
({ n }) =>
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
'à partir de la première ligne illisible.',
],
[
'JOURNAL_FORMAT_PLUS_RECENT',
({ format }) =>
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
"il ne se lit pas, et l'événement reste en lecture seule.",
],
[
'JOURNAL_ETRANGER',
({ evenement }) =>
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
],
[
'PROPOSITIONS_FAUTIVES',
({ ids }) =>
ids.length <= 1
? `Une proposition incohérente est écartée (${numeros(ids)}).`
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
],
[
'DERIVE',
(details) => {
const { texte, pluriel } = touchees(details);
return pluriel
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
},
],
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ----
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
[
'DONNEES_APPLICATIVES',
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
],
[
'SONDE_ECHOUEE',
({ cause }) =>
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause)} : ` +
'les événements se rangent dans les Documents.',
],
// Une cause de la sonde, que les textes citent entre parenthèses.
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
[
'PORTABLE_NON_INSCRIPTIBLE',
({ racine, evenements, cause }) => {
const porte =
evenements === null
? 'porte peut-être des événements'
: `porte ${compte(evenements, 'événement', 'événements')}`;
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause)}.`;
},
],
[
'DOCUMENTS_NON_INSCRIPTIBLE',
({ racine, cause }) =>
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause)} : ` +
"aucun geste ne s'y enregistrerait.",
],
[
'PLATEFORME_WEB',
() =>
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
],
[
'SUPPORT_AMOVIBLE',
() =>
'Le dossier de travail est sur un support amovible : ' +
'retirer le support pendant une écriture peut la laisser incomplète.',
],
[
'REGLAGES_ILLISIBLES',
({ chemin, raison }) => {
const pourquoi =
raison === 'JSON' || raison === 'FORME' ? texteDe(raison) : `La lecture est refusée (${raison}).`;
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
},
],
// --- Le CSV (§ 10.1) -------------------------------------------------------
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
[
'SEPARATEUR_INTROUVABLE',
({ ligne, cause }) => {
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
if (ligne === null || ligne === undefined) return constat;
const ecart = texteDe(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
return `${constat} ${ecart}`;
},
],
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
[
'GUILLEMET_OUVERT',
({ ligne }) =>
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
'tout ce qui le suit tiendrait dans un seul champ.',
],
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
['NOM_ABSENT', () => 'Le nom manque.'],
[
'EXCLU_INCONNU',
({ valeur }) =>
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
],
[
'PLUSIEURS_CORRESPONDENT',
({ participants }) =>
'Plusieurs personnes de la liste correspondent à cette ligne ' +
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
],
[
'DEJA_DESIGNE',
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
],
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
[
'AMBIGUITE',
({ champs }) =>
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
],
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
['FORMAT', (details) => texteDe('FORMAT_PLUS_RECENT', details)],
['BLOQUE', () => 'Le plan est bloqué.'],
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
[
'SUPPORT_ROMPU',
({ echec, ...details }) => {
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details)} n'a pas pu s'écrire`;
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
const perte =
echec === 'ETAT_NON_ECRIT'
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
: "le dernier geste n'est pas enregistré";
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
}
if (echec === 'JALON_NON_ECRIT') {
return (
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
);
}
if (echec === 'VERROU_NON_ECRIT') {
return (
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
);
}
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
},
],
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
[
'VERROU_REPRIS',
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
],
[
'VERROU_NON_RENDU',
({ cause }) =>
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause)} : ` +
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
],
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
[
'JALON_NON_ECRIT',
({ dossier, cause }) =>
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}.`,
],
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
// --- Les refus et les avertissements des commandes (§ 4.4, § 9, § 10.2) ---
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
[
'RESERVATIONS_SUSPENDUES',
({ personne, reservations, titres }) =>
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'RESERVATIONS_RETABLIES',
({ personne, reservations, titres }) =>
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
],
[
'RESERVATIONS_RETIREES',
({ personne, reservations, titres }) =>
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'IMPORT_EN_RETENU',
() =>
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
"et rien n'est effacé ; à vous de juger s'il convient encore.",
],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
['JALON_CREATION', () => 'Création'],
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
[
'ENTREE_MODIFIER_PARTICIPANT',
({ personne, champs, devenu }) => {
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
},
],
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
[
'ENTREE_IMPORTER_PARTICIPANTS',
({ mode, ajoutes, misAJour, retires }) => {
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
if (mode === 'mettreAJour') {
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
}
if (mode === 'remplacer') {
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
}
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
},
],
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
[
'ENTREE_ENREGISTRER_GENERATION',
({ n, premier, dernier }) =>
`Généré ${compte(n, 'proposition', 'propositions')} ` +
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
],
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
[
'ENTREE_EFFACER_PROPOSITIONS',
({ n, gardee }) =>
`Effacé ${compte(n, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
],
]);
// Le texte d'un code de la table, ou RangeError qui le nomme.
function texteDe(code, details = {}) {
const texte = TEXTES.get(code);
if (texte === undefined) throw new RangeError(`libelle : code inconnu ${JSON.stringify(code)}`);
return texte(details ?? {});
}
/** Les codes qui ont un texte, triés par unités UTF-16 croissantes. */
export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
/**
* Le texte français d'un code et de ses détails (§ 14.6) : un refus, un
* avertissement, une raison, ou le libellé figé d'une entrée ou d'un jalon.
*
* @param {string} code
* @param {Object} [details]
* @returns {string}
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
* — raison, état, champ, mode, échec — que la table ne connaît pas
*/
export function libelle(code, details = {}) {
return texteDe(code, details);
}

View file

@ -0,0 +1,466 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la table des libellés (§ 14.6, § 14.2). Le balayage relève, dans
// les sources de src/stockage, src/csv et src/application — épreuves et table
// exclues —, chaque code qu'elles émettent : le premier argument littéral d'un
// appel, constructeur d'erreur ou fabrique d'avertissement ; la valeur
// littérale d'une clé code ; celle d'une clé raison, cause ou remede, qui
// nomme un code de détail ; la valeur littérale d'une constante, qui nomme le
// code que le module émet par elle ; et les tables de codes des contrats,
// celle de src/stockage/types.js et celle de src/csv/erreurs.js quand il en
// porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et
// un balayage vide échoue. Chaque texte se compose ensuite de détails
// d'exemple. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { CODES, libelle } from './libelles.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
// Les arbres balayés, et les fichiers qui portent une table de codes.
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
// La table des textes elle-même n'émet rien : ses clés sont ce qu'on éprouve.
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
// --- Le balayage ----------------------------------------------------------
// Commentaires et littéraux de chaîne, dans l'ordre où ils s'ouvrent : un
// commentaire se blanchit, sauf ses fins de ligne, et une chaîne reste, si
// bien qu'un « // » dans une chaîne n'ouvre rien.
const JETONS = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\[\s\S]|[^`\\])*`/g;
const sansCommentaires = (texte) =>
texte.replace(JETONS, (jeton) => (jeton.startsWith('/') ? jeton.replace(/[^\n]/g, ' ') : jeton));
// Un code : des capitales, des chiffres, des soulignés entre deux mots.
const CODE = String.raw`'([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)'`;
const MOTIFS_EMISSION = [
// Premier argument d'un appel : new ErreurStockage('ABSENT', …),
// refuser('UTF8_INVALIDE'), ajouter('DERIVE', …), libelle('ENTREE_…', …).
// normalize reçoit le nom d'une forme Unicode, non un code.
new RegExp(String.raw`\b(?!normalize\b)[A-Za-z_$][\w$]*\s*\(\s*${CODE}`, 'g'),
// Un objet qui porte un code : { code: 'NOM_ABSENT', … }.
new RegExp(String.raw`\bcode\s*:\s*${CODE}`, 'g'),
// Un code de détail : { raison: 'BLOQUE' }, { cause: 'NOMBRE_DE_CHAMPS' },
// { remede: 'ENREGISTRER_EN_UTF8' }.
new RegExp(String.raw`\b(?:raison|cause|remede)\s*:\s*${CODE}`, 'g'),
// Une constante qui nomme un code, que le module émet ensuite par elle :
// const RELU = 'RELECTURE_DIFFERENTE', puis { cause: RELU }.
new RegExp(String.raw`\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*${CODE}`, 'g'),
];
/** Codes qu'un texte source émet, hors commentaires, dans l'ordre du texte. */
function codesEmis(texte) {
const net = sansCommentaires(texte);
return MOTIFS_EMISSION.flatMap((motif) => [...net.matchAll(motif)].map((trouve) => ({ indice: trouve.index, code: trouve[1] })))
.sort((a, b) => a.indice - b.indice)
.map(({ code }) => code);
}
/**
* Codes d'une table de contrat : dans un commentaire de documentation qui
* s'ouvre sur « Codes d… », chaque ligne qui porte, trois espaces après
* l'astérisque, un code en tête.
*/
function codesDesTables(texte) {
const codes = [];
for (const [bloc] of texte.matchAll(/\/\*\*[\s\S]*?\*\//g)) {
if (!/^\s*\*\s*Codes d/m.test(bloc)) continue;
for (const [, code] of bloc.matchAll(/^[ \t]*\*[ \t]{3}([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)(?=\s|$)/gm)) codes.push(code);
}
return codes;
}
// Modules d'un arbre, épreuves et table des textes exclues, triés.
function modulesDe(arbre) {
return readdirSync(join(RACINE, arbre), { recursive: true })
.map((nom) => join(arbre, nom))
.filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && chemin !== TABLE_DES_TEXTES)
.sort();
}
// Relevé de l'arbre réel : par arbre, les codes émis et le fichier qui émet
// chacun en premier ; puis ceux des tables de contrat.
function releverArbre() {
const parArbre = new Map();
const emetteurs = new Map();
for (const arbre of ARBRES) {
const codes = new Set();
for (const chemin of modulesDe(arbre)) {
for (const code of codesEmis(readFileSync(join(RACINE, chemin), 'utf8'))) {
codes.add(code);
if (!emetteurs.has(code)) emetteurs.set(code, relative('.', chemin));
}
}
parArbre.set(arbre, codes);
}
const tables = new Set(TABLES.flatMap((chemin) => codesDesTables(readFileSync(join(RACINE, chemin), 'utf8'))));
for (const code of tables) if (!emetteurs.has(code)) emetteurs.set(code, 'table de contrat');
return { parArbre, tables, emetteurs };
}
describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => {
test("le relevé lit le premier argument d'un appel, une clé code, un code de détail, et rien d'un commentaire", () => {
const source = [
"// throw new ErreurStockage('DANS_UN_COMMENTAIRE');",
"/** refuser('DANS_UN_BLOC') */",
"throw new ErreurStockage('ABSENT', { chemin });",
"const refus = refuser(\n 'UTF8_INVALIDE');",
"avertissements.push({ code: 'DERIVE', details });",
"throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });",
"const texte = brut.normalize('NFC');",
"const url = 'http://exemple.test'; ajouter('APRES_UNE_ADRESSE');",
"if (erreur.code === 'ECARTE') return 'PAS_UN_APPEL';",
"const minuscule = verifier('minuscule');",
"const RELU = 'CONSTANTE_CODE'; const motif = raisonLue ?? 'PAS_UNE_CONSTANTE';",
].join('\n');
assert.deepEqual(codesEmis(source), [
'ABSENT',
'UTF8_INVALIDE',
'DERIVE',
'LECTURE_SEULE',
'BLOQUE',
'APRES_UNE_ADRESSE',
'CONSTANTE_CODE',
]);
});
test("le relevé des tables lit chaque ligne de code d'un bloc « Codes d… », et ignore les autres blocs", () => {
const source = [
'/**',
" * Codes d'une erreur d'essai :",
' *',
' * PREMIER {chemin} un premier code',
' * qui continue ici',
' * SECOND_CODE',
' * {} un second',
' */',
'/**',
' * Autre chose :',
' * IGNORE {}',
' */',
].join('\n');
assert.deepEqual(codesDesTables(source), ['PREMIER', 'SECOND_CODE']);
});
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
const { parArbre, tables } = releverArbre();
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
assert.ok(tables.size > 0, 'tables de contrat : balayage vide');
assert.ok(tables.has('VERROU_PRIS') && tables.has('IDENTIFIANT_HORS_COMPTEUR'));
});
test('chaque code que src/stockage, src/csv et src/application émettent a son texte', () => {
const { parArbre, tables, emetteurs } = releverArbre();
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
assert.ok(releves.size > 0, 'balayage vide');
const connus = new Set(CODES);
const sansTexte = [...releves].filter((code) => !connus.has(code)).sort();
assert.deepEqual(
sansTexte.map((code) => `${code} (${emetteurs.get(code)})`),
[],
'codes émis sans texte dans src/application/libelles.js',
);
});
test('chaque texte de la table a un émetteur : aucune entrée morte', () => {
const { parArbre, tables } = releverArbre();
const releves = new Set([...tables, ...ARBRES.flatMap((arbre) => [...parArbre.get(arbre)])]);
assert.ok(CODES.length > 0, 'table vide');
assert.deepEqual(CODES.filter((code) => !releves.has(code)), [], 'textes sans émetteur');
});
});
// --- Les textes -----------------------------------------------------------
// Détails d'exemple de chaque code de la table : chaque texte s'y compose une
// fois. Un code ajouté à la table sans exemple fait échouer l'épreuve.
const EXEMPLES = new Map([
// Système de fichiers et dépôt
['ABSENT', { chemin: 'Soirée des Lucioles.gtt.json' }],
['ECRITURE', { chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EROFS' }],
['LECTURE', { chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EACCES' }],
['EXISTE', { chemin: 'Soirée des Lucioles.gtt.json' }],
['CHEMIN_REFUSE', { chemin: '../ailleurs.gtt.json' }],
['ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'JSON', chemin: null, secours: { precedent: 4, journal: 5 } }],
['CORRECTIF', { rang: 2 }],
['CHEMIN_TROP_LONG', { racine: 'E:\\un\\dossier\\profond' }],
['IDENTIFIANT_PRESENT', { id: 'evt-lucioles', base: 'Soirée des Lucioles' }],
['VERROU_PRIS', { seance: 'seance-autre', depuis: '2031-05-17T08:05:00-04:00', vivant: true }],
['LECTURE_SEULE', { raison: 'BLOQUE' }],
['ETAT_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt.json', dossier: 'E:\\data', cause: 'EIO' }],
['CORBEILLE_SATUREE', { dossier: 'corbeille/2031-05-17_08-05-00_99' }],
['COMPTEUR_SATURE', { compteur: 'proposition' }],
['NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' }],
// Raisons d'un état illisible
['VIDE', {}],
['JSON', {}],
['FORME', { chemin: 'charge.participants[2].nom' }],
['COMPTES', { chemin: 'entete.comptes.tables' }],
['REFERENCE', { chemin: 'charge.reservations[0].table' }],
['FORMAT_INCONNU', { chemin: 'entete.format' }],
['FORMAT_PLUS_RECENT', { format: 2 }],
// Fautes d'un placement
['LONGUEUR', { tour: 2, table: 4, declare: 8, ecrit: 9 }],
['DOUBLON', { tour: 1, participant: 3 }],
['INCONNU', { tour: 1, participant: 12 }],
['MANQUANT', { tour: 2, participant: 7 }],
['IDENTIFIANT_REPETE', {}],
['IDENTIFIANT_HORS_COMPTEUR', {}],
// Raisons de dérive
['PARTICIPANT_EXCLU', { participant: 3 }],
['PARTICIPANT_SUPPRIME', { participant: 4 }],
['PARTICIPANT_NON_PLACE', { participant: 9 }],
['TABLE_SUPPRIMEE', { table: 2 }],
['TABLE_AJOUTEE', { table: 5 }],
['CAPACITE_CHANGEE', { table: 1, avant: 8, maintenant: 6 }],
['TOURS_CHANGES', { avant: 4, maintenant: 3 }],
// Avertissements de l'ouverture
['RETENU_FAUTIF', {}],
['JOURNAL_REPRIS', {}],
['JOURNAL_EN_AVANCE_DE', { n: 3 }],
['JOURNAL_INCOMPLET', { etat: 9, journal: 6 }],
['JOURNAL_ECARTEES', { n: 64 }],
['JOURNAL_FORMAT_PLUS_RECENT', { format: 2 }],
['JOURNAL_ETRANGER', { evenement: 'evt-autre' }],
['PROPOSITIONS_FAUTIVES', { ids: [3, null] }],
['DERIVE', { ids: [1, 2], retenu: true }],
// Dossier de travail et réglages locaux
['NON_PUBLIE', {}],
['DONNEES_APPLICATIVES', {}],
['SONDE_ECHOUEE', { cause: 'EROFS' }],
['RELECTURE_DIFFERENTE', {}],
['PORTABLE_NON_INSCRIPTIBLE', { racine: { id: 'portable', chemin: 'E:\\data' }, evenements: 3, cause: 'EROFS' }],
['DOCUMENTS_NON_INSCRIPTIBLE', { racine: { id: 'documents', chemin: 'C:\\Documents\\Table' }, evenements: 0, cause: 'EACCES' }],
['PLATEFORME_WEB', {}],
['SUPPORT_AMOVIBLE', {}],
['REGLAGES_ILLISIBLES', { chemin: 'reglages_locaux.json', raison: 'FORME' }],
// CSV
['UTF16_SANS_MARQUE', { remede: 'ENREGISTRER_EN_UTF8' }],
['UTF16_INVALIDE', { remede: 'ENREGISTRER_EN_UTF8' }],
['UTF8_INVALIDE', { remede: 'ENREGISTRER_EN_UTF8' }],
['CARACTERE_NUL', { remede: 'ENREGISTRER_EN_UTF8' }],
['ENREGISTRER_EN_UTF8', {}],
['SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' }],
['NOMBRE_DE_CHAMPS', { ligne: 3 }],
['GUILLEMET_OUVERT', { ligne: 2 }],
['AUCUN_ENTETE', {}],
['NOM_NON_ASSOCIE', {}],
['AUCUNE_LIGNE_VALIDE', {}],
['CHAMPS_EN_TROP', { ligne: 4, valeur: null }],
['NOM_ABSENT', { ligne: 5, valeur: null }],
['EXCLU_INCONNU', { ligne: 6, valeur: 'peut-être' }],
['PLUSIEURS_CORRESPONDENT', { ligne: 7, valeur: null, participants: [3, 8] }],
['DEJA_DESIGNE', { ligne: 9, valeur: null, participants: [3], premiereLigne: 4 }],
['PLAN_BLOQUE', { remede: { geste: 'debloquer' } }],
['AMBIGUITE', { champs: ['appartenance', 'courriel'] }],
// Séance
['AUCUN_EVENEMENT', {}],
['MODE_LECTURE', {}],
['FORMAT', {}],
['BLOQUE', {}],
['SUPPORT_ROMPU', { echec: 'ECRITURE', chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
['SUPPORT_INTACT', {}],
['VERROU_REPRIS', { seance: 'seance-morte', depuis: '2031-05-01T21:40:00-04:00' }],
['VERROU_NON_RENDU', { chemin: 'Soirée des Lucioles.gtt.verrou', dossier: 'E:\\data', cause: 'EROFS' }],
['RIEN_A_DEFAIRE', {}],
['RIEN_A_REFAIRE', {}],
['INSTANT_NON_RESTITUABLE', { revision: 3 }],
['PLAN_NON_BLOQUE', { remede: null }],
['JALON_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
['SANS_EFFET', { commande: 'modifierParticipant', remede: null }],
// Refus et avertissements des commandes
['NOM_REQUIS', { remede: null }],
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
['SIEGES_INVALIDES', { sieges: 1, remede: null }],
['TOURS_INVALIDES', { tours: 0, remede: null }],
['PARTICIPANT_INCONNU', { id: 42, remede: null }],
['PROPOSITION_INCONNUE', { id: 17, remede: null }],
['AUCUNE_PROPOSITION', { remede: { geste: 'ouvrirSection', section: 'generation' } }],
['AUCUN_RETENU', { remede: { geste: 'ouvrirSection', section: 'propositions' } }],
['RESERVATIONS_SUSPENDUES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
['RESERVATIONS_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
['IMPORT_EN_RETENU', {}],
// Libellés figés du journal
['ENTREE_CREATION', { nom: "Soirée d'essai" }],
['JALON_CREATION', {}],
['JALON_ETAT', { etat: 'retenu' }],
['JALON_AVANT_GENERATION', {}],
['ENTREE_REPRISE', {}],
['ENTREE_DEFAIRE', { libelle: 'Ajouté Théo Pervenche' }],
['ENTREE_REFAIRE', { libelle: 'Ajouté Théo Pervenche' }],
['ENTREE_REVENIR', { libelle: 'Ajouté Iris Ombrelle' }],
['ENTREE_AJOUTER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_MODIFIER_PARTICIPANT', { personne: 'Iris Ombrelle', champs: ['courriel'], devenu: null }],
['ENTREE_EXCLURE_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_REINTEGRER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_SUPPRIMER_PARTICIPANT', { personne: 'Iris Ombrelle' }],
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
['ENTREE_DEBLOQUER', { etat: 'retenu' }],
['ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }],
['ENTREE_RETENIR_PROPOSITION', { id: 3 }],
['ENTREE_EFFACER_PROPOSITIONS', { n: 4, gardee: 2 }],
]);
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni
// objet : chaque trace est un mot entier, « nulle part » n'en est pas une.
const TRACES = [/\bundefined\b/, /\bnull\b/, /\bNaN\b/, /\b(?:true|false)\b/, /\[object /];
describe('libellés : les textes (§ 14.6, § 8.2)', () => {
test('un code inconnu lève, et le message nomme le code', () => {
assert.throws(() => libelle('CODE_QUI_N_EXISTE_PAS', {}), (erreur) => {
assert.ok(erreur instanceof RangeError);
assert.match(erreur.message, /CODE_QUI_N_EXISTE_PAS/);
return true;
});
assert.throws(() => libelle(undefined), RangeError);
});
test("chaque texte de la table se compose de ses détails d'exemple", () => {
assert.ok(CODES.length > 0, 'table vide');
assert.deepEqual(CODES.filter((code) => !EXEMPLES.has(code)), [], "codes de la table sans détails d'exemple");
assert.deepEqual([...EXEMPLES.keys()].filter((code) => !CODES.includes(code)), [], 'exemples sans code dans la table');
for (const code of CODES) {
const texte = libelle(code, EXEMPLES.get(code));
assert.equal(typeof texte, 'string', code);
assert.ok(texte.trim().length > 0, `${code} : texte vide`);
assert.notEqual(texte, code, `${code} : le code brut tient lieu de texte`);
for (const trace of TRACES) assert.doesNotMatch(texte, trace, `${code} : « ${texte} »`);
}
});
test('la table est triée, sans doublon, et figée', () => {
assert.deepEqual([...CODES], [...new Set(CODES)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
assert.ok(Object.isFrozen(CODES));
});
test('les libellés figés des exemples du contrat, détails interpolés', () => {
assert.equal(libelle('ENTREE_CREATION', { nom: "Soirée d'essai" }), "Création de l'événement « Soirée d'essai »");
assert.equal(libelle('JALON_CREATION'), 'Création');
assert.equal(libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: 'Iris Ombrelle' }), 'Ajouté Iris Ombrelle');
assert.equal(
libelle('ENTREE_MODIFIER_PARTICIPANT', { personne: 'Ondine Lacasse', champs: ['courriel'], devenu: null }),
'Modifié Ondine Lacasse : courriel',
);
assert.equal(
libelle('ENTREE_MODIFIER_PARTICIPANT', { personne: 'Iris Ombrelle', champs: ['nom', 'titrePressenti'], devenu: 'Iris Parasol' }),
'Modifié Iris Ombrelle : nom, titre pressenti (désormais Iris Parasol)',
);
assert.equal(libelle('ENTREE_REVENIR', { libelle: 'Ajouté Iris Ombrelle' }), 'Retour à « Ajouté Iris Ombrelle »');
assert.equal(libelle('ENTREE_DEFAIRE', { libelle: 'Ajouté Théo Pervenche' }), 'Défait : « Ajouté Théo Pervenche »');
assert.equal(libelle('ENTREE_REFAIRE', { libelle: 'Ajouté Théo Pervenche' }), 'Refait : « Ajouté Théo Pervenche »');
assert.equal(libelle('ENTREE_REPRISE'), "Reprise de l'état trouvé sur le disque");
assert.equal(libelle('JALON_ETAT', { etat: 'propose' }), 'État « Proposé »');
assert.equal(libelle('ENTREE_CHANGER_ETAT', { etat: 'bloque' }), "Plan passé à l'état « Bloqué »");
assert.equal(libelle('ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }), 'Généré 5 propositions (n° 3 à 7)');
assert.equal(libelle('ENTREE_ENREGISTRER_GENERATION', { n: 1, premier: 4, dernier: 4 }), 'Généré 1 proposition (n° 4)');
assert.equal(libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 1, misAJour: 0, retires: 0 }), 'Importé 1 participant');
assert.equal(
libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode: 'remplacer', ajoutes: 250, misAJour: 0, retires: 260 }),
'Remplacé la liste par import : 260 retirés, 250 ajoutés',
);
});
test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => {
const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' });
assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
assert.match(libelle('SEPARATEUR_INTROUVABLE', { ligne: 3 }), /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
assert.match(libelle('SEPARATEUR_INTROUVABLE', { separateur: ',', ligne: 2, cause: 'GUILLEMET_OUVERT' }), /ligne 2/);
assert.match(libelle('GUILLEMET_OUVERT', { ligne: 2 }), /ligne 2/);
assert.doesNotMatch(libelle('SEPARATEUR_INTROUVABLE', {}), /ligne/);
});
test("les quatre refus d'encodage nomment le même remède : réenregistrer en UTF-8", () => {
const remede = libelle('ENREGISTRER_EN_UTF8');
assert.match(remede, /UTF-8/);
for (const code of ['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']) {
const texte = libelle(code, { remede: 'ENREGISTRER_EN_UTF8' });
assert.ok(texte.endsWith(remede), `${code} : « ${texte} »`);
}
});
test('le plan bloqué nomme « Débloquer », le mode lecture nomme « Modifier »', () => {
assert.match(libelle('LECTURE_SEULE', { raison: 'BLOQUE' }), /« Débloquer »/);
assert.match(libelle('PLAN_BLOQUE', { remede: { geste: 'debloquer' } }), /« Débloquer »/);
assert.match(libelle('MODE_LECTURE'), /« Modifier »/);
assert.doesNotMatch(libelle('LECTURE_SEULE', { raison: 'FORMAT' }), /Débloquer/);
for (const trace of TRACES) assert.doesNotMatch(libelle('LECTURE_SEULE', { raison: 'FORMAT' }), trace);
assert.equal(libelle('LECTURE_SEULE', { raison: 'FORMAT' }), libelle('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' }));
});
test("un état illisible nomme sa raison et ses secours ; chaque raison du contrat a son texte", () => {
const texte = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'VIDE', chemin: null, secours: { precedent: 4, journal: null } });
assert.match(texte, /« Soirée des Lucioles »/);
assert.match(texte, new RegExp(libelle('VIDE')));
assert.match(texte, /révision 4/);
const sansSecours = libelle('ETAT_ILLISIBLE', { base: 'Soirée des Lucioles', raison: 'JSON', chemin: null, secours: { precedent: null, journal: null } });
assert.match(sansSecours, /Aucune version de secours/);
for (const raison of ['ABSENT', 'VIDE', 'JSON', 'FORME', 'COMPTES', 'REFERENCE', 'FORMAT_INCONNU', 'FORMAT_PLUS_RECENT']) {
const details = { base: 'Soirée des Lucioles', raison, chemin: raison === 'FORME' ? 'charge.tables[0].forme' : null, secours: { precedent: null, journal: 2 } };
assert.match(libelle('ETAT_ILLISIBLE', details), /révision 2/, raison);
}
assert.throws(() => libelle('ETAT_ILLISIBLE', { base: 'x', raison: 'RAISON_INVENTEE', chemin: null }), RangeError);
assert.throws(() => libelle('LECTURE_SEULE', { raison: 'RAISON_INVENTEE' }), RangeError);
});
test("une cause que la table porte se lit par son texte ; une cause du système s'affiche comme il la nomme", () => {
const avecCause = CODES.filter((code) => Object.hasOwn(EXEMPLES.get(code) ?? {}, 'cause'));
assert.ok(avecCause.length > 0, 'aucun texte ne reçoit de cause');
for (const code of avecCause) {
const texte = libelle(code, { ...EXEMPLES.get(code), cause: 'RELECTURE_DIFFERENTE' });
assert.doesNotMatch(texte, /RELECTURE_DIFFERENTE/, `${code} : « ${texte} »`);
}
const relue = libelle('RELECTURE_DIFFERENTE');
for (const code of ['ECRITURE', 'SONDE_ECHOUEE', 'PORTABLE_NON_INSCRIPTIBLE', 'DOCUMENTS_NON_INSCRIPTIBLE']) {
const details = EXEMPLES.get(code);
assert.ok(libelle(code, { ...details, cause: 'RELECTURE_DIFFERENTE' }).includes(`(${relue})`), code);
assert.ok(libelle(code, { ...details, cause: 'TypeMismatchError' }).includes('(TypeMismatchError)'), code);
}
});
test("le support rompu dit ce que l'écriture refusée laisse sur le disque ; un échec inconnu lève", () => {
const refus = { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' };
const textes = ['ECRITURE', 'ETAT_NON_ECRIT', 'JALON_NON_ECRIT', 'VERROU_NON_ECRIT'].map((echec) =>
libelle('SUPPORT_ROMPU', { echec, ...refus }),
);
for (const texte of textes) {
assert.ok(texte.includes('« Soirée des Lucioles.gtt-journal.jsonl » dans le dossier « E:\\data » (EROFS)'), texte);
assert.match(texte, /« Écrire ailleurs »/);
}
assert.equal(new Set(textes).size, 4);
const [ecriture, etat, jalon, verrou] = textes;
assert.match(ecriture, /le dernier geste n'est pas enregistré/);
assert.match(etat, /le dernier geste est inscrit à l'historique, mais pas dans l'état/);
assert.match(jalon, /le dernier geste est enregistré, mais pas le jalon qui le nomme/i);
for (const texte of [jalon, verrou]) assert.doesNotMatch(texte, /n'est pas enregistré|ne se correspondent plus/);
assert.throws(() => libelle('SUPPORT_ROMPU', { echec: 'ECHEC_INVENTE', ...refus }), RangeError);
});
test('un verrou pris dit depuis quand, et distingue la séance vivante, inconnue ou morte', () => {
const vivant = libelle('VERROU_PRIS', { seance: 's', depuis: '2031-05-01T21:40:00-04:00', vivant: true });
assert.match(vivant, /1er mai 2031 à 21 h 40/);
const inconnu = libelle('VERROU_PRIS', { seance: null, depuis: null, vivant: null });
assert.notEqual(inconnu, vivant);
assert.notEqual(libelle('VERROU_PRIS', { seance: 's', depuis: '2031-05-01T21:40:00-04:00', vivant: false }), vivant);
});
test('un état, un champ, un mode inconnus lèvent au lieu de se taire', () => {
assert.throws(() => libelle('JALON_ETAT', { etat: 'archive' }), RangeError);
assert.throws(() => libelle('ENTREE_MODIFIER_PARTICIPANT', { personne: 'x', champs: ['age'], devenu: null }), RangeError);
assert.throws(() => libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode: 'fusionner', ajoutes: 1, misAJour: 0, retires: 0 }), RangeError);
});
test('les comptes accordent le singulier et le pluriel', () => {
assert.match(libelle('JOURNAL_ECARTEES', { n: 1 }), /^1 entrée de l'historique est écartée/);
assert.match(libelle('JOURNAL_ECARTEES', { n: 64 }), /^64 entrées de l'historique sont écartées/);
assert.equal(libelle('DERIVE', { ids: [], retenu: true }).startsWith('Le placement retenu ne décrit plus'), true);
assert.match(libelle('DERIVE', { ids: [1, 2], retenu: true }), /^Le placement retenu et les propositions n° 1 et 2 ne décrivent plus/);
assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/);
});
});

683
src/application/seance.js Normal file
View file

@ -0,0 +1,683 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La séance (§ 8.1, § 8.3, § 8.4, § 8.5, § 8.6, § 8.8) : elle tient un seul
// événement ouvert, ferme le précédent sans question, et l'ouvre toujours en
// lecture ; ouvrir n'écrit rien. Le mode — lecture ou écriture — est une
// posture de la séance, jamais écrite dans le fichier. Passer en écriture
// prend le verrou de l'événement ; revenir en lecture, par la commande ou au
// terme de l'inactivité, le rend.
//
// Un geste est une commande nommée (commandes.js) : la charge qu'elle rend
// s'enregistre par le dépôt, le journal d'abord, l'état ensuite (§ 8.2) ; un
// geste refusé n'écrit rien. Défaire, refaire et revenir restituent un
// instant du journal et l'écrivent comme une entrée de retour, sans jamais
// tronquer le journal (§ 8.3). Les jalons automatiques nomment la création,
// chaque changement d'état, et l'instant qu'une génération quitte.
//
// Ce qui écrirait l'événement se refuse dans cet ordre, sans rien écrire :
// un fichier d'un format plus récent, LECTURE_SEULE {raison: 'FORMAT'} ; un
// plan bloqué, LECTURE_SEULE {raison: 'BLOQUE'} ; le mode lecture,
// MODE_LECTURE ; le support rompu, SUPPORT_ROMPU. Sur un plan bloqué, débloquer
// est le seul geste admis : il prend lui-même le verrou, et laisse la séance
// en écriture (§ 8.5). Un geste qui laisse le plan bloqué ramène la séance en
// lecture et rend le verrou.
//
// Une écriture refusée par le support, ECRITURE ou ETAT_NON_ECRIT — celle
// d'un geste, d'un jalon ou du verrou qu'un passage en écriture pose —, rompt
// l'équivalence du § 8.2 : la séance garde l'Ouvert que le disque porte
// encore — celui d'avant le geste, ou celui que le refus a avancé, son geste
// au journal —, refuse tout geste jusqu'à ce qu'ecrireAilleurs écrive
// l'événement dans un autre dossier, et s'y poursuit (§ 8.6). Le geste refusé
// ne se rejoue pas. Écrire ailleurs refuse un dossier qui porte déjà
// l'événement, celui de la séance compris : deux copies d'un même
// identifiant ne se côtoient jamais dans un dossier (§ 8.6).
//
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
// laissé. L'horloge et les minuteries entrent en paramètre : la séance ne lit
// ni l'heure ni un aléa, et n'arme aucune minuterie elle-même.
import { serialiserCharge } from '../stockage/canonique.js';
import {
creer as creerSurDisque,
deverrouiller,
enregistrer,
enregistrerAilleurs,
lister,
nommer as nommerSurDisque,
ouvrir as ouvrirSurDisque,
ouvrirSecours as ouvrirSecoursSurDisque,
verrouiller,
} from '../stockage/depot.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { fil, reconstruire } from '../stockage/journal.js';
import { appliquerCommande, chargeNeuve } from './commandes.js';
import { libelle } from './libelles.js';
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
export const INACTIVITE_MS = 10 * 60 * 1000;
/**
* Le refus d'un geste par la séance : son code et ses détails. VERROU_PRIS
* et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
* ErreurStockage. Le texte affiché vient de la table des libellés (§ 14.6).
*/
export class ErreurSeance extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurSeance.prototype.name = 'ErreurSeance';
// Un avertissement : il n'empêche rien.
const avertissement = (code, details) => ({ code, details });
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
// Le libellé de l'entrée de révision donnée.
const libelleDeRevision = (journal, revision) => journal.entrees.find((entree) => entree.revision === revision).libelle;
// Le libellé figé d'une entrée de retour, qui nomme l'instant : celui que
// défaire quitte, celui que refaire ou revenir restituent.
function libelleDuRetour(sens, instant) {
if (sens === 'defaire') return libelle('ENTREE_DEFAIRE', { libelle: instant });
if (sens === 'refaire') return libelle('ENTREE_REFAIRE', { libelle: instant });
return libelle('ENTREE_REVENIR', { libelle: instant });
}
/**
* Une séance sur le dossier de travail racine.
*
* @param {Object} parametres
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
* @param {import('../stockage/systeme_fichiers.js').Racine} parametres.racine
* le dossier de travail que la règle du § 8.6 a déterminé
* @param {{horodatage: () => string}} parametres.horloge
* horodatage rend AAAA-MM-JJTHH:MM:SS±HH:MM
* @param {() => string} parametres.identifiant tiré une fois pour le verrou
* de la séance, puis une fois par événement qu'elle crée
* @param {string} parametres.produitVersion la construction qui écrit
* @param {(fn: () => unknown, ms: number) => unknown} parametres.planifier
* arme une minuterie ; rend ce qu'annuler reçoit
* @param {(minuterie: unknown) => void} parametres.annuler
* @param {number} [parametres.inactiviteMs]
*/
export function creerSeance({
fs,
racine,
horloge,
identifiant,
produitVersion,
planifier,
annuler,
inactiviteMs = INACTIVITE_MS,
}) {
const id = identifiant();
const libelleReprise = libelle('ENTREE_REPRISE');
// L'état de la séance. ouvert : l'Ouvert courant, null sans événement ;
// verrouTenu : la séance tient le verrou de cet événement ; refusSupport :
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
// passage en écriture ; gestes, calculs : ceux qui sont ouverts, qui
// suspendent le compte d'inactivité ; minuterie : celle qu'il a armée.
const etat = {
racine,
ouvert: null,
mode: 'lecture',
verrouTenu: false,
supportRompu: false,
refusSupport: null,
verrouAutre: null,
avertissementsOuverture: [],
avertissementsGeste: [],
gestes: 0,
calculs: 0,
minuterie: null,
};
// La file des appels qui lisent ou écrivent l'événement : chacun part
// quand le précédent a fini, réussi ou non.
let file = Promise.resolve();
function enFile(travail) {
const suite = file.then(travail);
file = suite.then(
() => undefined,
() => undefined,
);
return suite;
}
// --- L'inactivité (§ 8.4) -----------------------------------------------
function suspendre() {
if (etat.minuterie === null) return;
annuler(etat.minuterie);
etat.minuterie = null;
}
// Relance le compte d'inactivité : en écriture, sans geste ni calcul
// ouvert ; suspendu sinon.
function armer() {
suspendre();
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
const minuterie = planifier(() => enFile(() => expirer(minuterie)), inactiviteMs);
etat.minuterie = minuterie;
}
// Le terme d'une minuterie, à son tour dans la file : celle qui est
// toujours armée ramène la séance en lecture, tant que rien n'est ouvert.
async function expirer(minuterie) {
if (etat.minuterie !== minuterie) return;
etat.minuterie = null;
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
await quitterEcriture();
}
// --- Le verrou (§ 8.8) -----------------------------------------------------
// Prend le verrou de l'événement de l'Ouvert. Le verrou de cette séance,
// resté d'un rendu refusé, est le sien. Celui d'une séance morte de ce
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS.
// Celui d'une séance vivante, d'un autre poste ou illisible ne se reprend
// pas : VERROU_PRIS, que verrouAutre garde. Un système sans verrou le rend
// toujours pris. Un support qui refuse d'écrire le verrou, ou d'effacer
// celui du mort, rompt le support sous l'échec VERROU_NON_ECRIT, et son
// ECRITURE remonte (§ 8.6). Rend les avertissements.
async function prendreVerrou(ouvert) {
let verrou;
const avertissements = [];
try {
verrou = await verrouiller(ouvert, id);
if (!verrou.pris && verrou.seance === id) verrou = { pris: true };
if (!verrou.pris && verrou.vivant === false) {
const mort = verrou;
await deverrouiller(ouvert, mort.seance);
verrou = await verrouiller(ouvert, id);
if (verrou.pris) {
avertissements.push(avertissement('VERROU_REPRIS', { seance: mort.seance, depuis: mort.depuis }));
}
}
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert, 'VERROU_NON_ECRIT');
throw erreur;
}
if (!verrou.pris) {
const { seance, depuis, vivant } = verrou;
etat.verrouAutre = { seance, depuis, vivant };
throw new ErreurStockage('VERROU_PRIS', { seance, depuis, vivant });
}
etat.verrouAutre = null;
etat.verrouTenu = true;
return avertissements;
}
// Rend le verrou que la séance tient sur l'événement de l'Ouvert. Un refus
// du système de fichiers ne retient pas la séance : VERROU_NON_RENDU le
// dit, et le verrou tombe avec ce processus.
async function rendreVerrou(ouvert) {
if (!etat.verrouTenu) return [];
etat.verrouTenu = false;
try {
await deverrouiller(ouvert, id);
return [];
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
return [avertissement('VERROU_NON_RENDU', erreur.details)];
}
}
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
// retour explicite annule en plus le geste ouvert. Les avertissements du
// rendu s'ajoutent à ceux du dernier geste.
async function quitterEcriture({ explicite = false } = {}) {
suspendre();
if (explicite) etat.gestes = 0;
etat.mode = 'lecture';
const avertissements = await rendreVerrou(etat.ouvert);
etat.avertissementsGeste = [...etat.avertissementsGeste, ...avertissements];
return avertissements;
}
// --- Les refus --------------------------------------------------------------
function exigerOuvert() {
if (etat.ouvert === null) throw new ErreurSeance('AUCUN_EVENEMENT');
return etat.ouvert;
}
function exigerSupport() {
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
}
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
// l'ordre de l'en-tête du module.
function exigerEcriture() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE');
exigerSupport();
return ouvert;
}
// --- L'écriture d'un geste (§ 8.2, § 8.3, § 8.6) -------------------------------
// Le support rompu par erreur, ECRITURE ou ETAT_NON_ECRIT : la séance garde
// ouvert, l'Ouvert que le disque porte, et le refus, dont echec nomme ce
// que le support n'a pas reçu : par défaut le code de l'erreur, la ligne
// ou l'état d'un geste ; JALON_NON_ECRIT, le jalon d'un geste enregistré ;
// VERROU_NON_ECRIT, le verrou d'un passage en écriture.
function rompre(erreur, ouvert, echec = erreur.code) {
const { chemin, dossier, cause } = erreur.details;
etat.ouvert = ouvert;
etat.supportRompu = true;
etat.refusSupport = { echec, chemin, dossier, cause };
}
// Une écriture du dépôt depuis l'Ouvert reçu ; ce qu'elle rend devient
// l'Ouvert de la séance. ECRITURE rompt le support en gardant l'Ouvert
// reçu, que rien n'a changé ; ETAT_NON_ECRIT, en gardant l'Ouvert avancé de
// ses détails, son geste au journal. Le refus remonte.
async function ecrire(ouvert, ecriture) {
try {
etat.ouvert = await ecriture(ouvert);
return etat.ouvert;
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
throw erreur;
}
}
// Écrit un geste depuis l'Ouvert depart : le jalon jalonAvant sur l'instant
// courant quand il est donné, puis l'entrée et l'état, puis, quand l'état
// du plan change, le jalon qui le nomme (§ 8.3). Un horodatage pour tout
// le geste. Le jalon d'état qu'un refus empêche laisse le geste
// enregistré : JALON_NON_ECRIT s'ajoute aux avertissements, et le support
// est rompu sous ce même échec. Rend le libellé ; les avertissements
// deviennent ceux du dernier geste.
async function ecrireGeste(depart, { charge, libelle: texte, avertissements }, options = {}) {
const { sens = null, retour = null, jalonAvant = null } = options;
const horodatage = horloge.horodatage();
const commun = { horodatage, libelleReprise };
let ouvert = depart;
if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun }));
const entree = { libelle: texte, produitVersion, sens, retour, ...commun };
ouvert = await ecrire(ouvert, (o) => enregistrer(o, charge, entree));
const suite = [...avertissements];
const etatDuPlan = ouvert.charge.evenement.etat;
if (etatDuPlan !== depart.charge.evenement.etat) {
const nom = libelle('JALON_ETAT', { etat: etatDuPlan });
try {
etat.ouvert = await nommerSurDisque(ouvert, { nom, ...commun });
} catch (erreur) {
if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur;
rompre(erreur, ouvert, 'JALON_NON_ECRIT');
const { chemin, dossier, cause } = erreur.details;
suite.push(avertissement('JALON_NON_ECRIT', { chemin, dossier, cause }));
}
}
etat.avertissementsGeste = suite;
return texte;
}
// Après un geste, achevé ou refusé : un plan bloqué ne reste pas en
// écriture, la séance revient en lecture et rend le verrou ; sinon le
// compte d'inactivité repart.
async function apresGeste() {
if (etat.mode === 'ecriture' && etat.ouvert !== null && estBloque(etat.ouvert)) {
await quitterEcriture();
return;
}
armer();
}
// Débloquer (§ 8.5, § 9) : le seul geste qu'un plan bloqué admet. La
// commande d'abord, qui ne touche rien ; puis le verrou, que la séance
// prend en lecture ; puis l'entrée et le jalon de l'état rendu. La séance
// reste en écriture.
async function debloquerPlan(ouvert, args) {
exigerSupport();
const resultat = appliquerCommande('debloquer', ouvert.charge, args);
const avertissements = etat.mode === 'ecriture' ? [] : await prendreVerrou(ouvert);
etat.mode = 'ecriture';
return ecrireGeste(ouvert, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
}
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
// commande ajoute.
async function geste(nom, args) {
const ouvert = exigerOuvert();
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
return { libelle: await debloquerPlan(ouvert, args) };
}
exigerEcriture();
const resultat = appliquerCommande(nom, ouvert.charge, args);
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
}
// Un geste et son suivi (apresGeste), qu'il aboutisse ou non : ce qu'il
// rend, et les avertissements du dernier geste, rendu du verrou compris.
async function suivi(travail) {
let rendu;
try {
rendu = await travail();
} finally {
await apresGeste();
}
return { ...rendu, avertissements: [...etat.avertissementsGeste] };
}
// --- Les retours (§ 8.3) -----------------------------------------------------
// Le fil de l'Ouvert : les cibles de défaire et de refaire, null quand
// elles n'existent pas, et le libellé de l'instant courant. Quand le
// journal finit sur une autre révision que la charge ouverte, ou manque, la
// prochaine écriture pose d'abord l'entrée de reprise, une entrée ordinaire
// qui restitue la charge ouverte : l'instant courant est le sien, défaire
// vise celui où finit le journal, et rien ne se refait.
function filDe(ouvert) {
const { journal } = ouvert;
if (journal === null || journal.entrees.length === 0) {
return { cibleDefaire: null, cibleRefaire: null, courant: libelleReprise };
}
const lu = fil(journal);
if (journal.entrees.at(-1).revision !== ouvert.revision) {
return { cibleDefaire: lu.position, cibleRefaire: null, courant: libelleReprise };
}
const courant = libelleDeRevision(journal, lu.position);
return { cibleDefaire: lu.cibleDefaire, cibleRefaire: lu.cibleRefaire, courant };
}
// La charge de l'instant revision du journal de l'Ouvert, ou
// INSTANT_NON_RESTITUABLE : un instant que l'élagage a retiré, ou qu'un
// correctif qui ne s'applique pas a perdu.
function restituer(ouvert, revision) {
if (!Number.isSafeInteger(revision)) {
throw new TypeError(`revision : entier attendu, reçu ${JSON.stringify(revision)}`);
}
if (ouvert.journal === null) throw new ErreurSeance('INSTANT_NON_RESTITUABLE', { revision });
try {
return reconstruire(ouvert.journal, revision);
} catch (erreur) {
if (erreur instanceof RangeError || (erreur instanceof ErreurStockage && erreur.code === 'CORRECTIF')) {
throw new ErreurSeance('INSTANT_NON_RESTITUABLE', { revision });
}
throw erreur;
}
}
// Un retour, sans son suivi : défaire vise le prédécesseur de l'instant
// courant sur le fil, refaire le sommet de sa pile, revenir l'instant
// donné ; l'entrée porte la charge restituée, son sens et sa cible.
async function retour(sens, visee) {
const ouvert = exigerEcriture();
const { cibleDefaire, cibleRefaire, courant } = filDe(ouvert);
let cible = visee;
if (sens === 'defaire') {
if (cibleDefaire === null) throw new ErreurSeance('RIEN_A_DEFAIRE');
cible = cibleDefaire;
}
if (sens === 'refaire') {
if (cibleRefaire === null) throw new ErreurSeance('RIEN_A_REFAIRE');
cible = cibleRefaire;
}
const charge = restituer(ouvert, cible);
if (serialiserCharge(charge) === serialiserCharge(ouvert.charge)) throw new ErreurSeance('SANS_EFFET', { sens });
const instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };
const texte = await ecrireGeste(ouvert, entree, { sens, retour: cible });
return { libelle: texte };
}
// --- Ouvrir, créer, fermer (§ 8.1, § 8.4) ----------------------------------------
// Ferme l'événement ouvert : le compte s'arrête, le verrou se rend, et la
// séance oublie l'événement. Rend les avertissements du rendu.
async function fermerEvenement() {
suspendre();
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
Object.assign(etat, {
ouvert: null,
mode: 'lecture',
verrouTenu: false,
supportRompu: false,
refusSupport: null,
verrouAutre: null,
avertissementsOuverture: [],
avertissementsGeste: [],
gestes: 0,
calculs: 0,
});
return avertissements;
}
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
// fermé le précédent.
async function ouvrirAvec(lire) {
await fermerEvenement();
const ouvert = await lire();
etat.ouvert = ouvert;
etat.avertissementsOuverture = ouvert.avertissements;
return { base: ouvert.base, avertissements: ouvert.avertissements };
}
// Crée l'événement de ces champs (§ 2.1, étape 1) : la charge neuve
// (chargeNeuve), dont un champ refusé lève avant que rien ne change —
// l'événement ouvert reste, son mode et son verrou compris ; puis le
// précédent se ferme, et s'écrivent l'entrée de création et le jalon
// « Création », puis l'état. Le nouveau reste ouvert en lecture. Un état
// que le support refuse d'écrire, le journal écrit, rompt le support.
async function creerEvenement(champs) {
const { charge, libelle: texte } = chargeNeuve(champs, identifiant);
await fermerEvenement();
const options = {
libelle: texte,
nomJalon: libelle('JALON_CREATION'),
horodatage: horloge.horodatage(),
produitVersion,
};
try {
etat.ouvert = await creerSurDisque(fs, etat.racine, charge, options);
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
throw erreur;
}
etat.avertissementsOuverture = etat.ouvert.avertissements;
return { base: etat.ouvert.base };
}
// --- Les modes (§ 8.4, § 8.8) -------------------------------------------------------
async function passerEnEcriture() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
const avertissements = await prendreVerrou(ouvert);
etat.mode = 'ecriture';
etat.avertissementsGeste = avertissements;
armer();
return { avertissements };
}
async function passerEnLecture() {
etat.gestes = 0;
suspendre();
if (etat.mode === 'lecture') return { avertissements: [] };
etat.avertissementsGeste = [];
return { avertissements: await quitterEcriture({ explicite: true }) };
}
// Nomme l'instant courant (§ 8.3) : un jalon, sans entrée ; le nom entre en
// NFC, ses blancs de bord retirés.
async function nommer(nom) {
try {
const ouvert = exigerEcriture();
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
const texte = nom.normalize('NFC').trim();
if (texte === '') throw new ErreurSeance('NOM_REQUIS');
await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: texte, horodatage: horloge.horodatage(), libelleReprise }));
} finally {
await apresGeste();
}
return { revision: etat.ouvert.revision };
}
// Le résumé (lister) de l'événement d'identifiant idEvenement quand le
// dossier de racine le porte déjà : par un état lisible, par un état
// illisible que son .precedent ou son journal apparie à cet identifiant,
// ou par un journal sans état ; null sinon, et pour un dossier qui
// n'existe pas encore. Rien ne s'écrit.
async function dejaPresent(racine, idEvenement) {
let resumes;
try {
resumes = await lister(fs, racine);
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ABSENT') return null;
throw erreur;
}
return resumes.find((resume) => resume.id === idEvenement) ?? null;
}
// Écrit l'événement dans la racine nouvelle, la réponse au support rompu
// (§ 8.6) : la paire alignée de l'Ouvert gardé (enregistrerAilleurs), puis
// le verrou rendu là où il était, puis, en écriture, pris là-bas. La séance
// s'y poursuit, son support rétabli. Un dossier qui porte déjà
// l'événement (dejaPresent), celui de la séance compris, se refuse
// IDENTIFIANT_PRESENT, sans rien écrire : la paire s'y poserait sous une
// seconde base. Un état que le support refuse là-bas, le journal écrit, y
// transporte la séance : l'Ouvert avancé du refus et la racine nouvelle, en
// lecture, le support rompu par ce refus, le verrou rendu là où il était ;
// un nouvel essai y trouve ce journal, et se refuse. Tout autre échec
// laisse tout en l'état, le support toujours rompu.
async function ecrireAilleurs(nouvelle) {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (!etat.supportRompu) throw new ErreurSeance('SUPPORT_INTACT');
const present = await dejaPresent(nouvelle, ouvert.charge.evenement.id);
if (present !== null) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id: present.id, base: present.base });
const options = { existantes: [], libelleReprise, horodatage: horloge.horodatage() };
let ailleurs;
try {
ailleurs = await enregistrerAilleurs(ouvert, nouvelle, options);
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') {
suspendre();
etat.mode = 'lecture';
etat.racine = nouvelle;
rompre(erreur, erreur.details.ouvert);
etat.avertissementsGeste = await rendreVerrou(ouvert);
}
throw erreur;
}
const avertissements = await rendreVerrou(ouvert);
Object.assign(etat, { ouvert: ailleurs, racine: nouvelle, supportRompu: false, refusSupport: null });
if (etat.mode === 'ecriture') {
try {
avertissements.push(...(await prendreVerrou(ailleurs)));
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
etat.mode = 'lecture';
avertissements.push(avertissement(erreur.code, erreur.details));
}
}
etat.avertissementsGeste = avertissements;
armer();
return { base: ailleurs.base, avertissements };
}
return {
/** L'identifiant de la séance, qui signe son verrou. */
get id() {
return id;
},
/** Le dossier de travail, que l'écriture ailleurs remplace. */
get racine() {
return etat.racine;
},
/** 'lecture' ou 'ecriture' (§ 8.4). */
get mode() {
return etat.mode;
},
/** L'Ouvert de l'événement (depot.js), null sans événement. */
get ouvert() {
return etat.ouvert;
},
get charge() {
return etat.ouvert?.charge ?? null;
},
/** Vrai quand une écriture refusée a rompu l'équivalence du § 8.2. */
get supportRompu() {
return etat.supportRompu;
},
/** {echec, chemin, dossier, cause} de l'écriture qui a rompu le support, ou null. */
get refusSupport() {
return etat.refusSupport;
},
/** {seance, depuis, vivant} du verrou qui a refusé le dernier passage en écriture, ou null. */
get verrouAutre() {
return etat.verrouAutre;
},
/** Ceux de l'ouverture, puis ceux du dernier geste. */
get avertissements() {
return [...etat.avertissementsOuverture, ...etat.avertissementsGeste];
},
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
get retours() {
if (etat.ouvert === null) return { defaire: false, refaire: false };
const { cibleDefaire, cibleRefaire } = filDe(etat.ouvert);
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
},
get gesteEnCours() {
return etat.gestes > 0;
},
get calculEnCours() {
return etat.calculs > 0;
},
ouvrir: (base) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base))),
ouvrirSecours: (base, source) =>
enFile(() => ouvrirAvec(() => ouvrirSecoursSurDisque(fs, etat.racine, base, source))),
creer: (champs) => enFile(() => creerEvenement(champs)),
fermer: () => enFile(async () => ({ avertissements: await fermerEvenement() })),
passerEnEcriture: () => enFile(passerEnEcriture),
passerEnLecture: () => enFile(passerEnLecture),
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
debloquer: () => enFile(() => suivi(() => geste('debloquer', {}))),
defaire: () => enFile(() => suivi(() => retour('defaire', null))),
refaire: () => enFile(() => suivi(() => retour('refaire', null))),
revenir: (revision) => enFile(() => suivi(() => retour('revenir', revision))),
nommer: (nom) => enFile(() => nommer(nom)),
ecrireAilleurs: (nouvelle) => enFile(() => ecrireAilleurs(nouvelle)),
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
// ils s'emboîtent, et un fini de trop ne compte pas.
debuterGeste() {
etat.gestes += 1;
suspendre();
},
finirGeste() {
etat.gestes = Math.max(0, etat.gestes - 1);
armer();
},
debuterCalcul() {
etat.calculs += 1;
suspendre();
},
finirCalcul() {
etat.calculs = Math.max(0, etat.calculs - 1);
armer();
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -139,8 +139,11 @@ import { examiner } from './placements.js';
* Un événement du dossier, tel que lister le résume. * Un événement du dossier, tel que lister le résume.
* *
* Les champs qui viennent de l'état lu — nom, date, etat, participants — * Les champs qui viennent de l'état lu — nom, date, etat, participants —
* valent null quand il ne se lit pas ou qu'il manque ; id aussi, sauf pour un * valent null quand il ne se lit pas ou qu'il manque. id est celui de l'état
* journal sans état, dont il est l'événement que nomme sa première ligne. * lu ; pour un état illisible, celui par lequel il s'apparie
* (identifiantSansEtat), null quand il n'en a aucun ; pour un journal sans
* état, l'événement que nomme sa première ligne. Un événement du dossier se
* reconnaît ainsi à son id, que son état se lise ou non.
* *
* @typedef {Object} Resume * @typedef {Object} Resume
* @property {string} base * @property {string} base
@ -542,9 +545,10 @@ async function refuser(vue, base, details) {
* et qu'aucun état lisible ne porte son identifiant, illisible ABSENT ; il * et qu'aucun état lisible ne porte son identifiant, illisible ABSENT ; il
* ne se montre pas sinon : une copie d'un journal apparié, ou le journal * ne se montre pas sinon : une copie d'un journal apparié, ou le journal
* qu'un état illisible s'apparie par son .precedent. Un identifiant que * qu'un état illisible s'apparie par son .precedent. Un identifiant que
* portent deux états lisibles les signale doublon. Résumés triés par nom, * portent deux états lisibles les signale doublon ; le résumé d'un état
* les noms inconnus après les autres, puis par base, unités UTF-16 * illisible porte en id celui qui l'apparie, sans le compter en doublon.
* comparées. * Résumés triés par nom, les noms inconnus après les autres, puis par base,
* unités UTF-16 comparées.
* *
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs * @param {import('./systeme_fichiers.js').SystemeFichiers} fs
* @param {import('./systeme_fichiers.js').Racine} racine * @param {import('./systeme_fichiers.js').Racine} racine
@ -578,7 +582,7 @@ export async function lister(fs, racine) {
else if (id !== null) journal = (await apparier(vue, base, id))?.base ?? null; else if (id !== null) journal = (await apparier(vue, base, id))?.base ?? null;
if (journal !== null) apparies.add(journal); if (journal !== null) apparies.add(journal);
if (lecture === null) { if (lecture === null) {
resumes.push(sansLecture(base, null, modifie, journal, details.raison)); resumes.push(sansLecture(base, id, modifie, journal, details.raison));
continue; continue;
} }
const { evenement, participants } = lecture.charge; const { evenement, participants } = lecture.charge;

View file

@ -451,7 +451,7 @@ describe('dépôt : créer, ouvrir, lister', () => {
]); ]);
}); });
test("lister apparie par l'identifiant, jamais par le nom : journal renommé, état illisible, journal sans état, doublon, journal d'un autre", async () => { test("lister apparie par l'identifiant, jamais par le nom : journal renommé, état illisible et l'identifiant qui l'apparie, journal sans état, doublon, journal d'un autre", async () => {
const ouverture = (evenement) => `${ligneOuverture(evenement)}\n`; const ouverture = (evenement) => `${ligneOuverture(evenement)}\n`;
const etat = (id, nom) => etatTexte(chargeEssai({ id, nom })); const etat = (id, nom) => etatTexte(chargeEssai({ id, nom }));
const { fs } = disqueAvec([ const { fs } = disqueAvec([
@ -478,6 +478,9 @@ describe('dépôt : créer, ouvrir, lister', () => {
['Hortensia.gtt.json', etat('evt-h', 'Hortensia'), 8000], ['Hortensia.gtt.json', etat('evt-h', 'Hortensia'), 8000],
// état illisible, ni .precedent ni journal // état illisible, ni .precedent ni journal
['Jacinthe.gtt.json', '{"entete":', 8500], ['Jacinthe.gtt.json', '{"entete":', 8500],
// état illisible, son .precedent lisible, sans journal
['Kalmia.gtt.json', '{', 8600],
['Kalmia.gtt.json.precedent', etat('evt-k', 'Kalmia'), 8601],
// ni état ni journal // ni état ni journal
['notes.txt', 'à lire', 9000], ['notes.txt', 'à lire', 9000],
]); ]);
@ -512,11 +515,12 @@ describe('dépôt : créer, ouvrir, lister', () => {
lisible('Dahlia bis', 'evt-d', 'Dahlia', 4001, null, true), lisible('Dahlia bis', 'evt-d', 'Dahlia', 4001, null, true),
lisible('Esquisse', 'evt-e', 'Esquisse', 5000, null), lisible('Esquisse', 'evt-e', 'Esquisse', 5000, null),
lisible('Hortensia', 'evt-h', 'Hortensia', 8000, 'Glycine'), lisible('Hortensia', 'evt-h', 'Hortensia', 8000, 'Glycine'),
illisible('Bleuet', null, 2000, 'Bleuet', 'VIDE'), illisible('Bleuet', 'evt-b', 2000, 'Bleuet', 'VIDE'),
illisible('Capucine', 'evt-c', 3000, 'Capucine', 'ABSENT'), illisible('Capucine', 'evt-c', 3000, 'Capucine', 'ABSENT'),
illisible('Fougere', null, 6000, 'Fougere', 'ABSENT'), illisible('Fougere', null, 6000, 'Fougere', 'ABSENT'),
illisible('Glycine', null, 7000, null, 'JSON'), illisible('Glycine', null, 7000, null, 'JSON'),
illisible('Jacinthe', null, 8500, null, 'JSON'), illisible('Jacinthe', null, 8500, null, 'JSON'),
illisible('Kalmia', 'evt-k', 8600, null, 'JSON'),
]); ]);
assert.equal(fs.compteurs.ecritures, 0); assert.equal(fs.compteurs.ecritures, 0);
}); });