diff --git a/src/application/commandes.js b/src/application/commandes.js index 8286615..136e6cf 100644 --- a/src/application/commandes.js +++ b/src/application/commandes.js @@ -30,28 +30,15 @@ import { serialiserCharge } from '../stockage/canonique.js'; import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { fautes, formeNommee, versFichier } from '../stockage/placements.js'; +import { COMMANDES_MOBILIER } from './commandes_mobilier.js'; +import { COMMANDES_PLACES } from './commandes_places.js'; +import { ErreurCommande, refus } from './erreurs.js'; import { libelle } from './libelles.js'; +import { nomAffiche } from './personnes.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 }); +// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne +// dans personnes.js : les importateurs de ce module les y trouvent encore. +export { ErreurCommande, nomAffiche }; // Un avertissement : il n'empêche rien. const avertissement = (code, details) => ({ code, details }); @@ -120,9 +107,6 @@ function participantDe(charge, 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' }); @@ -171,7 +155,7 @@ function ajouterParticipant(charge, { champs }) { suivante.prochainsIds.participant = id + 1; return { charge: suivante, - libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }), + libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: nomAffiche(participant) }), avertissements: [], }; } @@ -192,9 +176,9 @@ function modifierParticipant(charge, { id, champs }) { return { charge: avecParticipant(charge, id, () => apres), libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', { - personne: personneDe(avant), + personne: nomAffiche(avant), champs: changes, - devenu: renomme ? personneDe(apres) : null, + devenu: renomme ? nomAffiche(apres) : null, }), avertissements: [], }; @@ -206,7 +190,7 @@ function modifierParticipant(charge, { id, champs }) { 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 }; + return { participant: participant.id, personne: nomAffiche(participant), reservations, titres }; } /** @@ -255,7 +239,7 @@ function supprimerParticipant(charge, { id }) { const reservations = charge.reservations.length - suivante.reservations.length; const details = { participant: id, - personne: personneDe(participant), + personne: nomAffiche(participant), reservations, titres: titresPourvus(charge) - titresPourvus(suivante), }; @@ -423,28 +407,52 @@ function effacerPropositions(charge) { }; } +/** + * Un registre réuni de plusieurs : chaque nom une fois, dans l'ordre des + * registres reçus, puis de chacun. + * + * @param {...Map} registres + * @returns {Map} + * @throws {RangeError} un nom porté par deux registres, qu'il nomme + */ +export function reunirCommandes(...registres) { + const reunies = new Map(); + for (const registre of registres) { + for (const [nom, commande] of registre) { + if (reunies.has(nom)) throw new RangeError(`commandes : ${JSON.stringify(nom)} est dans deux registres`); + reunies.set(nom, commande); + } + } + return reunies; +} + /** * Le registre des commandes, par nom : chacune (charge, arguments) → * { charge, libelle, avertissements }, et importerParticipants y ajoute le - * résumé de l'import. + * résumé de l'import. Il réunit les commandes de ce module, celles du + * mobilier et celles des places. * * @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], -]); +export const COMMANDES = reunirCommandes( + new Map([ + ['ajouterParticipant', ajouterParticipant], + ['modifierParticipant', modifierParticipant], + ['exclureParticipant', exclureParticipant], + ['reintegrerParticipant', reintegrerParticipant], + ['supprimerParticipant', supprimerParticipant], + ['importerParticipants', importerParticipants], + ['changerEtat', changerEtat], + ['debloquer', debloquer], + ['enregistrerGeneration', enregistrerGeneration], + ['retenirProposition', retenirProposition], + ['effacerPropositions', effacerPropositions], + ]), + COMMANDES_MOBILIER, + COMMANDES_PLACES, +); /** * Applique la commande nom à la charge : ce qu'elle rend, avertissements [] diff --git a/src/application/commandes.test.js b/src/application/commandes.test.js index 7ffe2f9..4c5e16a 100644 --- a/src/application/commandes.test.js +++ b/src/application/commandes.test.js @@ -14,7 +14,12 @@ import { rechercher } from '../moteur/recherche.js'; import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js'; import { VERSION } from '../version.genere.js'; -import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; +import { readFileSync } from 'node:fs'; +import { chargements } from '../../test/chargements.js'; +import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe, reunirCommandes } from './commandes.js'; +import { COMMANDES_MOBILIER } from './commandes_mobilier.js'; +import { COMMANDES_PLACES } from './commandes_places.js'; +import { ErreurCommande as ErreurDesErreurs, refus } from './erreurs.js'; const V1 = VERSION.affichee; @@ -603,3 +608,61 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => { } }); }); + +describe('commandes : les registres réunis et les erreurs partagées', () => { + test("reunirCommandes réunit des registres ; un nom de deux registres lève et le nomme", () => { + const premiere = () => null; + const seconde = () => null; + const reunies = reunirCommandes(new Map([['essaiUn', premiere]]), new Map([['essaiDeux', seconde]])); + assert.ok(reunies instanceof Map); + assert.deepEqual([...reunies.keys()], ['essaiUn', 'essaiDeux']); + assert.equal(reunies.get('essaiDeux'), seconde); + assert.throws( + () => reunirCommandes(new Map([['essaiPartage', premiere]]), new Map([['essaiPartage', seconde]])), + (erreur) => { + assert.match(erreur.message, /essaiPartage/); + return true; + }, + ); + }); + + test("COMMANDES réunit les onze commandes d'A1 et les deux registres de F1, vides tant que leurs tâches ne les remplissent pas", () => { + assert.equal(COMMANDES.size, 11 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size); + assert.ok(COMMANDES_MOBILIER instanceof Map); + assert.ok(COMMANDES_PLACES instanceof Map); + for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) { + for (const [nom, commande] of registre) assert.equal(COMMANDES.get(nom), commande, nom); + } + }); + + test("ErreurCommande importée de commandes.js est celle d'erreurs.js ; refus pose details.remede", () => { + assert.equal(ErreurCommande, ErreurDesErreurs); + const sans = refus('NOM_REQUIS'); + assert.ok(sans instanceof ErreurCommande); + assert.equal(sans.name, 'ErreurCommande'); + assert.equal(sans.code, 'NOM_REQUIS'); + assert.deepEqual(sans.details, { remede: null }); + const avec = refus('PLAN_BLOQUE', { table: 2 }, { geste: 'debloquer' }); + assert.deepEqual(avec.details, { table: 2, remede: { geste: 'debloquer' } }); + refuse(() => executer('debloquer', chargeEssai(), {}), 'PLAN_NON_BLOQUE'); + }); + + test("ni commandes_mobilier.js ni commandes_places.js n'atteignent commandes.js par leurs imports, à toute profondeur", () => { + const cible = new URL('./commandes.js', import.meta.url).href; + for (const depart of ['./commandes_mobilier.js', './commandes_places.js']) { + const vus = new Set(); + const aVoir = [new URL(depart, import.meta.url).href]; + while (aVoir.length > 0) { + const adresse = aVoir.pop(); + if (vus.has(adresse)) continue; + vus.add(adresse); + for (const { specificateur, argument } of chargements(readFileSync(new URL(adresse), 'utf8'))) { + assert.equal(argument, undefined, `${adresse} : chargement calculé`); + if (specificateur.startsWith('.')) aVoir.push(new URL(specificateur, adresse).href); + } + } + assert.ok(vus.size >= 1, `${depart} : aucun module parcouru`); + assert.ok(!vus.has(cible), `${depart} atteint commandes.js`); + } + }); +}); diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js new file mode 100644 index 0000000..bb6d9f3 --- /dev/null +++ b/src/application/commandes_mobilier.js @@ -0,0 +1,9 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le registre des commandes du mobilier — tables, sièges, tours (§ 6, § 8.2), +// que commandes.js réunit aux autres. Ses imports n'atteignent jamais +// commandes.js : le registre réuni dépend de lui, non l'inverse. + +/** @type {Map Object>} */ +export const COMMANDES_MOBILIER = new Map(); diff --git a/src/application/commandes_places.js b/src/application/commandes_places.js new file mode 100644 index 0000000..a8a6be1 --- /dev/null +++ b/src/application/commandes_places.js @@ -0,0 +1,9 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le registre des commandes des places — réservations, titres, pose (§ 5.8, +// § 5.9, § 8.2), que commandes.js réunit aux autres. Ses imports n'atteignent +// jamais commandes.js : le registre réuni dépend de lui, non l'inverse. + +/** @type {Map Object>} */ +export const COMMANDES_PLACES = new Map(); diff --git a/src/application/csv.js b/src/application/csv.js new file mode 100644 index 0000000..24a90a5 --- /dev/null +++ b/src/application/csv.js @@ -0,0 +1,29 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le CSV tel que l'interface l'atteint (§ 13.4) : elle passe par +// l'application, jamais par src/csv. Ce module réexporte les fonctions de +// src/csv dont un écran a besoin, telles quelles, et lie le motif du CSV des +// refus au libellé français : l'interface ne choisit pas ce motif, qui est +// une donnée du fichier écrit, jamais un texte de la langue témoin. +import { exporterRefus as exporterRefusCsv } from '../csv/import.js'; +import { libelle } from './libelles.js'; + +export { apercevoir } from '../csv/apercu.js'; +export { decoder } from '../csv/encodage.js'; +export { exporterParticipants } from '../csv/export.js'; +export { bilanRemplacement } from '../csv/import.js'; +export { cleNormalisee } from '../csv/normalisation.js'; + +/** + * Le CSV des refus d'un aperçu (exporterRefus de src/csv), chaque motif + * composé par libelle de son code et de ses détails. + * + * @param {import('../csv/apercu.js').Apercu} apercu + * @param {Array} [refusees] par défaut ceux de l'aperçu ; après + * l'application, resume.refusees + * @returns {Uint8Array} + */ +export function exporterRefus(apercu, refusees = apercu.refusees) { + return exporterRefusCsv(apercu, libelle, refusees); +} diff --git a/src/application/csv.test.js b/src/application/csv.test.js new file mode 100644 index 0000000..a18f19e --- /dev/null +++ b/src/application/csv.test.js @@ -0,0 +1,64 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du relais CSV de l'application (§ 13.4, § 10.1) : l'interface +// atteint src/csv par ce module, qui réexporte ses fonctions telles quelles +// et lie le motif du CSV des refus au libellé français. Les noms sont +// inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import * as apercu from '../csv/apercu.js'; +import * as encodage from '../csv/encodage.js'; +import * as exportCsv from '../csv/export.js'; +import * as importCsv from '../csv/import.js'; +import { decouper } from '../csv/lecture.js'; +import * as normalisation from '../csv/normalisation.js'; +import * as relais from './csv.js'; +import { libelle } from './libelles.js'; + +describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => { + test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => { + const attendus = [ + ['decoder', encodage.decoder], + ['apercevoir', apercu.apercevoir], + ['exporterParticipants', exportCsv.exporterParticipants], + ['bilanRemplacement', importCsv.bilanRemplacement], + ['cleNormalisee', normalisation.cleNormalisee], + ]; + assert.equal(attendus.length, 5); + for (const [nom, fonction] of attendus) { + assert.equal(typeof fonction, 'function', nom); + assert.equal(relais[nom], fonction, nom); + } + assert.deepEqual(Object.keys(relais).sort(), [ + 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus', + ]); + }); + + test("exporterRefus écrit sous l'en-tête motif exactement libelle(code, details) d'une ligne refusée NOM_ABSENT", () => { + const texte = ['nom;prenom', 'Ombrelle;Iris', ';Théo', 'Pervenche;Ondine', ''].join('\r\n'); + const lu = relais.apercevoir(texte); + assert.equal(lu.refusees.length, 1); + const [{ code, brut, ...details }] = lu.refusees; + assert.equal(code, 'NOM_ABSENT'); + const { enregistrements } = decouper(relais.decoder(relais.exporterRefus(lu)).texte, ';'); + assert.equal(enregistrements.length, 2); + const [entete, ligne] = enregistrements; + const rang = entete.indexOf('motif'); + assert.ok(rang >= 0, entete.join(';')); + assert.equal(ligne[rang], libelle('NOM_ABSENT', details)); + assert.equal(ligne[entete.indexOf('ligne')], '3'); + assert.ok(brut.length > 0); + }); + + test('exporterRefus écrit les refus reçus en second argument, à la place de ceux de l’aperçu', () => { + const lu = relais.apercevoir(['nom;exclu', 'Ombrelle;peut-être', ''].join('\r\n')); + assert.equal(lu.refusees.length, 1); + const vide = decouper(relais.decoder(relais.exporterRefus(lu, [])).texte, ';').enregistrements; + assert.deepEqual(vide, [['nom', 'exclu', 'ligne', 'motif']]); + const [entete, ligne] = decouper(relais.decoder(relais.exporterRefus(lu, lu.refusees)).texte, ';').enregistrements; + const [{ code, brut, ...details }] = lu.refusees; + assert.equal(ligne[entete.indexOf('motif')], libelle(code, details)); + assert.ok(brut.length > 0); + }); +}); diff --git a/src/application/erreurs.js b/src/application/erreurs.js new file mode 100644 index 0000000..881a5f7 --- /dev/null +++ b/src/application/erreurs.js @@ -0,0 +1,36 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le refus d'une commande (§ 8.2, § 14.6), que lèvent les registres de +// commandes et leurs contrôles : un code, et des détails qui portent le +// remède, details.remede, null quand aucun geste ne lève le refus. + +/** + * 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. + * + * @param {string} code + * @param {Object} [details] + * @param {Object|null} [remede] + * @returns {ErreurCommande} + */ +export function refus(code, details = {}, remede = null) { + return new ErreurCommande(code, { ...details, remede }); +} diff --git a/src/application/format.js b/src/application/format.js new file mode 100644 index 0000000..52fdd7b --- /dev/null +++ b/src/application/format.js @@ -0,0 +1,149 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La mise en forme des nombres, des durées, des dates et des horodatages +// (§ 5.4, § 5.10, § 11.7, § 18.4). Aucune fonction ne lit la locale du +// système : les chiffres se groupent par trois, séparés d'une espace fine +// insécable (U+202F), la décimale suit une virgule, un négatif porte le signe +// moins (U+2212). Tout texte rendu qui porte une lettre — unité, mois, ordre +// de grandeur — vient d'une clé format.… par t, en un seul appel : en langue +// témoin, il est encadré de la marque une fois. Un nombre, qui ne porte +// aucune lettre, n'y passe pas. + +const ESPACE_FINE = '\u{202F}'; +const MOINS = '\u{2212}'; + +// L'échelle des ordres de grandeur d'une durée (§ 5.10) : chaque pas en +// secondes et sa clé. Sous le premier seuil, moins d'une minute ; dès le +// dernier, plusieurs heures ; entre les deux, le pas le plus proche en +// rapport : la frontière de deux pas voisins a et b est √(a·b), comparée +// exactement, s² contre a·b. +const MOINS_D_UNE_MINUTE = 60; +const PLUSIEURS_HEURES = 5400; +const PAS = [ + [120, 'format.duree.deuxMinutes'], + [300, 'format.duree.cinqMinutes'], + [600, 'format.duree.dixMinutes'], + [1200, 'format.duree.vingtMinutes'], + [1800, 'format.duree.demiHeure'], + [3600, 'format.duree.uneHeure'], +]; + +const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; +const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})[+-](\d{2}):(\d{2})$/; + +// Le nombre de jours d'un mois du calendrier grégorien. +function joursDuMois(annee, mois) { + if (mois === 2) return annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0) ? 29 : 28; + return [4, 6, 9, 11].includes(mois) ? 30 : 31; +} + +// Année, mois et jour d'une date AAAA-MM-JJ du calendrier, ou RangeError. +function lireDate(annee, mois, jour, texte) { + const lue = { annee: Number(annee), mois: Number(mois), jour: Number(jour) }; + if (lue.mois < 1 || lue.mois > 12 || lue.jour < 1 || lue.jour > joursDuMois(lue.annee, lue.mois)) { + throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas un jour du calendrier`); + } + return lue; +} + +// Un nombre fini, ou l'erreur qui dit pourquoi il ne se met pas en forme. +function exigerFini(valeur, quoi) { + if (typeof valeur !== 'number') throw new TypeError(`format : ${quoi} doit être un nombre, reçu ${String(valeur)}`); + if (!Number.isFinite(valeur)) throw new RangeError(`format : ${quoi} non fini, ${valeur}`); +} + +/** + * Les fonctions de mise en forme, dont chaque texte vient de t. + * + * @param {(cle: string, details?: Object) => string} t la traduction + * @returns {{ + * nombre: (valeur: number|null, options?: {decimales?: number}) => string, + * duree: (ms: number) => string, + * ordreDeGrandeur: (secondes: number) => string, + * date: (date: string) => string, + * horodatage: (horodatage: string) => string, + * }} + */ +export function creerFormat(t) { + // Les chiffres d'un nombre fini, arrondi à decimales chiffres après la + // virgule, sans passer par la traduction. + function chiffres(valeur, decimales) { + if (!Number.isInteger(decimales) || decimales < 0 || decimales > 20) { + throw new RangeError(`format : décimales hors de 0 à 20, ${decimales}`); + } + const ecrit = Math.abs(valeur).toFixed(decimales); + if (ecrit.includes('e')) throw new RangeError(`format : ${valeur} dépasse ce qui s'écrit en chiffres`); + const [entiere, fraction] = ecrit.split('.'); + const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ESPACE_FINE); + const nul = /^[0.]*$/.test(ecrit); + const signe = valeur < 0 && !nul ? MOINS : ''; + return `${signe}${groupee}${fraction === undefined ? '' : `,${fraction}`}`; + } + + /** Un nombre ; null, une valeur absente, s'écrit format.vide, jamais 0. */ + function nombre(valeur, { decimales = 0 } = {}) { + if (valeur === null) return t('format.vide'); + exigerFini(valeur, 'nombre'); + return chiffres(valeur, decimales); + } + + /** + * Une durée en millisecondes : sous la minute, au dixième de seconde, un + * compte rond sans décimale ; sous l'heure, minutes et secondes entières ; + * au-delà, heures et minutes entières. Un arrondi qui atteint l'unité + * suivante s'écrit dans elle. + */ + function duree(ms) { + exigerFini(ms, 'durée'); + if (ms < 0) throw new RangeError(`format : durée négative, ${ms}`); + const dixiemes = Math.round(ms / 100); + if (dixiemes < 600) { + const secondes = dixiemes % 10 === 0 ? chiffres(dixiemes / 10, 0) : chiffres(dixiemes / 10, 1); + return t('format.duree.secondes', { secondes }); + } + const secondes = Math.round(ms / 1000); + if (secondes < 3600) { + return t('format.duree.minutes', { minutes: Math.floor(secondes / 60), secondes: secondes % 60 }); + } + const minutes = Math.round(ms / 60_000); + return t('format.duree.heures', { heures: chiffres(Math.floor(minutes / 60), 0), minutes: minutes % 60 }); + } + + /** L'ordre de grandeur d'une durée en secondes (§ 5.10). */ + function ordreDeGrandeur(secondes) { + exigerFini(secondes, 'durée'); + if (secondes < 0) throw new RangeError(`format : durée négative, ${secondes}`); + if (secondes < MOINS_D_UNE_MINUTE) return t('format.duree.moinsDUneMinute'); + if (secondes >= PLUSIEURS_HEURES) return t('format.duree.plusieursHeures'); + const carre = secondes * secondes; + let rang = 0; + while (rang + 1 < PAS.length && carre >= PAS[rang][0] * PAS[rang + 1][0]) rang += 1; + return t(PAS[rang][1]); + } + + /** Une date AAAA-MM-JJ en lettres, « 1er » pour le premier du mois. */ + function date(texte) { + const morceaux = DATE.exec(String(texte)); + if (morceaux === null) throw new RangeError(`format : date AAAA-MM-JJ attendue, reçu ${JSON.stringify(texte)}`); + return t('format.date', lireDate(...morceaux.slice(1), texte)); + } + + /** + * Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en lettres, à l'heure locale + * qu'il porte : la date, puis l'heure et la minute. + */ + function horodatage(texte) { + const morceaux = HORODATAGE.exec(String(texte)); + if (morceaux === null) { + throw new RangeError(`format : horodatage AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${JSON.stringify(texte)}`); + } + const [heure, minute, seconde, decalageH, decalageM] = morceaux.slice(4).map(Number); + if (heure > 23 || minute > 59 || seconde > 59 || decalageH > 23 || decalageM > 59) { + throw new RangeError(`format : ${JSON.stringify(texte)} n'est pas une heure du jour`); + } + return t('format.horodatage', { ...lireDate(...morceaux.slice(1, 4), texte), heure, minute }); + } + + return { nombre, duree, ordreDeGrandeur, date, horodatage }; +} diff --git a/src/application/format.test.js b/src/application/format.test.js new file mode 100644 index 0000000..145a13c --- /dev/null +++ b/src/application/format.test.js @@ -0,0 +1,177 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la mise en forme (§ 5.4, § 5.10, § 11.7, § 18.4) : les valeurs +// du contrat, exactement ; « — » pour une valeur absente, jamais « 0 » ; un +// nombre non fini lève ; l'échelle des ordres de grandeur et ses frontières ; +// en langue témoin, un texte qui porte une lettre est encadré de la marque. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { creerFormat } from './format.js'; +import { TABLES_APPLICATION } from './libelles.js'; +import { MARQUE_TEMOIN, creerTraduction } from './traduction.js'; + +const { ouverture: O, fermeture: F } = MARQUE_TEMOIN; +const FINE = '\u{202F}'; + +const francais = creerTraduction(TABLES_APPLICATION); +const format = creerFormat(francais.t); +const temoin = creerFormat(creerTraduction(TABLES_APPLICATION, { langue: 'temoin' }).t); + +describe('format : les nombres (§ 5.4, § 11.7)', () => { + test('les valeurs du contrat : « — » pour null, espace fine insécable entre les milliers, virgule décimale', () => { + assert.equal(format.nombre(null), '\u{2014}'); + assert.equal(format.nombre(null), francais.t('format.vide')); + assert.equal(format.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`); + assert.equal(format.nombre(0), '0'); + assert.notEqual(format.nombre(null), '0'); + }); + + test("l'arrondi suit les décimales demandées ; les grands nombres se groupent par trois", () => { + assert.equal(format.nombre(2.25, { decimales: 0 }), '2'); + assert.equal(format.nombre(2.75), '3'); + assert.equal(format.nombre(999), '999'); + assert.equal(format.nombre(1000), `1${FINE}000`); + assert.equal(format.nombre(1234567.891, { decimales: 2 }), `1${FINE}234${FINE}567,89`); + assert.equal(format.nombre(0.5, { decimales: 2 }), '0,50'); + }); + + test('un négatif porte le signe moins typographique ; zéro négatif s’écrit 0', () => { + assert.equal(format.nombre(-1234), `\u{2212}1${FINE}234`); + assert.equal(format.nombre(-0), '0'); + assert.equal(format.nombre(-0.04, { decimales: 1 }), '0,0'); + }); + + test('Infinity et NaN lèvent ; une valeur qui n’est pas un nombre lève ; des décimales hors de 0 à 20 lèvent', () => { + for (const valeur of [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NaN]) { + assert.throws(() => format.nombre(valeur), RangeError, String(valeur)); + } + for (const valeur of [undefined, '12', 12n]) assert.throws(() => format.nombre(valeur), TypeError, String(valeur)); + for (const decimales of [-1, 1.5, 21]) assert.throws(() => format.nombre(1, { decimales }), RangeError, String(decimales)); + }); + + test("en langue témoin, la valeur absente est encadrée ; un nombre, qui ne porte aucune lettre, ne l'est pas", () => { + assert.equal(temoin.nombre(null), `${O}\u{2014}${F}`); + assert.equal(temoin.nombre(1234.5, { decimales: 1 }), `1${FINE}234,5`); + }); +}); + +describe('format : les durées (§ 5.10)', () => { + test('les valeurs du contrat', () => { + assert.equal(format.duree(1800), '1,8 s'); + assert.equal(format.duree(150000), '2 min 30 s'); + }); + + test("sous la minute au dixième de seconde, puis minutes et secondes, puis heures et minutes ; une part nulle se tait", () => { + assert.equal(format.duree(0), '0 s'); + assert.equal(format.duree(12000), '12 s'); + assert.equal(format.duree(59940), '59,9 s'); + assert.equal(format.duree(59960), '1 min'); + assert.equal(format.duree(120000), '2 min'); + assert.equal(format.duree(3599400), '59 min 59 s'); + assert.equal(format.duree(3599600), '1 h'); + assert.equal(format.duree(5400000), '1 h 30 min'); + assert.equal(format.duree(36000000), '10 h'); + }); + + test('une durée négative ou non finie lève', () => { + for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY]) assert.throws(() => format.duree(ms), RangeError, String(ms)); + assert.throws(() => format.duree(null), TypeError); + }); + + test('en langue témoin, une durée est encadrée de la marque, une fois', () => { + assert.equal(temoin.duree(1800), `${O}1,8 s${F}`); + assert.equal(temoin.duree(150000), `${O}2 min 30 s${F}`); + }); +}); + +describe("format : l'ordre de grandeur d'une durée (§ 5.10)", () => { + const ATTENDUS = [ + [30, "moins d'une minute"], + [59, "moins d'une minute"], + [60, 'environ deux minutes'], + [848, 'environ dix minutes'], + [849, 'environ vingt minutes'], + [1200, 'environ vingt minutes'], + [1900, 'environ une demi-heure'], + [5399, 'environ une heure'], + [5400, 'plusieurs heures'], + [7200, 'plusieurs heures'], + ]; + + test("les valeurs du contrat, frontières comprises", () => { + assert.equal(ATTENDUS.length, 10); + for (const [secondes, texte] of ATTENDUS) assert.equal(format.ordreDeGrandeur(secondes), texte, String(secondes)); + assert.equal(format.ordreDeGrandeur(1200), francais.t('format.duree.vingtMinutes')); + assert.equal(francais.t('format.duree.vingtMinutes'), 'environ vingt minutes'); + }); + + test('chaque pas a sa frontière en √(a·b) : le dernier entier de chaque pas, puis le premier du suivant', () => { + // Les pas en secondes, et le premier entier au-delà de chaque frontière. + const pas = [120, 300, 600, 1200, 1800, 3600]; + const textes = pas.map((s) => format.ordreDeGrandeur(s)); + assert.equal(new Set(textes).size, 6); + for (let i = 0; i + 1 < pas.length; i += 1) { + const produit = pas[i] * pas[i + 1]; + let premier = Math.ceil(Math.sqrt(produit)); + while (premier * premier < produit) premier += 1; + while ((premier - 1) * (premier - 1) >= produit) premier -= 1; + assert.equal(format.ordreDeGrandeur(premier - 1), textes[i], `${premier - 1} s`); + assert.equal(format.ordreDeGrandeur(premier), textes[i + 1], `${premier} s`); + } + }); + + test('-1, NaN et Infinity lèvent', () => { + for (const secondes of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => format.ordreDeGrandeur(secondes), RangeError, String(secondes)); + } + }); + + test('en langue témoin, un ordre de grandeur est encadré de la marque', () => { + assert.equal(temoin.ordreDeGrandeur(1200), `${O}environ vingt minutes${F}`); + }); +}); + +describe('format : les dates et les horodatages (§ 18.4)', () => { + test('les valeurs du contrat : la date en lettres, « 1er » pour le premier du mois', () => { + assert.equal(format.date('2026-10-06'), '6 octobre 2026'); + assert.equal(format.date('2026-10-01'), '1er octobre 2026'); + assert.equal(format.horodatage('2026-10-06T19:02:11-04:00'), '6 octobre 2026, 19 h 02'); + }); + + test("chaque mois a son nom ; l'heure du matin s'écrit sans zéro de tête, à l'heure que l'horodatage porte", () => { + const mois = Array.from({ length: 12 }, (_, i) => format.date(`2031-${String(i + 1).padStart(2, '0')}-17`)); + assert.equal(mois.length, 12); + assert.deepEqual(mois.map((texte) => texte.split(' ')[1]), [ + 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', + 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre', + ]); + assert.equal(format.horodatage('2031-05-01T08:05:00+02:00'), '1er mai 2031, 8 h 05'); + }); + + test("une date ou un horodatage hors de leur forme, ou hors du calendrier, lèvent", () => { + for (const date of ['2026-10-6', '06/10/2026', '2026-13-01', '2026-02-29', '2026-04-31', '2026-10-00', null]) { + assert.throws(() => format.date(date), RangeError, String(date)); + } + assert.equal(format.date('2028-02-29'), '29 février 2028'); + for (const horodatage of ['2026-10-06T19:02:11', '2026-10-06T24:00:00-04:00', '2026-10-06T19:60:00-04:00', '2026-10-06']) { + assert.throws(() => format.horodatage(horodatage), RangeError, horodatage); + } + }); + + test('en langue témoin, une date et un horodatage sont encadrés de la marque, une fois', () => { + assert.equal(temoin.date('2026-10-01'), `${O}1er octobre 2026${F}`); + assert.equal(temoin.horodatage('2026-10-06T19:02:11-04:00'), `${O}6 octobre 2026, 19 h 02${F}`); + }); +}); + +describe('format : rien ne vient de la locale du système', () => { + test("les textes qui portent une lettre viennent des clés format.… de la table", () => { + const cles = francais.cles().filter((cle) => cle.startsWith('format.')); + assert.ok(cles.length > 0, 'aucune clé format.'); + for (const cle of ['format.vide', 'format.duree.vingtMinutes']) assert.ok(cles.includes(cle), cle); + // Une table substituée change le texte rendu : il ne vient que d'elle. + const autre = creerFormat((cle, details) => (cle === 'format.vide' ? 'néant' : francais.t(cle, details))); + assert.equal(autre.nombre(null), 'néant'); + }); +}); diff --git a/src/application/horloge.js b/src/application/horloge.js new file mode 100644 index 0000000..2d5645f --- /dev/null +++ b/src/application/horloge.js @@ -0,0 +1,193 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'horloge de l'application (§ 19.4, § 14.7) : la seule lecture de +// l'horloge du moteur d'exécution, et les seules minuteries. Hors du moteur, +// src/application et src/interface lisent l'heure et arment leurs minuteries +// par l'horloge qu'on leur injecte, jamais autrement : creerHorlogeSysteme est +// le seul code de ces deux couches qui appelle new Date, performance.now, +// setTimeout et clearTimeout. Les épreuves injectent l'horloge pilotée, où +// rien n'avance seul. +// +// Une horloge est quatre fermetures, appelables détachées de leur objet : +// horodatage() AAAA-MM-JJTHH:MM:SS±HH:MM, heure locale et décalage +// instant() millisecondes, monotone : les durées +// planifier(fn, ms) arme une minuterie, rend un identifiant opaque — un +// nombre sous le navigateur et sous l'horloge pilotée, +// un Timeout sous node —, que l'appelant ne fait que +// rendre à annuler +// annuler(id) un identifiant inconnu ou déjà parti : sans effet + +/** + * @typedef {Object} Horloge + * @property {() => string} horodatage + * @property {() => number} instant + * @property {(fn: () => void, ms: number) => unknown} planifier + * @property {(id: unknown) => void} annuler + */ + +const deux = (n) => String(n).padStart(2, '0'); + +// Un décalage en minutes à l'est de UTC, écrit ±HH:MM. +function ecrireDecalage(minutes) { + const absolu = Math.abs(minutes); + return `${minutes < 0 ? '-' : '+'}${deux(Math.floor(absolu / 60))}:${deux(absolu % 60)}`; +} + +// Les contrôles communs aux deux horloges : un rappel qui se lance, un délai +// fini et positif. +function exigerMinuterie(fn, ms) { + if (typeof fn !== 'function') throw new TypeError('horloge : planifier attend une fonction'); + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) { + throw new RangeError(`horloge : délai fini et positif attendu, reçu ${String(ms)}`); + } +} + +/** + * L'horloge du moteur d'exécution : l'heure locale du système, son décalage, + * le compteur monotone et les minuteries du navigateur ou de node. + * + * @returns {Horloge} + */ +export function creerHorlogeSysteme() { + const horodatage = () => { + const maintenant = new Date(); + const jour = `${maintenant.getFullYear()}-${deux(maintenant.getMonth() + 1)}-${deux(maintenant.getDate())}`; + const heure = `${deux(maintenant.getHours())}:${deux(maintenant.getMinutes())}:${deux(maintenant.getSeconds())}`; + return `${jour}T${heure}${ecrireDecalage(-maintenant.getTimezoneOffset())}`; + }; + const instant = () => performance.now(); + const planifier = (fn, ms) => { + exigerMinuterie(fn, ms); + return setTimeout(fn, ms); + }; + const annuler = (id) => { + clearTimeout(id); + }; + return { horodatage, instant, planifier, annuler }; +} + +// --- Le calendrier de l'horloge pilotée --------------------------------------- +// +// L'horloge pilotée compte en millisecondes depuis 1970-01-01T00:00:00 UTC et +// convertit par l'arithmétique du calendrier grégorien proleptique, sans lire +// l'horloge du système. Les jours se comptent depuis cette origine. + +const MS_PAR_JOUR = 86_400_000; +const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/; + +// Le nombre de jours depuis 1970-01-01 d'une date civile. +function joursDepuisOrigine(annee, mois, jour) { + const a = mois <= 2 ? annee - 1 : annee; + const ere = Math.floor(a / 400); + const anneeDEre = a - ere * 400; + const jourDAnnee = Math.floor((153 * (mois + (mois > 2 ? -3 : 9)) + 2) / 5) + jour - 1; + const jourDEre = anneeDEre * 365 + Math.floor(anneeDEre / 4) - Math.floor(anneeDEre / 100) + jourDAnnee; + return ere * 146_097 + jourDEre - 719_468; +} + +// La date civile du jour compté depuis 1970-01-01. +function dateCivile(jours) { + const z = jours + 719_468; + const ere = Math.floor(z / 146_097); + const jourDEre = z - ere * 146_097; + const anneeDEre = Math.floor( + (jourDEre - Math.floor(jourDEre / 1460) + Math.floor(jourDEre / 36_524) - Math.floor(jourDEre / 146_096)) / 365, + ); + const jourDAnnee = jourDEre - (365 * anneeDEre + Math.floor(anneeDEre / 4) - Math.floor(anneeDEre / 100)); + const moisDecale = Math.floor((5 * jourDAnnee + 2) / 153); + const jour = jourDAnnee - Math.floor((153 * moisDecale + 2) / 5) + 1; + const mois = moisDecale < 10 ? moisDecale + 3 : moisDecale - 9; + return { annee: anneeDEre + ere * 400 + (mois <= 2 ? 1 : 0), mois, jour }; +} + +// Un horodatage de départ lu : l'instant UTC en millisecondes et le décalage +// en minutes à l'est de UTC. Une date hors du calendrier lève. +function lireDepart(depart) { + const morceaux = FORME.exec(String(depart)); + if (morceaux === null) throw new RangeError(`horloge : départ AAAA-MM-JJTHH:MM:SS±HH:MM attendu, reçu ${depart}`); + const [annee, mois, jour, heure, minute, seconde] = morceaux.slice(1, 7).map(Number); + const decalage = (morceaux[7] === '-' ? -1 : 1) * (Number(morceaux[8]) * 60 + Number(morceaux[9])); + const jours = joursDepuisOrigine(annee, mois, jour); + const relue = dateCivile(jours); + if (mois < 1 || mois > 12 || relue.mois !== mois || relue.jour !== jour || heure > 23 || minute > 59 || seconde > 59) { + throw new RangeError(`horloge : ${depart} n'est pas un instant du calendrier`); + } + const local = jours * MS_PAR_JOUR + ((heure * 60 + minute) * 60 + seconde) * 1000; + return { utc: local - decalage * 60_000, decalage }; +} + +/** + * Une horloge où rien n'avance seul. avancer(ms) est synchrone : il + * déclenche les minuteries échues dans l'ordre de leur échéance, puis de + * leur création, chacune à l'instant de son échéance — horodatage() et + * instant() y rendent cette échéance — ; une minuterie qu'un rappel arme et + * qui échoit dans la fenêtre part dans le même avancer ; à son retour, + * l'horloge a avancé de ms exactement. Il n'attend aucune promesse : l'épreuve + * attend elle-même ce que les rappels lancent. Un rappel qui lève arrête + * avancer à son échéance, et l'erreur remonte. + * + * horodatage() garde le décalage du départ ; instant() part de 0. + * + * @param {{depart?: string}} [options] + * @returns {Horloge & {avancer: (ms: number) => undefined}} + * @throws {RangeError} un départ hors de sa forme ou du calendrier + */ +export function creerHorlogePilotee({ depart = '2026-10-06T19:00:00-04:00' } = {}) { + const { utc, decalage } = lireDepart(depart); + let ecoule = 0; + let prochainId = 1; + // Les minuteries en attente, par identifiant : échéance et rappel. Les + // identifiants croissent avec la création. + const enAttente = new Map(); + + const horodatage = () => { + const local = utc + ecoule + decalage * 60_000; + const jours = Math.floor(local / MS_PAR_JOUR); + const { annee, mois, jour } = dateCivile(jours); + const secondes = Math.floor((local - jours * MS_PAR_JOUR) / 1000); + const heure = `${deux(Math.floor(secondes / 3600))}:${deux(Math.floor(secondes / 60) % 60)}:${deux(secondes % 60)}`; + return `${String(annee).padStart(4, '0')}-${deux(mois)}-${deux(jour)}T${heure}${ecrireDecalage(decalage)}`; + }; + const instant = () => ecoule; + const planifier = (fn, ms) => { + exigerMinuterie(fn, ms); + const id = prochainId; + prochainId += 1; + enAttente.set(id, { echeance: ecoule + ms, fn }); + return id; + }; + const annuler = (id) => { + enAttente.delete(id); + }; + + // La prochaine minuterie échue au plus tard à fin : la plus petite + // échéance, puis le plus petit identifiant ; null quand il n'y en a pas. + function prochaine(fin) { + let choisie = null; + for (const [id, { echeance }] of enAttente) { + if (echeance > fin) continue; + if (choisie === null || echeance < choisie.echeance || (echeance === choisie.echeance && id < choisie.id)) { + choisie = { id, echeance }; + } + } + return choisie; + } + + function avancer(ms) { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) { + throw new RangeError(`horloge : avancer attend un délai fini et positif, reçu ${String(ms)}`); + } + const fin = ecoule + ms; + for (let suivante = prochaine(fin); suivante !== null; suivante = prochaine(fin)) { + const { fn } = enAttente.get(suivante.id); + enAttente.delete(suivante.id); + ecoule = suivante.echeance; + fn(); + } + ecoule = fin; + return undefined; + } + + return { horodatage, instant, planifier, annuler, avancer }; +} diff --git a/src/application/horloge.test.js b/src/application/horloge.test.js new file mode 100644 index 0000000..3e1e9c8 --- /dev/null +++ b/src/application/horloge.test.js @@ -0,0 +1,187 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'horloge (§ 19.4, § 14.7, § 14.14) : la pilotée déclenche ses +// minuteries dans l'ordre de leur échéance, chacune à son instant, sans rien +// attendre du temps réel ; la système porte l'heure locale et son décalage, et +// aucune épreuve n'attend qu'une de ses minuteries parte. Les quatre lectures +// de chaque horloge répondent détachées de leur objet. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { creerHorlogePilotee, creerHorlogeSysteme } from './horloge.js'; + +const MINUTE = 60_000; +const FORME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-])(\d{2}):(\d{2})$/; + +describe('horloge pilotée : les minuteries (§ 19.4)', () => { + test("deux minuteries à 10 et 5 min : avancer(6 min) déclenche la seconde seule, avancer(4 min) la première", () => { + const horloge = creerHorlogePilotee(); + const parties = []; + horloge.planifier(() => parties.push('dix'), 10 * MINUTE); + horloge.planifier(() => parties.push('cinq'), 5 * MINUTE); + assert.equal(horloge.avancer(6 * MINUTE), undefined); + assert.deepEqual(parties, ['cinq']); + horloge.avancer(4 * MINUTE); + assert.deepEqual(parties, ['cinq', 'dix']); + }); + + test('une minuterie annulée ne part jamais', () => { + const horloge = creerHorlogePilotee(); + const parties = []; + const id = horloge.planifier(() => parties.push('annulee'), MINUTE); + horloge.planifier(() => parties.push('gardee'), MINUTE); + horloge.annuler(id); + horloge.avancer(10 * MINUTE); + assert.deepEqual(parties, ['gardee']); + }); + + test("un rappel lit horodatage() et instant() à son échéance, non à la fin de la fenêtre", () => { + const horloge = creerHorlogePilotee(); + const lus = []; + horloge.planifier(() => lus.push([horloge.horodatage(), horloge.instant()]), 5 * MINUTE); + horloge.avancer(10 * MINUTE); + assert.deepEqual(lus, [['2026-10-06T19:05:00-04:00', 5 * MINUTE]]); + assert.equal(horloge.horodatage(), '2026-10-06T19:10:00-04:00'); + assert.equal(horloge.instant(), 10 * MINUTE); + }); + + test("une minuterie de 3 min qu'arme un rappel parti à 5 min part dans le même avancer(10 min), à 8 min", () => { + const horloge = creerHorlogePilotee(); + const parties = []; + horloge.planifier(() => { + parties.push(['premiere', horloge.instant()]); + horloge.planifier(() => parties.push(['armee', horloge.instant()]), 3 * MINUTE); + }, 5 * MINUTE); + horloge.avancer(10 * MINUTE); + assert.deepEqual(parties, [ + ['premiere', 5 * MINUTE], + ['armee', 8 * MINUTE], + ]); + assert.equal(horloge.instant(), 10 * MINUTE); + }); + + test("à échéance égale, l'ordre de création ; une minuterie hors de la fenêtre attend l'avancer suivant", () => { + const horloge = creerHorlogePilotee(); + const parties = []; + horloge.planifier(() => parties.push('b'), 2 * MINUTE); + horloge.planifier(() => parties.push('a1'), MINUTE); + horloge.planifier(() => parties.push('a2'), MINUTE); + horloge.planifier(() => parties.push('c'), 3 * MINUTE); + horloge.avancer(2 * MINUTE); + assert.deepEqual(parties, ['a1', 'a2', 'b']); + horloge.avancer(MINUTE - 1); + assert.deepEqual(parties, ['a1', 'a2', 'b']); + horloge.avancer(1); + assert.deepEqual(parties, ['a1', 'a2', 'b', 'c']); + }); + + test('avancer rend undefined ; un ms négatif ou non fini lève', () => { + const horloge = creerHorlogePilotee(); + assert.equal(horloge.avancer(0), undefined); + for (const ms of [-1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.throws(() => horloge.avancer(ms), RangeError, String(ms)); + } + assert.equal(horloge.instant(), 0); + }); + + test("horodatage avance d'autant, décalage compris, par-delà minuit, la fin du mois et de l'année", () => { + const horloge = creerHorlogePilotee(); + assert.equal(horloge.horodatage(), '2026-10-06T19:00:00-04:00'); + horloge.avancer(90 * MINUTE + 7_000); + assert.equal(horloge.horodatage(), '2026-10-06T20:30:07-04:00'); + horloge.avancer(4 * 60 * MINUTE); + assert.equal(horloge.horodatage(), '2026-10-07T00:30:07-04:00'); + const fin = creerHorlogePilotee({ depart: '2028-12-31T23:59:30+05:30' }); + fin.avancer(45_000); + assert.equal(fin.horodatage(), '2029-01-01T00:00:15+05:30'); + const bissextile = creerHorlogePilotee({ depart: '2028-02-28T12:00:00+00:00' }); + bissextile.avancer(24 * 60 * MINUTE); + assert.equal(bissextile.horodatage(), '2028-02-29T12:00:00+00:00'); + }); + + test('instant est monotone, et commence à zéro', () => { + const horloge = creerHorlogePilotee(); + const lus = [horloge.instant()]; + for (const pas of [0, 1, 250, 0, 60_000]) { + horloge.avancer(pas); + lus.push(horloge.instant()); + } + assert.equal(lus[0], 0); + for (let i = 1; i < lus.length; i += 1) assert.ok(lus[i] >= lus[i - 1], `${lus[i - 1]} puis ${lus[i]}`); + assert.equal(lus.at(-1), 60_251); + }); + + test("rien n'attend le temps réel : un avancer d'une semaine se rend sur-le-champ", () => { + const horloge = creerHorlogePilotee(); + let parties = 0; + for (let i = 1; i <= 100; i += 1) horloge.planifier(() => (parties += 1), i * 60 * MINUTE); + horloge.avancer(7 * 24 * 60 * MINUTE); + assert.equal(parties, 100); + }); + + test('un départ hors de la forme AAAA-MM-JJTHH:MM:SS±HH:MM lève', () => { + for (const depart of ['2026-10-06 19:00:00', '2026-13-01T00:00:00+00:00', '2026-02-30T00:00:00+00:00', 'demain']) { + assert.throws(() => creerHorlogePilotee({ depart }), RangeError, depart); + } + }); + + test('planifier refuse un délai négatif ou non fini, et un rappel qui ne se lance pas', () => { + const horloge = creerHorlogePilotee(); + assert.throws(() => horloge.planifier(() => {}, -5), RangeError); + assert.throws(() => horloge.planifier(() => {}, Number.NaN), RangeError); + assert.throws(() => horloge.planifier('rappel', 5), TypeError); + }); +}); + +describe('horloge système (§ 19.4, § 14.14)', () => { + test("horodatage a la forme AAAA-MM-JJTHH:MM:SS±HH:MM, l'heure courante, et porte le décalage local", () => { + const horloge = creerHorlogeSysteme(); + const lu = horloge.horodatage(); + const morceaux = FORME.exec(lu); + assert.ok(morceaux !== null, lu); + const instant = Date.parse(lu); + assert.ok(Math.abs(instant - Date.now()) < 5_000, `${lu} loin de l'heure courante`); + const [, , , , , , , signe, heures, minutes] = morceaux; + const decalage = (signe === '-' ? -1 : 1) * (Number(heures) * 60 + Number(minutes)); + assert.equal(decalage, -new Date(instant).getTimezoneOffset()); + }); + + test("instant est monotone ; planifier rend un identifiant qu'annuler accepte, sans qu'aucune épreuve n'attende qu'il parte", () => { + const horloge = creerHorlogeSysteme(); + const avant = horloge.instant(); + assert.ok(horloge.instant() >= avant); + let partie = false; + const id = horloge.planifier(() => (partie = true), 60 * MINUTE); + assert.notEqual(id, undefined); + assert.equal(horloge.annuler(id), undefined); + assert.equal(horloge.annuler(id), undefined, 'un identifiant déjà annulé est sans effet'); + assert.equal(horloge.annuler('inconnu'), undefined); + assert.equal(partie, false); + }); +}); + +describe('horloges : quatre fermetures appelables détachées', () => { + test('la pilotée répond détachée comme sur son objet ; une minuterie annulée détachée ne part pas', () => { + const horloge = creerHorlogePilotee(); + const { horodatage, instant, planifier, annuler } = horloge; + const parties = []; + const id = planifier(() => parties.push('annulee'), MINUTE); + planifier(() => parties.push('gardee'), MINUTE); + annuler(id); + horloge.avancer(2 * MINUTE); + assert.deepEqual(parties, ['gardee']); + assert.equal(instant(), horloge.instant()); + assert.equal(horodatage(), horloge.horodatage()); + assert.equal(horodatage(), '2026-10-06T19:02:00-04:00'); + }); + + test('la système répond détachée : armer puis annuler', () => { + const { horodatage, instant, planifier, annuler } = creerHorlogeSysteme(); + assert.match(horodatage(), FORME); + assert.equal(typeof instant(), 'number'); + const id = planifier(() => { + throw new Error('une minuterie annulée est partie'); + }, 60 * MINUTE); + assert.equal(annuler(id), undefined); + }); +}); diff --git a/src/application/libelles.js b/src/application/libelles.js index ed5cbd6..a27996c 100644 --- a/src/application/libelles.js +++ b/src/application/libelles.js @@ -1,657 +1,70 @@ // © 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 — état, champ, mode, échec — -// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison -// qu'un texte ne traite pas à part ne lève jamais : une phrase générique la -// nomme, par son texte quand la table en porte un, telle quelle sinon. 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. +// L'assemblage 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 —, chaque libellé figé d'une +// entrée ou d'un jalon du journal, et les textes de la mise en forme ont leur +// texte français dans l'une des tables de libelles/, une par domaine. Ce +// module les réunit par le mécanisme de traduction (traduction.js), dans +// l'ordre alphabétique de leurs fichiers ; aucune table ne l'importe. +// +// Un code sans texte lève : l'écran ne montre jamais un code brut. Une valeur +// codée d'un détail — état, champ, mode, échec — que la table ne connaît pas +// lève aussi, au lieu de se taire. // // 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. +// change aucun libellé déjà écrit. libelle compose toujours en français : un +// texte qui devient une donnée — libellé figé, motif du CSV des refus — ne +// passe jamais par la langue témoin. +import { TABLE as CSV } from './libelles/csv.js'; +import { TABLE as EVENEMENTS } from './libelles/evenements.js'; +import { TABLE as FORMAT } from './libelles/format.js'; +import { TABLE as GENERATION } from './libelles/generation.js'; +import { TABLE as MOBILIER } from './libelles/mobilier.js'; +import { TABLE as MOTEUR } from './libelles/moteur.js'; +import { TABLE as PARTICIPANTS } from './libelles/participants.js'; +import { TABLE as PLACES } from './libelles/places.js'; +import { TABLE as SEANCE } from './libelles/seance.js'; +import { TABLE as STOCKAGE } from './libelles/stockage.js'; +import { creerTraduction } from './traduction.js'; -// 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 tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */ +export const TABLES_APPLICATION = Object.freeze([ + CSV, + EVENEMENTS, + FORMAT, + GENERATION, + MOBILIER, + MOTEUR, + PARTICIPANTS, + PLACES, + SEANCE, + STOCKAGE, ]); -// 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 FRANCAIS = creerTraduction(TABLES_APPLICATION); -const MOIS = [ - 'janvier', - 'février', - 'mars', - 'avril', - 'mai', - 'juin', - 'juillet', - 'août', - 'septembre', - 'octobre', - 'novembre', - 'décembre', -]; +// Un code : des capitales, des chiffres, des soulignés entre deux mots. Un +// libellé, préfixé du nom de sa table, porte au moins un point. +const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/; -// 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}`; - -// Une raison codée en une phrase : son texte quand la table en porte un et -// que les détails reçus lui suffisent ; sinon une phrase générique qui la -// cite telle quelle. Un texte qui attend ses propres détails lève ou rend -// « undefined » sans eux : la phrase générique le remplace alors. -function phraseDeRaison(raison, details = {}) { - const generique = `La raison donnée est ${cite(String(raison))}.`; - if (!TEXTES.has(raison)) return generique; - try { - const texte = texteDe(raison, details); - return typeof texte === 'string' && !texte.includes('undefined') ? texte : generique; - } catch { - return generique; - } -} - -// 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 phraseDeRaison(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 dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — 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.`; - } - if (raison === 'ENCODAGE') { - return ( - `Le fichier d'état a changé sur le disque. ${texteDe('ENCODAGE')} ` + - "L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " + - "range ce fichier dans la corbeille avant d'écrire l'état." - ); - } - return `${phraseDeRaison(raison)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`; - }, - ], - [ - '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}.`; - }, - ], - [ - 'ENCODAGE', - () => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.", - ], - - // --- 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_RECREE', - () => - "Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " + - 'tel que la séance le connaissait.', - ], - [ - '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_ILLISIBLE', - ({ raison }) => - `L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison)} ` + - "Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.", - ], - [ - '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)}).`, - ], - [ - 'PROPOSITIONS_MANQUANTES', - ({ n }) => - `${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` + - 'dans le fichier : celles qu\'il porte sont gardées.', - ], - [ - '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.', - ], - // dossier, quand il est donné, nomme le dossier où la séance écrit - // désormais (ecrireAilleurs) ; sans lui, le dossier de travail. - [ - 'SUPPORT_AMOVIBLE', - ({ dossier }) => - `${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : '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."], - // Quitter l'événement d'un support rompu : la séance tient la seule copie - // que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son - // dernier geste n'est qu'à l'historique de ce support. - [ - 'SUPPORT_ROMPU_NON_SAUVE', - ({ echec, dossier }) => { - const geste = - echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : ''; - return ( - `Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` + - "l'événement ouvert n'est sauvé dans aucun autre dossier. " + - "« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support." - ); - }, - ], - [ - 'EVENEMENT_CHANGE', - ({ revision }) => - `Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` + - `à la révision ${revision}, et les gestes suivants s'y ajoutent.`, - ], - [ - '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)); +/** Les clés de forme code, seules, triées par unités UTF-16 croissantes. */ +export const CODES = Object.freeze(FRANCAIS.cles().filter((cle) => FORME_CODE.test(cle))); /** - * 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. + * Le texte français d'une clé et de ses détails (§ 14.6) : un refus, un + * avertissement, une raison, le libellé figé d'une entrée ou d'un jalon, ou + * un libellé de l'application. * - * @param {string} code + * @param {string} cle * @param {Object} [details] * @returns {string} - * @throws {RangeError} un code sans texte, ou une valeur codée de ses détails + * @throws {RangeError} une clé sans texte, ou une valeur codée de ses détails * — état, champ, mode, échec — que la table ne connaît pas ; jamais * une raison, qu'une phrase générique nomme */ -export function libelle(code, details = {}) { - return texteDe(code, details); +export function libelle(cle, details = {}) { + return FRANCAIS.t(cle, details); } diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index b97b500..46261ae 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -12,20 +12,27 @@ // 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. +// +// La table se partage en fichiers, un par domaine, sous libelles/ : chacun +// exporte TABLE, une Map, et libelles.js les assemble par ordre alphabétique +// des fichiers ; les tables, comme l'assemblage, sont hors du balayage. import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, test } from '../../test/lanceur.js'; -import { CODES, libelle } from './libelles.js'; +import { CODES, TABLES_APPLICATION, 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. +// L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce +// qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre. const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js'); +const DOSSIER_DES_TABLES = join('src', 'application', 'libelles'); +const horsDesTextes = (chemin) => chemin !== TABLE_DES_TEXTES && !chemin.startsWith(DOSSIER_DES_TABLES + sep); // --- Le balayage ---------------------------------------------------------- @@ -79,7 +86,7 @@ function codesDesTables(texte) { 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) + .filter((chemin) => chemin.endsWith('.js') && !/\.test\.js$/.test(chemin) && horsDesTextes(chemin)) .sort(); } @@ -158,6 +165,16 @@ describe('libellés : le balayage des codes (§ 14.6, § 14.2)', () => { } }); + test("le balayage exclut l'assemblage et chaque table de libelles/, et garde les autres modules de l'application", () => { + const tables = readdirSync(join(RACINE, DOSSIER_DES_TABLES)).filter((nom) => nom.endsWith('.js')); + assert.ok(tables.length >= 10, `${DOSSIER_DES_TABLES} : ${tables.length} modules`); + const balayes = modulesDe('src/application'); + assert.ok(balayes.length > 0, 'src/application : aucun module balayé'); + const dossier = join('src', 'application', 'libelles'); + assert.deepEqual(balayes.filter((chemin) => chemin === `${dossier}.js` || chemin.startsWith(dossier + sep)), []); + for (const garde of ['commandes.js', 'seance.js']) assert.ok(balayes.includes(join('src', 'application', garde)), garde); + }); + 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`); @@ -525,3 +542,70 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => { assert.match(libelle('PROPOSITIONS_FAUTIVES', { ids: [3, null] }), /n° 3/); }); }); + +// --- Les tables par domaine --------------------------------------------------- + +// Les deux formes de clé du contrat : un code, nu ; un libellé, préfixé du nom +// de sa table, en camelCase. +const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/; +const formeLibelle = (table) => new RegExp(`^${table}(?:\\.[a-z][A-Za-z0-9]*)+$`); + +// Les modules de table du dossier, aides et épreuves exceptées, par ordre +// alphabétique, et ce que chacun exporte. +async function tablesDuDossier() { + const noms = readdirSync(join(RACINE, DOSSIER_DES_TABLES)) + .filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js') && nom !== 'aides.js') + .sort(); + return Promise.all(noms.map(async (nom) => ({ table: nom.slice(0, -3), module: await import(`./libelles/${nom}`) }))); +} + +describe('libellés : les tables par domaine (§ 14.6)', () => { + test('chaque module de libelles/, aides.js excepté, exporte TABLE, une Map ; TABLES_APPLICATION les assemble, dix, par ordre alphabétique', async () => { + const tables = await tablesDuDossier(); + assert.equal(tables.length, 10); + assert.deepEqual(tables.map(({ table }) => table), [ + 'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'seance', 'stockage', + ]); + for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table); + assert.equal(TABLES_APPLICATION.length, tables.length); + tables.forEach(({ table, module }, rang) => assert.equal(TABLES_APPLICATION[rang], module.TABLE, table)); + }); + + test("chaque clé a l'une des deux formes ; un libellé commence par le nom de sa table ; participants et generation ne portent que des codes", async () => { + const tables = await tablesDuDossier(); + let comptees = 0; + for (const { table, module } of tables) { + for (const cle of module.TABLE.keys()) { + comptees += 1; + const code = FORME_CODE.test(cle); + assert.ok(code || formeLibelle(table).test(cle), `${table} : clé ${JSON.stringify(cle)}`); + if (table === 'participants' || table === 'generation') assert.ok(code, `${table} : ${cle} n'est pas un code`); + } + } + assert.ok(comptees >= CODES.length, `${comptees} clés`); + }); + + test("chaque texte d'A1 est dans la table du domaine qui l'émet", async () => { + const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE])); + const ATTENDUS = [ + ['stockage', ['ABSENT', 'VERROU_PRIS', 'LECTURE_SEULE', 'DERIVE', 'RELECTURE_DIFFERENTE', 'COMPTEUR_SATURE']], + ['csv', ['NOM_ABSENT', 'UTF8_INVALIDE', 'ENREGISTRER_EN_UTF8', 'PLAN_BLOQUE', 'AMBIGUITE']], + ['seance', ['MODE_LECTURE', 'SUPPORT_ROMPU', 'SANS_EFFET', 'ENTREE_DEFAIRE', 'JALON_CREATION']], + ['participants', ['NOM_REQUIS', 'PARTICIPANT_INCONNU', 'RESERVATIONS_SUSPENDUES', 'IMPORT_EN_RETENU', 'ENTREE_AJOUTER_PARTICIPANT']], + ['evenements', ['ENTREE_CREATION', 'DATE_INVALIDE', 'AUCUN_RETENU', 'PLAN_NON_BLOQUE', 'ENTREE_DEBLOQUER']], + ['generation', ['PROPOSITION_INCONNUE', 'ENTREE_ENREGISTRER_GENERATION', 'ENTREE_EFFACER_PROPOSITIONS']], + ['format', ['format.vide', 'format.duree.vingtMinutes']], + ]; + assert.equal(ATTENDUS.length, 7); + for (const [table, cles] of ATTENDUS) for (const cle of cles) assert.ok(parTable.get(table).has(cle), `${cle} hors de ${table}.js`); + }); + + test("CODES ne compte que les clés de forme code ; libelle lit aussi un libellé de l'application", () => { + assert.ok(CODES.length > 0); + assert.deepEqual(CODES.filter((cle) => !FORME_CODE.test(cle)), []); + assert.ok(!CODES.includes('format.vide')); + assert.equal(libelle('format.vide'), '\u{2014}'); + const toutes = TABLES_APPLICATION.flatMap((table) => [...table.keys()]).filter((cle) => FORME_CODE.test(cle)); + assert.deepEqual([...CODES], [...toutes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))); + }); +}); diff --git a/src/application/libelles/aides.js b/src/application/libelles/aides.js new file mode 100644 index 0000000..8885a31 --- /dev/null +++ b/src/application/libelles/aides.js @@ -0,0 +1,102 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les aides de composition que partagent les tables de l'application : citer, +// compter, énumérer, dater, nommer un état. Ce module n'est pas une table et +// n'exporte aucune TABLE. Une aide qui lit le texte d'une autre clé reçoit +// texte, la fonction de composition que la traduction passe à chaque entrée +// (traduction.js) : elle ne connaît pas l'assemblage des tables. +// +// Les aides 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. + +const NOMS_DES_ETATS = new Map([ + ['brouillon', 'Brouillon'], + ['propose', 'Proposé'], + ['retenu', 'Retenu'], + ['bloque', 'Bloqué'], +]); + +/** Valeur d'une table de noms, ou RangeError qui la nomme. */ +export function nomDans(table, valeur, quoi) { + if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`); + return table.get(valeur); +} + +/** Le nom d'un état du plan (§ 9) ; un état inconnu lève RangeError. */ +export const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état'); + +/** Un texte cité entre guillemets français. */ +export const cite = (texte) => `« ${texte} »`; + +/** Un compte et son nom, au singulier jusqu'à un, comme le veut le français. */ +export const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`; + +/** Une énumération : « a », « a et b », « a, b et c ». */ +export function enumeration(termes) { + if (termes.length <= 1) return termes.join(''); + return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`; +} + +/** La première lettre en capitale. */ +export const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1); + +/** Le chemin de l'élément fautif, rien quand il manque ou désigne la racine. */ +export const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : ''); + +/** Le chemin affichable d'une racine, reçue entière ou par son seul chemin. */ +export const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin); + +/** + * Une cause entre parenthèses, rien quand elle manque : son texte quand les + * tables en portent un, sinon la cause telle que le système la nomme. + */ +export function causeEntreParentheses(cause, texte) { + if (cause === null || cause === undefined || cause === '') return ''; + return ` (${texte.existe(cause) ? texte(cause) : cause})`; +} + +/** Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause. */ +export const ouEcrire = ({ chemin, dossier, cause }, texte) => + `${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause, texte)}`; + +/** + * Une raison codée en une phrase : son texte quand les tables en portent un + * et que les détails reçus lui suffisent ; sinon une phrase générique qui la + * cite telle quelle. Un texte qui attend ses propres détails lève ou rend + * « undefined » sans eux : la phrase générique le remplace alors. + */ +export function phraseDeRaison(raison, details, texte) { + const generique = `La raison donnée est ${cite(String(raison))}.`; + if (!texte.existe(raison)) return generique; + try { + const rendu = texte(raison, details); + return typeof rendu === 'string' && !rendu.includes('undefined') ? rendu : generique; + } catch { + return generique; + } +} + +/** Des numéros de proposition, un illisible nommé comme tel. */ +export const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`))); + +// La forme d'un horodatage : date, heure et minute se lisent, les secondes et +// le décalage se taisent. +const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/; + +/** + * Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à + * 8 h 05 », à l'heure qu'il porte, par les textes de la mise en forme + * (format.date, format.heure) ; une autre forme se cite telle quelle. + */ +export function dater(horodatage, texte) { + 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); + return `le ${texte('format.date', { annee, mois, jour })} à ${texte('format.heure', { heure, minute })}`; +} + +/** « depuis » et l'horodatage en mots, rien quand il manque. */ +export const depuisQuand = (depuis, texte) => + depuis === null || depuis === undefined ? '' : ` depuis ${dater(depuis, texte)}`; diff --git a/src/application/libelles/csv.js b/src/application/libelles/csv.js new file mode 100644 index 0000000..9f5b055 --- /dev/null +++ b/src/application/libelles/csv.js @@ -0,0 +1,62 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des codes de src/csv (§ 10.1, § 14.6) : encodage, séparateur, +// en-tête, refus d'une ligne, refus de la mise à jour. +import { cite, enumeration } from './aides.js'; + +// 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}`; + +export const TABLE = new Map([ + // --- 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 }, texte) => { + 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 = texte(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.`, + ], +]); diff --git a/src/application/libelles/evenements.js b/src/application/libelles/evenements.js new file mode 100644 index 0000000..04cb5a1 --- /dev/null +++ b/src/application/libelles/evenements.js @@ -0,0 +1,22 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes de la création d'un événement, de ses états et du déblocage +// (§ 8.1, § 9, § 14.6) : refus et libellés figés. Un état inconnu lève +// RangeError, au lieu de se taire. +import { cite, nomEtat } from './aides.js'; + +export const TABLE = new Map([ + // --- Les refus de la création, des états et du déblocage (§ 8.1, § 9) ----- + ['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."], + ['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.'], + ['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."], + + // --- Les libellés figés du journal (§ 8.2, § 8.3) -------------------------- + ['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`], + ['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`], + ['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`], +]); diff --git a/src/application/libelles/format.js b/src/application/libelles/format.js new file mode 100644 index 0000000..1b4487a --- /dev/null +++ b/src/application/libelles/format.js @@ -0,0 +1,60 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes de la mise en forme (format.js, § 5.4, § 5.10, § 18.4) : la +// valeur absente, les unités d'une durée, son ordre de grandeur, les mois et +// l'heure. Les entrées reçoivent des nombres déjà contrôlés par creerFormat, +// ou des chiffres déjà mis en forme ; un mois hors de 1 à 12 lève RangeError. + +const MOIS = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', +]; + +// Le nom d'un mois, de 1 à 12. +function nomDuMois(mois) { + if (!Number.isInteger(mois) || mois < 1 || mois > 12) { + throw new RangeError(`format : mois inconnu ${JSON.stringify(mois)}`); + } + return MOIS[mois - 1]; +} + +export const TABLE = new Map([ + // Une valeur absente, celle d'une population vide (§ 5.4). + ['format.vide', '\u{2014}'], + + // Une durée : secondes, déjà écrites ; minutes et secondes ; heures et + // minutes. Une part nulle se tait. + ['format.duree.secondes', ({ secondes }) => `${secondes} s`], + [ + 'format.duree.minutes', + ({ minutes, secondes }) => (secondes === 0 ? `${minutes} min` : `${minutes} min ${secondes} s`), + ], + ['format.duree.heures', ({ heures, minutes }) => (minutes === 0 ? `${heures} h` : `${heures} h ${minutes} min`)], + + // L'ordre de grandeur d'une durée (§ 5.10), du plus court au plus long. + ['format.duree.moinsDUneMinute', "moins d'une minute"], + ['format.duree.deuxMinutes', 'environ deux minutes'], + ['format.duree.cinqMinutes', 'environ cinq minutes'], + ['format.duree.dixMinutes', 'environ dix minutes'], + ['format.duree.vingtMinutes', 'environ vingt minutes'], + ['format.duree.demiHeure', 'environ une demi-heure'], + ['format.duree.uneHeure', 'environ une heure'], + ['format.duree.plusieursHeures', 'plusieurs heures'], + + // Une date en lettres, « 1er » pour le premier du mois (§ 18.4) ; l'heure + // sans zéro de tête, la minute sur deux chiffres. + ['format.date', ({ annee, mois, jour }) => `${jour === 1 ? '1er' : jour} ${nomDuMois(mois)} ${annee}`], + ['format.heure', ({ heure, minute }) => `${heure} h ${String(minute).padStart(2, '0')}`], + ['format.horodatage', (details, texte) => `${texte('format.date', details)}, ${texte('format.heure', details)}`], +]); diff --git a/src/application/libelles/generation.js b/src/application/libelles/generation.js new file mode 100644 index 0000000..0c17a47 --- /dev/null +++ b/src/application/libelles/generation.js @@ -0,0 +1,26 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des commandes de la génération et des propositions (§ 5.7, +// § 14.6) : refus et libellés figés. Cette table ne porte que des codes. +import { compte } from './aides.js'; + +export const TABLE = new Map([ + // --- Les refus des propositions (§ 5.7) ------------------------------------ + ['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`], + + // --- Les libellés figés du journal (§ 8.2, § 8.3) -------------------------- + [ + '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})`}`, + ], +]); diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js new file mode 100644 index 0000000..f86c064 --- /dev/null +++ b/src/application/libelles/mobilier.js @@ -0,0 +1,5 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des commandes du mobilier — tables, sièges, tours (§ 6, § 14.6). +export const TABLE = new Map(); diff --git a/src/application/libelles/moteur.js b/src/application/libelles/moteur.js new file mode 100644 index 0000000..d9359f2 --- /dev/null +++ b/src/application/libelles/moteur.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les +// libellés de ses critères (§ 5, § 14.6). +export const TABLE = new Map(); diff --git a/src/application/libelles/participants.js b/src/application/libelles/participants.js new file mode 100644 index 0000000..bc5dc36 --- /dev/null +++ b/src/application/libelles/participants.js @@ -0,0 +1,80 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des commandes de la liste des participants et de l'import +// (§ 4.4, § 10.2, § 14.6) : leurs refus, leurs avertissements et leurs +// libellés figés. Cette table ne porte que des codes. Un champ inconnu lève +// RangeError, au lieu de se taire. +import { compte, nomDans } from './aides.js'; + +// 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 nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ'); + +// 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}` : ''; + +export const TABLE = new Map([ + // --- Les refus et les avertissements des commandes (§ 4.4, § 10.2) --------- + ['NOM_REQUIS', () => 'Le nom est obligatoire.'], + ['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`], + [ + '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_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)}`); + }, + ], +]); diff --git a/src/application/libelles/places.js b/src/application/libelles/places.js new file mode 100644 index 0000000..2eac819 --- /dev/null +++ b/src/application/libelles/places.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des commandes des places — réservations, titres, pose (§ 5.8, +// § 5.9, § 14.6). +export const TABLE = new Map(); diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js new file mode 100644 index 0000000..847fa60 --- /dev/null +++ b/src/application/libelles/seance.js @@ -0,0 +1,98 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des codes de la séance (§ 8.3 à § 8.8, § 14.6), et les libellés +// figés que la séance écrit au journal : jalons, reprise, retours. 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). +import { causeEntreParentheses, cite, depuisQuand, nomEtat, ouEcrire } from './aides.js'; + +export const TABLE = new Map([ + // --- 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, texte) => texte('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 }, texte) => { + const refus = `Le support ne prend plus les écritures : ${ouEcrire(details, texte)} 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."], + // Quitter l'événement d'un support rompu : la séance tient la seule copie + // que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son + // dernier geste n'est qu'à l'historique de ce support. + [ + 'SUPPORT_ROMPU_NON_SAUVE', + ({ echec, dossier }) => { + const geste = + echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : ''; + return ( + `Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` + + "l'événement ouvert n'est sauvé dans aucun autre dossier. " + + "« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support." + ); + }, + ], + [ + 'EVENEMENT_CHANGE', + ({ revision }) => + `Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` + + `à la révision ${revision}, et les gestes suivants s'y ajoutent.`, + ], + [ + 'VERROU_REPRIS', + ({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`, + ], + [ + 'VERROU_NON_RENDU', + ({ cause }, texte) => + `Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause, texte)} : ` + + '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}.`], + [ + 'JALON_NON_ECRIT', + ({ dossier, cause }, texte) => + `Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` + + `dans le dossier ${cite(dossier)}${causeEntreParentheses(cause, texte)}.`, + ], + ['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."], + + // --- Les libellés figés du journal (§ 8.2, § 8.3) -------------------------- + ['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)}`], +]); diff --git a/src/application/libelles/stockage.js b/src/application/libelles/stockage.js new file mode 100644 index 0000000..8595429 --- /dev/null +++ b/src/application/libelles/stockage.js @@ -0,0 +1,312 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des codes de src/stockage (§ 14.6) : le système de fichiers et le +// dépôt, les raisons d'un fichier illisible, les fautes et les raisons de +// dérive d'un placement, ce que l'ouverture annonce, le dossier de travail et +// les réglages locaux. Une raison qu'un texte ne traite pas à part ne lève +// jamais : une phrase générique la nomme, par son texte quand les tables en +// portent un, telle quelle sinon. 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. +import { + capitale, + causeEntreParentheses, + cheminDe, + cite, + compte, + depuisQuand, + element, + enumeration, + numeros, + ouEcrire, + phraseDeRaison, +} from './aides.js'; + +// La raison d'un fichier d'état illisible, en une phrase. +function raisonIllisible({ raison, ...details }, texte) { + if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique."; + return phraseDeRaison(raison, details, texte); +} + +// 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 }; +} + +export const TABLE = 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, texte) => `L'écriture de ${ouEcrire(details, texte)} a échoué ; le fichier d'avant reste intact.`], + ['LECTURE', (details, texte) => `La lecture de ${ouEcrire(details, texte)} 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, texte) => { + const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier'; + return `${sujet} ne s'ouvre pas. ${raisonIllisible(details, texte)}${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 }, texte) => { + if (vivant === true) { + return `Une autre séance modifie cet événement${depuisQuand(depuis, texte)} : il reste en lecture ici.`; + } + if (vivant === false) { + return ( + `Une séance interrompue a laissé son verrou${depuisQuand(depuis, texte)}, et il n'a pas pu se reprendre : ` + + "l'événement reste en lecture." + ); + } + return ( + `Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis, texte)} : ` + + 'il reste en lecture ici, et rien ne le reprend seul.' + ); + }, + ], + [ + 'LECTURE_SEULE', + ({ raison }, texte) => { + if (raison === 'BLOQUE') { + return `${texte('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`; + } + if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') { + return `${texte(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`; + } + if (raison === 'ENCODAGE') { + return ( + `Le fichier d'état a changé sur le disque. ${texte('ENCODAGE')} ` + + "L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " + + "range ce fichier dans la corbeille avant d'écrire l'état." + ); + } + return `${phraseDeRaison(raison, {}, texte)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`; + }, + ], + [ + 'ETAT_NON_ECRIT', + (details, texte) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details, texte)} 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 }, texte) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause, texte)}.`], + + // --- 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}.`; + }, + ], + [ + 'ENCODAGE', + () => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.", + ], + + // --- 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_RECREE', + () => + "Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " + + 'tel que la séance le connaissait.', + ], + [ + '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_ILLISIBLE', + ({ raison }, texte) => + `L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison, {}, texte)} ` + + "Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.", + ], + [ + '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)}).`, + ], + [ + 'PROPOSITIONS_MANQUANTES', + ({ n }) => + `${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` + + 'dans le fichier : celles qu\'il porte sont gardées.', + ], + [ + '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 }, texte) => + `Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause, texte)} : ` + + '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 }, texte) => { + 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, texte)}.`; + }, + ], + [ + 'DOCUMENTS_NON_INSCRIPTIBLE', + ({ racine, cause }, texte) => + `Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause, texte)} : ` + + "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.', + ], + // dossier, quand il est donné, nomme le dossier où la séance écrit + // désormais (ecrireAilleurs) ; sans lui, le dossier de travail. + [ + 'SUPPORT_AMOVIBLE', + ({ dossier }) => + `${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` + + 'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.', + ], + [ + 'REGLAGES_ILLISIBLES', + ({ chemin, raison }, texte) => { + const pourquoi = + raison === 'JSON' || raison === 'FORME' ? texte(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.`; + }, + ], +]); diff --git a/src/application/personnes.js b/src/application/personnes.js new file mode 100644 index 0000000..3bb6b79 --- /dev/null +++ b/src/application/personnes.js @@ -0,0 +1,15 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le nom d'une personne tel que les libellés figés et le plan l'écrivent +// (§ 8.2) : la seule composition de ce nom dans l'application. + +/** + * Le prénom, puis le nom ; le nom seul quand le prénom est null. + * + * @param {{nom: string, prenom: string|null}} personne + * @returns {string} + */ +export function nomAffiche({ nom, prenom }) { + return prenom === null ? nom : `${prenom} ${nom}`; +} diff --git a/src/application/personnes.test.js b/src/application/personnes.test.js new file mode 100644 index 0000000..e1306aa --- /dev/null +++ b/src/application/personnes.test.js @@ -0,0 +1,33 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du nom affiché (§ 8.2) : la seule composition du nom d'une +// personne, celle des libellés figés et du plan. Les noms sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { nomAffiche as nomDesCommandes } from './commandes.js'; +import { libelle } from './libelles.js'; +import { nomAffiche } from './personnes.js'; + +describe('personnes : le nom affiché (§ 8.2)', () => { + test('prénom puis nom ; le nom seul quand le prénom est null', () => { + assert.equal(nomAffiche({ nom: 'Pervenche', prenom: 'Théo' }), 'Théo Pervenche'); + assert.equal(nomAffiche({ nom: 'Grisaille', prenom: null }), 'Grisaille'); + }); + + test('les autres champs de la personne ne comptent pas', () => { + assert.equal( + nomAffiche({ id: 4, nom: 'Lacasse', prenom: 'Ondine', appartenance: 'Club des Merles', exclu: true }), + 'Ondine Lacasse', + ); + }); + + test('nomAffiche importé de commandes.js est celui de personnes.js', () => { + assert.equal(nomDesCommandes, nomAffiche); + }); + + test('un libellé figé qui nomme une personne la nomme par nomAffiche', () => { + const personne = nomAffiche({ nom: 'Ombrelle', prenom: 'Iris' }); + assert.equal(libelle('ENTREE_AJOUTER_PARTICIPANT', { personne }), 'Ajouté Iris Ombrelle'); + }); +}); diff --git a/src/application/traduction.js b/src/application/traduction.js new file mode 100644 index 0000000..f4e5e47 --- /dev/null +++ b/src/application/traduction.js @@ -0,0 +1,73 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le mécanisme de traduction (§ 14.6), unique pour l'application et les +// écrans. Il réunit des tables — chacune une Map clé → chaîne, ou clé → +// fonction (details, texte) → chaîne — et rend t(cle, details), le texte +// d'une clé. Une clé portée par deux tables lève à la création ; une clé +// absente lève RangeError, qui la nomme : l'écran ne montre jamais une clé +// brute ni un texte vide. +// +// texte(cle, details), que reçoit une fonction de table, compose une autre +// clé des mêmes tables, toujours en français ; texte.existe(cle) dit si une +// clé a son texte, pour un détail qui porte un code ou une cause du système. +// En langue témoin, t encadre de la marque son résultat entier, une fois : +// une composition par texte n'en ajoute aucune. + +/** Les deux marques de la langue témoin, U+2045 et U+2046. */ +export const MARQUE_TEMOIN = Object.freeze({ ouverture: '\u{2045}', fermeture: '\u{2046}' }); + +const LANGUES = ['fr', 'temoin']; + +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +/** + * La traduction de ces tables, dans cette langue. + * + * @param {Array string)>>} tables + * @param {{langue?: 'fr'|'temoin'}} [options] + * @returns {{t: (cle: string, details?: Object) => string, cles: () => string[], langue: string}} + * t lève RangeError sur une clé absente, TypeError quand une fonction de + * table ne rend pas une chaîne ; cles() rend toutes les clés, triées par + * unités UTF-16 + * @throws {RangeError} une clé de deux tables, une langue inconnue + * @throws {TypeError} une table qui n'est pas une Map, une entrée ni chaîne + * ni fonction + */ +export function creerTraduction(tables, { langue = 'fr' } = {}) { + if (!LANGUES.includes(langue)) throw new RangeError(`traduction : langue inconnue ${JSON.stringify(langue)}`); + const entrees = new Map(); + for (const table of tables) { + if (!(table instanceof Map)) throw new TypeError('traduction : chaque table est une Map'); + for (const [cle, entree] of table) { + if (entrees.has(cle)) { + throw new RangeError(`traduction : la clé ${JSON.stringify(cle)} est portée par deux tables`); + } + if (typeof entree !== 'string' && typeof entree !== 'function') { + throw new TypeError(`traduction : ${JSON.stringify(cle)} n'est ni une chaîne ni une fonction`); + } + entrees.set(cle, entree); + } + } + + // Le texte français d'une clé : la chaîne, ou ce que rend la fonction de + // ses détails, un objet vide quand ils manquent. + function texte(cle, details = {}) { + const entree = entrees.get(cle); + if (entree === undefined) throw new RangeError(`traduction : clé inconnue ${JSON.stringify(cle)}`); + if (typeof entree === 'string') return entree; + const rendu = entree(details ?? {}, texte); + if (typeof rendu !== 'string') throw new TypeError(`traduction : ${JSON.stringify(cle)} ne rend pas une chaîne`); + return rendu; + } + texte.existe = (cle) => entrees.has(cle); + + const { ouverture, fermeture } = MARQUE_TEMOIN; + const t = + langue === 'temoin' + ? (cle, details) => `${ouverture}${texte(cle, details)}${fermeture}` + : (cle, details) => texte(cle, details); + const triees = Object.freeze([...entrees.keys()].sort(comparerTextes)); + return { t, cles: () => [...triees], langue }; +} diff --git a/src/application/traduction.test.js b/src/application/traduction.test.js new file mode 100644 index 0000000..81d5667 --- /dev/null +++ b/src/application/traduction.test.js @@ -0,0 +1,111 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du mécanisme de traduction (§ 14.6) : une clé absente lève et se +// nomme, une clé de deux tables lève à la création, les clés s'énumèrent +// triées, une entrée en compose une autre, et la langue témoin encadre le +// résultat entier de t d'une seule paire de marques. Les tables d'épreuve sont +// inventées. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { MARQUE_TEMOIN, creerTraduction } from './traduction.js'; + +const { ouverture: O, fermeture: F } = MARQUE_TEMOIN; + +const TABLE_A = new Map([ + ['essai.vide', '\u{2014}'], + ['essai.salut', ({ nom }) => `Bonjour ${nom}`], + ['essai.compose', ({ nom }, texte) => `${texte('essai.salut', { nom })}, ${texte('essai.vide')}`], +]); +const TABLE_B = new Map([ + ['AUTRE_CODE', () => 'Un code d’essai.'], + ['autre.libelle', 'Un libellé'], +]); + +describe('traduction : le mécanisme (§ 14.6)', () => { + test('les deux marques de la langue témoin sont U+2045 et U+2046, et figées', () => { + assert.equal(O, '\u{2045}'); + assert.equal(F, '\u{2046}'); + assert.ok(Object.isFrozen(MARQUE_TEMOIN)); + }); + + test('une clé absente lève RangeError, et le message nomme la clé ; jamais la clé brute, jamais une chaîne vide', () => { + const { t } = creerTraduction([TABLE_A]); + assert.throws(() => t('essai.inconnue'), (erreur) => { + assert.ok(erreur instanceof RangeError); + assert.match(erreur.message, /essai\.inconnue/); + return true; + }); + assert.throws(() => t(undefined), RangeError); + assert.throws(() => t('constructor'), RangeError); + }); + + test('une clé portée par deux tables lève à la création, et se nomme', () => { + const doublon = new Map([['essai.salut', 'Salut']]); + assert.throws(() => creerTraduction([TABLE_A, doublon]), (erreur) => { + assert.ok(erreur instanceof RangeError); + assert.match(erreur.message, /essai\.salut/); + return true; + }); + }); + + test('cles() énumère toutes les clés des tables, triées par unités UTF-16', () => { + const { cles } = creerTraduction([TABLE_A, TABLE_B]); + const attendues = ['AUTRE_CODE', 'autre.libelle', 'essai.compose', 'essai.salut', 'essai.vide']; + assert.equal(cles().length, 5); + assert.deepEqual(cles(), attendues); + }); + + test('une chaîne se rend telle quelle, une fonction reçoit ses détails, et une entrée en compose une autre par texte', () => { + const { t, langue } = creerTraduction([TABLE_A, TABLE_B]); + assert.equal(langue, 'fr'); + assert.equal(t('autre.libelle'), 'Un libellé'); + assert.equal(t('essai.salut', { nom: 'Iris Ombrelle' }), 'Bonjour Iris Ombrelle'); + assert.equal(t('essai.compose', { nom: 'Théo Pervenche' }), 'Bonjour Théo Pervenche, \u{2014}'); + assert.equal(t('AUTRE_CODE'), 'Un code d’essai.'); + }); + + test("une fonction appelée sans détails en reçoit un objet vide ; une entrée qui ne rend pas une chaîne lève", () => { + const table = new Map([ + ['essai.details', (details) => `${Object.keys(details).length}`], + ['essai.nombre', () => 3], + ]); + const { t } = creerTraduction([table]); + assert.equal(t('essai.details'), '0'); + assert.equal(t('essai.details', null), '0'); + assert.throws(() => t('essai.nombre'), TypeError); + }); + + test("une table qui n'est pas une Map, une entrée ni chaîne ni fonction, une langue inconnue lèvent à la création", () => { + assert.throws(() => creerTraduction([{ 'essai.objet': 'x' }]), TypeError); + assert.throws(() => creerTraduction([new Map([['essai.nombre', 4]])]), TypeError); + assert.throws(() => creerTraduction([TABLE_A], { langue: 'en' }), RangeError); + }); +}); + +describe('traduction : la langue témoin (§ 14.6)', () => { + test('t encadre son résultat de la marque, une fois', () => { + const { t, langue } = creerTraduction([TABLE_A, TABLE_B], { langue: 'temoin' }); + assert.equal(langue, 'temoin'); + assert.equal(t('essai.vide'), `${O}\u{2014}${F}`); + assert.equal(t('autre.libelle'), `${O}Un libellé${F}`); + }); + + test('une fonction de détails est décorée sur son résultat entier', () => { + const { t } = creerTraduction([TABLE_A], { langue: 'temoin' }); + assert.equal(t('essai.salut', { nom: 'Ondine Lacasse' }), `${O}Bonjour Ondine Lacasse${F}`); + }); + + test("une composition ne porte qu'une paire de marques : texte compose en français", () => { + const { t } = creerTraduction([TABLE_A], { langue: 'temoin' }); + const rendu = t('essai.compose', { nom: 'Grisaille' }); + assert.equal(rendu, `${O}Bonjour Grisaille, \u{2014}${F}`); + assert.equal(rendu.split(O).length - 1, 1); + assert.equal(rendu.split(F).length - 1, 1); + }); + + test('une clé absente lève en langue témoin comme en français', () => { + const { t } = creerTraduction([TABLE_A], { langue: 'temoin' }); + assert.throws(() => t('essai.inconnue'), RangeError); + }); +}); diff --git a/src/interface/libelles.js b/src/interface/libelles.js index 5d5132c..8130de9 100644 --- a/src/interface/libelles.js +++ b/src/interface/libelles.js @@ -1,37 +1,64 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Table des chaînes visibles de l'interface. Un composant n'écrit aucune -// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction -// remplace (§ 14.6). +// L'assemblage des textes des écrans (§ 14.6). Un composant n'écrit aucune +// chaîne visible en dur : il les lit par la traduction, dont les tables +// d'écran sont sous libelles/, une par écran, réunies ici dans l'ordre +// alphabétique de leurs fichiers ; aucune table n'importe cet assemblage. +// +// libelleProvenance, aUnLibelle et titreAvecVersion lisent en français la +// table de la coquille : le bandeau, le titre de la fenêtre, l'essai de +// démarrage et le livrable les appellent sans connaître la traduction. +import { creerTraduction } from '../application/traduction.js'; +import { TABLE as ACCUEIL } from './libelles/accueil.js'; +import { TABLE as COMMUNS } from './libelles/communs.js'; +import { TABLE as COQUILLE } from './libelles/coquille.js'; +import { TABLE as DIAGNOSTIC } from './libelles/diagnostic.js'; +import { TABLE as EVENEMENT } from './libelles/evenement.js'; +import { TABLE as GENERATION } from './libelles/generation.js'; +import { TABLE as IMPORT } from './libelles/import.js'; +import { TABLE as PARTICIPANTS } from './libelles/participants.js'; +import { TABLE as PLAN } from './libelles/plan.js'; +import { TABLE as PROPOSITIONS } from './libelles/propositions.js'; +import { TABLE as TABLES } from './libelles/tables.js'; -const NOM_APPLICATION = 'Gestion table tournante Libre'; - -// Mode de construction de Vite → provenance affichée dans le bandeau -// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé -// comme une propriété héritée d'Object ne rend pas une fonction. -const PROVENANCES = new Map([ - ['developpement', 'développement'], - ['livraison', 'livraison'], - ['documentation', 'documentation'], - ['test', 'épreuve'], +/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */ +export const TABLES_INTERFACE = Object.freeze([ + ACCUEIL, + COMMUNS, + COQUILLE, + DIAGNOSTIC, + EVENEMENT, + GENERATION, + IMPORT, + PARTICIPANTS, + PLAN, + PROPOSITIONS, + TABLES, ]); -// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme -// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom. -export function libelleProvenance(mode) { - return PROVENANCES.get(mode) ?? mode; -} +const { t } = creerTraduction(TABLES_INTERFACE); + +// La clé de la provenance d'un mode de Vite (§ 18.6). La table est une Map : +// elle ne répond qu'aux clés qu'on y a posées, et un mode nommé comme une +// propriété héritée d'Object n'en trouve aucune. +const cleProvenance = (mode) => `coquille.provenance.${mode}`; // Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à // mode ne le dit pas : « livraison » et « documentation » ont un libellé // identique à leur nom. export function aUnLibelle(mode) { - return PROVENANCES.has(mode); + return COQUILLE.has(cleProvenance(mode)); +} + +// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme +// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom. +export function libelleProvenance(mode) { + return aUnLibelle(mode) ? t(cleProvenance(mode)) : mode; } // Nom de l'application suivi de la version affichée : texte du bandeau et // titre de la fenêtre (§ 18.6). export function titreAvecVersion(version) { - return `${NOM_APPLICATION} — ${version}`; + return t('coquille.titre', { version }); } diff --git a/src/interface/libelles.test.js b/src/interface/libelles.test.js index 86e0bd9..06ea21b 100644 --- a/src/interface/libelles.test.js +++ b/src/interface/libelles.test.js @@ -5,10 +5,16 @@ // libellé de provenance, comme le mode test que pose Vitest ; un mode sans // libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet // sur la provenance. +// +// Les textes des écrans se partagent en tables, une par écran, sous +// libelles/ : chacune exporte TABLE, une Map de libellés, et libelles.js les +// assemble par ordre alphabétique des fichiers. import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; +import { TABLES_APPLICATION } from '../application/libelles.js'; +import { creerTraduction } from '../application/traduction.js'; import { describe, test } from '../../test/lanceur.js'; -import { aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js'; +import { TABLES_INTERFACE, aUnLibelle, libelleProvenance, titreAvecVersion } from './libelles.js'; // Modes nommés par les scripts de package.json, triés, sans doublon. function modesDesScripts() { @@ -48,3 +54,54 @@ describe('libellés', () => { assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X'); }); }); + +// Les modules de table de libelles/, épreuves exceptées, par ordre +// alphabétique, et ce que chacun exporte. +async function tablesDuDossier() { + const noms = readdirSync(new URL('./libelles/', import.meta.url)) + .filter((nom) => nom.endsWith('.js') && !nom.endsWith('.test.js')) + .sort(); + return Promise.all(noms.map(async (nom) => ({ table: nom.slice(0, -3), module: await import(`./libelles/${nom}`) }))); +} + +describe('libellés : les tables des écrans (§ 14.6)', () => { + test('chaque module de libelles/ exporte TABLE, une Map ; TABLES_INTERFACE les assemble, onze, par ordre alphabétique', async () => { + const tables = await tablesDuDossier(); + assert.equal(tables.length, 11); + assert.deepEqual(tables.map(({ table }) => table), [ + 'accueil', 'communs', 'coquille', 'diagnostic', 'evenement', 'generation', + 'import', 'participants', 'plan', 'propositions', 'tables', + ]); + for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table); + assert.equal(TABLES_INTERFACE.length, tables.length); + tables.forEach(({ table, module }, rang) => assert.equal(TABLES_INTERFACE[rang], module.TABLE, table)); + }); + + test("les tables d'écran ne portent que des libellés, chacun préfixé du nom de sa table", async () => { + const tables = await tablesDuDossier(); + let comptees = 0; + for (const { table, module } of tables) { + const forme = new RegExp(`^${table}(?:\\.[a-z][A-Za-z0-9]*)+$`); + for (const cle of module.TABLE.keys()) { + comptees += 1; + assert.match(cle, forme, table); + } + } + assert.ok(comptees > 0, 'aucune clé'); + }); + + test("les tables de l'application et des écrans se réunissent sans clé partagée", () => { + assert.ok(TABLES_APPLICATION.length > 0 && TABLES_INTERFACE.length > 0); + const { cles } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + assert.ok(cles().length > 0); + }); + + test("la coquille se lit en français dans coquille.js : son titre est celui de titreAvecVersion, ses provenances celles de libelleProvenance", () => { + const { t } = creerTraduction(TABLES_INTERFACE); + assert.equal(t('coquille.titre', { version: 'X' }), titreAvecVersion('X')); + assert.equal(t('coquille.nom'), 'Gestion table tournante Libre'); + for (const mode of ['developpement', 'livraison', 'documentation', 'test']) { + assert.equal(t(`coquille.provenance.${mode}`), libelleProvenance(mode), mode); + } + }); +}); diff --git a/src/interface/libelles/accueil.js b/src/interface/libelles/accueil.js new file mode 100644 index 0000000..b773fad --- /dev/null +++ b/src/interface/libelles/accueil.js @@ -0,0 +1,7 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés de l'accueil : la liste des événements, les démonstrations, les +// opérations de fichiers, la corbeille, le dossier de travail et la +// récupération (§ 14.6). Chaque clé commence par « accueil. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/communs.js b/src/interface/libelles/communs.js new file mode 100644 index 0000000..0cbcb6b --- /dev/null +++ b/src/interface/libelles/communs.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés communs aux écrans : commandes, confirmations, remèdes d'un +// refus (§ 14.6). Chaque clé commence par « communs. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/coquille.js b/src/interface/libelles/coquille.js new file mode 100644 index 0000000..7dd0589 --- /dev/null +++ b/src/interface/libelles/coquille.js @@ -0,0 +1,14 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes de la coquille (§ 18.6) : le nom de l'application, le titre du +// bandeau et de la fenêtre, et la provenance de la construction par mode de +// Vite. Un mode sans clé s'affiche sous son propre nom (libelles.js). +export const TABLE = new Map([ + ['coquille.nom', 'Gestion table tournante Libre'], + ['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`], + ['coquille.provenance.developpement', 'développement'], + ['coquille.provenance.livraison', 'livraison'], + ['coquille.provenance.documentation', 'documentation'], + ['coquille.provenance.test', 'épreuve'], +]); diff --git a/src/interface/libelles/diagnostic.js b/src/interface/libelles/diagnostic.js new file mode 100644 index 0000000..ac6e261 --- /dev/null +++ b/src/interface/libelles/diagnostic.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés du diagnostic d'une configuration (§ 14.6). Chaque clé commence +// par « diagnostic. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/evenement.js b/src/interface/libelles/evenement.js new file mode 100644 index 0000000..65403ec --- /dev/null +++ b/src/interface/libelles/evenement.js @@ -0,0 +1,7 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés de l'événement ouvert : son en-tête, son mode, ses +// avertissements, le support rompu (§ 14.6). Chaque clé commence par « +// evenement. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/generation.js b/src/interface/libelles/generation.js new file mode 100644 index 0000000..695edb8 --- /dev/null +++ b/src/interface/libelles/generation.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés de la génération : réglages et avancement (§ 14.6). Chaque clé +// commence par « generation. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/import.js b/src/interface/libelles/import.js new file mode 100644 index 0000000..7241b32 --- /dev/null +++ b/src/interface/libelles/import.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque +// clé commence par « import. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js new file mode 100644 index 0000000..8955750 --- /dev/null +++ b/src/interface/libelles/participants.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés de la liste des participants : grille, formulaire, fiche (§ +// 14.6). Chaque clé commence par « participants. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js new file mode 100644 index 0000000..1a5b102 --- /dev/null +++ b/src/interface/libelles/plan.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés du plan de salle : vue, tables, places, panneaux et listes de +// travail (§ 14.6). Chaque clé commence par « plan. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js new file mode 100644 index 0000000..98ce176 --- /dev/null +++ b/src/interface/libelles/propositions.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par « +// propositions. ». +export const TABLE = new Map(); diff --git a/src/interface/libelles/tables.js b/src/interface/libelles/tables.js new file mode 100644 index 0000000..396ec20 --- /dev/null +++ b/src/interface/libelles/tables.js @@ -0,0 +1,6 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par « +// tables. ». +export const TABLE = new Map();