[ADD] outputs: dated placement, draft rule, page footer, machine paper
A badge or a printed page must say which placement it shows and whether it is a draft. The retained plan and each proposal carry an optional timestamp, set when their placement changes and kept when a command rebuilds it unchanged, with or without an instant; format 1 is unchanged and the shipped demonstrations stay byte-identical. Anything but the retained plan under a retained or locked state prints as a draft, proposals included. The footer names event, date, round, timestamp and visible version. Checked: red first; 3085 node and 639 browser tests green from the index. --- FR --- [ADD] sorties : placement daté, règle du brouillon, pied, papier Un badge ou une page imprimée doit dire quel placement il montre et s'il est un brouillon. Le retenu et chaque proposition portent un horodatage facultatif, posé quand leur placement change et gardé quand une commande le reconstruit à l'identique, avec ou sans instant ; le format 1 ne change pas et les démonstrations livrées restent identiques à l'octet. Tout ce qui n'est pas le retenu sous l'état retenu ou bloqué s'imprime en brouillon, propositions comprises. Le pied nomme événement, date, tour, horodatage et version visible. Vérifié : rouge d'abord ; 3085 node et 639 navigateur verts depuis l'index. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
e1245dcc08
commit
0e9ff8c708
20 changed files with 1158 additions and 117 deletions
|
|
@ -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 ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
13
src/application/libelles/sorties.js
Normal file
13
src/application/libelles/sorties.js
Normal file
|
|
@ -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'],
|
||||
]);
|
||||
|
|
@ -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<void>}
|
||||
* @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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
121
src/application/sorties.js
Normal file
121
src/application/sorties.js
Normal file
|
|
@ -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<SourceSortie>}
|
||||
*/
|
||||
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),
|
||||
};
|
||||
}
|
||||
196
src/application/sorties.test.js
Normal file
196
src/application/sorties.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
44
src/application/version.js
Normal file
44
src/application/version.js
Normal file
|
|
@ -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.<mode>, 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;
|
||||
}
|
||||
}
|
||||
81
src/application/version.test.js
Normal file
81
src/application/version.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue