diff --git a/src/application/commandes.js b/src/application/commandes.js index a86b120..7c81c67 100644 --- a/src/application/commandes.js +++ b/src/application/commandes.js @@ -10,7 +10,8 @@ // ErreurCommande(code, details), où details.remede nomme le geste qui le lève, // null quand il n'y en a pas ; une faute du code — un argument de forme // fausse, un état ou un champ inconnus — lève TypeError. Aucune commande -// n'écrit, ne lit l'horloge ni ne tire un aléa. +// n'écrit, ne lit l'horloge ni ne tire un aléa : appliquerCommande date le +// placement de l'instant que la séance lui passe (§ 8.9). // // La saisie entre en NFC, ses blancs de bord retirés, une chaîne vide valant // null (§ 10.2) ; la règle d'un champ est celle du schéma du fichier d'état, @@ -29,6 +30,7 @@ import { appliquerImport, bilanRemplacement } from '../csv/import.js'; import { serialiserCharge } from '../stockage/canonique.js'; import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; +import { estHorodatage } from '../stockage/journal.js'; import { fautes, formeNommee, versFichier } from '../stockage/placements.js'; import { COMMANDES_MOBILIER } from './commandes_mobilier.js'; import { COMMANDES_PLACES } from './commandes_places.js'; @@ -498,6 +500,32 @@ export const COMMANDES = reunirCommandes( COMMANDES_PLACES, ); +// Le retenu sans son horodatage : une copie de premier niveau, que la +// comparaison des placements lit sans le modifier. +function sansHorodatage(retenu) { + const { horodatage, ...reste } = retenu; + return reste; +} + +// Le texte canonique du retenu, horodatage retiré, sous une charge vidée de +// tout le reste : deux retenus de même texte décrivent le même placement, +// quel que soit l'ordre de leurs clés ou des listes que la canonisation +// range. La copie canonique d'un retenu ne lit que lui. +const VIDE = { participants: [], tables: [], reservations: [], titres: [], propositions: [] }; +const texteDuPlacement = (charge, retenu) => serialiserCharge({ ...charge, ...VIDE, retenu: sansHorodatage(retenu) }); + +// La charge rendue datée de horodatage (§ 8.9) : le retenu quand retenuNeuf, +// et chaque proposition dont l'identifiant manque à la charge reçue. Les +// objets datés sont neufs : ni la charge rendue ni la reçue ne changent. +function dater(recue, rendue, horodatage, retenuNeuf) { + const anciennes = new Set(recue.propositions.map(({ id }) => id)); + return { + ...rendue, + propositions: rendue.propositions.map((p) => (anciennes.has(p.id) ? p : { ...p, horodatage })), + retenu: retenuNeuf ? { ...rendue.retenu, horodatage } : rendue.retenu, + }; +} + /** * Applique la commande nom à la charge : ce qu'elle rend, avertissements [] * quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la @@ -506,20 +534,49 @@ export const COMMANDES = reunirCommandes( * à lire — l'import dont seules des lignes refusées auraient changé la * liste : il passe tel quel, et rien ne s'écrit. * + * horodatage, l'instant du geste que la séance lit à son horloge, date le + * placement (§ 8.9, § 11.8). Un retenu que la commande rend inchangé à + * l'horodatage près garde celui du reçu, présent ou absent, même quand elle + * l'a reconstruit sans lui ; SANS_EFFET se juge ensuite, avant toute + * datation : un geste sans effet ne le devient pas par son instant. Puis le + * retenu qui change, ou qui apparaît, prend horodatage, et chaque proposition + * dont l'identifiant manque à la charge reçue aussi ; les autres gardent le + * leur. Corriger un nom ne date rien : l'horodatage date le placement, non + * la page. horodatage null, le défaut, ne pose ni ne retire rien : un + * retenu reconstruit à l'identique garde l'horodatage reçu, et aucun autre + * n'est posé. + * * @param {string} nom * @param {Object} charge * @param {Object} [args] + * @param {{horodatage?: string|null}} [options] horodatage de la forme + * d'estHorodatage, ou null * @returns {{charge: Object, libelle: string|null, avertissements: Array<{code: string, details: Object}>}} * @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET - * @throws {TypeError} une commande inconnue, un argument de forme fausse + * @throws {TypeError} une commande inconnue, un argument de forme fausse, un + * horodatage ni null ni de la forme d'estHorodatage */ -export function appliquerCommande(nom, charge, args = {}) { +export function appliquerCommande(nom, charge, args = {}, { horodatage = null } = {}) { const commande = COMMANDES.get(nom); if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`); + if (horodatage !== null && !estHorodatage(horodatage)) { + throw new TypeError(`horodatage : AAAA-MM-JJTHH:MM:SS±HH:MM ou null attendu, reçu ${JSON.stringify(horodatage)}`); + } const rendu = commande(charge, args ?? {}); if (rendu.libelle === null) return { ...rendu, avertissements: rendu.avertissements ?? [] }; - if (serialiserCharge(rendu.charge) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom }); - return { ...rendu, avertissements: rendu.avertissements ?? [] }; + let suivante = rendu.charge; + const { retenu } = suivante; + const memePlacement = + retenu !== null && + charge.retenu !== null && + texteDuPlacement(charge, charge.retenu) === texteDuPlacement(charge, retenu); + if (memePlacement) { + const { horodatage: recu } = charge.retenu; + suivante = { ...suivante, retenu: recu === undefined ? sansHorodatage(retenu) : { ...sansHorodatage(retenu), horodatage: recu } }; + } + if (serialiserCharge(suivante) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom }); + if (horodatage !== null) suivante = dater(charge, suivante, horodatage, retenu !== null && !memePlacement); + return { ...rendu, charge: suivante, avertissements: rendu.avertissements ?? [] }; } /** diff --git a/src/application/commandes.test.js b/src/application/commandes.test.js index 57eabab..9839064 100644 --- a/src/application/commandes.test.js +++ b/src/application/commandes.test.js @@ -730,3 +730,105 @@ describe('commandes : les registres réunis et les erreurs partagées', () => { } }); }); + +describe('commandes : le placement daté (§ 8.9, § 11.8, Q2)', () => { + const H = '2031-05-17T20:15:00-04:00'; + const H2 = '2031-05-17T21:40:00-04:00'; + const H3 = '2031-05-17T22:05:00-04:00'; + const dater = (nom, charge, args, horodatage) => appliquerCommande(nom, charge, args, { horodatage }); + const horodatages = (charge) => charge.propositions.map((p) => [p.id, p.horodatage ?? null]); + + // La charge d'essai, la table 2 surchargée à trois places et une troisième + // table de deux places : deux propositions engendrées sous H, la première + // retenue sous H. + function chargeDatee() { + const charge = chargeEssai(); + charge.tables[1].sieges = 3; + charge.tables.push(table(3, 500)); + charge.prochainsIds.table = 4; + const generee = dater('enregistrerGeneration', charge, generer(charge, 2), H).charge; + return dater('retenirProposition', generee, { id: 1 }, H).charge; + } + + test('enregistrerGeneration date les seules propositions nouvelles ; retenirProposition date le retenu, sans toucher les propositions', () => { + const premiere = dater('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2), H).charge; + assert.deepEqual(horodatages(premiere), [[1, H], [2, H]]); + const seconde = dater('enregistrerGeneration', premiere, generer(premiere, 2, 11), H2).charge; + assert.deepEqual(horodatages(seconde), [[1, H], [2, H], [3, H2], [4, H2]]); + const retenue = dater('retenirProposition', seconde, { id: 3 }, H3).charge; + assert.equal(retenue.retenu.horodatage, H3); + assert.deepEqual(retenue.propositions, seconde.propositions); + }); + + test('deplacerDansRetenu et realignerTable, qui changent le retenu, le datent de leur geste', () => { + const charge = chargeDatee(); + assert.equal(charge.retenu.horodatage, H); + const assises = charge.retenu.placement[0].sieges; + const arrivee = assises.findIndex((liste, rang) => !liste.includes(3) && liste.length < charge.retenu.capacites[rang]); + assert.ok(arrivee >= 0, 'une table d’arrivée libre'); + const deplace = dater('deplacerDansRetenu', charge, { tour: 1, participant: 3, table: charge.retenu.tables[arrivee], siege: null }, H2).charge; + assert.notDeepEqual(deplace.retenu.placement, charge.retenu.placement); + assert.equal(deplace.retenu.horodatage, H2); + assert.deepEqual(horodatages(deplace), horodatages(charge)); + const realignee = dater('realignerTable', charge, { id: 2 }, H3).charge; + assert.notDeepEqual(realignee.retenu.capacites, charge.retenu.capacites); + assert.equal(realignee.retenu.horodatage, H3); + }); + + test('une commande qui ne touche pas le placement le garde daté de son instant : corriger un nom, déplacer une table', () => { + const charge = chargeDatee(); + const renommee = dater('modifierParticipant', charge, { id: 3, champs: { nom: 'Pervenche-Lacasse' } }, H2).charge; + assert.equal(renommee.participants.find(({ id }) => id === 3).nom, 'Pervenche-Lacasse'); + assert.equal(renommee.retenu.horodatage, H); + assert.deepEqual(horodatages(renommee), [[1, H], [2, H]]); + const deplacee = dater('deplacerTable', charge, { id: 3, position: { x: 700, y: 0 } }, H2).charge; + assert.equal(deplacee.retenu.horodatage, H); + }); + + test("une commande qui reconstruit le retenu à l'identique garde son horodatage, présent ou absent", () => { + const proposee = dater('changerEtat', chargeDatee(), { etat: 'propose' }, H2).charge; + assert.equal(proposee.retenu.horodatage, H); + const reprise = dater('retenirProposition', proposee, { id: 1 }, H3).charge; + assert.equal(reprise.evenement.etat, 'retenu'); + assert.equal(reprise.retenu.horodatage, H); + const ancienne = structuredClone(proposee); + delete ancienne.retenu.horodatage; + const repriseAncienne = dater('retenirProposition', ancienne, { id: 1 }, H3).charge; + assert.equal(Object.hasOwn(repriseAncienne.retenu, 'horodatage'), false); + const autre = dater('retenirProposition', proposee, { id: 2 }, H3).charge; + assert.equal(autre.retenu.horodatage, H3); + // Sans instant, la même reconstruction garde aussi l'horodatage reçu, et + // SANS_EFFET rend le même verdict qu'avec un instant. + const repriseSansInstant = dater('retenirProposition', proposee, { id: 1 }, null).charge; + assert.equal(repriseSansInstant.retenu.horodatage, H); + refuse(() => dater('retenirProposition', chargeDatee(), { id: 1 }, null), 'SANS_EFFET', { commande: 'retenirProposition', remede: null }); + }); + + test('SANS_EFFET se juge avant la datation : retenir de nouveau le même retenu, ou poser le même état, sous un autre instant', () => { + const charge = chargeDatee(); + refuse(() => dater('retenirProposition', charge, { id: 1 }, H2), 'SANS_EFFET', { commande: 'retenirProposition', remede: null }); + refuse(() => dater('changerEtat', charge, { etat: 'retenu' }, H2), 'SANS_EFFET', { commande: 'changerEtat', remede: null }); + }); + + test("horodatage null, ou absent : rien n'est posé ni retiré", () => { + const generee = appliquerCommande('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 2)).charge; + assert.deepEqual(horodatages(generee), [[1, null], [2, null]]); + const retenue = dater('retenirProposition', generee, { id: 1 }, null).charge; + assert.equal(Object.hasOwn(retenue.retenu, 'horodatage'), false); + const datee = chargeDatee(); + const renommee = dater('modifierParticipant', datee, { id: 3, champs: { nom: 'Pervenche-Lacasse' } }, null).charge; + assert.equal(renommee.retenu.horodatage, H); + assert.deepEqual(horodatages(renommee), [[1, H], [2, H]]); + }); + + test("la charge reçue ne change pas ; un horodatage d'une autre forme est une faute du code", () => { + const charge = figer(chargeDatee()); + const avant = structuredClone(charge); + dater('deplacerTable', charge, { id: 3, position: { x: 700, y: 0 } }, H2); + dater('retenirProposition', figer(dater('changerEtat', charge, { etat: 'propose' }, H2).charge), { id: 2 }, H3); + assert.deepEqual(charge, avant); + for (const faux of ['2031-05-17 20:15', 12, '']) { + assert.throws(() => dater('deplacerTable', charge, { id: 3, position: { x: 700, y: 0 } }, faux), TypeError, JSON.stringify(faux)); + } + }); +}); diff --git a/src/application/libelles.js b/src/application/libelles.js index a27996c..d0139dd 100644 --- a/src/application/libelles.js +++ b/src/application/libelles.js @@ -27,6 +27,7 @@ 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 SORTIES } from './libelles/sorties.js'; import { TABLE as STOCKAGE } from './libelles/stockage.js'; import { creerTraduction } from './traduction.js'; @@ -41,6 +42,7 @@ export const TABLES_APPLICATION = Object.freeze([ PARTICIPANTS, PLACES, SEANCE, + SORTIES, STOCKAGE, ]); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index a789c43..92ed150 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -819,11 +819,11 @@ async function tablesDuDossier() { } 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 () => { + test('chaque module de libelles/, aides.js excepté, exporte TABLE, une Map ; TABLES_APPLICATION les assemble, onze, par ordre alphabétique', async () => { const tables = await tablesDuDossier(); - assert.equal(tables.length, 10); + assert.equal(tables.length, 11); assert.deepEqual(tables.map(({ table }) => table), [ - 'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'seance', 'stockage', + 'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'seance', 'sorties', 'stockage', ]); for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table); assert.equal(TABLES_APPLICATION.length, tables.length); diff --git a/src/application/libelles/sorties.js b/src/application/libelles/sorties.js new file mode 100644 index 0000000..1650477 --- /dev/null +++ b/src/application/libelles/sorties.js @@ -0,0 +1,13 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes des sorties (§ 11.7, § 18.6, § 19.4) : ce que porte une page +// imprimée ou la feuille de contrôle des badges, et que l'écran reprend. +// L'écran les lit par t, le papier par libelle : chaque clé vit donc dans +// une table de l'application, que les deux traductions réunissent. +export const TABLE = new Map([ + // La marque qui remplace la version dans une construction de + // documentation (§ 19.4) : au bandeau, au premier écran, au pied des pages + // imprimées et sur la feuille de contrôle. + ['sorties.version.documentation', 'construction de documentation'], +]); diff --git a/src/application/seance.js b/src/application/seance.js index bbbfee5..9bae452 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -67,7 +67,13 @@ // neuf à chaque changement et le même entre deux, et subscribe suit le // contrat des magasins de Svelte. Le cadrage de l'événement ouvert se range // hors du fichier, dans reglages_locaux.json, par un écrivain différé -// (§ 8.5) : regarder n'écrit jamais l'événement. +// (§ 8.5) : regarder n'écrit jamais l'événement. Le papier des planches, de +// la machine et non du plan, s'y range aussi, dans tout mode, avec ou sans +// événement (§ 8.5, § 11.2) ; une seule file y écrit, que le cadrage et le +// papier partagent. +// +// Chaque geste lit l'horloge une fois : l'instant de son entrée au journal +// date aussi le placement qu'il change (§ 8.9, § 11.8). // // Les appels qui lisent ou écrivent l'événement passent l'un après l'autre, // dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a @@ -89,7 +95,7 @@ import { FORMAT, analyser } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { fil, reconstruire } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; -import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js'; +import { avecCadrage, avecPapier, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js'; import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js'; import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; import { libelle } from './libelles.js'; @@ -216,7 +222,10 @@ export function creerSeance({ // une autre ; fautives : les propositions que l'ouverture a écartées ; // formatLu : le format de l'état relu, null dès qu'un geste l'a écrit ; // retourAutomatique : le dernier retour en lecture est celui de - // l'inactivité ; cadrage : celui de l'événement ouvert, lu ou demandé. + // l'inactivité ; cadrage : celui de l'événement ouvert, lu ou demandé ; + // papier : celui que portent les réglages de la racine, relu à chaque + // changement de racine et après chaque écriture des réglages, null sans + // racine ou sans choix. const etat = { racine, ouvert: null, @@ -235,6 +244,7 @@ export function creerSeance({ formatLu: null, retourAutomatique: false, cadrage: null, + papier: null, }; // --- L'observation ------------------------------------------------------------ @@ -303,6 +313,7 @@ export function creerSeance({ supportRompu: etat.supportRompu ? { ...etat.refusSupport, ailleurs: etat.ailleurs } : null, calculEnCours: etat.calculs > 0, verrouAutre: etat.verrouAutre, + papier: etat.papier, }); return figer({ racine: petit.racine, @@ -317,6 +328,7 @@ export function creerSeance({ supportRompu: petit.supportRompu, calculEnCours: petit.calculEnCours, verrouAutre: petit.verrouAutre, + papier: petit.papier, }); } @@ -341,20 +353,72 @@ export function creerSeance({ for (const fonction of [...annulations]) fonction(); } - // --- Le cadrage (§ 8.5) ----------------------------------------------------- + // --- Les réglages locaux : le cadrage et le papier (§ 8.5) -------------------- - // Écrit le cadrage demandé dans les réglages de sa racine, relus au moment - // d'écrire : un cadrage qu'une autre séance y a posé entre-temps reste. - // Des réglages que la lecture ne prend pas (REGLAGES_ILLISIBLES : lecture - // refusée, JSON ou forme fautifs) ne s'écrasent pas des défauts, qui - // perdraient le papier et les autres cadrages : le refus lève, et - // l'écrivain garde le cadrage en attente pour la demande suivante. - async function ecrireCadrage({ racine: dossier, evenement, cadrage }) { - const { reglages, avertissement } = await lireReglages(fs, dossier); - if (avertissement !== null) throw new ErreurSeance(avertissement.code, avertissement.details); - await ecrireReglages(fs, dossier, avecCadrage(reglages, evenement, cadrage)); + // La file unique des accès à reglages_locaux.json, écritures et relectures + // du papier : chacun part quand le précédent a fini, réussi ou non. Deux + // écrivains qui liraient le fichier avant que l'autre l'ait écrit + // perdraient l'un des deux réglages ; une relecture partie avant une + // écriture et rendue après remettrait dans l'état le papier d'avant. + let fileReglages = Promise.resolve(); + + // Met travail à son tour dans la file des réglages et rend sa promesse ; + // la file continue après un refus. + function enFileReglages(travail) { + const suite = fileReglages.then(travail); + fileReglages = suite.then( + () => undefined, + () => undefined, + ); + return suite; } + // À son tour dans la file, relit les réglages de dossier, leur applique + // transformer et écrit ce qu'il rend : un réglage qu'une autre séance y a + // posé entre-temps reste. Des réglages que la lecture ne prend pas + // (REGLAGES_ILLISIBLES : lecture refusée, JSON ou forme fautifs) ne + // s'écrasent pas des défauts, qui perdraient le papier et les cadrages : + // le refus lève, ErreurSeance de l'avertissement, et rien ne s'écrit. + // L'écriture faite, le papier de la séance est celui qu'elle a écrit quand + // dossier est la racine de la séance. + function ecrireReglagesLocaux(dossier, transformer) { + return enFileReglages(async () => { + const { reglages, avertissement } = await lireReglages(fs, dossier); + if (avertissement !== null) throw new ErreurSeance(avertissement.code, avertissement.details); + const ecrits = transformer(reglages); + await ecrireReglages(fs, dossier, ecrits); + if (memeRacine(dossier, etat.racine)) { + etat.papier = ecrits.papier; + publier(); + } + }); + } + + // À son tour dans la file des réglages, relit le papier des réglages de + // dossier — null sans dossier, sans choix, et pour des réglages que la + // lecture ne prend pas — et le pose dans l'état quand dossier est encore + // la racine de la séance. Ne lève jamais ; ne publie pas : l'appelant + // est un travail de la file des appels, qui publie en finissant. + function relirePapier(dossier) { + return enFileReglages(async () => { + let papier = null; + if (dossier !== null) { + try { + papier = (await lireReglages(fs, dossier)).reglages.papier; + } catch { + papier = null; + } + } + if (memeRacine(dossier, etat.racine)) etat.papier = papier; + }); + } + + // Le cadrage demandé, écrit par la file dans les réglages de sa racine. Un + // refus remonte à l'écrivain, qui garde le cadrage en attente pour la + // demande suivante. + const ecrireCadrage = ({ racine: dossier, evenement, cadrage }) => + ecrireReglagesLocaux(dossier, (reglages) => avecCadrage(reglages, evenement, cadrage)); + // Le refus d'une écriture lancée par la minuterie se tait : le cadrage // n'est qu'une commodité, et l'écrivain le réessaie à la demande suivante. const ecrivain = creerEcrivainDiffere({ ecrire: ecrireCadrage, planifier, annuler, delaiMs: delaiReglagesMs }); @@ -677,13 +741,13 @@ export function creerSeance({ // Écrit un geste depuis l'Ouvert depart : le jalon jalonAvant sur l'instant // courant quand il est donné, puis l'entrée et l'état, puis, quand l'état // du plan change, le jalon qui le nomme (§ 8.3). Un horodatage pour tout - // le geste. Le jalon d'état qu'un refus empêche laisse le geste + // le geste : options.horodatage, celui qui a daté la commande, ou une + // lecture de l'horloge. Le jalon d'état qu'un refus empêche laisse le geste // enregistré : JALON_NON_ECRIT s'ajoute aux avertissements, et le support // est rompu sous ce même échec. Rend le libellé ; les avertissements // deviennent ceux du dernier geste. async function ecrireGeste(depart, { charge, libelle: texte, avertissements }, options = {}) { - const { sens = null, retour = null, jalonAvant = null } = options; - const horodatage = horloge.horodatage(); + const { sens = null, retour = null, jalonAvant = null, horodatage = horloge.horodatage() } = options; const commun = { horodatage, libelleReprise }; let ouvert = depart; if (jalonAvant !== null) ouvert = await ecrire(ouvert, (o) => nommerSurDisque(o, { nom: jalonAvant, ...commun })); @@ -724,11 +788,12 @@ export function creerSeance({ // commande s'applique alors à ce qui est relu. Celui-ci d'un format plus // récent, ou que la commande refuse — une autre séance l'a débloqué —, // rend le verrou, la séance restant en lecture sur ce qu'elle a relu, et - // le refus remonte. Sinon l'entrée et le jalon de l'état rendu s'écrivent, + // le refus remonte. Sinon l'horloge se lit, le verrou pris : la commande + // datée de cet instant, l'entrée et le jalon de l'état rendu s'écrivent, // et la séance reste en écriture. async function debloquerPlan(ouvert, args) { exigerSupport(); - let resultat = appliquerCommande('debloquer', ouvert.charge, args); + appliquerCommande('debloquer', ouvert.charge, args); let depart = ouvert; const avertissements = []; if (etat.mode !== 'ecriture') { @@ -737,14 +802,17 @@ export function creerSeance({ depart = etat.ouvert; try { if (depart.formatPlusRecent) throw refusFormat(); - resultat = appliquerCommande('debloquer', depart.charge, args); + appliquerCommande('debloquer', depart.charge, args); } catch (erreur) { etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))]; throw erreur; } } + const horodatage = horloge.horodatage(); + const resultat = appliquerCommande('debloquer', depart.charge, args, { horodatage }); entrerEnEcriture(); - return ecrireGeste(depart, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] }); + const tous = [...avertissements, ...resultat.avertissements]; + return ecrireGeste(depart, { ...resultat, avertissements: tous }, { horodatage }); } // La séance passe en écriture : le retour automatique précédent ne se dit @@ -764,14 +832,15 @@ export function creerSeance({ return { libelle: await debloquerPlan(ouvert, args) }; } exigerEcriture(); - const resultat = appliquerCommande(nom, ouvert.charge, args); + const horodatage = horloge.horodatage(); + const resultat = appliquerCommande(nom, ouvert.charge, args, { horodatage }); if (resultat.libelle === null) { etat.avertissementsGeste = [...resultat.avertissements]; return { libelle: null, resume: resultat.resume }; } const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null; const avertissements = [...resultat.avertissements, ...avertissementsApres(ouvert.charge, resultat.charge)]; - const texte = await ecrireGeste(ouvert, { ...resultat, avertissements }, { jalonAvant }); + const texte = await ecrireGeste(ouvert, { ...resultat, avertissements }, { jalonAvant, horodatage }); return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume }; } @@ -1091,6 +1160,7 @@ export function creerSeance({ ailleurs: null, formatLu: null, }); + await relirePapier(nouvelle); // Le cadrage de l'événement suit la séance : demandé de nouveau pour la // racine nouvelle, il y remplace celui qui attendait l'ancienne, que son // support refuse. @@ -1126,6 +1196,10 @@ export function creerSeance({ } } + // Le papier de la racine reçue se lit à son tour dans la file des appels, + // puis dans celle des réglages, comme à un changement de racine. + if (racine !== null) enFile(() => relirePapier(racine)); + return { /** L'identifiant de la séance, qui signe son verrou. */ get id() { @@ -1221,6 +1295,32 @@ export function creerSeance({ etat.cadrage = cadrage; ecrivain.demander({ racine: etat.ouvert.racine, evenement, cadrage }); }, + /** + * Choisit le papier des planches de la machine (§ 8.5, § 11.2) : + * l'écrit dans reglages_locaux.json de la racine, par la file des + * réglages, puis EtatSeance.papier le porte. Vivant dans tout mode, avec + * ou sans événement, sur un plan bloqué comme sous un format plus + * récent : l'état et le journal ne s'écrivent pas. Le domaine est celui + * d'avecPapier ; null, qui y efface le choix, ne se choisit pas. + * + * @param {'A4'|'Lettre'} papier + * @returns {Promise} + * @throws {TypeError} un papier hors du domaine, ou une séance sans + * dossier de travail, sans rien lire ni écrire + * @throws {ErreurSeance} REGLAGES_ILLISIBLES {chemin, raison} : les + * réglages ne se lisent pas, ni ne s'écrasent ; le papier de la + * séance reste celui d'avant + * @throws {ErreurStockage} ECRITURE, le fichier intact + */ + async choisirPapier(papier) { + if (papier === null) throw new TypeError('choisirPapier : un papier attendu, reçu null'); + const choisir = (reglages) => avecPapier(reglages, papier); + // Essayé sur des réglages vides, avecPapier lève sur un papier hors du + // domaine avant que rien ne se lise. + choisir({ format: 1, papier: null, cadrages: [] }); + if (etat.racine === null) throw new TypeError('choisirPapier : aucun dossier de travail'); + await ecrireReglagesLocaux(etat.racine, choisir); + }, // quitter.abandonner : vrai pour quitter l'événement d'un support rompu // (exigerSauve). @@ -1235,14 +1335,15 @@ export function creerSeance({ }), /** * Ferme l'événement ouvert, comme fermer, puis fait de racine le dossier - * de travail, où la séance ouvre et crée désormais ; rien ne s'écrit - * sans événement ouvert. + * de travail, où la séance ouvre et crée désormais, et relit le papier + * de ses réglages ; rien ne s'écrit sans événement ouvert. */ changerRacine: (nouvelle, quitter) => enFile(async () => { exigerSauve(quitter); const avertissements = await fermerEvenement(); etat.racine = nouvelle; + await relirePapier(nouvelle); return { avertissements }; }), passerEnEcriture: () => enFile(passerEnEcriture), diff --git a/src/application/seance.test.js b/src/application/seance.test.js index 91f2343..80b887a 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -2944,3 +2944,192 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6)", ( assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/); }); }); + +describe('séance : le placement daté (§ 8.9, § 11.8, Q2)', () => { + // L'horodatage de la dernière entrée du journal sur le disque. + const derniereDate = (fs) => journalDe(fs).entrees.at(-1).horodatage; + + test("chaque geste date le placement de l'instant de son entrée, une lecture d'horloge ; corriger un nom ne date rien ; défaire restitue l'ancien", async () => { + const { fs, seance } = await ouvertSur(); + await seance.passerEnEcriture(); + await seance.executer('enregistrerGeneration', generer(seance.charge)); + const generation = derniereDate(fs); + assert.deepEqual(seance.charge.propositions.map((p) => p.horodatage), [generation, generation]); + await seance.executer('retenirProposition', { id: 1 }); + const premier = derniereDate(fs); + assert.notEqual(premier, generation); + assert.equal(seance.charge.retenu.horodatage, premier); + assert.equal(etatDe(fs).charge.retenu.horodatage, premier); + await seance.executer('modifierParticipant', { id: 3, champs: { nom: 'Pervenche-Lacasse' } }); + assert.equal(seance.charge.retenu.horodatage, premier); + await seance.executer('retenirProposition', { id: 2 }); + const second = derniereDate(fs); + assert.notEqual(second, premier); + assert.deepEqual([seance.charge.retenu.proposition, seance.charge.retenu.horodatage], [2, second]); + assert.deepEqual(seance.charge.propositions.map((p) => p.horodatage), [generation, generation]); + await seance.defaire(); + assert.deepEqual([seance.charge.retenu.proposition, seance.charge.retenu.horodatage], [1, premier]); + assert.equal(etatDe(fs).charge.retenu.horodatage, premier); + }); + + test('débloquer, depuis la lecture, garde le placement daté de son instant', async () => { + const charge = avecRetenu(chargeEssai()); + charge.retenu.horodatage = '2031-05-17T07:30:00-04:00'; + charge.evenement.etat = 'bloque'; + const { seance } = await ouvertSur(charge); + await seance.debloquer(); + assert.deepEqual([seance.charge.evenement.etat, seance.charge.retenu.horodatage], ['retenu', '2031-05-17T07:30:00-04:00']); + }); +}); + +describe('séance : le papier de la machine (§ 8.5, § 11.2, Q1)', () => { + const papierLu = (fs) => JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)).papier; + // Le texte de l'état et du journal sur le disque, null pour un absent. + const evenementSurDisque = (fs) => [fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)]; + + test("null au départ ; choisirPapier écrit les seuls réglages, et une séance neuve sur la même racine le lit, jamais redemandé", async () => { + const { fs, seance } = await ouvertSur(); + assert.equal(seance.etat().papier, null); + const avant = fs.compteurs.ecritures; + const textes = evenementSurDisque(fs); + await seance.choisirPapier('Lettre'); + assert.equal(fs.compteurs.ecritures, avant + 1); + assert.deepEqual(evenementSurDisque(fs), textes); + assert.equal(papierLu(fs), 'Lettre'); + assert.equal(seance.etat().papier, 'Lettre'); + const neuve = monter({ fs, racine: null, prefixe: 'neuve' }); + assert.equal(neuve.seance.etat().papier, null, 'sans racine'); + await neuve.seance.changerRacine(DOC); + assert.equal(neuve.seance.etat().papier, 'Lettre'); + const surRacine = monter({ fs, prefixe: 'sur-racine' }); + await surRacine.seance.attendre(); + assert.equal(surRacine.seance.etat().papier, 'Lettre'); + }); + + test("vivant en lecture, en écriture, sur un plan bloqué et sans événement ; jamais l'état ni le journal", async () => { + const bloque = chargeEssai(); + bloque.evenement.etat = 'bloque'; + const cas = [ + ['lecture', async () => ouvertSur()], + ['écriture', async () => { + const montage = await ouvertSur(); + await montage.seance.passerEnEcriture(); + return montage; + }], + ['bloqué', async () => ouvertSur(bloque)], + ['sans événement', async () => monter()], + ]; + assert.equal(cas.length, 4); + for (const [libelleCas, monterCas] of cas) { + const { fs, seance } = await monterCas(); + const textes = evenementSurDisque(fs); + const avant = fs.compteurs.ecritures; + for (const [rang, papier] of ['A4', 'Lettre'].entries()) { + await seance.choisirPapier(papier); + assert.equal(fs.compteurs.ecritures, avant + rang + 1, libelleCas); + assert.deepEqual([papierLu(fs), seance.etat().papier], [papier, papier], libelleCas); + } + assert.deepEqual(evenementSurDisque(fs), textes, libelleCas); + } + }); + + test('un papier hors du domaine est une faute du code, et rien ne s’écrit', async () => { + const { fs, seance } = await ouvertSur(); + const avant = fs.compteurs.ecritures; + for (const faux of ['A3', 'a4', 'lettre', null, undefined, 4]) { + await assert.rejects(seance.choisirPapier(faux), TypeError, String(faux)); + } + assert.equal(fs.compteurs.ecritures, avant); + assert.equal(seance.etat().papier, null); + }); + + test('des réglages illisibles refusent le choix REGLAGES_ILLISIBLES, comme le cadrage : le fichier intact, le papier inchangé', async () => { + for (const corrompu of ['pas du json\n', '{"cadrages":[],"format":1,"papier":"A5"}\n']) { + const { fs, seance } = await ouvertSur(); + fs.deposer('documents', FICHIER_REGLAGES, corrompu); + await refus(seance.choisirPapier('A4'), 'REGLAGES_ILLISIBLES'); + assert.equal(fs.contenu('documents', FICHIER_REGLAGES), corrompu); + assert.equal(seance.etat().papier, null); + } + }); + + test("un cadrage différé et le choix du papier écrits ensemble : les deux restent, l'un après l'autre", async () => { + const { fs, seance, planificateur } = await ouvertSur(); + seance.demanderCadrage({ k: 1.5, tx: 3, ty: 4 }); + const cadrage = planificateur.avancer(DELAI_REGLAGES_MS); + const papier = seance.choisirPapier('Lettre'); + await cadrage; + await papier; + await seance.fermer(); + await laisser(); + const relus = JSON.parse(fs.contenu('documents', FICHIER_REGLAGES)); + assert.equal(relus.papier, 'Lettre'); + assert.deepEqual(relus.cadrages, [{ evenement: 'evt-lucioles', k: 1.5, tx: 3, ty: 4 }]); + }); + + test("le papier qu'une autre séance a choisi se lit après l'écriture suivante de cette séance, cadrage compris", async () => { + const { fs, seance, planificateur } = await ouvertSur(); + const autre = monter({ fs, prefixe: 'autre' }); + await autre.seance.choisirPapier('A4'); + assert.equal(seance.etat().papier, null); + seance.demanderCadrage({ k: 2, tx: 0, ty: 0 }); + await planificateur.avancer(DELAI_REGLAGES_MS); + await laisser(); + assert.equal(seance.etat().papier, 'A4'); + assert.equal(papierLu(fs), 'A4'); + }); + + // Retient la réponse de la première lecture de reglages_locaux.json qui + // suit : la lecture se fait aussitôt, sa réponse attend liberer(). + function retenirLectureReglages(fs) { + const lireTexte = fs.lireTexte; + let liberer = null; + const retenue = new Promise((resolve) => { + liberer = resolve; + }); + let prise = false; + fs.lireTexte = async (racine, chemin) => { + if (prise || chemin !== FICHIER_REGLAGES) return lireTexte(racine, chemin); + prise = true; + const reponse = await lireTexte(racine, chemin); + await retenue; + return reponse; + }; + return { liberer: () => liberer(), prise: () => prise }; + } + + test("une relecture du papier partie avant un choix ne l'écrase pas : à la création, l'état suit le fichier", async () => { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"A4"}\n'); + const lecture = retenirLectureReglages(fs); + const { seance } = monter({ fs }); + await laisser(); + assert.equal(lecture.prise(), true, 'la relecture de la création est partie'); + const choix = seance.choisirPapier('Lettre'); + await laisser(); + lecture.liberer(); + await choix; + await seance.attendre(); + assert.deepEqual([papierLu(fs), seance.etat().papier], ['Lettre', 'Lettre']); + }); + + test("une relecture du papier partie avant un choix ne l'écrase pas : au changement de racine, l'état suit le fichier", async () => { + const { fs, seance } = monter({ fs: creerFichiersSimules({ executable: 'E:\\soirees' }) }); + await seance.attendre(); + fs.deposerDossier(PORTABLE.id); + fs.deposer(PORTABLE.id, FICHIER_REGLAGES, '{"cadrages":[],"format":1,"papier":"A4"}\n'); + const lecture = retenirLectureReglages(fs); + const changement = seance.changerRacine(PORTABLE); + await laisser(); + assert.equal(lecture.prise(), true, 'la relecture du changement de racine est partie'); + const choix = seance.choisirPapier('Lettre'); + await laisser(); + lecture.liberer(); + await changement; + await choix; + await seance.attendre(); + assert.equal(JSON.parse(fs.contenu(PORTABLE.id, FICHIER_REGLAGES)).papier, 'Lettre'); + assert.equal(seance.etat().papier, 'Lettre'); + }); +}); diff --git a/src/application/sorties.js b/src/application/sorties.js new file mode 100644 index 0000000..251a069 --- /dev/null +++ b/src/application/sorties.js @@ -0,0 +1,121 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les sorties de l'application (§ 11.4, § 11.7, § 11.8, § 18.6) : ce que +// toute sortie — page imprimée, planche de badges, feuille de contrôle — lit +// de la charge pour dire quel placement elle porte. L'horodatage du +// placement, la règle du filigrane BROUILLON et le pied de page n'ont ici +// qu'une implémentation, que l'aperçu et la planche partagent (§ 13.2). +// Chaque fonction lit la charge et rien d'autre : ni horloge, ni disque, ni +// tirage ; deux appels sur la même charge rendent les mêmes valeurs. +// +// Une sortie imprime l'une des trois sources de l'état des places : le +// retenu, une proposition désignée par son identifiant, ou les seules +// réservations quand aucun placement n'est montré. +import { refus } from './erreurs.js'; +import { versionVisible } from './version.js'; + +/** + * Les sources d'une sortie, celles de l'état des places. + * @typedef {'retenu'|'proposition'|'reservations'} SourceSortie + * @type {ReadonlyArray} + */ +export const SOURCES_SORTIE = Object.freeze(['retenu', 'proposition', 'reservations']); + +// Les états du plan qu'un placement retenu rend définitif (§ 9, § 11.7). +const ETATS_DEFINITIFS = new Set(['retenu', 'bloque']); + +// La source, contrôlée : une autre est une faute du code. +function exigerSource(source) { + if (!SOURCES_SORTIE.includes(source)) { + throw new TypeError(`source : ${SOURCES_SORTIE.join(', ')} attendue, reçu ${JSON.stringify(source)}`); + } +} + +// Le tour d'une sortie, contrôlé : un entier ≥ 1, ou 'tous' pour la soirée +// entière ; un autre est une faute du code. +function exigerTour(tour) { + if (tour !== 'tous' && !(Number.isSafeInteger(tour) && tour >= 1)) { + throw new TypeError(`tour : entier ≥ 1 ou 'tous' attendu, reçu ${JSON.stringify(tour)}`); + } +} + +/** + * L'horodatage du placement qu'imprime une sortie (§ 11.4, § 11.8) : celui + * du retenu pour la source retenu ; celui de la proposition d'identifiant + * proposition pour la source proposition, même quand elle est montrée après + * la retenue ; null pour les réservations, qui ne sont pas un placement. + * null aussi quand le placement ne le porte pas — un fichier écrit avant le + * champ — ou que la charge n'a pas de retenu : l'horodatage est inconnu. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {{source: SourceSortie, proposition?: number|null}} options + * @returns {string|null} + * @throws {TypeError} une source inconnue ; pour la source proposition, un + * identifiant qui n'est pas un entier + * @throws {import('./erreurs.js').ErreurCommande} PROPOSITION_INCONNUE {id} + */ +export function horodatagePlacement(charge, { source, proposition = null }) { + exigerSource(source); + if (source === 'reservations') return null; + if (source === 'retenu') return charge.retenu?.horodatage ?? null; + if (!Number.isSafeInteger(proposition)) { + throw new TypeError(`proposition : identifiant entier attendu, reçu ${JSON.stringify(proposition)}`); + } + const designee = charge.propositions.find(({ id }) => id === proposition); + if (designee === undefined) throw refus('PROPOSITION_INCONNUE', { id: proposition }); + return designee.horodatage ?? null; +} + +/** + * Vrai quand la sortie porte le filigrane BROUILLON (§ 11.4, § 11.7) : faux + * seulement quand ce qui s'imprime est le retenu, que le plan est retenu ou + * bloqué, que la charge porte un retenu, et que le tour imprimé est porté + * par lui — tour ≤ retenu.tours, ou 'tous' quand le retenu couvre chacun + * des tours de la soirée, retenu.tours égal à evenement.tours. Une proposition imprimée sur un plan retenu reste + * un brouillon : l'état seul ne dit pas ce qui s'imprime. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {{source: SourceSortie, tour: number|'tous'}} options + * @returns {boolean} + * @throws {TypeError} une source inconnue, un tour qui n'est ni un entier ≥ 1 + * ni 'tous' + */ +export function estBrouillon(charge, { source, tour }) { + exigerSource(source); + exigerTour(tour); + const { retenu } = charge; + if (source !== 'retenu' || !ETATS_DEFINITIFS.has(charge.evenement.etat) || retenu === null) return true; + const porte = tour === 'tous' ? retenu.tours === charge.evenement.tours : tour <= retenu.tours; + return !porte; +} + +/** + * Le pied de toute page imprimée (§ 11.7, § 18.6) : le nom de l'événement, + * sa date, le tour imprimé, l'horodatage du placement (horodatagePlacement) + * et la version visible (versionVisible) — cinq éléments, des données que la + * vue met en forme. texte compose la marque d'une construction de + * documentation : t à l'aperçu, libelle sur la feuille de contrôle. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {Object} options + * @param {SourceSortie} options.source + * @param {number|null} [options.proposition] pour la source proposition + * @param {number|'tous'} options.tour + * @param {string} options.provenance le mode de construction de Vite + * @param {string} options.version la version affichée + * @param {(cle: string) => string} options.texte + * @returns {{nom: string, date: string|null, tour: number|'tous', horodatage: string|null, version: string}} + * @throws {TypeError} une source ou un tour hors de leur domaine + * @throws {import('./erreurs.js').ErreurCommande} PROPOSITION_INCONNUE {id} + */ +export function piedDePage(charge, { source, proposition = null, tour, provenance, version, texte }) { + exigerTour(tour); + return { + nom: charge.evenement.nom, + date: charge.evenement.date, + tour, + horodatage: horodatagePlacement(charge, { source, proposition }), + version: versionVisible(provenance, version, texte), + }; +} diff --git a/src/application/sorties.test.js b/src/application/sorties.test.js new file mode 100644 index 0000000..9ecb385 --- /dev/null +++ b/src/application/sorties.test.js @@ -0,0 +1,196 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des sorties (§ 11.4, § 11.7, § 11.8, § 18.6) : l'horodatage du +// placement qu'une sortie imprime, selon sa source ; la règle du filigrane +// BROUILLON, sur la table complète des états et des sources ; le pied de +// page, ses cinq éléments, la version remplacée par la marque dans une +// construction de documentation, et aucune lecture d'horloge. Les noms +// d'épreuve sont inventés ; la version vient du module engendré. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { TABLES_INTERFACE } from '../interface/libelles.js'; +import { creerCharge } from '../stockage/document.js'; +import { VERSION } from '../version.genere.js'; +import { ErreurCommande } from './erreurs.js'; +import { creerHorlogePilotee } from './horloge.js'; +import { TABLES_APPLICATION, libelle } from './libelles.js'; +import { SOURCES_SORTIE, estBrouillon, horodatagePlacement, piedDePage } from './sorties.js'; +import { creerTraduction } from './traduction.js'; + +const H = '2031-05-17T20:15:00-04:00'; +const H2 = '2031-05-17T21:40:00-04:00'; +const H3 = '2031-05-17T22:05:00-04:00'; +const ETATS = ['brouillon', 'propose', 'retenu', 'bloque']; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +// Le placement de deux tables de deux places, sur tours tours. +const placement = (tours) => + Array.from({ length: tours }, (_, rang) => ({ sieges: rang % 2 === 0 ? [[1, 2], [3, 4]] : [[1, 3], [2, 4]], reserve: [] })); + +// Une proposition d'identifiant id sur tours tours, datée quand horodatage +// est donné. +function proposition(id, tours, horodatage) { + const p = { + id, + graine: id, + arret: 40, + historique: 500, + produitVersion: VERSION.affichee, + siegesAttribues: false, + tables: [1, 2], + capacites: [2, 2], + tours, + participants: [1, 2, 3, 4], + placement: placement(tours), + }; + return horodatage === undefined ? p : { ...p, horodatage }; +} + +// Une soirée de R tours, deux propositions — la première datée H, la +// seconde sans date —, et la première retenue, retouchée à H3, à l'état +// donné ; retenu faux : sans retenu. +function soiree({ etat = 'retenu', R = 2, toursRetenus = R, retenu = true } = {}) { + const charge = creerCharge({ id: 'evt-grives', nom: 'Veillée des Grives', date: '2031-05-17', siegesParDefaut: 2, tours: R }); + charge.evenement.etat = etat; + charge.prochainsIds.proposition = 3; + charge.propositions = [proposition(1, R, H), proposition(2, R)]; + if (retenu) { + const { id, graine, arret, historique, produitVersion, ...plan } = proposition(1, toursRetenus); + charge.retenu = { proposition: id, ...plan, horodatage: H3 }; + } + return charge; +} + +describe('horodatagePlacement : la date du placement que la sortie imprime (§ 11.4, § 11.8, Q2)', () => { + test("le retenu rend le sien, une proposition le sien, même montrée après la retenue ; les réservations n'en ont pas", () => { + const charge = soiree(); + assert.equal(horodatagePlacement(charge, { source: 'retenu' }), H3); + assert.equal(horodatagePlacement(charge, { source: 'proposition', proposition: 1 }), H); + assert.equal(horodatagePlacement(charge, { source: 'proposition', proposition: 2 }), null); + assert.equal(horodatagePlacement(charge, { source: 'reservations' }), null); + }); + + test('sans le champ — un fichier ancien —, ou sans retenu : null, horodatage inconnu', () => { + const ancienne = soiree(); + delete ancienne.retenu.horodatage; + assert.equal(horodatagePlacement(ancienne, { source: 'retenu' }), null); + assert.equal(horodatagePlacement(soiree({ retenu: false }), { source: 'retenu' }), null); + }); + + test('un identifiant absent lève PROPOSITION_INCONNUE ; une source inconnue ou un identifiant qui n’est pas un entier, TypeError', () => { + assert.throws( + () => horodatagePlacement(soiree(), { source: 'proposition', proposition: 9 }), + (erreur) => erreur instanceof ErreurCommande && erreur.code === 'PROPOSITION_INCONNUE' && erreur.details.id === 9, + ); + for (const source of ['brouillon', 'Retenu', undefined, null]) { + assert.throws(() => horodatagePlacement(soiree(), { source }), TypeError, String(source)); + } + for (const id of [null, '1', 1.5]) { + assert.throws(() => horodatagePlacement(soiree(), { source: 'proposition', proposition: id }), TypeError, String(id)); + } + }); +}); + +describe('estBrouillon : le filigrane BROUILLON (§ 11.4, § 11.7, Q5)', () => { + test('la table complète état × source au tour 1 : faux seulement pour le retenu, sur un plan retenu ou bloqué', () => { + assert.deepEqual(SOURCES_SORTIE, ['retenu', 'proposition', 'reservations']); + const definitifs = []; + let cases = 0; + for (const etat of ETATS) { + for (const source of SOURCES_SORTIE) { + cases += 1; + const brouillon = estBrouillon(soiree({ etat }), { source, tour: 1 }); + assert.equal(typeof brouillon, 'boolean'); + if (!brouillon) definitifs.push(`${etat}/${source}`); + } + } + assert.equal(cases, 12); + assert.deepEqual(definitifs, ['retenu/retenu', 'bloque/retenu']); + }); + + test("un tour que le retenu ne porte pas, 'tous' sur un retenu plus court que la soirée, un plan bloqué sans retenu : BROUILLON", () => { + const court = soiree({ R: 4, toursRetenus: 3 }); + const cas = [ + ['retenu à 3 tours, R = 4, tour 4', court, 4, true], + ['retenu à 3 tours, R = 4, tour 3', court, 3, false], + ["retenu à 3 tours, R = 4, 'tous'", court, 'tous', true], + ["retenu à 4 tours, R = 4, 'tous'", soiree({ R: 4 }), 'tous', false], + ['bloqué sans retenu', soiree({ etat: 'bloque', retenu: false }), 1, true], + ['bloqué sans retenu, tous', soiree({ etat: 'bloque', retenu: false }), 'tous', true], + ]; + const brouillons = cas.filter(([libelle, charge, tour, attendu]) => { + assert.equal(estBrouillon(charge, { source: 'retenu', tour }), attendu, libelle); + return attendu; + }); + assert.ok(brouillons.length > 0); + }); + + test('une proposition imprimée est toujours BROUILLON, tour par tour comme pour la soirée (Q3)', () => { + for (const etat of ETATS) { + for (const tour of [1, 2, 'tous']) assert.equal(estBrouillon(soiree({ etat }), { source: 'proposition', tour }), true, `${etat} ${tour}`); + } + }); + + test("un tour qui n'est ni un entier ≥ 1 ni 'tous', une source inconnue : TypeError", () => { + for (const tour of [0, -1, 1.5, 'toutes', null, undefined, '1']) { + assert.throws(() => estBrouillon(soiree(), { source: 'retenu', tour }), TypeError, String(tour)); + } + assert.throws(() => estBrouillon(soiree(), { source: 'plan', tour: 1 }), TypeError); + }); +}); + +describe('piedDePage : les cinq éléments de toute page imprimée (§ 11.7, § 18.6)', () => { + test("nom, date, tour, horodatage du placement et version, et rien d'autre", () => { + const charge = soiree(); + const pied = piedDePage(charge, { source: 'retenu', tour: 2, provenance: 'livraison', version: VERSION.affichee, texte: t }); + assert.deepEqual(Object.keys(pied).sort(), ['date', 'horodatage', 'nom', 'tour', 'version']); + assert.deepEqual(pied, { nom: 'Veillée des Grives', date: '2031-05-17', tour: 2, horodatage: H3, version: VERSION.affichee }); + const soireeEntiere = piedDePage(charge, { source: 'proposition', proposition: 2, tour: 'tous', provenance: 'test', version: VERSION.affichee, texte: libelle }); + assert.deepEqual(soireeEntiere, { nom: 'Veillée des Grives', date: '2031-05-17', tour: 'tous', horodatage: null, version: VERSION.affichee }); + const sansDate = soiree(); + sansDate.evenement.date = null; + assert.equal(piedDePage(sansDate, { source: 'reservations', tour: 1, provenance: 'test', version: VERSION.affichee, texte: t }).date, null); + }); + + test("dans une construction de documentation, la version cède la place à la marque, par t comme par libelle", () => { + for (const texte of [t, libelle]) { + const { version } = piedDePage(soiree(), { source: 'retenu', tour: 1, provenance: 'documentation', version: VERSION.affichee, texte }); + assert.equal(version, libelle('sorties.version.documentation')); + assert.ok(!version.includes(VERSION.affichee), version); + } + }); + + test("deux appels sous une horloge avancée rendent les mêmes valeurs : le pied ne lit aucune horloge", () => { + const horloge = creerHorlogePilotee({ depart: H }); + const charge = soiree(); + charge.retenu.horodatage = horloge.horodatage(); + const args = { source: 'retenu', tour: 1, provenance: 'livraison', version: VERSION.affichee, texte: t }; + const avant = piedDePage(charge, args); + horloge.avancer(3_600_000); + const { Date: DateReelle } = globalThis; + const maintenant = performance.now; + const piege = () => { + throw new Error('horloge lue'); + }; + let apres; + try { + globalThis.Date = new Proxy(DateReelle, { construct: piege, apply: piege, get: (cible, cle) => (cle === 'now' ? piege : cible[cle]) }); + performance.now = piege; + apres = piedDePage(charge, args); + } finally { + globalThis.Date = DateReelle; + performance.now = maintenant; + } + assert.deepEqual(apres, avant); + assert.equal(apres.horodatage, H); + }); + + test("un tour hors de son domaine, une source inconnue : TypeError ; un identifiant absent : PROPOSITION_INCONNUE", () => { + const base = { provenance: 'test', version: VERSION.affichee, texte: t }; + assert.throws(() => piedDePage(soiree(), { ...base, source: 'retenu', tour: 0 }), TypeError); + assert.throws(() => piedDePage(soiree(), { ...base, source: 'inventee', tour: 1 }), TypeError); + assert.throws(() => piedDePage(soiree(), { ...base, source: 'proposition', proposition: 7, tour: 1 }), ErreurCommande); + }); +}); diff --git a/src/application/version.js b/src/application/version.js new file mode 100644 index 0000000..4259330 --- /dev/null +++ b/src/application/version.js @@ -0,0 +1,44 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La version visible (§ 18.6, § 19.4) : ce qui tient lieu de version au +// bandeau, au premier écran, au pied des pages imprimées et sur la feuille +// de contrôle des badges, et la provenance de la construction. La règle vit +// dans l'application, que le papier lit comme l'écran ; src/interface/ +// apropos.js la réexporte. texte compose une clé : t à l'écran, libelle +// (français, jamais décoré) sur le papier. + +/** + * Ce qui tient lieu de version : la marque de provenance dans une + * construction de documentation, dont les captures ne changent pas d'une + * livraison à l'autre (§ 19.4) ; la version affichée sinon. + * + * @param {string} provenance le mode de construction de Vite + * @param {string} version la version affichée + * @param {(cle: string) => string} texte + * @returns {string} + */ +export function versionVisible(provenance, version, texte) { + return provenance === 'documentation' ? texte('sorties.version.documentation') : version; +} + +/** + * La provenance à l'écran : le libellé du mode, clé + * coquille.provenance., par la traduction reçue ; le mode lui-même + * quand elle n'a pas cette clé — celui d'un « vite build » lancé sans + * --mode —, qui s'affiche alors comme une donnée, sans la marque de la + * langue témoin. Une traduction lève RangeError sur une clé qu'elle n'a pas, + * avant de rien encadrer : c'est ce refus qui dit le mode sans libellé. + * + * @param {string} provenance le mode de construction de Vite + * @param {(cle: string) => string} texte + * @returns {string} + */ +export function provenanceVisible(provenance, texte) { + try { + return texte(`coquille.provenance.${provenance}`); + } catch (erreur) { + if (erreur instanceof RangeError) return provenance; + throw erreur; + } +} diff --git a/src/application/version.test.js b/src/application/version.test.js new file mode 100644 index 0000000..ca2e850 --- /dev/null +++ b/src/application/version.test.js @@ -0,0 +1,81 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la version visible (§ 18.6, § 19.4) : ce qui tient lieu de +// version à l'écran, au pied des pages imprimées et sur la feuille de +// contrôle — la marque de provenance dans une construction de +// documentation —, et la provenance à l'écran. Une seule définition, que +// l'interface réexporte. La version vient du module engendré. +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from '../../test/lanceur.js'; +import { TABLES_INTERFACE, libelleProvenance } from '../interface/libelles.js'; +import { VERSION } from '../version.genere.js'; +import { TABLES_APPLICATION, libelle } from './libelles.js'; +import { MARQUE_TEMOIN, creerTraduction } from './traduction.js'; +import { provenanceVisible, versionVisible } from './version.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const temoin = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' }).t; + +const RACINE = fileURLToPath(new URL('../..', import.meta.url)); + +// Les modules JavaScript de src/, épreuves comprises, en chemins relatifs à +// la racine. Les composants n'y entrent pas : ils importent la règle, et une +// épreuve qui les lirait par le disque devrait se relancer à chacun d'eux. +function sources(dossier = join(RACINE, 'src')) { + return readdirSync(dossier, { withFileTypes: true }).flatMap((entree) => { + const chemin = join(dossier, entree.name); + if (entree.isDirectory()) return sources(chemin); + return entree.name.endsWith('.js') ? [relative(RACINE, chemin)] : []; + }); +} + +describe('versionVisible (§ 18.6, § 19.4)', () => { + test('la version affichée, hors d’une construction de documentation', () => { + for (const provenance of ['developpement', 'livraison', 'test', 'production']) { + assert.equal(versionVisible(provenance, VERSION.affichee, t), VERSION.affichee, provenance); + assert.equal(versionVisible(provenance, VERSION.affichee, libelle), VERSION.affichee, provenance); + } + }); + + test("la marque de provenance, sans le numéro, dans une construction de documentation : par t à l'écran, par libelle sur le papier", () => { + const marque = versionVisible('documentation', VERSION.affichee, t); + assert.equal(marque, libelle('sorties.version.documentation')); + assert.equal(versionVisible('documentation', VERSION.affichee, libelle), marque); + assert.ok(!marque.includes(VERSION.affichee), marque); + const encadree = versionVisible('documentation', VERSION.affichee, temoin); + assert.equal(encadree, `${MARQUE_TEMOIN.ouverture}${marque}${MARQUE_TEMOIN.fermeture}`); + }); +}); + +describe('provenanceVisible (§ 18.6)', () => { + test('le libellé du mode par la traduction reçue ; un mode sans libellé sous son propre nom', () => { + for (const mode of ['developpement', 'livraison', 'documentation', 'test']) { + assert.equal(provenanceVisible(mode, t), libelleProvenance(mode), mode); + } + assert.equal(provenanceVisible('production', t), 'production'); + assert.equal(provenanceVisible('constructor', t), 'constructor'); + assert.equal(provenanceVisible('__proto__', t), '__proto__'); + }); + + test('en langue témoin, le libellé est encadré, le mode sans libellé non : il est une donnée', () => { + const { ouverture, fermeture } = MARQUE_TEMOIN; + assert.equal(provenanceVisible('test', temoin), `${ouverture}${libelleProvenance('test')}${fermeture}`); + assert.equal(provenanceVisible('production', temoin), 'production'); + }); +}); + +describe('la version visible : une seule définition (§ 13.2, § 18.6)', () => { + test("versionVisible et provenanceVisible ne se définissent qu'ici ; apropos.js les réexporte, les mêmes fonctions", async () => { + const definitions = sources().filter((chemin) => + /\bfunction\s+(versionVisible|provenanceVisible)\b|\b(versionVisible|provenanceVisible)\s*=/.test(readFileSync(join(RACINE, chemin), 'utf8')), + ); + assert.deepEqual(definitions, [join('src', 'application', 'version.js')]); + const apropos = await import('../interface/apropos.js'); + assert.equal(apropos.versionVisible, versionVisible); + assert.equal(apropos.provenanceVisible, provenanceVisible); + }); +}); diff --git a/src/interface/App.navigateur.test.js b/src/interface/App.navigateur.test.js index 6e2aefb..e07e02b 100644 --- a/src/interface/App.navigateur.test.js +++ b/src/interface/App.navigateur.test.js @@ -166,17 +166,17 @@ describe('titre et bandeau (§ 8.4, § 18.6)', () => { test('construction de documentation : la marque remplace le numéro ; en livraison, le numéro', async () => { const documentation = await monter({ provenance: 'documentation' }); expect(bandeau(documentation).textContent).not.toContain(VERSION.affichee); - expect(bandeau(documentation).textContent).toContain(t('coquille.version.documentation')); + expect(bandeau(documentation).textContent).toContain(t('sorties.version.documentation')); const accueil = (m) => dans(m, '[data-ecran="accueil"]'); expect(accueil(documentation).textContent).not.toContain(VERSION.affichee); - expect(accueil(documentation).textContent).toContain(t('coquille.version.documentation')); + expect(accueil(documentation).textContent).toContain(t('sorties.version.documentation')); documentation.demonter(); montes = []; const livraison = await monter({ provenance: 'livraison' }); expect(bandeau(livraison).textContent).toContain(VERSION.affichee); expect(bandeau(livraison).textContent).toContain(libelleProvenance('livraison')); expect(accueil(livraison).textContent).toContain(VERSION.affichee); - expect(accueil(livraison).textContent).not.toContain(t('coquille.version.documentation')); + expect(accueil(livraison).textContent).not.toContain(t('sorties.version.documentation')); }); }); diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index 1db8a09..bfc5ed7 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -312,7 +312,7 @@ describe('le premier écran (§ 8.1, § 18.6)', () => { const m = await monter({ provenance: 'documentation' }); const version = dans(m, '[data-version]').textContent; expect(version).toContain(t('coquille.version.libelle')); - expect(version).toContain(t('coquille.version.documentation')); + expect(version).toContain(t('sorties.version.documentation')); expect(version).not.toContain(VERSION.affichee); }); diff --git a/src/interface/apropos.js b/src/interface/apropos.js index 7d64f4a..6adc5b1 100644 --- a/src/interface/apropos.js +++ b/src/interface/apropos.js @@ -6,8 +6,10 @@ // formes de la version, l'affichée et celle que montrent les propriétés du // fichier sous Windows, que scripts/version.js engendre. Sa composition est // une seule clé de la table de la coquille : la langue témoin l'encadre une -// fois, en entier. -import { aUnLibelle } from './libelles.js'; +// fois, en entier. La version et la provenance à l'écran se définissent +// dans l'application (version.js), que le papier lit aussi : ce module les +// réexporte. +export { provenanceVisible, versionVisible } from '../application/version.js'; /** * Bloc d'une ligne du panneau « À propos » (§ 18.6) : nom, version affichée, @@ -42,30 +44,3 @@ export function blocAPropos({ version, plateforme, provenance, format }, t) { format, }); } - -/** - * Ce qui tient lieu de version à l'écran : la marque de provenance dans une - * construction de documentation, dont les captures d'écran ne changent pas - * d'une livraison à l'autre (§ 19.4) ; la version affichée sinon. - * - * @param {string} provenance le mode de construction de Vite - * @param {string} version la version affichée - * @param {(cle: string) => string} t - * @returns {string} - */ -export function versionVisible(provenance, version, t) { - return provenance === 'documentation' ? t('coquille.version.documentation') : version; -} - -/** - * La provenance à l'écran : le libellé du mode de construction par la - * traduction reçue, ou le mode lui-même quand il n'en a pas — celui d'un - * « vite build » lancé sans --mode —, qui s'affiche alors comme une donnée. - * - * @param {string} provenance le mode de construction de Vite - * @param {(cle: string) => string} t - * @returns {string} - */ -export function provenanceVisible(provenance, t) { - return aUnLibelle(provenance) ? t(`coquille.provenance.${provenance}`) : provenance; -} diff --git a/src/interface/apropos.test.js b/src/interface/apropos.test.js index 7e8351b..89f091a 100644 --- a/src/interface/apropos.test.js +++ b/src/interface/apropos.test.js @@ -3,8 +3,9 @@ // Le bloc « À propos » (§ 18.6) : une ligne que l'opérateur copie dans un // courriel — nom, version affichée, version Windows, plateforme, provenance, -// version de format —, et ce qui tient lieu de version à l'écran, la marque -// de provenance dans une construction de documentation (§ 19.4). +// version de format. Ce qui tient lieu de version à l'écran, et la +// provenance, s'éprouvent avec leur définition (src/application/version.js), +// que ce module réexporte. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { describe, test } from '../../test/lanceur.js'; @@ -12,7 +13,7 @@ import { TABLES_APPLICATION } from '../application/libelles.js'; import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js'; import { FORMAT } from '../stockage/document.js'; import { VERSION } from '../version.genere.js'; -import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js'; +import { blocAPropos } from './apropos.js'; import { TABLES_INTERFACE, libelleProvenance } from './libelles.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -76,36 +77,6 @@ describe('blocAPropos (§ 18.6)', () => { }); }); -describe('versionVisible (§ 18.6, § 19.4)', () => { - test('la version affichée, hors d’une construction de documentation', () => { - for (const provenance of ['developpement', 'livraison', 'test']) { - assert.equal(versionVisible(provenance, VERSION.affichee, t), VERSION.affichee, provenance); - } - }); - - test('la marque de provenance, sans le numéro, dans une construction de documentation', () => { - const marque = versionVisible('documentation', VERSION.affichee, t); - assert.equal(marque, t('coquille.version.documentation')); - assert.ok(!marque.includes(VERSION.affichee), marque); - }); -}); - -describe('provenanceVisible (§ 18.6)', () => { - test('le libellé du mode par la traduction reçue ; un mode sans libellé sous son propre nom', () => { - for (const mode of ['developpement', 'livraison', 'documentation', 'test']) { - assert.equal(provenanceVisible(mode, t), libelleProvenance(mode), mode); - } - assert.equal(provenanceVisible('production', t), 'production'); - assert.equal(provenanceVisible('constructor', t), 'constructor'); - }); - - test('en langue témoin, le libellé est encadré, le mode sans libellé non : il est une donnée', () => { - const { ouverture, fermeture } = MARQUE_TEMOIN; - assert.equal(provenanceVisible('test', temoin), `${ouverture}${libelleProvenance('test')}${fermeture}`); - assert.equal(provenanceVisible('production', temoin), 'production'); - }); -}); - describe('la version de l’interface vient des dépendances (§ 13.4, § 18.6)', () => { test('App.svelte ne lit pas la version engendrée : la version affichée et la forme Windows arrivent par ses propriétés, produitVersion et versionWindows', () => { const source = readFileSync(new URL('./App.svelte', import.meta.url), 'utf8'); diff --git a/src/interface/libelles/coquille.js b/src/interface/libelles/coquille.js index 70d8704..61fb46b 100644 --- a/src/interface/libelles/coquille.js +++ b/src/interface/libelles/coquille.js @@ -16,10 +16,10 @@ export const TABLE = new Map([ ['coquille.provenance.documentation', 'documentation'], ['coquille.provenance.test', 'épreuve'], - // La version à l'écran : le mot qui la précède au premier écran, et la - // marque qui la remplace dans une construction de documentation (§ 19.4). + // Le mot qui précède la version au premier écran. La marque qui la + // remplace dans une construction de documentation est une clé de + // l'application (sorties.version.documentation) : le papier la lit aussi. ['coquille.version.libelle', 'version'], - ['coquille.version.documentation', 'construction de documentation'], // Le libellé de mode, que rend cleDeMode (mode.js). ['coquille.mode.lecture', 'Lecture'], diff --git a/src/stockage/correctifs.test.js b/src/stockage/correctifs.test.js index b69fe4a..db7a177 100644 --- a/src/stockage/correctifs.test.js +++ b/src/stockage/correctifs.test.js @@ -21,6 +21,7 @@ import { canoniser, serialiser, serialiserCharge } from './canonique.js'; import { appliquer, difference } from './correctifs.js'; import { analyser, creerCharge } from './document.js'; import { ErreurStockage } from './erreurs.js'; +import { ligneEntree, ligneOuverture, lireJournal, reconstruire } from './journal.js'; import { examiner, fautes } from './placements.js'; // En-tête sous lequel une charge d'épreuve s'écrit pour être relue. @@ -1802,3 +1803,81 @@ describe('la cible de la somme de redondance : un champ facultatif dans un corre } }); }); + +describe('le placement daté : un champ facultatif du retenu et des propositions dans un correctif (§ 8.6, § 8.9, Q2)', () => { + const H = '2031-05-17T20:15:00-04:00'; + const H2 = '2031-05-17T21:40:00-04:00'; + const RETENU = ['retenu', 'horodatage']; + const PROPOSITION = ['propositions', { id: 1 }, 'horodatage']; + + // La petite démonstration, sa proposition 1 retenue ; horodatage, quand il + // est donné, date le retenu. + function retenue(horodatage) { + const charge = petite(); + charge.evenement.etat = 'retenu'; + charge.retenu = retenuDe(charge.propositions[0]); + if (horodatage !== undefined) charge.retenu.horodatage = horodatage; + return charge; + } + // La même, sa proposition 1 datée. + function propositionDatee(horodatage) { + const charge = retenue(H); + charge.propositions[0].horodatage = horodatage; + return charge; + } + + test('un correctif pose, change et retire le champ du retenu et d’une proposition, et rien quand il ne change pas', () => { + const cas = [ + ['posé sur le retenu', retenue(), retenue(H), [{ op: 'poser', chemin: RETENU, valeur: H }]], + ['changé sur le retenu', retenue(H), retenue(H2), [{ op: 'poser', chemin: RETENU, valeur: H2 }]], + ['retiré du retenu', retenue(H2), retenue(), [{ op: 'retirer', chemin: RETENU }]], + ['inchangé', retenue(H), retenue(H), []], + ['posé sur une proposition', retenue(H), propositionDatee(H2), [{ op: 'poser', chemin: PROPOSITION, valeur: H2 }]], + ['retiré d’une proposition', propositionDatee(H2), retenue(H), [{ op: 'retirer', chemin: PROPOSITION }]], + ]; + assert.equal(cas.length, 6); + for (const [libelle, a, b, attendu] of cas) { + const correctif = difference(a, b); + assert.deepEqual(correctif, attendu, libelle); + const rendue = appliquer(a, JSON.parse(JSON.stringify(correctif))); + assert.deepEqual(rendue, canoniser(b), libelle); + assert.equal(serialiser(rendue, ENTETE), serialiser(b, ENTETE), libelle); + } + }); + + test('un horodatage hors de sa forme, ou le retrait d’un horodatage absent, lèvent CORRECTIF', () => { + const cas = [ + ['date sans heure', retenue(H), { op: 'poser', chemin: RETENU, valeur: '2026-10-09 19:00' }], + ['un nombre', retenue(), { op: 'poser', chemin: RETENU, valeur: 12 }], + ['null', retenue(H), { op: 'poser', chemin: RETENU, valeur: null }], + ['sur une proposition, sans décalage', retenue(H), { op: 'poser', chemin: PROPOSITION, valeur: '2031-05-17T20:15:00' }], + ['retrait du retenu qui ne le porte pas', retenue(), { op: 'retirer', chemin: RETENU }], + ['retrait de la proposition qui ne le porte pas', retenue(H), { op: 'retirer', chemin: PROPOSITION }], + ]; + assert.equal(cas.length, 6); + for (const [libelle, charge, operation] of cas) { + assert.deepEqual(refusDe(charge, [operation]), { rang: 0 }, libelle); + } + }); + + test('reconstruire redonne chaque charge du journal : le retenu daté, redaté, puis sans date', () => { + const charges = [retenue(), retenue(H), propositionDatee(H2), retenue(H2), retenue()]; + const lignes = [ligneOuverture('demo-petite')]; + charges.forEach((apres, rang) => { + lignes.push(ligneEntree({ + revision: rang + 1, + libelle: `Geste ${rang + 1}`, + horodatage: H, + produitVersion: rang === 0 ? VERSION.affichee : null, + avant: rang === 0 ? undefined : charges[rang - 1], + apres, + instantane: rang === 0, + })); + }); + const journal = lireJournal(`${lignes.join('\n')}\n`); + assert.equal(journal.entrees.length, charges.length); + charges.forEach((charge, rang) => { + assert.deepEqual(reconstruire(journal, rang + 1), canoniser(charge), `révision ${rang + 1}`); + }); + }); +}); diff --git a/src/stockage/document.js b/src/stockage/document.js index de117bc..7edb9c6 100644 --- a/src/stockage/document.js +++ b/src/stockage/document.js @@ -80,6 +80,24 @@ export const GENERATION_PAR_DEFAUT = Object.freeze({ nombre: 5, arret: 200_000, */ export const CIBLE_REDONDANCE_PAR_DEFAUT = 1000; +// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application. +// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre. +const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/; + +/** + * Vrai pour une chaîne de la forme AAAA-MM-JJTHH:MM:SS±HH:MM, celle que rend + * l'horloge de l'application : la forme d'un horodatage, que le placement + * daté, le journal et le verrou partagent. Ni le calendrier ni les bornes + * des heures ne se contrôlent ici. Elle vit dans ce module : le schéma en a + * besoin dès son évaluation, et ce module ne peut importer le journal, qui + * lit le schéma dès la sienne ; le journal la réexporte, et le code qui + * n'est pas le schéma la prend de lui. + * + * @param {unknown} valeur + * @returns {boolean} + */ +export const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur); + // Une règle du schéma ; types.js en décrit les propriétés (Regle). Un objet // porte ses champs, paires [clé, règle] dans l'ordre des clés, et l'ensemble // de leurs clés ; une liste porte la règle de ses éléments. Un champ @@ -119,6 +137,9 @@ const IDENTIFIANT = entier(1, 2 ** 31 - 1); // attribués, et jamais en fin de liste. const OCCUPANT = regle('entier', { ...IDENTIFIANT, nul: true }); const DATE_OU_NUL = regle('date', { nul: true }); +// L'instant où un placement a pris sa forme courante (§ 8.9) : facultatif, +// absent quand il est inconnu, null refusé comme toute autre forme. +const HORODATAGE_FACULTATIF = regle('horodatage', { optionnel: true }); // Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les // copies canoniques des éléments : clés dans l'ordre du schéma, listes @@ -255,13 +276,18 @@ const PLACEMENT = liste( // Champs qu'une proposition et le retenu partagent, après ce qui les // identifie : tables et capacités se correspondent rang à rang, et leur // ordre se garde ; participants est l'ensemble des identifiants que le plan -// place, rangé par identifiant croissant. +// place, rangé par identifiant croissant. horodatage, facultatif et en +// dernier, date le placement : pour le retenu, l'instant où il a pris sa +// forme courante ; pour une proposition, celui de la génération qui l'a +// produite. Absent, l'objet s'écrit octet pour octet comme avant qu'il +// existe. const PLAN = [ ['tables', liste(IDENTIFIANT)], ['capacites', liste(entier(2))], ['tours', entier(1)], ['participants', liste(IDENTIFIANT, { tri: croissant })], ['placement', PLACEMENT], + ['horodatage', HORODATAGE_FACULTATIF], ]; const PROPOSITION = objet( @@ -409,6 +435,8 @@ function conforme(valeur, { genre, min, max, valeurs }) { return valeurs.includes(valeur); case 'date': return typeof valeur === 'string' && estJour(valeur); + case 'horodatage': + return estHorodatage(valeur); } } diff --git a/src/stockage/document.test.js b/src/stockage/document.test.js index 91428ed..7d6103d 100644 --- a/src/stockage/document.test.js +++ b/src/stockage/document.test.js @@ -10,6 +10,7 @@ // correctifs. Les textes d'épreuve sont du JSON compact : l'analyse ne // dépend pas de la mise en page. Les noms sont inventés. import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { describe, test } from '../../test/lanceur.js'; import { STATUT, normaliser } from '../moteur/configuration.js'; import { ErreurConfiguration } from '../moteur/erreurs.js'; @@ -34,6 +35,7 @@ import { premiereFauteEnPlace, } from './document.js'; import { ErreurStockage } from './erreurs.js'; +import { fautes as fautesDePlacement } from './placements.js'; // Un document valide : quatre participants, dont un exclu, l'identifiant 4 // retiré et jamais réattribué ; deux tables, dont une surchargée ; une @@ -1199,3 +1201,92 @@ describe('la cible de la somme de redondance : un réglage facultatif (§ 8.9)', assert.equal(Object.hasOwn(lecture.charge.reglages, 'autreReglage'), false); }); }); + +describe('le placement daté : un champ facultatif du retenu et des propositions (§ 8.9, Q2)', () => { + const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1]; + const CHARGE = regleDe(SCHEMA, 'charge'); + const RETENU = regleDe(CHARGE, 'retenu'); + const PROPOSITION = regleDe(CHARGE, 'propositions').element; + const H = '2031-05-17T20:15:00-04:00'; + const H2 = '2031-05-17T21:40:00+02:00'; + const ENTETE = { revision: 12, produitVersion: VERSION.affichee }; + + // Le document valide, son retenu posé ; modifier passe sur lui. + function avecRetenu(modifier = () => {}) { + const copie = documentValide(); + copie.entete.comptes.retenu = 1; + copie.charge.evenement.etat = 'retenu'; + copie.charge.retenu = retenuValide(); + modifier(copie); + return copie; + } + + test('posé sur le retenu et sur une proposition, il se lit, s’écrit après placement, en dernière clé, et se relit pareil', () => { + const lu = analyser(JSON.stringify(avecRetenu((d) => { + d.charge.retenu.horodatage = H; + d.charge.propositions[0].horodatage = H2; + }))); + assert.equal(lu.charge.retenu.horodatage, H); + assert.equal(lu.charge.propositions[0].horodatage, H2); + assert.equal(premiereFaute(lu.charge.retenu, RETENU), null); + assert.equal(premiereFaute(lu.charge.propositions[0], PROPOSITION), null); + assert.deepEqual(RETENU.champs.at(-1)[0], 'horodatage'); + assert.deepEqual(PROPOSITION.champs.at(-1)[0], 'horodatage'); + const texte = serialiser(lu.charge, ENTETE); + assert.equal(texte.split('"horodatage"').length, 3, texte); + assert.ok(texte.includes(`\n ],"horodatage":${JSON.stringify(H)}}\n`), texte); + assert.ok(texte.includes(`\n ],"horodatage":${JSON.stringify(H2)}}\n`), texte); + assert.equal(serialiser(analyser(texte).charge, ENTETE), texte); + }); + + test('absent, la charge ne le porte pas et s’écrit sans lui ; posé puis retiré, le texte revient octet pour octet', () => { + const sans = analyser(JSON.stringify(avecRetenu())).charge; + assert.equal(Object.hasOwn(sans.retenu, 'horodatage'), false); + assert.equal(Object.hasOwn(sans.propositions[0], 'horodatage'), false); + const texteSans = serialiser(sans, ENTETE); + assert.equal(texteSans.includes('horodatage'), false); + const date = structuredClone(sans); + date.retenu.horodatage = H; + date.propositions[0].horodatage = H2; + assert.notEqual(serialiser(date, ENTETE), texteSans); + delete date.retenu.horodatage; + delete date.propositions[0].horodatage; + assert.equal(serialiser(date, ENTETE), texteSans); + }); + + test('chaque démonstration livrée se relit et se réécrit octet pour octet, sans horodatage', () => { + const livrees = ['grande', 'grande-sans-exception', 'petite', 'petite-conflit']; + assert.equal(livrees.length, 4); + for (const cle of livrees) { + const surDisque = readFileSync(new URL(`../demo/livrees/${cle}.gtt.json`, import.meta.url), 'utf8'); + assert.equal(texteLivre(cle), surDisque, cle); + const { entete, charge } = analyser(surDisque); + assert.equal(serialiser(charge, entete), surDisque, cle); + assert.equal(surDisque.includes('horodatage'), false, cle); + } + }); + + test('une valeur d’une autre forme est une faute FORME de son placement, nommée par son chemin', () => { + const fautives = ['2026-10-09 19:00', 12, null, '', '2031-05-17T20:15:00', '2031-05-17T20:15:00Z', ' 2031-05-17T20:15:00-04:00', [H]]; + assert.equal(fautives.length, 8); + for (const valeur of fautives) { + const retenu = { ...retenuValide(), horodatage: valeur }; + assert.equal(premiereFaute(retenu, RETENU), 'horodatage', JSON.stringify(valeur)); + const proposition = { ...documentValide().charge.propositions[0], horodatage: valeur }; + assert.equal(premiereFaute(proposition, PROPOSITION, 'charge.propositions[0]'), 'charge.propositions[0].horodatage', JSON.stringify(valeur)); + assert.deepEqual(fautesDePlacement(proposition), [{ code: 'FORME', chemin: 'horodatage' }], JSON.stringify(valeur)); + assert.deepEqual(fautesDePlacement(retenu), [{ code: 'FORME', chemin: 'horodatage' }], JSON.stringify(valeur)); + } + }); + + test('un fichier d’un format plus récent garde l’horodatage qu’il porte, réduit aux clés connues', () => { + const lecture = analyser(JSON.stringify(avecRetenu((d) => { + d.entete.format = FORMAT + 1; + d.charge.retenu.horodatage = H; + d.charge.retenu.autreCle = 1; + }))); + assert.equal(lecture.formatPlusRecent, true); + assert.equal(lecture.charge.retenu.horodatage, H); + assert.equal(Object.hasOwn(lecture.charge.retenu, 'autreCle'), false); + }); +}); diff --git a/src/stockage/journal.js b/src/stockage/journal.js index 90cd65d..0875ea5 100644 --- a/src/stockage/journal.js +++ b/src/stockage/journal.js @@ -34,7 +34,7 @@ // suivre un correctif à un instant qui n'est pas le sien (§ 8.6). import { canoniser } from './canonique.js'; import { appliquer, difference } from './correctifs.js'; -import { FORMAT, SCHEMA, clesRangees, premiereFaute } from './document.js'; +import { FORMAT, SCHEMA, clesRangees, estHorodatage, premiereFaute } from './document.js'; /** Révisions d'un instantané au suivant (§ 8.6). */ export const INTERVALLE_INSTANTANE = 50; @@ -68,25 +68,16 @@ const LF = 0x0a; // Décodage strict d'une ligne : un octet hors UTF-8 lève, et la marque // d'ordre d'octets reste dans le texte, que la lecture retire. const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); -// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application. -// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre. -const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/; const SENS = [null, 'defaire', 'refaire', 'revenir']; const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1; const estTexte = (valeur) => typeof valeur === 'string' && valeur !== ''; -/** - * Vrai pour une chaîne de la forme AAAA-MM-JJTHH:MM:SS±HH:MM, celle que rend - * l'horloge de l'application : la seule définition de la forme d'un - * horodatage, que le journal, le verrou et le placement daté partagent. Ni - * le calendrier ni les bornes des heures ne se contrôlent ici. - * - * @param {unknown} valeur - * @returns {boolean} - */ -export const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur); +// La forme d'un horodatage se définit avec le schéma du fichier d'état, qui +// la lit pour le placement daté ; le journal, le verrou et le format la +// reprennent d'ici. +export { estHorodatage }; // Chemin de la première valeur de charge que l'analyse du fichier d'état // refuserait, ou null : la forme de la charge, propositions et retenu lus