[ADD] application: clock, translation, formatting, displayed name
The screens need one clock, one translation mechanism and one way to format and name, or each component grows its own. horloge.js is the only code that reads time and arms timers, injected so tests drive it. traduction.js keys texts by domain, in two families of tables, raises on a missing key and never shows a raw key; libelles/ splits the label table by domain. format.js formats numbers and dates in French, and personnes.js composes the displayed name the frozen labels use. Nothing persists between sessions. Checked: 1773 node and 43 browser tests. --- FR --- [ADD] application : horloge, traduction, mise en forme, nom affiché Les écrans exigent une horloge, un mécanisme de traduction et une façon de mettre en forme et de nommer, sans quoi chaque composant ferait les siens. horloge.js est le seul code qui lit l'heure et arme des minuteries, injecté pour que les épreuves le pilotent. traduction.js range les textes par domaine, en deux familles de tables, lève sur une clé absente et ne montre jamais une clé brute ; libelles/ découpe la table des libellés par domaine. format.js met en forme nombres et dates en français, et personnes.js compose le nom affiché qu'emploient les libellés figés. Rien ne persiste d'une séance à l'autre. Vérifié : 1773 node et 43 navigateur. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
63b997faa9
commit
71076afe4d
41 changed files with 2298 additions and 706 deletions
|
|
@ -30,28 +30,15 @@ import { serialiserCharge } from '../stockage/canonique.js';
|
||||||
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { fautes, formeNommee, versFichier } from '../stockage/placements.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 { libelle } from './libelles.js';
|
||||||
|
import { nomAffiche } from './personnes.js';
|
||||||
|
|
||||||
/**
|
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
|
||||||
* Le refus d'une commande : son code, et des détails que l'appelant lit pour
|
// dans personnes.js : les importateurs de ce module les y trouvent encore.
|
||||||
* agir, remede compris. Le message, code et détails en JSON, sert aux traces ;
|
export { ErreurCommande, nomAffiche };
|
||||||
* le texte affiché vient de la table des libellés (§ 14.6).
|
|
||||||
*/
|
|
||||||
export class ErreurCommande extends Error {
|
|
||||||
/**
|
|
||||||
* @param {string} code
|
|
||||||
* @param {Object} [details]
|
|
||||||
*/
|
|
||||||
constructor(code, details = {}) {
|
|
||||||
super(`${code} ${JSON.stringify(details)}`);
|
|
||||||
this.code = code;
|
|
||||||
this.details = details;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ErreurCommande.prototype.name = 'ErreurCommande';
|
|
||||||
|
|
||||||
// Le refus de code donné : ses détails, puis le remède, null sans lui.
|
|
||||||
const refus = (code, details = {}, remede = null) => new ErreurCommande(code, { ...details, remede });
|
|
||||||
|
|
||||||
// Un avertissement : il n'empêche rien.
|
// Un avertissement : il n'empêche rien.
|
||||||
const avertissement = (code, details) => ({ code, details });
|
const avertissement = (code, details) => ({ code, details });
|
||||||
|
|
@ -120,9 +107,6 @@ function participantDe(charge, id) {
|
||||||
return participant;
|
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).
|
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
|
||||||
function exigerModifiable(charge) {
|
function exigerModifiable(charge) {
|
||||||
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
||||||
|
|
@ -171,7 +155,7 @@ function ajouterParticipant(charge, { champs }) {
|
||||||
suivante.prochainsIds.participant = id + 1;
|
suivante.prochainsIds.participant = id + 1;
|
||||||
return {
|
return {
|
||||||
charge: suivante,
|
charge: suivante,
|
||||||
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: personneDe(participant) }),
|
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: nomAffiche(participant) }),
|
||||||
avertissements: [],
|
avertissements: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -192,9 +176,9 @@ function modifierParticipant(charge, { id, champs }) {
|
||||||
return {
|
return {
|
||||||
charge: avecParticipant(charge, id, () => apres),
|
charge: avecParticipant(charge, id, () => apres),
|
||||||
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
|
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
|
||||||
personne: personneDe(avant),
|
personne: nomAffiche(avant),
|
||||||
champs: changes,
|
champs: changes,
|
||||||
devenu: renomme ? personneDe(apres) : null,
|
devenu: renomme ? nomAffiche(apres) : null,
|
||||||
}),
|
}),
|
||||||
avertissements: [],
|
avertissements: [],
|
||||||
};
|
};
|
||||||
|
|
@ -206,7 +190,7 @@ function modifierParticipant(charge, { id, champs }) {
|
||||||
function bilanExclusion(avant, apres, participant) {
|
function bilanExclusion(avant, apres, participant) {
|
||||||
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
|
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
|
||||||
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
|
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 reservations = charge.reservations.length - suivante.reservations.length;
|
||||||
const details = {
|
const details = {
|
||||||
participant: id,
|
participant: id,
|
||||||
personne: personneDe(participant),
|
personne: nomAffiche(participant),
|
||||||
reservations,
|
reservations,
|
||||||
titres: titresPourvus(charge) - titresPourvus(suivante),
|
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<string, Function>} registres
|
||||||
|
* @returns {Map<string, Function>}
|
||||||
|
* @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) →
|
* Le registre des commandes, par nom : chacune (charge, arguments) →
|
||||||
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
|
* { 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 {{code: string, details: Object}} Avertissement
|
||||||
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
|
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
|
||||||
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
|
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
|
||||||
*/
|
*/
|
||||||
export const COMMANDES = new Map([
|
export const COMMANDES = reunirCommandes(
|
||||||
['ajouterParticipant', ajouterParticipant],
|
new Map([
|
||||||
['modifierParticipant', modifierParticipant],
|
['ajouterParticipant', ajouterParticipant],
|
||||||
['exclureParticipant', exclureParticipant],
|
['modifierParticipant', modifierParticipant],
|
||||||
['reintegrerParticipant', reintegrerParticipant],
|
['exclureParticipant', exclureParticipant],
|
||||||
['supprimerParticipant', supprimerParticipant],
|
['reintegrerParticipant', reintegrerParticipant],
|
||||||
['importerParticipants', importerParticipants],
|
['supprimerParticipant', supprimerParticipant],
|
||||||
['changerEtat', changerEtat],
|
['importerParticipants', importerParticipants],
|
||||||
['debloquer', debloquer],
|
['changerEtat', changerEtat],
|
||||||
['enregistrerGeneration', enregistrerGeneration],
|
['debloquer', debloquer],
|
||||||
['retenirProposition', retenirProposition],
|
['enregistrerGeneration', enregistrerGeneration],
|
||||||
['effacerPropositions', effacerPropositions],
|
['retenirProposition', retenirProposition],
|
||||||
]);
|
['effacerPropositions', effacerPropositions],
|
||||||
|
]),
|
||||||
|
COMMANDES_MOBILIER,
|
||||||
|
COMMANDES_PLACES,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import { rechercher } from '../moteur/recherche.js';
|
||||||
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
import { configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
|
||||||
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
import { derive, fautes, formeNommee, planDepuisFichier } from '../stockage/placements.js';
|
||||||
import { VERSION } from '../version.genere.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;
|
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
9
src/application/commandes_mobilier.js
Normal file
9
src/application/commandes_mobilier.js
Normal file
|
|
@ -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<string, (charge: Object, args: Object) => Object>} */
|
||||||
|
export const COMMANDES_MOBILIER = new Map();
|
||||||
9
src/application/commandes_places.js
Normal file
9
src/application/commandes_places.js
Normal file
|
|
@ -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<string, (charge: Object, args: Object) => Object>} */
|
||||||
|
export const COMMANDES_PLACES = new Map();
|
||||||
29
src/application/csv.js
Normal file
29
src/application/csv.js
Normal file
|
|
@ -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<Object>} [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);
|
||||||
|
}
|
||||||
64
src/application/csv.test.js
Normal file
64
src/application/csv.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/application/erreurs.js
Normal file
36
src/application/erreurs.js
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
149
src/application/format.js
Normal file
149
src/application/format.js
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
177
src/application/format.test.js
Normal file
177
src/application/format.test.js
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
193
src/application/horloge.js
Normal file
193
src/application/horloge.js
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
187
src/application/horloge.test.js
Normal file
187
src/application/horloge.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,657 +1,70 @@
|
||||||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// 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
|
// L'assemblage des textes de l'application (§ 14.6) : chaque code que lèvent
|
||||||
// rendent src/stockage, src/csv et src/application — refus, avertissements,
|
// 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
|
// raisons, causes et remèdes de leurs détails —, chaque libellé figé d'une
|
||||||
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
|
// entrée ou d'un jalon du journal, et les textes de la mise en forme ont leur
|
||||||
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
|
// texte français dans l'une des tables de libelles/, une par domaine. Ce
|
||||||
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
|
// module les réunit par le mécanisme de traduction (traduction.js), dans
|
||||||
// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison
|
// l'ordre alphabétique de leurs fichiers ; aucune table ne l'importe.
|
||||||
// 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
|
// Un code sans texte lève : l'écran ne montre jamais un code brut. Une valeur
|
||||||
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
|
// codée d'un détail — état, champ, mode, échec — que la table ne connaît pas
|
||||||
// un code de l'application : les textes la citent telle quelle, entre
|
// lève aussi, au lieu de se taire.
|
||||||
// parenthèses ; une cause que la table porte s'y lit par son texte.
|
|
||||||
//
|
//
|
||||||
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
|
// 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
|
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
|
||||||
// change aucun libellé déjà écrit.
|
// 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
|
||||||
// Les textes ne lisent ni l'horloge ni la langue du système : un horodatage
|
// passe jamais par la langue témoin.
|
||||||
// se met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
|
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é.
|
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
|
||||||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
export const TABLES_APPLICATION = Object.freeze([
|
||||||
|
CSV,
|
||||||
const NOMS_DES_ETATS = new Map([
|
EVENEMENTS,
|
||||||
['brouillon', 'Brouillon'],
|
FORMAT,
|
||||||
['propose', 'Proposé'],
|
GENERATION,
|
||||||
['retenu', 'Retenu'],
|
MOBILIER,
|
||||||
['bloque', 'Bloqué'],
|
MOTEUR,
|
||||||
|
PARTICIPANTS,
|
||||||
|
PLACES,
|
||||||
|
SEANCE,
|
||||||
|
STOCKAGE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
|
const FRANCAIS = creerTraduction(TABLES_APPLICATION);
|
||||||
const NOMS_DES_CHAMPS = new Map([
|
|
||||||
['nom', 'nom'],
|
|
||||||
['prenom', 'prénom'],
|
|
||||||
['appartenance', 'appartenance'],
|
|
||||||
['courriel', 'courriel'],
|
|
||||||
['titrePressenti', 'titre pressenti'],
|
|
||||||
['notes', 'notes'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const MOIS = [
|
// Un code : des capitales, des chiffres, des soulignés entre deux mots. Un
|
||||||
'janvier',
|
// libellé, préfixé du nom de sa table, porte au moins un point.
|
||||||
'février',
|
const FORME_CODE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
|
||||||
'mars',
|
|
||||||
'avril',
|
|
||||||
'mai',
|
|
||||||
'juin',
|
|
||||||
'juillet',
|
|
||||||
'août',
|
|
||||||
'septembre',
|
|
||||||
'octobre',
|
|
||||||
'novembre',
|
|
||||||
'décembre',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Valeur d'une table de noms, ou RangeError qui la nomme.
|
/** Les clés de forme code, seules, triées par unités UTF-16 croissantes. */
|
||||||
function nomDans(table, valeur, quoi) {
|
export const CODES = Object.freeze(FRANCAIS.cles().filter((cle) => FORME_CODE.test(cle)));
|
||||||
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));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le texte français d'un code et de ses détails (§ 14.6) : un refus, un
|
* Le texte français d'une clé 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.
|
* 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]
|
* @param {Object} [details]
|
||||||
* @returns {string}
|
* @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
|
* — état, champ, mode, échec — que la table ne connaît pas ; jamais
|
||||||
* une raison, qu'une phrase générique nomme
|
* une raison, qu'une phrase générique nomme
|
||||||
*/
|
*/
|
||||||
export function libelle(code, details = {}) {
|
export function libelle(cle, details = {}) {
|
||||||
return texteDe(code, details);
|
return FRANCAIS.t(cle, details);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,27 @@
|
||||||
// porte une. Chaque code relevé a son texte, chaque texte a un émetteur, et
|
// 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
|
// un balayage vide échoue. Chaque texte se compose ensuite de détails
|
||||||
// d'exemple. Les noms d'épreuve sont inventés.
|
// 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 assert from 'node:assert/strict';
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
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 { fileURLToPath } from 'node:url';
|
||||||
import { describe, test } from '../../test/lanceur.js';
|
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));
|
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
|
||||||
|
|
||||||
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
// Les arbres balayés, et les fichiers qui portent une table de codes.
|
||||||
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
|
||||||
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
|
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 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 ----------------------------------------------------------
|
// --- Le balayage ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -79,7 +86,7 @@ function codesDesTables(texte) {
|
||||||
function modulesDe(arbre) {
|
function modulesDe(arbre) {
|
||||||
return readdirSync(join(RACINE, arbre), { recursive: true })
|
return readdirSync(join(RACINE, arbre), { recursive: true })
|
||||||
.map((nom) => join(arbre, nom))
|
.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();
|
.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', () => {
|
test('chaque arbre émet des codes, et la table de contrat du stockage en porte', () => {
|
||||||
const { parArbre, tables } = releverArbre();
|
const { parArbre, tables } = releverArbre();
|
||||||
for (const arbre of ARBRES) assert.ok(parArbre.get(arbre).size > 0, `${arbre} : balayage vide`);
|
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/);
|
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)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
102
src/application/libelles/aides.js
Normal file
102
src/application/libelles/aides.js
Normal file
|
|
@ -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)}`;
|
||||||
62
src/application/libelles/csv.js
Normal file
62
src/application/libelles/csv.js
Normal file
|
|
@ -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.`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
22
src/application/libelles/evenements.js
Normal file
22
src/application/libelles/evenements.js
Normal file
|
|
@ -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))}`],
|
||||||
|
]);
|
||||||
60
src/application/libelles/format.js
Normal file
60
src/application/libelles/format.js
Normal file
|
|
@ -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)}`],
|
||||||
|
]);
|
||||||
26
src/application/libelles/generation.js
Normal file
26
src/application/libelles/generation.js
Normal file
|
|
@ -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})`}`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
5
src/application/libelles/mobilier.js
Normal file
5
src/application/libelles/mobilier.js
Normal file
|
|
@ -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();
|
||||||
6
src/application/libelles/moteur.js
Normal file
6
src/application/libelles/moteur.js
Normal file
|
|
@ -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();
|
||||||
80
src/application/libelles/participants.js
Normal file
80
src/application/libelles/participants.js
Normal file
|
|
@ -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)}`);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
6
src/application/libelles/places.js
Normal file
6
src/application/libelles/places.js
Normal file
|
|
@ -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();
|
||||||
98
src/application/libelles/seance.js
Normal file
98
src/application/libelles/seance.js
Normal file
|
|
@ -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)}`],
|
||||||
|
]);
|
||||||
312
src/application/libelles/stockage.js
Normal file
312
src/application/libelles/stockage.js
Normal file
|
|
@ -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.`;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
15
src/application/personnes.js
Normal file
15
src/application/personnes.js
Normal file
|
|
@ -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}`;
|
||||||
|
}
|
||||||
33
src/application/personnes.test.js
Normal file
33
src/application/personnes.test.js
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/application/traduction.js
Normal file
73
src/application/traduction.js
Normal file
|
|
@ -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<Map<string, string|((details: Object, texte: Function) => 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 };
|
||||||
|
}
|
||||||
111
src/application/traduction.test.js
Normal file
111
src/application/traduction.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,37 +1,64 @@
|
||||||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// 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
|
// L'assemblage des textes des écrans (§ 14.6). Un composant n'écrit aucune
|
||||||
// chaîne visible en dur : il les lit ici, seul endroit qu'une traduction
|
// chaîne visible en dur : il les lit par la traduction, dont les tables
|
||||||
// remplace (§ 14.6).
|
// 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';
|
/** Les tables de libelles/, dans l'ordre alphabétique de leurs fichiers. */
|
||||||
|
export const TABLES_INTERFACE = Object.freeze([
|
||||||
// Mode de construction de Vite → provenance affichée dans le bandeau
|
ACCUEIL,
|
||||||
// (§ 18.6). Une Map ne répond qu'aux clés qu'on y a posées : un mode nommé
|
COMMUNS,
|
||||||
// comme une propriété héritée d'Object ne rend pas une fonction.
|
COQUILLE,
|
||||||
const PROVENANCES = new Map([
|
DIAGNOSTIC,
|
||||||
['developpement', 'développement'],
|
EVENEMENT,
|
||||||
['livraison', 'livraison'],
|
GENERATION,
|
||||||
['documentation', 'documentation'],
|
IMPORT,
|
||||||
['test', 'épreuve'],
|
PARTICIPANTS,
|
||||||
|
PLAN,
|
||||||
|
PROPOSITIONS,
|
||||||
|
TABLES,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Libellé de la provenance pour un mode de Vite. Un mode sans libellé, comme
|
const { t } = creerTraduction(TABLES_INTERFACE);
|
||||||
// celui d'un « vite build » lancé sans --mode, s'affiche sous son propre nom.
|
|
||||||
export function libelleProvenance(mode) {
|
// La clé de la provenance d'un mode de Vite (§ 18.6). La table est une Map :
|
||||||
return PROVENANCES.get(mode) ?? mode;
|
// 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) à
|
// Vrai quand le mode a un libellé déclaré. Comparer libelleProvenance(mode) à
|
||||||
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
|
// mode ne le dit pas : « livraison » et « documentation » ont un libellé
|
||||||
// identique à leur nom.
|
// identique à leur nom.
|
||||||
export function aUnLibelle(mode) {
|
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
|
// Nom de l'application suivi de la version affichée : texte du bandeau et
|
||||||
// titre de la fenêtre (§ 18.6).
|
// titre de la fenêtre (§ 18.6).
|
||||||
export function titreAvecVersion(version) {
|
export function titreAvecVersion(version) {
|
||||||
return `${NOM_APPLICATION} — ${version}`;
|
return t('coquille.titre', { version });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,16 @@
|
||||||
// libellé de provenance, comme le mode test que pose Vitest ; un mode sans
|
// 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
|
// libellé s'affiche sous son propre nom plutôt que de laisser le bandeau muet
|
||||||
// sur la provenance.
|
// 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 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 { 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.
|
// Modes nommés par les scripts de package.json, triés, sans doublon.
|
||||||
function modesDesScripts() {
|
function modesDesScripts() {
|
||||||
|
|
@ -48,3 +54,54 @@ describe('libellés', () => {
|
||||||
assert.equal(titreAvecVersion('X'), 'Gestion table tournante Libre — X');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
7
src/interface/libelles/accueil.js
Normal file
7
src/interface/libelles/accueil.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/communs.js
Normal file
6
src/interface/libelles/communs.js
Normal file
|
|
@ -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();
|
||||||
14
src/interface/libelles/coquille.js
Normal file
14
src/interface/libelles/coquille.js
Normal file
|
|
@ -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'],
|
||||||
|
]);
|
||||||
6
src/interface/libelles/diagnostic.js
Normal file
6
src/interface/libelles/diagnostic.js
Normal file
|
|
@ -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();
|
||||||
7
src/interface/libelles/evenement.js
Normal file
7
src/interface/libelles/evenement.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/generation.js
Normal file
6
src/interface/libelles/generation.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/import.js
Normal file
6
src/interface/libelles/import.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/participants.js
Normal file
6
src/interface/libelles/participants.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/plan.js
Normal file
6
src/interface/libelles/plan.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/propositions.js
Normal file
6
src/interface/libelles/propositions.js
Normal file
|
|
@ -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();
|
||||||
6
src/interface/libelles/tables.js
Normal file
6
src/interface/libelles/tables.js
Normal file
|
|
@ -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();
|
||||||
Loading…
Reference in a new issue