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