[ADD] storage: file format 2, tags, goals, converted journal

Tags on people and goals on tables are operator input that an older
build must not silently drop. Files written now carry format 2 with
every new field optional and last; a format-1 file opens with no write,
and its journal is rewritten as format 2 at the first gesture with every
revision rebuilt byte for byte, so undo crosses the conversion. An older
build opens a format-2 pair read-only. Tags follow one shared form and
split rule, which a guard forbids reimplementing.
Checked: node 4215, navigateur 884, node-long 113; a real format-1 event
written by the previous build stays a fixture; depot and journal 100%.

--- FR ---

[ADD] stockage : format 2, étiquettes, objectifs, journal converti

Les étiquettes des personnes et les objectifs des tables sont de la saisie
qu'une version antérieure ne doit pas perdre sans bruit. Les fichiers
écrits portent le format 2, chaque champ nouveau facultatif et en dernier ;
un fichier du format 1 s'ouvre sans écriture, et son journal passe au
format 2 au premier geste, chaque révision reconstruite à l'octet près :
Défaire traverse la conversion. Une version antérieure ouvre un couple du
format 2 en lecture seule. Une forme et une découpe uniques des étiquettes,
qu'une garde interdit de refaire ailleurs.
Vérifié : node 4215, navigateur 884, longues 113 ; un vrai événement du
format 1, écrit par la version précédente, reste en fixture ; 100 %.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 13:55:19 -04:00
parent c445df3dc8
commit 2d782f919f
30 changed files with 2822 additions and 328 deletions

View file

@ -319,13 +319,13 @@ describe("engendrer_livrables : la charge d'une démonstration (§ 15)", () => {
});
describe("engendrer_livrables : le fichier d'état écrit (§ 8.8, § 18.6)", () => {
test('révision 1, la version de la construction qui écrit, la forme canonique ; UTF-8 sans marque, des LF seules, une fin de ligne finale', () => {
test('format 2, révision 1, la version de la construction qui écrit, la forme canonique ; UTF-8 sans marque, des LF seules, une fin de ligne finale', () => {
for (const entree of CATALOGUE) {
const texte = texteDeDemonstration(entree);
const lu = analyser(texte);
assert.deepEqual(
[lu.entete.format, lu.entete.produitVersion, lu.entete.revision],
[1, VERSION.affichee, 1],
[2, VERSION.affichee, 1],
entree.cle,
);
assert.equal(serialiser(lu.charge, lu.entete), texte, entree.cle);

View file

@ -1528,8 +1528,8 @@ describe('importer (§ 8.6, § 8.7)', () => {
test('un fichier d’un format plus récent s’importe tel quel', async () => {
const { fs, facade } = monter();
await facade.demarrer();
const texte = texteEtat(MERLES).replace('"format":1', '"format":2');
assert.ok(texte.includes('"format":2'));
const texte = texteEtat(MERLES).replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`);
assert.ok(texte.includes(`"format":${FORMAT + 1}`));
const octets = UTF8.encode(texte);
fs.choisirFichierAImporter = async () => ({ nom: 'futur.gtt.json', octets });
const avant = ecritures(fs);

View file

@ -1256,10 +1256,11 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", ()
});
// L'événement réécrit par une construction plus récente : l'état, ou la
// première ligne du journal, déclarent le format 2.
// première ligne du journal, déclarent le format qui suit FORMAT.
const versPlusRecent = (texte) => texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`);
const plusRecent = {
etat: (fs) => fs.deposer('documents', ETAT, fs.contenu('documents', ETAT).replace('"format":1', '"format":2')),
journal: (fs) => fs.deposer('documents', JOURNAL, fs.contenu('documents', JOURNAL).replace('"format":1', '"format":2')),
etat: (fs) => fs.deposer('documents', ETAT, versPlusRecent(fs.contenu('documents', ETAT))),
journal: (fs) => fs.deposer('documents', JOURNAL, versPlusRecent(fs.contenu('documents', JOURNAL))),
};
for (const [fichier, designe] of [['etat', "l'état"], ['journal', 'le journal']]) {
@ -1277,7 +1278,7 @@ describe("séance : une autre séance a écrit entre-temps (§ 8.2, § 8.8)", ()
const { seconde, fs } = await surPrecedent();
if (fichier === 'etat') {
const precedent = fs.contenu('documents', `${BASE}${SUFFIXES.precedent}`);
fs.deposer('documents', ETAT, precedent.replace('"format":1', '"format":2'));
fs.deposer('documents', ETAT, versPlusRecent(precedent));
} else {
plusRecent.journal(fs);
}
@ -1653,7 +1654,11 @@ describe("séance : un fichier d'un format plus récent (§ 8.8)", () => {
fs.deposerDossier('documents');
const charge = chargeEssai();
charge.evenement.etat = 'bloque';
fs.deposer('documents', ETAT, serialiser(charge, { revision: 1, produitVersion: V1 }).replace('"format":1', '"format":2'));
fs.deposer(
'documents',
ETAT,
serialiser(charge, { revision: 1, produitVersion: V1 }).replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`),
);
const { seance } = monter({ fs });
const avant = fs.compteurs.ecritures;
await seance.ouvrir(BASE);

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":260,"tables":33,"reservations":33,"titres":33,"propositions":0,"retenu":0}},
"entete": {"format":2,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":260,"tables":33,"reservations":33,"titres":33,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:grande-sans-exception","nom":"Grande démonstration sans exception","date":null,"siegesParDefaut":8,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":260,"tables":33,"reservations":33,"titres":33,"propositions":0,"retenu":0}},
"entete": {"format":2,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":260,"tables":33,"reservations":33,"titres":33,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:grande","nom":"Grande démonstration","date":null,"siegesParDefaut":8,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"entete": {"format":2,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:petite-conflit","nom":"Petite démonstration, conflit inévitable","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"entete": {"format":2,"produitVersion":"2026.10.05.01","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"demo:petite","nom":"Petite démonstration","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -39,6 +39,16 @@ function cheminsDuSchema() {
return chemins;
}
// Les réglages du schéma qu'aucune entrée de REGLAGES_GENERATION ne porte,
// chacun avec sa raison. Un réglage nommé ici doit exister au schéma et
// n'avoir aucune entrée : l'entrée qui s'ajoute l'en retire.
const SANS_ENTREE = new Map([
['regleObjectifs', 'la règle par défaut des objectifs s’écrit depuis les tables, non depuis la génération'],
['respecterObjectifs', 'case des objectifs, sans entrée à l’écran de la génération'],
['modeObjectifs', 'mode des objectifs, sans entrée à l’écran de la génération'],
['rencontrerAnimateurs', 'case des animateurs, sans entrée à l’écran de la génération'],
]);
// La commande qui écrit un réglage de chaque section, et ses arguments.
const ECRITURES = new Map([
['generation', (entree, valeur) => ['modifierReglage', { cle: entree.cle, valeur }]],
@ -123,13 +133,15 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
);
});
test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau', () => {
test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau, hors des réglages nommés sans entrée', () => {
const chemins = cheminsDuSchema();
assert.ok(chemins.includes('cibleRedondance') && chemins.includes('generation.nombre'), chemins.join(', '));
assert.deepEqual(
[...REGLAGES_GENERATION.map(({ cle }) => cle)].sort(),
[...chemins].sort(),
);
const cles = REGLAGES_GENERATION.map(({ cle }) => cle);
for (const cle of SANS_ENTREE.keys()) {
assert.ok(chemins.includes(cle), `${cle} : absent du schéma, à retirer de SANS_ENTREE`);
assert.ok(!cles.includes(cle), `${cle} : a son entrée, à retirer de SANS_ENTREE`);
}
assert.deepEqual([...cles].sort(), chemins.filter((cle) => !SANS_ENTREE.has(cle)).sort());
});
test('chaque entrée s’écrit par la commande de sa section, et chaque libellé existe', () => {

View file

@ -5,9 +5,9 @@
// exact que le contrat de données attend du sérialiseur, la canonicité quel
// que soit l'ordre dans lequel une charge s'est construite, la charge seule
// dans la mise en page du fichier, l'aller-retour par l'analyse, le retenu
// que sa règle refuse, recopié hors du schéma, et un sérialiseur dont la
// sortie ne dépend que de ses arguments. Les noms des charges d'épreuve sont
// inventés.
// que sa règle refuse, recopié hors du schéma, les champs facultatifs du
// format 2 et leur rang, et un sérialiseur dont la sortie ne dépend que de
// ses arguments. Les noms des charges d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
@ -21,7 +21,7 @@ const ENTETE = Object.freeze({ revision: 3, produitVersion: VERSION.affichee });
// Texte exact que le contrat de données (types.js) attend de serialiser pour
// chargeContrat() sous ENTETE, fin de ligne finale comprise.
const TEXTE_CONTRAT = `{
"entete": {"format":1,"produitVersion":"${VERSION.affichee}","revision":3,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":1,"retenu":0}},
"entete": {"format":2,"produitVersion":"${VERSION.affichee}","revision":3,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":1,"retenu":0}},
"charge": {
"evenement": {"id":"evt-essai","nom":"Soirée d'essai","date":null,"siegesParDefaut":2,"tours":2,"unite":"cm","etat":"propose","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
@ -187,7 +187,7 @@ function chargeSansListe() {
};
}
const TEXTE_SANS_LISTE = `{
"entete": {"format":1,"produitVersion":"${VERSION.affichee}","revision":1,"comptes":{"participants":0,"tables":0,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"entete": {"format":2,"produitVersion":"${VERSION.affichee}","revision":1,"comptes":{"participants":0,"tables":0,"reservations":0,"titres":0,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"evt-neuf","nom":"Atelier \\"jeudi\\"","date":"2026-11-12","siegesParDefaut":8,"tours":3,"unite":"cm","etat":"brouillon","filiation":{"source":{"id":"evt-modele","nom":"Atelier modèle"},"instant":{"revision":7,"libelle":"Tables posées"}}},
"reglages": {"separerAppartenances":false,"nouveauxVoisins":true,"nouvelleTable":false,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":2,"arret":1000,"historique":10}},
@ -580,7 +580,7 @@ describe('serialiserCharge : la charge seule (§ 8.8)', () => {
assert.deepEqual(avant.flatMap((ligne, i) => (ligne === apres[i] ? [] : [i])), [1]);
assert.equal(
apres[1],
` "entete": {"format":1,"produitVersion":"${autre}","revision":41,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":1,"retenu":0}},`,
` "entete": {"format":2,"produitVersion":"${autre}","revision":41,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":1,"retenu":0}},`,
);
});
});
@ -727,3 +727,84 @@ describe('déterminisme (§ 8.8, § 14.7)', () => {
assert.deepEqual(sansHorlogeNiAlea(rendre), rendre());
});
});
describe('les champs facultatifs du format 2 (§ 8.9, A16)', () => {
// La charge du contrat, chaque champ du format 2 posé hors de son défaut,
// ses listes à rebours de l'ordre canonique, et le texte qui l'écrit.
function chargeFormat2() {
const charge = chargeContrat();
charge.reglages.rencontrerAnimateurs = true;
charge.reglages.modeObjectifs = 'chacun';
charge.reglages.respecterObjectifs = false;
charge.reglages.regleObjectifs = 'et';
charge.reglages.cibleRedondance = 800;
charge.participants[0].etiquettes = ['finance', 'bénévole', 'Montréal'];
charge.tables[1].objectifs = [
{ portee: 'tour', tour: 2, regle: 'ou', etiquettes: ['ressources humaines', 'finance'] },
{ portee: 'tour', tour: 1, regle: null, etiquettes: ['bénévole'] },
{ portee: 'tous', tour: null, regle: null, etiquettes: ['Montréal'] },
];
return charge;
}
const TEXTE_FORMAT_2 = remplacer(
remplacer(
remplacer(
TEXTE_CONTRAT,
'"generation":{"nombre":5,"arret":200000,"historique":1000}}',
'"generation":{"nombre":5,"arret":200000,"historique":1000},"cibleRedondance":800,"regleObjectifs":"et","respecterObjectifs":false,"modeObjectifs":"chacun","rencontrerAnimateurs":true}',
),
'"notes":null,"exclu":false},\n {"id":2',
'"notes":null,"exclu":false,"etiquettes":["Montréal","bénévole","finance"]},\n {"id":2',
),
'"position":{"x":250,"y":0}}',
'"position":{"x":250,"y":0},"objectifs":[{"portee":"tous","tour":null,"regle":null,"etiquettes":["Montréal"]},{"portee":"tour","tour":1,"regle":null,"etiquettes":["bénévole"]},{"portee":"tour","tour":2,"regle":"ou","etiquettes":["finance","ressources humaines"]}]}',
);
test('chacun s’écrit après les clés du format 1 de son objet, les réglages dans l’ordre de A16, les étiquettes par unités de code, les objectifs la soirée puis les tours', () => {
assert.equal(serialiser(chargeFormat2(), ENTETE), TEXTE_FORMAT_2);
assert.equal(serialiser(aRebours(chargeFormat2()), ENTETE), TEXTE_FORMAT_2);
const lu = analyser(TEXTE_FORMAT_2);
assert.equal(serialiser(lu.charge, lu.entete), TEXTE_FORMAT_2);
});
test('absents, ils ne s’écrivent pas : la charge du format 1 rend le texte du contrat', () => {
const charge = chargeFormat2();
for (const cle of ['cibleRedondance', 'regleObjectifs', 'respecterObjectifs', 'modeObjectifs', 'rencontrerAnimateurs']) {
delete charge.reglages[cle];
}
delete charge.participants[0].etiquettes;
delete charge.tables[1].objectifs;
assert.equal(serialiser(charge, ENTETE), TEXTE_CONTRAT);
});
test('l’ordre des étiquettes est celui des unités de code : une lettre hors du plan de base passe avant U+FF21, une majuscule avant une minuscule', () => {
const charge = chargeContrat();
charge.participants[1].etiquettes = ['\u{FF21}', 'été', 'Été', '\u{1D538}', 'zèbre'];
// Par points de code, U+FF21 passerait avant U+1D538 ; ses deux unités
// commencent par 0xD835, plus petite que 0xFF21.
assert.deepEqual(canoniser(charge).participants[1].etiquettes, ['zèbre', 'Été', 'été', '\u{1D538}', '\u{FF21}']);
});
test('deux objectifs d’une même portée et d’un même tour, que l’analyse refuse, s’écrivent pareil quel que soit l’ordre reçu : l’ordre reste total', () => {
const doubles = [
{ portee: 'tous', tour: null, regle: 'et', etiquettes: ['finance'] },
{ portee: 'tous', tour: null, regle: null, etiquettes: ['bénévole'] },
];
const une = chargeContrat();
une.tables[0].objectifs = structuredClone(doubles);
const autre = chargeContrat();
autre.tables[0].objectifs = structuredClone(doubles).reverse();
assert.equal(serialiser(une, ENTETE), serialiser(autre, ENTETE));
// Deux objectifs identiques s'écrivent deux fois, à l'identique.
une.tables[0].objectifs = [structuredClone(doubles[0]), structuredClone(doubles[0])];
assert.deepEqual(canoniser(une).tables[0].objectifs, [doubles[0], doubles[0]]);
});
test('la copie canonique ne partage aucune liste d’étiquettes ni aucun objectif avec la charge reçue, qui reste intacte', () => {
const charge = geler(chargeFormat2());
const copie = canoniser(charge);
const recus = objetsDe(charge);
for (const objet of objetsDe(copie)) assert.ok(!recus.has(objet));
assert.deepEqual(charge.participants[0].etiquettes, ['finance', 'bénévole', 'Montréal']);
});
});

View file

@ -33,7 +33,11 @@
// franchit ; une liste d'identifiants de même longueur se pose alors
// entière dès que ses poses rang par rang pèsent plus que cette pose, et
// l'échange ne coûte jamais plus que les deux listes posées entières. Une
// liste qui change de longueur se pose entière.
// liste qui change de longueur se pose entière. Une liste dont la règle
// veut les éléments distincts — les étiquettes d'une personne ou d'un
// objectif — se pose toujours entière, et rien ne se pose sous elle : une
// pose à un rang y répéterait un élément sans que la règle de ce rang le
// voie.
//
// Une charge que rend appliquer, l'analyse l'admet comme celle qu'il
// reçoit, et chacune de ses propositions suit sa règle, comme dans la
@ -82,6 +86,10 @@ const OCCUPANT = regleDuChamp(regleDuChamp(PROPOSITION, 'placement').element, 's
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
// Vrai pour la règle d'une liste qui se pose entière : ses éléments sont
// distincts, et seule sa règle, qui voit la liste entière, le contrôle.
const sePoseEntiere = (regle) => regle?.genre === 'liste' && regle.distincts === true;
// Vrai quand chaque élément de liste est un objet dont l'id est un entier
// exact, et qu'aucun id ne s'y répète : la seule forme où { id } désigne un
// enregistrement et un seul. Une copie canonique ne porte que les clés du
@ -105,21 +113,24 @@ const poids = (operations) => JSON.stringify(operations).length;
// Ajoute à operations ce qui mène de a à b, deux valeurs canoniques de la
// règle regle, au bout de chemin. null contre une valeur, et deux scalaires
// différents, posent b. Un retenu que sa règle refuse, d'un côté ou de
// l'autre, n'a pas de champs que le schéma décrive : b se pose entier quand
// les deux copies canoniques s'écrivent autrement, et rien sinon. Deux
// différents, posent b ; deux listes qui se posent entières (sePoseEntiere)
// et s'écrivent autrement aussi. Un retenu que sa règle refuse, d'un côté ou
// de l'autre, n'a pas de champs que le schéma décrive : b se pose entier
// quand les deux copies canoniques s'écrivent autrement, et rien sinon. Deux
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
// d'enregistrements à identifiants distincts se comparent par identifiant ;
// deux autres listes, de longueurs différentes, posent b entière, et de même
// longueur se comparent rang par rang — deux listes d'identifiants ou
// d'occupants exceptées, qui posent b entière quand leurs poses rang par
// rang pèsent plus que la sienne. Un champ facultatif absent d'un côté se
// pose quand b le porte, et se retire quand b ne le porte pas.
// d'occupants exceptées, qui posent b entière quand leurs poses rang par rang
// pèsent plus que la sienne. Un champ facultatif absent d'un côté se pose
// quand b le porte, et se retire quand b ne le porte pas.
function comparer(a, b, regle, chemin, operations) {
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
} else if (retenuHorsDeSaRegle(a, regle) || retenuHorsDeSaRegle(b, regle)) {
if (JSON.stringify(a) !== JSON.stringify(b)) operations.push({ op: 'poser', chemin, valeur: b });
} else if (sePoseEntiere(regle)) {
if (JSON.stringify(a) !== JSON.stringify(b)) operations.push({ op: 'poser', chemin, valeur: b });
} else if (regle.genre === 'objet') {
for (const [cle, regleDeCle] of regle.champs) {
const place = [...chemin, cle];
@ -193,20 +204,21 @@ function comparerParIdentifiant(a, b, regleElement, chemin, operations) {
* prise à la copie canonique de b, et ne partage aucun objet avec b. Ni a ni
* b ne sont modifiées.
*
* Règles : les objets clé par clé dans l'ordre du schéma ; les listes
* d'enregistrements à identifiant — participants, tables, propositions —
* par identifiant : retirés, posés entiers, communs comparés ; une telle
* liste dont un identifiant se répète, ce que la lecture admet pour deux
* propositions, se compare comme les autres listes ; les autres listes de
* longueurs différentes posées entières, et de même longueur rang par rang,
* sauf une liste d'identifiants dont les poses rang par rang pèsent plus,
* en JSON, que sa pose entière : elle se pose entière ; null contre une
* valeur, et deux scalaires différents, posent la valeur de b ; un retenu
* que sa règle refuse, dans a ou dans b, se pose entier quand les deux
* diffèrent, sans rien pour lui sinon. L'échange de deux personnes, au même
* tour d'une proposition, ne touche ainsi que les listes de leurs deux
* tables, et ne pèse jamais plus que ces deux listes posées entières,
* attribution des sièges ou non (§ 8.9).
* Règles : les objets clé par clé dans l'ordre du schéma ; une liste
* d'étiquettes posée entière quand elle change ; les listes d'enregistrements
* à identifiant — participants, tables, propositions — par identifiant :
* retirés, posés entiers, communs comparés ; une telle liste dont un
* identifiant se répète, ce que la lecture admet pour deux propositions, se
* compare comme les autres listes ; les autres listes de longueurs
* différentes posées entières, et de même longueur rang par rang, sauf une
* liste d'identifiants dont les poses rang par rang pèsent plus, en JSON, que
* sa pose entière : elle se pose entière ; null contre une valeur, et deux
* scalaires différents, posent la valeur de b ; un retenu que sa règle
* refuse, dans a ou dans b, se pose entier quand les deux diffèrent, sans
* rien pour lui sinon. L'échange de deux personnes, au même tour d'une
* proposition, ne touche ainsi que les listes de leurs deux tables, et ne
* pèse jamais plus que ces deux listes posées entières, attribution des
* sièges ou non (§ 8.9).
*
* @param {import('./types.js').Charge} a
* @param {import('./types.js').Charge} b
@ -232,21 +244,25 @@ function regleSuivante(regle, etape) {
// dont l'élément a un champ id : participants, tables, propositions.
const estListeDEnregistrements = (regle) => regle?.element?.cles?.has('id') === true;
// Lecture de chemin dans le schéma, depuis la charge : { regle }, la règle
// de la place qu'il désigne, undefined quand une étape sort du schéma ; null
// quand une étape { id } ne tombe pas dans une liste d'enregistrements à
// identifiant. C'est le schéma qui en décide, et non le contenu de la
// liste : une liste vide n'a aucun élément qui montre la forme des autres,
// et tout identifiant y est absent, ce qui ouvrirait une insertion par
// { id } dans une réserve, une liste de table ou les réservations. Une clé
// et un rang se contrôlent sur la valeur, qui les porte ou non.
// Lecture de chemin dans le schéma, depuis la charge : { regle, parent }, la
// règle de la place qu'il désigne et celle de son conteneur, undefined quand
// une étape sort du schéma ; null quand une étape { id } ne tombe pas dans
// une liste d'enregistrements à identifiant, ou qu'une étape descend sous une
// liste qui se pose entière (sePoseEntiere). C'est le schéma qui en décide,
// et non le contenu de la liste : une liste vide n'a aucun élément qui montre
// la forme des autres, et tout identifiant y est absent, ce qui ouvrirait une
// insertion par { id } dans une réserve, une liste de table ou les
// réservations. Une clé et un rang se contrôlent sur la valeur, qui les porte
// ou non.
function lireChemin(chemin) {
let regle = CHARGE;
let parent;
for (const etape of chemin) {
if (estObjet(etape) && !estListeDEnregistrements(regle)) return null;
if ((estObjet(etape) && !estListeDEnregistrements(regle)) || sePoseEntiere(regle)) return null;
parent = regle;
regle = regleSuivante(regle, etape);
}
return { regle };
return { regle, parent };
}
// Vrai quand valeur, posée au bout de chemin dans charge, à la place de
@ -300,20 +316,32 @@ function descendre(conteneur, etape) {
return rangs?.length === 1 ? conteneur[rangs[0]] : undefined;
}
// Pose une copie de valeur à la place que désigne etape dans conteneur :
// une clé qu'il porte déjà, ou la clé d'un champ facultatif — regle, celle
// de la place, le dit — qu'un objet ne porte pas ; un rang qu'il contient ;
// ou { id } absent de la liste, quand valeur, un enregistrement que
// valeurAdmise a contrôlé, porte cet id ; l'enregistrement s'insère alors
// avant le premier d'id plus grand, ce qui garde une liste rangée par
// identifiant croissant. Rend faux quand etape ne désigne aucune de ces
// places.
function poser(conteneur, etape, valeur, regle) {
const facultatifAbsent = regle.optionnel && typeof etape === 'string' && estObjet(conteneur);
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape) || facultatifAbsent) {
// Pose une copie de valeur à la place que désigne etape dans conteneur : une
// clé qu'il porte déjà, ou la clé d'un champ facultatif — regle, celle de la
// place, le dit — qu'un objet ne porte pas, posée à son rang dans l'ordre des
// champs de parent, la règle de l'objet : les clés qui la suivent dans le
// schéma se reposent après elle, et l'objet garde l'ordre canonique de ses
// clés ; un rang qu'il contient ; ou { id } absent de la liste, quand valeur,
// un enregistrement que valeurAdmise a contrôlé, porte cet id ;
// l'enregistrement s'insère alors avant le premier d'id plus grand, ce qui
// garde une liste rangée par identifiant croissant. Rend faux quand etape ne
// désigne aucune de ces places.
function poser(conteneur, etape, valeur, regle, parent) {
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) {
conteneur[etape] = structuredClone(valeur);
return true;
}
if (regle.optionnel && typeof etape === 'string' && estObjet(conteneur)) {
conteneur[etape] = structuredClone(valeur);
const rang = parent.champs.findIndex(([cle]) => cle === etape);
for (const [cle] of parent.champs.slice(rang + 1)) {
if (!Object.hasOwn(conteneur, cle)) continue;
const suivante = conteneur[cle];
delete conteneur[cle];
conteneur[cle] = suivante;
}
return true;
}
if (rangsDe(conteneur, etape)?.length !== 0 || valeur.id !== etape.id) return false;
const suivant = conteneur.findIndex((element) => element.id > etape.id);
conteneur.splice(suivant === -1 ? conteneur.length : suivant, 0, structuredClone(valeur));
@ -357,7 +385,7 @@ function executer(charge, operation) {
const derniere = chemin[chemin.length - 1];
if (op === 'poser') {
if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false;
return poser(conteneur, derniere, valeur, lecture.regle);
return poser(conteneur, derniere, valeur, lecture.regle, lecture.parent);
}
if (op === 'retirer') return retirer(conteneur, derniere, lecture.regle);
return false;
@ -378,19 +406,19 @@ function executer(charge, operation) {
* tables et des propositions, même dans une liste vide ; une étape qui ne
* trouve pas sa clé, que l'objet porte en propre et n'hérite pas, son rang
* ou exactement un enregistrement ; poser à une clé que l'objet ne porte
* pas, hors d'un champ facultatif, ou au-delà de la fin d'une liste ; poser
* par { id } un identifiant déjà présent, ou une valeur qui n'est pas
* l'enregistrement de cet identifiant ; poser une valeur qui sort de la
* règle de sa place, en genre, en domaine, null compris, ou en clés, jusque
* dans les propositions et le retenu ; à la place du retenu, autre chose
* qu'un objet ou null ; sous un retenu que sa règle refuse, quoi que ce
* soit ; retirer autre chose qu'un enregistrement par { id } ou un champ
* facultatif présent. Une charge rendue garde ainsi chaque proposition dans
* sa règle, et canoniser, difference et serialiser la lisent sans lever. Le
* contrôle porte sur la forme : une opération ne porte pas la valeur qu'elle
* remplace, et un correctif dont chaque chemin existe aussi dans une autre
* charge s'y applique sans lever. Un correctif qui n'est pas une liste lève
* TypeError.
* pas, hors d'un champ facultatif, ou au-delà de la fin d'une liste, ou sous
* une liste d'étiquettes, qui ne se pose qu'entière ; poser par { id } un
* identifiant déjà présent, ou une valeur qui n'est pas l'enregistrement de
* cet identifiant ; poser une valeur qui sort de la règle de sa place, en
* genre, en domaine, null compris, ou en clés, jusque dans les propositions
* et le retenu ; à la place du retenu, autre chose qu'un objet ou null ; sous
* un retenu que sa règle refuse, quoi que ce soit ; retirer autre chose qu'un
* enregistrement par { id } ou un champ facultatif présent. Une charge rendue
* garde ainsi chaque proposition dans sa règle, et canoniser, difference et
* serialiser la lisent sans lever. Le contrôle porte sur la forme : une
* opération ne porte pas la valeur qu'elle remplace, et un correctif dont
* chaque chemin existe aussi dans une autre charge s'y applique sans lever.
* Un correctif qui n'est pas une liste lève TypeError.
*
* @param {import('./types.js').Charge} a
* @param {Operation[]} correctif

View file

@ -223,6 +223,11 @@ const NOMS_INVENTES = ['Brindille', 'Calembour', 'Dentelle', 'Escarbille', 'Falb
const PRENOMS_INVENTES = ['Azur', 'Céladon', 'Nacre', 'Opale'];
const APPARTENANCES = ['Cercle du Héron', 'Club Lanterne', 'Atelier Boussole'];
const LIBELLES = ['animation', 'accueil', 'secrétariat'];
const ETIQUETTES_INVENTEES = ['finance', 'Montréal', 'bénévole', 'ressources humaines', 'Finance'];
// Une liste d'étiquettes tirée : une à trois, distinctes, dans l'ordre du
// tirage, qui n'est pas l'ordre canonique.
const desEtiquettes = fc.uniqueArray(fc.constantFrom(...ETIQUETTES_INVENTEES), { minLength: 1, maxLength: 3 });
// Une valeur ou null : fast-check tire null une fois sur freq, ici une fois
// sur deux.
@ -233,28 +238,54 @@ const deuxFois = (arbitraire) => fc.tuple(arbitraire, arbitraire);
// Les champs de saisie se tirent parmi peu de valeurs : deux variantes d'un
// même enregistrement coïncident souvent champ par champ.
// Les étiquettes, facultatives, manquent une fois sur deux.
const unParticipant = (id) =>
fc.record({
id: fc.constant(id),
nom: fc.constantFrom(...NOMS_INVENTES),
prenom: ouNul(fc.constantFrom(...PRENOMS_INVENTES)),
appartenance: ouNul(fc.constantFrom(...APPARTENANCES)),
courriel: ouNul(fc.constantFrom('a@exemple.test', 'b@exemple.test')),
titrePressenti: ouNul(fc.constantFrom(...LIBELLES)),
notes: ouNul(fc.constantFrom('arrive tard', 'part tôt')),
exclu: fc.boolean(),
});
fc.record(
{
id: fc.constant(id),
nom: fc.constantFrom(...NOMS_INVENTES),
prenom: ouNul(fc.constantFrom(...PRENOMS_INVENTES)),
appartenance: ouNul(fc.constantFrom(...APPARTENANCES)),
courriel: ouNul(fc.constantFrom('a@exemple.test', 'b@exemple.test')),
titrePressenti: ouNul(fc.constantFrom(...LIBELLES)),
notes: ouNul(fc.constantFrom('arrive tard', 'part tôt')),
exclu: fc.boolean(),
etiquettes: desEtiquettes,
},
{ requiredKeys: ['id', 'nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes', 'exclu'] },
);
const coordonnee = fc.oneof(fc.integer({ min: -400, max: 400 }), fc.double({ min: -1e4, max: 1e4, noNaN: true }));
// Les objectifs candidats d'une table : un à trois, de portées et de tours
// distincts, dans l'ordre du tirage ; la charge écarte ceux dont le tour
// dépasse sa soirée.
const desObjectifs = fc.uniqueArray(fc.constantFrom(null, 1, 2, 3), { minLength: 1, maxLength: 3 }).chain((tours) =>
fc.tuple(
...tours.map((tour) =>
fc.record({
portee: fc.constant(tour === null ? 'tous' : 'tour'),
tour: fc.constant(tour),
regle: ouNul(fc.constantFrom('ou', 'et')),
etiquettes: desEtiquettes,
}),
),
),
);
// Les objectifs, facultatifs, manquent une fois sur deux.
const uneTable = (id) =>
fc.record({
id: fc.constant(id),
numero: fc.integer({ min: 1, max: 9 }),
sieges: ouNul(fc.integer({ min: 2, max: 5 })),
forme: fc.constantFrom('ronde', 'carree'),
position: fc.record({ x: coordonnee, y: coordonnee }),
});
fc.record(
{
id: fc.constant(id),
numero: fc.integer({ min: 1, max: 9 }),
sieges: ouNul(fc.integer({ min: 2, max: 5 })),
forme: fc.constantFrom('ronde', 'carree'),
position: fc.record({ x: coordonnee, y: coordonnee }),
objectifs: desObjectifs,
},
{ requiredKeys: ['id', 'numero', 'sieges', 'forme', 'position'] },
);
const unEvenement = fc.record({
id: fc.constantFrom('evt-heron', 'evt-lanterne'),
@ -275,18 +306,36 @@ const unEvenement = fc.record({
),
});
const desReglages = fc.record({
separerAppartenances: fc.boolean(),
nouveauxVoisins: fc.boolean(),
nouvelleTable: fc.boolean(),
varierAppartenances: fc.boolean(),
attribuerSieges: fc.boolean(),
generation: fc.record({
nombre: fc.integer({ min: 1, max: 20 }),
arret: fc.constantFrom(20_000, 200_000),
historique: fc.constantFrom(500, 1_000),
}),
});
// Les réglages facultatifs du format 2 manquent une fois sur deux, chacun
// pour lui-même ; présents, ils portent une valeur hors de leur défaut.
const desReglages = fc.record(
{
separerAppartenances: fc.boolean(),
nouveauxVoisins: fc.boolean(),
nouvelleTable: fc.boolean(),
varierAppartenances: fc.boolean(),
attribuerSieges: fc.boolean(),
generation: fc.record({
nombre: fc.integer({ min: 1, max: 20 }),
arret: fc.constantFrom(20_000, 200_000),
historique: fc.constantFrom(500, 1_000),
}),
regleObjectifs: fc.constant('et'),
respecterObjectifs: fc.constant(false),
modeObjectifs: fc.constantFrom('chacun', 'minimum'),
rencontrerAnimateurs: fc.constant(true),
},
{
requiredKeys: [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
'attribuerSieges',
'generation',
],
},
);
// Réservations et titres candidats : une charge garde ceux dont la
// personne, la table et le siège y existent, et le tour y tient.
@ -445,6 +494,13 @@ function construire(vivier, choisir) {
vivier[cle].flatMap((deux, rang) => (choisir(cle, rang) === 0 ? [] : [copie(deux[choisir(cle, rang) - 1])]));
const participants = variantes('participants');
const tables = variantes('tables');
// Un objectif dont le tour dépasse la soirée s'écarte, comme la commande
// qui retire un tour ; une table sans objectif restant n'en porte plus.
for (const table of tables) {
if (table.objectifs === undefined) continue;
table.objectifs = table.objectifs.filter(({ tour }) => tour === null || tour <= evenement.tours);
if (table.objectifs.length === 0) delete table.objectifs;
}
const capaciteDe = new Map(tables.map((t) => [t.id, t.sieges ?? evenement.siegesParDefaut]));
const presents = new Set(participants.map((p) => p.id));
const tient = (table, siege) => capaciteDe.has(table) && (siege === null || siege <= capaciteDe.get(table));
@ -526,6 +582,9 @@ const unePaire = fc
}),
}));
// Les réglages facultatifs du format 2.
const REGLAGES_DU_FORMAT_2 = ['regleObjectifs', 'respecterObjectifs', 'modeObjectifs', 'rencontrerAnimateurs'];
// Vrai quand deux propositions de la charge partagent un identifiant.
const identifiantsRepetes = (charge) => new Set(charge.propositions.map((p) => p.id)).size < charge.propositions.length;
const derniere = ({ chemin }) => chemin[chemin.length - 1];
@ -545,6 +604,9 @@ describe('difference et appliquer : aller-retour sur des paires tirées (§ 8.6)
retenuBascule: 0,
retenuAbime: 0,
retenuAbimePose: 0,
etiquettesPosees: 0,
objectifsParRang: 0,
reglagesDuFormat2: 0,
};
fc.assert(
fc.property(unePaire, ({ a, b }) => {
@ -583,6 +645,17 @@ describe('difference et appliquer : aller-retour sur des paires tirées (§ 8.6)
bilan.retenuAbime += 1;
if (correctif.some(({ chemin }) => chemin.length === 1 && chemin[0] === 'retenu')) bilan.retenuAbimePose += 1;
}
// Une liste d'étiquettes se pose entière : aucune opération ne
// descend sous elle.
const sousEtiquettes = correctif.filter(({ chemin }) => chemin.indexOf('etiquettes') >= 0 && chemin.at(-1) !== 'etiquettes');
assert.deepEqual(sousEtiquettes, []);
if (correctif.some(({ chemin }) => chemin.at(-1) === 'etiquettes')) bilan.etiquettesPosees += 1;
if (correctif.some(({ chemin }) => typeof chemin[chemin.indexOf('objectifs') + 1] === 'number' && chemin.includes('objectifs'))) {
bilan.objectifsParRang += 1;
}
if (correctif.some(({ chemin }) => chemin[0] === 'reglages' && REGLAGES_DU_FORMAT_2.includes(chemin[1]))) {
bilan.reglagesDuFormat2 += 1;
}
}),
{ seed: GRAINE, numRuns: TIRAGES },
);
@ -603,6 +676,9 @@ describe('difference et appliquer : aller-retour sur des paires tirées (§ 8.6)
['retenuBascule', 30],
['retenuAbime', 30],
['retenuAbimePose', 10],
['etiquettesPosees', 30],
['objectifsParRang', 10],
['reglagesDuFormat2', 30],
];
for (const [famille, plancher] of planchers) {
assert.ok(bilan[famille] >= plancher, `${famille} : ${bilan[famille]} tirages sur ${TIRAGES}`);
@ -1881,3 +1957,123 @@ describe('le placement daté : un champ facultatif du retenu et des propositions
});
});
});
describe('les étiquettes, les objectifs et les réglages du format 2 dans un correctif (§ 8.6, § 8.9)', () => {
// Une charge de deux personnes et deux tables, sans champ du format 2 ;
// modifier passe sur une copie.
function base(modifier = () => {}) {
const charge = creerCharge({ id: 'evt-format2', nom: 'Veillée des Grives', siegesParDefaut: 4, tours: 2 });
charge.prochainsIds = { participant: 3, table: 3, proposition: 1 };
charge.participants = [1, 2].map((id) => ({
id,
nom: ['Ombrelle', 'Pervenche'][id - 1],
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
modifier(charge);
return charge;
}
const P1 = ['participants', { id: 1 }, 'etiquettes'];
const T2 = ['tables', { id: 2 }, 'objectifs'];
// Affirme que difference(a, b) rend attendu, et que ce correctif, relu en
// JSON, mène a à la copie canonique de b.
function exigerCorrectif(a, b, attendu, libelle) {
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('une liste d’étiquettes se pose entière, même quand une seule change à longueur égale ; posée quand elle apparaît, retirée quand elle disparaît', () => {
const avec = (etiquettes) => base((c) => { c.participants[0].etiquettes = etiquettes; });
exigerCorrectif(base(), avec(['finance']), [{ op: 'poser', chemin: P1, valeur: ['finance'] }], 'apparaît');
exigerCorrectif(avec(['finance']), base(), [{ op: 'retirer', chemin: P1 }], 'disparaît');
exigerCorrectif(
avec(['bénévole', 'finance']),
avec(['finance', 'Montréal']),
[{ op: 'poser', chemin: P1, valeur: ['Montréal', 'finance'] }],
'une sur deux change',
);
exigerCorrectif(avec(['finance', 'Montréal']), avec(['Montréal', 'finance']), [], 'même ensemble, autre ordre');
});
test('un objectif se compare champ par champ, ses étiquettes posées entières ; un objectif ajouté pose la liste entière', () => {
const soiree = { portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] };
const tour2 = { portee: 'tour', tour: 2, regle: 'et', etiquettes: ['Montréal', 'finance'] };
const avec = (...objectifs) => base((c) => { c.tables[1].objectifs = structuredClone(objectifs); });
exigerCorrectif(base(), avec(soiree), [{ op: 'poser', chemin: T2, valeur: [soiree] }], 'apparaît');
exigerCorrectif(avec(soiree), base(), [{ op: 'retirer', chemin: T2 }], 'disparaît');
exigerCorrectif(avec(soiree), avec({ ...soiree, regle: 'et' }), [{ op: 'poser', chemin: [...T2, 0, 'regle'], valeur: 'et' }], 'règle propre');
exigerCorrectif(
avec(soiree),
avec({ ...soiree, etiquettes: ['bénévole'] }),
[{ op: 'poser', chemin: [...T2, 0, 'etiquettes'], valeur: ['bénévole'] }],
'étiquettes de l’objectif',
);
exigerCorrectif(avec(soiree), avec(tour2, soiree), [{ op: 'poser', chemin: T2, valeur: [soiree, tour2] }], 'un de plus');
});
test('chaque réglage du format 2 se pose quand il apparaît, se retire quand il revient à son défaut', () => {
const valeurs = [['regleObjectifs', 'et'], ['respecterObjectifs', false], ['modeObjectifs', 'minimum'], ['rencontrerAnimateurs', true]];
for (const [cle, valeur] of valeurs) {
const avec = base((c) => { c.reglages[cle] = valeur; });
exigerCorrectif(base(), avec, [{ op: 'poser', chemin: ['reglages', cle], valeur }], `${cle} posé`);
exigerCorrectif(avec, base(), [{ op: 'retirer', chemin: ['reglages', cle] }], `${cle} retiré`);
}
exigerCorrectif(
base((c) => { c.reglages.modeObjectifs = 'chacun'; }),
base((c) => { c.reglages.modeObjectifs = 'minimum'; }),
[{ op: 'poser', chemin: ['reglages', 'modeObjectifs'], valeur: 'minimum' }],
'mode changé',
);
});
test('un champ facultatif posé avant un autre déjà présent prend sa place dans l’ordre du schéma : la charge rendue est la copie canonique, ordre des clés compris', () => {
const a = base((c) => {
c.reglages.rencontrerAnimateurs = true;
c.participants[0].exclu = true;
});
const b = base((c) => {
c.reglages.cibleRedondance = 900;
c.reglages.respecterObjectifs = false;
c.reglages.rencontrerAnimateurs = true;
c.participants[0].etiquettes = ['finance'];
});
const rendue = appliquer(a, difference(a, b));
assert.deepEqual(Object.keys(rendue.reglages).slice(-3), ['cibleRedondance', 'respecterObjectifs', 'rencontrerAnimateurs']);
assert.equal(JSON.stringify(rendue), JSON.stringify(canoniser(b)));
});
test('une pose sous une liste d’étiquettes, une liste vide, en double ou mal formée, un champ écrit à son défaut, un retrait absent lèvent CORRECTIF', () => {
const avecEtiquettes = base((c) => {
c.participants[0].etiquettes = ['finance', 'Montréal'];
c.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
});
const cas = [
['une étiquette posée à son rang', avecEtiquettes, { op: 'poser', chemin: [...P1, 0], valeur: 'Montréal' }],
['une étiquette ajoutée au-delà', avecEtiquettes, { op: 'poser', chemin: [...P1, 2], valeur: 'RH' }],
['une étiquette d’objectif posée à son rang', avecEtiquettes, { op: 'poser', chemin: [...T2, 0, 'etiquettes', 0], valeur: 'RH' }],
['des étiquettes vides', avecEtiquettes, { op: 'poser', chemin: P1, valeur: [] }],
['un doublon exact', avecEtiquettes, { op: 'poser', chemin: P1, valeur: ['finance', 'finance'] }],
['une tabulation', avecEtiquettes, { op: 'poser', chemin: P1, valeur: ['a\u{0009}b'] }],
['21 étiquettes', avecEtiquettes, { op: 'poser', chemin: P1, valeur: Array.from({ length: 21 }, (_, n) => `g${n}`) }],
['des objectifs vides', avecEtiquettes, { op: 'poser', chemin: T2, valeur: [] }],
['un objectif sans étiquette', avecEtiquettes, { op: 'poser', chemin: [...T2, 0, 'etiquettes'], valeur: [] }],
['un mode « places »', base(), { op: 'poser', chemin: ['reglages', 'modeObjectifs'], valeur: 'places' }],
['une règle « ou »', base(), { op: 'poser', chemin: ['reglages', 'regleObjectifs'], valeur: 'ou' }],
['un mode inconnu', base(), { op: 'poser', chemin: ['reglages', 'modeObjectifs'], valeur: 'pondere' }],
['un retrait d’étiquettes absentes', base(), { op: 'retirer', chemin: P1 }],
['un retrait du mode absent', base(), { op: 'retirer', chemin: ['reglages', 'modeObjectifs'] }],
];
for (const [libelle, charge, operation] of cas) {
assert.deepEqual(refusDe(charge, [operation]), { rang: 0 }, libelle);
}
});
});

View file

@ -60,6 +60,16 @@
// nouvelle base appartient à l'événement dès que son journal existe, et
// l'ancienne paire ne s'apparie jamais ce journal.
//
// Un état ou un journal d'un format plus ancien que FORMAT se lit comme un
// fichier du format courant qui ne porte aucun champ nouveau (§ 8.8) : ni
// l'ouverture, ni le passage en écriture ne le réécrivent. Le premier geste
// qui écrit — un geste, un jalon, l'écriture ailleurs — réécrit le journal
// en entier, au format courant, chaque entrée lue gardée telle quelle :
// des entrées du format courant ne s'ajoutent jamais sous une ouverture
// d'un format plus ancien, qu'une construction ancienne lirait comme
// sienne. Un geste réécrit aussi l'état, au format courant ; un jalon, qui
// n'écrit pas l'état, le laisse à son format jusqu'au geste suivant.
//
// Un état sur le disque, ou un journal de la base, d'un format plus récent
// que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours
// compris : rien de ce qu'une construction plus récente a écrit dans l'état
@ -135,8 +145,9 @@ import { examiner } from './placements.js';
* restitue la dernière entrée du journal, d'où part le correctif
* de la suivante ; null sans journal
* @property {boolean} journalAReecrire le fichier du journal porte des
* lignes que la lecture a écartées : la prochaine écriture le
* réécrit sans elles
* lignes que la lecture a écartées, ou une ouverture d'un format
* plus ancien que FORMAT : la prochaine écriture le réécrit en
* entier, sans elles, au format courant
* @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur
* le disque, le .precedent qu'un secours ouvre ou le journal de la
* base est d'un format plus récent que FORMAT
@ -590,7 +601,9 @@ export function rattraper(etat, instant) {
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du
// journal trouvé pour elle. avecRattrapage : la source suit la règle de
// rattraper. La source d'un format plus récent, ou un journal d'un format
// plus récent, gardent l'Ouvert en lecture seule.
// plus récent, gardent l'Ouvert en lecture seule. Un journal dont la lecture
// a écarté des lignes, ou d'un format plus ancien, se réécrira en entier à
// la prochaine écriture (journalAReecrire).
function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage }) {
const lu = avecRattrapage ? rattraper(source, instant) : { ...source, rattrape: false };
const { charge, revision, produitVersion, manquants, rattrape } = lu;
@ -609,7 +622,7 @@ function assembler({ fs, racine, base, source, trouve, instant, avecRattrapage }
journal,
baseJournal: trouve.base,
chargeJournal: instant?.charge ?? null,
journalAReecrire: journal !== null && ecartees > 0,
journalAReecrire: journal !== null && (ecartees > 0 || journal.format < FORMAT),
formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
etatMalEncode: source.etatMalEncode,
examen,
@ -880,12 +893,13 @@ function texteDe({ evenement, entrees, jalons }) {
// ligne de l'entrée de reprise qui le fait finir sur la charge ouverte, ou
// null ; journal, avant suivi de la reprise ; charge, ce que restitue sa
// dernière entrée ; entier, vrai quand le fichier se réécrit en entier à sa
// prochaine écriture : il manque, sa lecture a écarté des lignes, ou il
// reçoit la reprise. Sans journal, la reprise le commence, instantané de la
// charge ouverte à sa révision et à sa version. Un journal qui finit sur une
// autre révision que la charge ouverte reçoit la reprise à la révision qui
// suit la sienne, qui restitue la charge ouverte. libelleReprise manque :
// ligneEntree lève TypeError, avant toute écriture.
// prochaine écriture : il manque, sa lecture a écarté des lignes ou son
// ouverture est d'un format plus ancien (journalAReecrire), ou il reçoit la
// reprise. Sans journal, la reprise le commence, instantané de la charge
// ouverte à sa révision et à sa version. Un journal qui finit sur une autre
// révision que la charge ouverte reçoit la reprise à la révision qui suit la
// sienne, qui restitue la charge ouverte. libelleReprise manque : ligneEntree
// lève TypeError, avant toute écriture.
function preparer(ouvert, { libelleReprise, horodatage }) {
const { journal, revision, charge, produitVersion } = ouvert;
if (journal === null) {
@ -897,7 +911,7 @@ function preparer(ouvert, { libelleReprise, horodatage }) {
apres: charge,
instantane: true,
});
const vide = { evenement: charge.evenement.id, entrees: [], jalons: [], ecartees: 0 };
const vide = { evenement: charge.evenement.id, format: FORMAT, entrees: [], jalons: [], ecartees: 0 };
return { avant: vide, reprise, journal: avec(vide, reprise), charge: canoniser(charge), entier: true };
}
const derniere = journal.entrees.at(-1).revision;
@ -965,12 +979,13 @@ async function aligner(ouvert, preparation, texte) {
}
// Le texte entier d'un journal en mémoire, élagué au-delà de PLAFOND_ENTREES
// (elaguer), et le journal que ce texte porte, sans ligne écartée : ce que
// le fichier porte une fois écrit d'un bloc.
// (elaguer), et le journal que ce texte porte, sans ligne écartée, au
// format courant que porte son ouverture (ligneOuverture) : ce que le
// fichier porte une fois écrit d'un bloc.
function textePlein(journal) {
const texte = texteDe(journal);
const elague = elaguer(texte);
if (elague === null) return { texte, journal: { ...journal, ecartees: 0 } };
if (elague === null) return { texte, journal: { ...journal, format: FORMAT, ecartees: 0 } };
return { texte: elague, journal: lireJournal(elague) };
}
@ -1177,7 +1192,7 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
const base = await deriver(fs, racine, charge.evenement.nom, []);
const entree = ligneEntree({ revision: 1, libelle, horodatage, produitVersion, apres: charge, instantane: true });
const jalon = ligneJalon({ revision: 1, nom: nomJalon, horodatage });
const vide = { evenement: charge.evenement.id, entrees: [], jalons: [], ecartees: 0 };
const vide = { evenement: charge.evenement.id, format: FORMAT, entrees: [], jalons: [], ecartees: 0 };
const journal = avec(avec(vide, entree), jalon);
const texteEtat = relisible(serialiser(charge, { revision: 1, produitVersion }), 'creer');
await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteDe(journal));
@ -1208,7 +1223,8 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
* ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et
* au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat,
* s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien
* s'écrit au format courant : la conversion tombe au premier geste. Avant
* s'écrit au format courant : la conversion tombe au premier geste, qui
* réécrit le journal en entier, chaque entrée lue gardée, puis l'état. Avant
* (1), l'état aux octets hors UTF-8 d'un Ouvert de secours passe dans un
* dossier daté de la corbeille, à l'horodatage du geste
* (mettreDeCoteEtatMalEncode).

View file

@ -9,8 +9,9 @@
// illisible nomme ses secours, qui s'ouvrent ; le journal s'apparie par
// identifiant, se répare au premier geste qui suit l'ouverture et s'élague ;
// une proposition fautive s'écarte, une périmée se garde ; le verrou ;
// l'écriture ailleurs. Les noms d'épreuve sont inventés ; les versions viennent
// du module engendré.
// l'écriture ailleurs ; un événement d'un format plus ancien, qui se lit et
// se convertit au premier geste, historique entier. Les noms d'épreuve sont
// inventés ; les versions viennent du module engendré.
import assert from 'node:assert/strict';
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
@ -29,7 +30,7 @@ import {
ouvrirSecours,
verrouiller,
} from './depot.js';
import { analyser, creerCharge } from './document.js';
import { FORMAT, analyser, creerCharge } from './document.js';
import { ErreurStockage } from './erreurs.js';
import { PLAFOND_ENTREES, elaguer, fil, ligneJalon, ligneOuverture, lireJournal, reconstruire } from './journal.js';
import { SUFFIXES } from './noms.js';
@ -2216,7 +2217,7 @@ describe('dépôt : propositions fautives et périmées, retenu fautif (§ 8.9)'
describe('dépôt : format plus récent, élagage', () => {
test("un fichier d'un format plus récent s'ouvre en lecture seule : enregistrer, nommer et enregistrerAilleurs refusent LECTURE_SEULE sans rien écrire", async () => {
const texte = etatTexte(chargeEssai()).replace('"format":1', '"format":2');
const texte = etatTexte(chargeEssai()).replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`);
const { disque } = disqueAvec([[ETAT, texte]]);
const ouvert = await rouvrir(disque, BASE);
assert.equal(ouvert.formatPlusRecent, true);
@ -2231,16 +2232,17 @@ describe('dépôt : format plus récent, élagage', () => {
assert.equal(ouvert.fs.compteurs.ecritures, 0);
});
test("ce qu'une construction plus récente laisse garde en lecture seule chaque ouverture, secours compris : un état ou un journal au format 2 ne se réécrit jamais ; le journal de la base se reconnaît plus récent avant toute copie du dossier, et un journal plus récent ne se dit jamais absent", async () => {
test("ce qu'une construction plus récente laisse garde en lecture seule chaque ouverture, secours compris : un état ou un journal d'un format plus récent ne se réécrit jamais ; le journal de la base se reconnaît plus récent avant toute copie du dossier, et un journal plus récent ne se dit jamais absent", async () => {
const { fichiers } = await troisRevisions();
const recent = (texte) => texte.replace('"format":1', '"format":2');
// L'état au format 2, d'une forme que ce code ne connaît pas : illisible,
// FORMAT_PLUS_RECENT. Le .precedent reste l'état au format 1 qu'il remplace.
const recent = (texte) => texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`);
// L'état d'un format plus récent, d'une forme que ce code ne connaît pas :
// illisible, FORMAT_PLUS_RECENT. Le .precedent reste l'état au format
// courant qu'il remplace.
const etatForme = recent(texteDe(fichiers, ETAT)).replace('"sieges":null', '"sieges":{"n":3}');
const journalRecent = recent(texteDe(fichiers, JOURNAL));
const plusRecent = { code: 'JOURNAL_FORMAT_PLUS_RECENT', details: { format: 2 } };
const plusRecent = { code: 'JOURNAL_FORMAT_PLUS_RECENT', details: { format: FORMAT + 1 } };
const enAvance = { code: 'JOURNAL_EN_AVANCE_DE', details: { n: 1 } };
const illisible = (journal, format = 2) => ({
const illisible = (journal, format = FORMAT + 1) => ({
code: 'ETAT_ILLISIBLE',
details: {
base: BASE,
@ -2253,13 +2255,13 @@ describe('dépôt : format plus récent, élagage', () => {
// Le secours d'un journal plus récent est refusé : il n'est pas absent.
const journalPlusRecent = {
code: 'ETAT_ILLISIBLE',
details: { raison: 'FORMAT_PLUS_RECENT', chemin: JOURNAL, format: 2 },
details: { raison: 'FORMAT_PLUS_RECENT', chemin: JOURNAL, format: FORMAT + 1 },
};
// L'état manque à côté d'un journal plus récent : son refus ne nomme
// aucun format, celui du journal s'y ajoute.
const sansEtat = (precedent) => ({
code: 'ETAT_ILLISIBLE',
details: { base: BASE, raison: 'ABSENT', chemin: null, format: 2, secours: { precedent, journal: null } },
details: { base: BASE, raison: 'ABSENT', chemin: null, format: FORMAT + 1, secours: { precedent, journal: null } },
});
const sansPrecedent = { code: 'ABSENT', details: { chemin: PRECEDENT } };
const copieALaMain = `${BASE} - Copie${SUFFIXES.journal}`;
@ -2268,40 +2270,40 @@ describe('dépôt : format plus récent, élagage', () => {
// ouvrirSecours('journal') — les avertissements d'un Ouvert, ou l'erreur
// —, et ce que la main change ensuite aux fichiers posés.
const cas = [
['état et journal au format 2', etatForme, journalRecent, [illisible(null), [plusRecent], journalPlusRecent]],
['état au format 2, journal resté au format 1', etatForme, null, [illisible(3), [enAvance], []]],
['état et journal plus récents', etatForme, journalRecent, [illisible(null), [plusRecent], journalPlusRecent]],
['état plus récent, journal resté au format courant', etatForme, null, [illisible(3), [enAvance], []]],
// Le refus de l'état nomme son propre format, que celui du journal ne
// remplace pas.
[
'état au format 3, journal au format 2',
etatForme.replace('"format":2', '"format":3'),
'état deux formats plus loin, journal un format plus loin',
etatForme.replace(`"format":${FORMAT + 1}`, `"format":${FORMAT + 2}`),
journalRecent,
[illisible(null, 3), [plusRecent], journalPlusRecent],
[illisible(null, FORMAT + 2), [plusRecent], journalPlusRecent],
],
['état au format 2 lisible', recent(texteDe(fichiers, ETAT)), null, [[], [enAvance], []]],
['journal seul au format 2', null, journalRecent, [[plusRecent], [plusRecent], journalPlusRecent]],
['état plus récent lisible', recent(texteDe(fichiers, ETAT)), null, [[], [enAvance], []]],
['journal seul plus récent', null, journalRecent, [[plusRecent], [plusRecent], journalPlusRecent]],
[
"journal seul au format 2, marque d'ordre d'octets en tête",
"journal seul plus récent, marque d'ordre d'octets en tête",
null,
`\u{FEFF}${journalRecent}`,
[[plusRecent], [plusRecent], journalPlusRecent],
],
[
'journal au format 2, sa copie à la main au format 1 sous une autre base',
'journal plus récent, sa copie à la main au format courant sous une autre base',
null,
journalRecent,
[[plusRecent], [plusRecent], journalPlusRecent],
(poses) => [...poses, [copieALaMain, texteDe(fichiers, JOURNAL)]],
],
[
'journal au format 2 sans état',
'journal plus récent sans état',
null,
journalRecent,
[sansEtat(2), [plusRecent], journalPlusRecent],
(poses) => remplacer(poses, ETAT, null),
],
[
'journal au format 2 sans état ni .precedent',
'journal plus récent sans état ni .precedent',
null,
journalRecent,
[sansEtat(null), sansPrecedent, journalPlusRecent],
@ -2350,13 +2352,16 @@ describe('dépôt : format plus récent, élagage', () => {
assert.equal(enLectureSeule, 14);
});
test("une ouverture de journal dont le format n'est pas un entier plus grand que FORMAT ne se lit pas : écartée, puis remplacée au premier geste", async () => {
test("une ouverture de journal dont le format n'est pas un entier ≥ 1 ne se lit pas : écartée, puis remplacée au premier geste", async () => {
const { fichiers } = await troisRevisions();
const journal = texteDe(fichiers, JOURNAL);
const ouverture = journal.slice(0, journal.indexOf('\n'));
const fausses = [
ouverture.replace('"format":1', '"format":"2"'),
ouverture.replace('"format":1', '"format":0'),
ouverture.replace(`"format":${FORMAT}`, `"format":"${FORMAT + 1}"`),
ouverture.replace(`"format":${FORMAT}`, '"format":0'),
ouverture.replace(`"format":${FORMAT}`, '"format":-1'),
ouverture.replace(`"format":${FORMAT}`, '"format":1.5'),
ouverture.replace(`"format":${FORMAT}`, '"format":null'),
ouverture.replace(/\}$/, ',"inconnue":1}'),
];
for (const fausse of fausses) {
@ -2868,3 +2873,243 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
assert.equal(fs.compteurs.ecritures, ecritures);
});
});
// --- Un événement du format 1 (§ 8.8, A15) ---------------------------------
// Le texte d'état au format 1 : ce qu'une construction du format 1 écrit
// pour la même charge, le format 2 n'ajoutant que des champs facultatifs.
function etatAuFormat1(texte) {
const avant = `"entete": {"format":${FORMAT},`;
assert.equal(texte.split(avant).length, 2, 'en-tête au format courant attendu');
return texte.replace(avant, '"entete": {"format":1,');
}
// Le texte de journal au format 1 : son ouverture déclare le format 1, le
// reste tel quel.
function journalAuFormat1(texte) {
const fin = texte.indexOf('\n');
const ouverture = JSON.parse(texte.slice(0, fin));
assert.equal(ouverture.format, FORMAT);
return JSON.stringify({ ...ouverture, format: 1 }) + texte.slice(fin);
}
// Les fichiers de troisRevisions écrits par une construction du format 1 :
// l'état à la révision 3, le .precedent à la 2, le journal de trois
// entrées et un jalon.
async function troisRevisionsAuFormat1() {
const { fichiers, ouvert } = await troisRevisions();
const auFormat1 = fichiers.map(([nom, texte]) => [
nom,
nom === JOURNAL ? journalAuFormat1(texte) : nom === ETAT || nom === PRECEDENT ? etatAuFormat1(texte) : texte,
]);
return { fichiers: auFormat1, courants: fichiers, ouvert };
}
const lignesDe = (texte) => texte.replace(/\n$/, '').split('\n');
const formatDeLEtat = (texte) => analyser(texte).entete.format;
const formatDuJournal = (texte) => lireJournal(texte).format;
describe('dépôt : un événement du format 1 se lit, et se convertit au premier geste (§ 8.8, A15)', () => {
test('ouvrir, lister et verrouiller lisent l’état et le journal du format 1 sans rien réécrire : ni conversion à l’ouverture, ni au passage en écriture', async () => {
const { fichiers, ouvert: temoin } = await troisRevisionsAuFormat1();
const { disque, fs } = disqueAvec(fichiers);
const ouvert = await rouvrir(disque, BASE);
assert.equal(ouvert.formatPlusRecent, false);
assert.deepEqual(ouvert.avertissements, []);
assert.equal(ouvert.revision, 3);
assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(temoin.charge));
assert.equal(ouvert.journal.format, 1);
assert.equal(ouvert.journal.entrees.length, 3);
assert.equal(ouvert.journalAReecrire, true);
const [resume] = await lister(fs, DOC);
assert.deepEqual([resume.base, resume.journal, resume.illisible], [BASE, BASE, null]);
await verrouiller(ouvert, 'seance-a', heure(9));
for (const nom of [ETAT, PRECEDENT, JOURNAL]) assert.equal(fs.contenu('documents', nom), texteDe(fichiers, nom), nom);
});
test('le premier geste réécrit le journal entier au format courant, chaque ligne d’historique intacte, puis le .precedent et l’état ; le suivant s’ajoute en fin', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
const { disque } = disqueAvec(fichiers);
const ouvert = await rouvrir(disque, BASE);
const { fs } = ouvert;
const trace = tracer(fs);
const apres = modifier(ouvert.charge, 4);
const suite = await enregistrer(ouvert, apres, geste(4));
assert.deepEqual(trace.splice(0), [
['ecrireAtomique', JOURNAL],
['ecrireAtomique', PRECEDENT],
['ecrireAtomique', ETAT],
]);
const journal = fs.contenu('documents', JOURNAL);
const anciennes = lignesDe(texteDe(fichiers, JOURNAL));
const nouvelles = lignesDe(journal);
assert.equal(nouvelles[0], ligneOuverture('evt-lucioles'));
assert.equal(formatDuJournal(journal), FORMAT);
assert.deepEqual(nouvelles.slice(1, -1), anciennes.slice(1));
assert.equal(nouvelles.length, anciennes.length + 1);
assert.equal(formatDeLEtat(fs.contenu('documents', ETAT)), FORMAT);
assert.equal(fs.contenu('documents', PRECEDENT), texteDe(fichiers, ETAT));
const relu = exigerEquivalence(fs, BASE);
assert.deepEqual(revisionsEtLibelles(relu), [
[1, CREATION.libelle],
[2, 'Geste 2'],
[3, 'Geste 3'],
[4, 'Geste 4'],
]);
assert.deepEqual(relu.jalons.map(({ revision, nom }) => [revision, nom]), [[1, 'Création']]);
// Chaque instant d'avant la conversion se restitue comme le journal du
// format 1 le restituait.
const avant = lireJournal(texteDe(fichiers, JOURNAL));
for (const revision of [1, 2, 3]) {
assert.equal(texteDeCharge(reconstruire(relu, revision)), texteDeCharge(reconstruire(avant, revision)), String(revision));
}
assert.equal(suite.journalAReecrire, false);
assert.equal(suite.journal.format, FORMAT);
await enregistrer(suite, modifier(suite.charge, 5), geste(5));
assert.deepEqual(trace.splice(0), [
['ajouterLigne', JOURNAL],
['ecrireAtomique', PRECEDENT],
['ecrireAtomique', ETAT],
]);
const rouvert = await rouvrir(disque, BASE);
assert.deepEqual([rouvert.journalAReecrire, rouvert.journal.format, rouvert.revision], [false, FORMAT, 5]);
});
test('défaire après la conversion revient à l’instant d’avant elle, puis à celui d’avant encore : l’historique du format 1 reste entier', async () => {
const { fichiers, ouvert: temoin } = await troisRevisionsAuFormat1();
const { disque } = disqueAvec(fichiers);
let ouvert = await rouvrir(disque, BASE);
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4));
const { cibleDefaire } = fil(ouvert.journal);
assert.equal(cibleDefaire, 3);
ouvert = await enregistrer(ouvert, reconstruire(ouvert.journal, 3), geste(5, { sens: 'defaire', retour: 3 }));
assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(temoin.charge));
assert.equal(fil(ouvert.journal).cibleDefaire, 2);
ouvert = await enregistrer(ouvert, reconstruire(ouvert.journal, 2), geste(6, { sens: 'defaire', retour: 2 }));
const precedent = analyser(texteDe(fichiers, PRECEDENT)).charge;
assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(precedent));
const rouvert = await rouvrir(disque, BASE);
assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(precedent));
assert.deepEqual(fil(rouvert.journal).courant, [2, 1]);
assert.equal(fil(rouvert.journal).cibleRefaire, 3);
exigerEquivalence(rouvert.fs, BASE);
});
test('un jalon posé en premier convertit le journal seul ; l’état reste au format 1, se relit, et se convertit au geste suivant', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
const { disque } = disqueAvec(fichiers);
const ouvert = await rouvrir(disque, BASE);
const { fs } = ouvert;
const trace = tracer(fs);
const nomme = await nommer(ouvert, { nom: 'Avant la génération', horodatage: heure(4) });
assert.deepEqual(trace.splice(0), [['ecrireAtomique', JOURNAL]]);
assert.equal(formatDuJournal(fs.contenu('documents', JOURNAL)), FORMAT);
assert.equal(fs.contenu('documents', ETAT), texteDe(fichiers, ETAT));
assert.equal(nomme.journalAReecrire, false);
const rouvert = await rouvrir(disque, BASE);
assert.deepEqual([rouvert.journalAReecrire, rouvert.revision, rouvert.journal.jalons.length], [false, 3, 2]);
await enregistrer(rouvert, modifier(rouvert.charge, 4), geste(4));
assert.equal(formatDeLEtat(fs.contenu('documents', ETAT)), FORMAT);
exigerEquivalence(fs, BASE);
});
test('un journal du format 1 en avance d’une entrée sur l’état se rattrape, puis se réécrit en entier au premier geste', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
// L'état et son .precedent d'une révision plus tôt : le geste 3 est au
// journal, l'état non.
const poses = remplacer(fichiers, ETAT, texteDe(fichiers, PRECEDENT));
const { disque } = disqueAvec(poses);
const ouvert = await rouvrir(disque, BASE);
assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
assert.deepEqual([ouvert.revision, ouvert.journalAReecrire], [3, true]);
const { fs } = ouvert;
const trace = tracer(fs);
await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4));
// L'état rattrapé s'écrit d'abord à la révision 3, puis le journal
// entier, puis l'état du geste (§ 8.2).
assert.deepEqual(trace, [
['ecrireAtomique', PRECEDENT],
['ecrireAtomique', ETAT],
['ecrireAtomique', JOURNAL],
['ecrireAtomique', PRECEDENT],
['ecrireAtomique', ETAT],
]);
assert.deepEqual(revisionsEtLibelles(exigerEquivalence(fs, BASE)).map(([revision]) => revision), [1, 2, 3, 4]);
assert.equal(formatDuJournal(fs.contenu('documents', JOURNAL)), FORMAT);
});
test('un journal du format 1 en retard sur l’état reçoit la reprise, et se réécrit au format courant ; une ligne finale coupée s’écarte de même', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
const journal = texteDe(fichiers, JOURNAL);
const sansLa3 = `${lignesDe(journal).slice(0, -1).join('\n')}\n`;
const coupe = journal.slice(0, -10);
for (const [nom, texte, avertissements, revisions] of [
['en retard', sansLa3, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 3, journal: 2 } }], [1, 2, 3, 4]],
['coupé', coupe, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 3, journal: 2 } }, { code: 'JOURNAL_ECARTEES', details: { n: 1 } }], [1, 2, 3, 4]],
]) {
const { disque, fs } = disqueAvec(remplacer(fichiers, JOURNAL, texte));
const ouvert = await rouvrir(disque, BASE);
assert.deepEqual(ouvert.avertissements, avertissements, nom);
assert.equal(ouvert.journal.format, 1, nom);
await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
const relu = exigerEquivalence(fs, BASE);
assert.deepEqual(revisionsEtLibelles(relu).map(([revision]) => revision), revisions, nom);
assert.equal(relu.entrees[2].libelle, REPRISE, nom);
assert.equal(relu.format, FORMAT, nom);
}
});
test('chaque coupure du premier geste laisse une paire qui se rouvre sur le geste dès que sa ligne est au journal', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
const entreLigneEtEtat = await exigerChaqueCoupure(
'format 1',
depuisFichiers(fichiers, (fs) => ouvrir(fs, DOC, BASE), []),
);
assert.ok(entreLigneEtEtat >= 1, String(entreLigneEtEtat));
});
test('un état illisible à côté d’un .precedent et d’un journal du format 1 nomme ses secours ; chacun s’ouvre, et son premier geste convertit le journal', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
const poses = remplacer(fichiers, ETAT, '{"entete":');
const { fs } = disqueAvec(poses);
const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
assert.deepEqual(erreur.details.secours, { precedent: 2, journal: 3 });
for (const source of ['precedent', 'journal']) {
const disque = disqueAvec(poses);
const ouvert = await ouvrirSecours(disque.fs, DOC, BASE, source);
assert.equal(disque.fs.compteurs.ecritures, 0, source);
assert.deepEqual([ouvert.formatPlusRecent, ouvert.journalAReecrire, ouvert.journal.format], [false, true, 1], source);
await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
const relu = exigerEquivalence(disque.fs, BASE);
assert.equal(relu.format, FORMAT, source);
assert.deepEqual(relu.entrees.slice(0, 3).map(({ libelle }) => libelle), [CREATION.libelle, 'Geste 2', 'Geste 3'], source);
assert.equal(formatDeLEtat(disque.fs.contenu('documents', ETAT)), FORMAT, source);
}
});
test('un état au format courant à côté d’un journal resté au format 1 se lit ; le premier geste réécrit le journal', async () => {
const { fichiers, courants } = await troisRevisionsAuFormat1();
const { disque } = disqueAvec(remplacer(fichiers, ETAT, texteDe(courants, ETAT)));
const ouvert = await rouvrir(disque, BASE);
assert.deepEqual([ouvert.avertissements, ouvert.journalAReecrire], [[], true]);
const { fs } = ouvert;
const trace = tracer(fs);
await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4));
assert.deepEqual(trace[0], ['ecrireAtomique', JOURNAL]);
assert.equal(formatDuJournal(fs.contenu('documents', JOURNAL)), FORMAT);
});
test('écrit ailleurs, un événement du format 1 pose une paire au format courant, historique entier', async () => {
const { fichiers } = await troisRevisionsAuFormat1();
const { disque, fs } = disqueAvec(fichiers);
const ouvert = await rouvrir(disque, BASE);
const AUTRE = { id: 'documents', chemin: DOCUMENTS };
const copie = await enregistrerAilleurs(ouvert, AUTRE, { existantes: [BASE] });
assert.equal(copie.base, `${BASE} (2)`);
const journal = fs.contenu('documents', `${copie.base}${SUFFIXES.journal}`);
assert.equal(formatDuJournal(journal), FORMAT);
assert.deepEqual(lignesDe(journal).slice(1), lignesDe(texteDe(fichiers, JOURNAL)).slice(1));
assert.equal(formatDeLEtat(fs.contenu('documents', `${copie.base}${SUFFIXES.etat}`)), FORMAT);
for (const nom of [ETAT, PRECEDENT, JOURNAL]) assert.equal(fs.contenu('documents', nom), texteDe(fichiers, nom), nom);
});
});

View file

@ -18,34 +18,45 @@
// tout le reste parce qu'il dit comment lire le reste (FORME s'il manque,
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
// participants et des tables, le tour des réservations, et les titres
// répétés à l'identique (FORME) ; les
// participants et des tables, le tour des réservations, la portée et le tour
// des objectifs de table, et les titres répétés à l'identique (FORME) ; les
// comptes de la saisie dans l'en-tête — participants, tables, réservations,
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) :
// ce que l'en-tête annonce sans que la charge le porte se rend à côté
// d'elle, et le dépôt l'annonce. Un fichier
// d'un format plus récent que FORMAT se lit réduit aux clés que le schéma
// connaît, et ce qui serait FORME au format courant y porte la raison
// FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du
// format. Ses comptes et ses références gardent leur raison, une
// corruption quel que soit le format ; tout refus d'un tel fichier porte le
// format lu dans ses détails, qui disent au dépôt d'où il vient. Le dépôt
// complète les détails — base, secours — et pose lui-même la raison ABSENT,
// quand le journal existe sans l'état. Des propositions et du retenu,
// l'analyse ne lit que le conteneur : leur forme, leur cohérence et leurs
// identifiants, comparés entre eux et à prochainsIds.proposition, sont
// l'affaire du contrôle des placements, qui écarte une proposition fautive
// et signale un retenu fautif sans refuser le fichier (§ 8.9, point 3).
// ce que l'en-tête annonce sans que la charge le porte se rend à côté d'elle,
// et le dépôt l'annonce. Un fichier d'un format plus récent que FORMAT se lit
// réduit aux clés que le schéma connaît, et ce qui serait FORME au format
// courant y porte la raison FORMAT_PLUS_RECENT : la lecture ne le distingue
// pas d'une évolution du format. Ses comptes et ses références gardent leur
// raison, une corruption quel que soit le format ; tout refus d'un tel
// fichier porte le format lu dans ses détails, qui disent au dépôt d'où il
// vient. Le dépôt complète les détails — base, secours — et pose lui-même la
// raison ABSENT, quand le journal existe sans l'état. Des propositions et du
// retenu, l'analyse ne lit que le conteneur : leur forme, leur cohérence et
// leurs identifiants, comparés entre eux et à prochainsIds.proposition, sont
// l'affaire du contrôle des placements, qui écarte une proposition fautive et
// signale un retenu fautif sans refuser le fichier (§ 8.9, point 3).
//
// Un fichier d'un format plus ancien se lit comme un fichier du format
// courant : chaque format n'ajoute au précédent que des champs facultatifs
// (§ 8.8, § 8.9). Le format 2 ajoute les étiquettes des personnes, les
// objectifs des tables et quatre réglages, chacun absent à son défaut ; un
// fichier du format 1 ne les porte pas, et se lit sans conversion. Il ne se
// réécrit qu'au premier geste, au format courant.
//
// Les autres fonctions créent une charge ou dérivent d'elle la configuration
// du moteur, l'état qu'impose son contenu et la capacité d'une table. Aucune
// ne modifie ce qu'elle reçoit.
// du moteur, l'état qu'impose son contenu, la capacité d'une table et la
// valeur d'un réglage facultatif. Aucune ne modifie ce qu'elle reçoit.
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { ETIQUETTES_MAX, estEtiquetteBienFormee } from './etiquettes.js';
import { ErreurStockage } from './erreurs.js';
/** Version de format que ce code écrit (§ 8.8). */
export const FORMAT = 1;
/**
* Version de format que ce code écrit (§ 8.8). Le format 2 ajoute au format
* 1 des champs facultatifs seulement (§ 8.9) : un fichier du format 1 se lit
* comme un fichier du format 2 qui n'en porte aucun.
*/
export const FORMAT = 2;
/**
* Le plus grand nombre de places d'une table, au défaut comme en surcharge
@ -100,9 +111,10 @@ export const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAG
// 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
// facultatif (optionnel) peut manquer à son objet : absent, il ne s'écrit
// pas, et sa valeur par défaut se lit par la fonction qui le nomme.
// de leurs clés ; une liste porte la règle de ses éléments, et peut borner
// sa longueur (min, max) et exiger des éléments distincts (distincts). Un
// champ facultatif (optionnel) peut manquer à son objet : absent, il ne
// s'écrit pas, et sa valeur par défaut se lit par la fonction qui le nomme.
function regle(genre, proprietes) {
return Object.freeze({ genre, nul: false, optionnel: false, ...proprietes });
}
@ -156,6 +168,13 @@ const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const nulEnTete = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a - b);
// La portée « tous » avant « tour », que l'ordre des chaînes inverserait.
const rangDePortee = (portee) => (portee === 'tous' ? 0 : 1);
// Les objectifs d'une table : la soirée, puis les tours croissants (§ 8.9).
// Deux objectifs d'une même portée et d'un même tour, que l'analyse refuse,
// se départagent par le JSON de leur copie canonique : l'ordre reste total.
const ordreDesObjectifs = (a, b) =>
rangDePortee(a.portee) - rangDePortee(b.portee) ||
nulEnTete(a.tour, b.tour) ||
comparerTextes(JSON.stringify(a), JSON.stringify(b));
const ordreDesReservations = (a, b) =>
a.participant - b.participant ||
a.table - b.table ||
@ -210,6 +229,30 @@ const EVENEMENT = objet([
['filiation', FILIATION],
]);
// Les étiquettes d'une personne ou d'un objectif (§ 4.5, § 8.9) : une liste
// non vide d'au plus ETIQUETTES_MAX étiquettes bien formées, sans doublon
// exact, rangée par unités de code à l'écriture. Le genre etiquettes est
// celui d'une seule étiquette (estEtiquetteBienFormee). Deux orthographes
// d'une même clé normalisée restent admises : un fichier retouché à la main
// peut les porter, et le moteur les tient pour deux (§ 4.5).
const ETIQUETTES = liste(regle('etiquettes'), { min: 1, max: ETIQUETTES_MAX, distincts: true, tri: comparerTextes });
// Un objectif de table (§ 4.5) : sa portée, son tour — null sous « tous »,
// de 1 à R sous « tour », que l'analyse compare au nombre de tours —, sa
// règle propre, null quand il suit le défaut de l'événement, et ses
// étiquettes.
const OBJECTIF = objet([
['portee', parmi('tous', 'tour')],
['tour', entier(1, undefined, { nul: true })],
['regle', regle('parmi', { valeurs: Object.freeze(['ou', 'et']), nul: true })],
['etiquettes', ETIQUETTES],
]);
// Un champ facultatif présent seulement hors de son défaut (§ 8.9) : la
// liste fermée des valeurs qu'il admet, le défaut exclu ; écrit à son
// défaut, il est une faute de forme.
const horsDuDefaut = (...valeurs) => regle('parmi', { valeurs: Object.freeze(valeurs), optionnel: true });
const REGLAGES = objet([
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
@ -217,9 +260,18 @@ const REGLAGES = objet([
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
// Facultatif, en dernier : un fichier qui ne le porte pas s'écrit sans
// lui, octet pour octet comme avant qu'il existe (cibleRedondance).
// Facultatifs, en dernier, dans l'ordre de A16 : un fichier qui n'en porte
// aucun s'écrit sans eux, au numéro de format près. La cible de la somme
// de redondance (cibleRedondance) ; la règle par défaut des objectifs,
// « et » seulement (regleObjectifs) ; la case des objectifs, décochée
// seulement (respecterObjectifs) ; le mode des objectifs hors du plus de
// places servies (modeObjectifs) ; la case des animateurs, cochée
// seulement (rencontrerAnimateurs).
['cibleRedondance', entier(0, Infinity, { optionnel: true })],
['regleObjectifs', horsDuDefaut('et')],
['respecterObjectifs', horsDuDefaut(false)],
['modeObjectifs', horsDuDefaut('chacun', 'minimum')],
['rencontrerAnimateurs', horsDuDefaut(true)],
]);
const PARTICIPANT = objet([
@ -231,6 +283,8 @@ const PARTICIPANT = objet([
['titrePressenti', CHAINE_OU_NUL],
['notes', CHAINE_OU_NUL],
['exclu', BOOLEEN],
// Facultatif, en dernier : absent, la personne ne porte aucune étiquette.
['etiquettes', regle('liste', { ...ETIQUETTES, optionnel: true })],
]);
const TABLE = objet([
@ -239,6 +293,9 @@ const TABLE = objet([
['sieges', entier(2, SIEGES_MAX, { nul: true })],
['forme', parmi('ronde', 'carree')],
['position', objet([['x', NOMBRE], ['y', NOMBRE]])],
// Facultatif, en dernier : absent, la table n'a aucun objectif. Présent,
// une liste non vide, la soirée d'abord, puis les tours croissants.
['objectifs', liste(OBJECTIF, { min: 1, tri: ordreDesObjectifs, optionnel: true })],
]);
const RESERVATION = objet([
@ -416,13 +473,19 @@ function estJour(texte) {
// Vrai quand valeur, non nulle, a le genre de sa règle et tient dans son
// domaine. Un entier du fichier est un entier exact, au plus 2^53 − 1 :
// au-delà, deux entiers distincts se lisent comme un seul, et la relecture
// rend un autre texte.
function conforme(valeur, { genre, min, max, valeurs }) {
// rend un autre texte. Une liste tient dans ses bornes de longueur quand sa
// règle en porte, et ne répète aucun élément quand elle les veut distincts.
function conforme(valeur, { genre, min, max, valeurs, distincts }) {
switch (genre) {
case 'objet':
return estObjet(valeur);
case 'liste':
return Array.isArray(valeur);
return (
Array.isArray(valeur) &&
valeur.length >= (min ?? 0) &&
valeur.length <= (max ?? Infinity) &&
(distincts !== true || new Set(valeur).size === valeur.length)
);
case 'chaine':
return typeof valeur === 'string' && valeur !== '';
case 'entier':
@ -437,6 +500,8 @@ function conforme(valeur, { genre, min, max, valeurs }) {
return typeof valeur === 'string' && estJour(valeur);
case 'horodatage':
return estHorodatage(valeur);
case 'etiquettes':
return estEtiquetteBienFormee(valeur);
}
}
@ -596,6 +661,23 @@ function examinerTours({ evenement, reservations }, faute) {
});
}
// Objectifs de chaque table, dans l'ordre des tables puis des rangs : le
// tour d'un objectif est null pour la portée « tous », de 1 à R pour la
// portée « tour » ; une table porte au plus un objectif par portée et tour,
// et le second est fautif (§ 4.5). faute(chemin) lève l'écart.
function examinerObjectifs({ evenement, tables }, faute) {
tables.forEach(({ objectifs = [] }, rangTable) => {
const vus = new Set();
objectifs.forEach(({ portee, tour }, rang) => {
const chemin = `charge.tables[${rangTable}].objectifs[${rang}]`;
const admis = portee === 'tous' ? tour === null : tour !== null && tour <= evenement.tours;
if (!admis) faute(`${chemin}.tour`);
if (vus.has(tour)) faute(chemin);
vus.add(tour);
});
});
}
// Comptes de l'en-tête qui portent sur les placements : une proposition ou
// un retenu perdus se recalculent, la saisie non.
const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']);
@ -687,6 +769,7 @@ export function analyser(texte) {
const { entete, charge } = fichier;
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerObjectifs(charge, faute);
examinerTitres(charge, faute);
const manquants = examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus);
@ -695,37 +778,63 @@ export function analyser(texte) {
/**
* Configuration du moteur tirée d'une charge : les participants avec leur
* appartenance et leur exclusion ; les tables avec leur capacité courante ;
* le nombre de tours ; les réservations sans leur siège, que le moteur ne
* lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des
* réglages. Les tables vont par identifiant croissant, l'ordre du texte
* canonique : le moteur assoit table après table, et une charge aux tables
* dans un autre ordre, relue après un enregistrement, ne régénérerait plus
* ses propositions (§ 8.9). Les participants et les réservations gardent
* l'ordre de la charge : le rang d'une réservation que nomme une
* ErreurConfiguration est son rang dans la charge.
* appartenance, leur exclusion et leurs étiquettes ; les tables avec leur
* capacité courante ; le nombre de tours ; les réservations sans leur siège,
* que le moteur ne lit pas, et sans tour pour la portée « tous » ; les
* quatre contraintes des réglages et les deux cases facultatives ; les
* objectifs des tables, chacun { table, tour, regle, etiquettes }, tour null
* pour la soirée, sa règle résolue : la sienne, sinon la règle par défaut
* (regleObjectifs) ; le mode des objectifs. Chaque champ nouveau du format 2
* est absent à son défaut — étiquettes d'une personne qui n'en porte pas,
* objectifs quand aucune table n'en a, modeObjectifs à « places »,
* respecterObjectifs coché, rencontrerAnimateurs décoché — : la
* configuration d'une charge sans eux est celle du format 1. Les tables vont
* par identifiant croissant, l'ordre du texte canonique : le moteur assoit
* table après table, et une charge aux tables dans un autre ordre, relue
* après un enregistrement, ne régénérerait plus ses propositions (§ 8.9).
* Les objectifs suivent l'ordre des tables, puis la soirée et les tours
* croissants. Les participants et les réservations gardent l'ordre de la
* charge : le rang d'une réservation que nomme une ErreurConfiguration est
* son rang dans la charge. Les listes d'étiquettes sont des copies.
*
* @param {import('./types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration}
*/
export function configurationDepuisCharge(charge) {
const { evenement, reglages } = charge;
return {
participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })),
tables: [...charge.tables]
.sort((a, b) => a.id - b.id)
.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
const tables = [...charge.tables].sort((a, b) => a.id - b.id);
const regleParDefaut = regleObjectifs(charge);
const objectifs = tables.flatMap(({ id, objectifs: deLaTable = [] }) =>
[...deLaTable]
.sort(ordreDesObjectifs)
.map(({ tour, regle: propre, etiquettes }) => ({ table: id, tour, regle: propre ?? regleParDefaut, etiquettes: [...etiquettes] })),
);
const contraintes = {
separerAppartenances: reglages.separerAppartenances,
nouveauxVoisins: reglages.nouveauxVoisins,
nouvelleTable: reglages.nouvelleTable,
varierAppartenances: reglages.varierAppartenances,
};
for (const [cle, defaut] of CASES_FACULTATIVES) {
if (critereCoche(charge, cle) !== defaut) contraintes[cle] = !defaut;
}
const configuration = {
participants: charge.participants.map(({ id, nom, appartenance, exclu, etiquettes }) =>
etiquettes === undefined
? { id, nom, appartenance, exclu }
: { id, nom, appartenance, exclu, etiquettes: [...etiquettes] },
),
tables: tables.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
tours: evenement.tours,
reservations: charge.reservations.map(({ participant, table, portee, tour }) =>
portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour },
),
contraintes: {
separerAppartenances: reglages.separerAppartenances,
nouveauxVoisins: reglages.nouveauxVoisins,
nouvelleTable: reglages.nouvelleTable,
varierAppartenances: reglages.varierAppartenances,
},
contraintes,
};
if (objectifs.length > 0) configuration.objectifs = objectifs;
const mode = modeObjectifs(charge);
if (mode !== MODE_OBJECTIFS_PAR_DEFAUT) configuration.modeObjectifs = mode;
return configuration;
}
/**
@ -754,6 +863,62 @@ export function cibleRedondance(charge) {
return charge.reglages.cibleRedondance ?? CIBLE_REDONDANCE_PAR_DEFAUT;
}
// La règle par défaut des objectifs et le mode des objectifs quand les
// réglages ne les portent pas (§ 8.9).
const REGLE_OBJECTIFS_PAR_DEFAUT = 'ou';
const MODE_OBJECTIFS_PAR_DEFAUT = 'places';
// Les quatre cases du format 1, toujours portées par les réglages, et les
// deux cases facultatives du format 2, chacune avec la valeur qu'elle prend
// quand les réglages ne la portent pas (§ 5.3, § 8.9).
const CASES_DU_FORMAT_1 = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances']);
const CASES_FACULTATIVES = new Map([
['respecterObjectifs', true],
['rencontrerAnimateurs', false],
]);
/**
* La règle par défaut des objectifs de l'événement (§ 4.5, § 8.9) : « et »
* quand les réglages la portent, « ou » sinon. C'est la seule lecture de ce
* réglage ; un objectif dont la règle propre est null la suit.
*
* @param {import('./types.js').Charge} charge
* @returns {'ou'|'et'}
*/
export function regleObjectifs(charge) {
return charge.reglages.regleObjectifs ?? REGLE_OBJECTIFS_PAR_DEFAUT;
}
/**
* Vrai quand la case cle des critères est cochée (§ 5.3, § 8.9) : l'une des
* quatre contraintes du format 1, que les réglages portent toujours, ou
* l'une des deux cases facultatives — respecterObjectifs, cochée quand les
* réglages ne la portent pas, rencontrerAnimateurs, décochée alors. C'est la
* seule lecture des deux cases facultatives.
*
* @param {import('./types.js').Charge} charge
* @param {'separerAppartenances'|'nouveauxVoisins'|'nouvelleTable'|'varierAppartenances'|'respecterObjectifs'|'rencontrerAnimateurs'} cle
* @returns {boolean}
* @throws {RangeError} pour toute autre clé
*/
export function critereCoche(charge, cle) {
if (CASES_DU_FORMAT_1.has(cle)) return charge.reglages[cle];
if (CASES_FACULTATIVES.has(cle)) return charge.reglages[cle] ?? CASES_FACULTATIVES.get(cle);
throw new RangeError(`critereCoche : case inconnue ${JSON.stringify(cle)}`);
}
/**
* Le mode des objectifs de l'événement (§ 8.9, D20) : « chacun » ou
* « minimum » quand les réglages le portent, « places », le plus de places
* servies, sinon. C'est la seule lecture de ce réglage.
*
* @param {import('./types.js').Charge} charge
* @returns {'places'|'chacun'|'minimum'}
*/
export function modeObjectifs(charge) {
return charge.reglages.modeObjectifs ?? MODE_OBJECTIFS_PAR_DEFAUT;
}
/**
* Capacité courante d'une table (§ 6.1) : ses sièges quand elle est
* surchargée, le défaut de l'événement quand elle le suit (sieges null).

View file

@ -1,13 +1,14 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du document d'événement (§ 4, § 6.1, § 8.1, § 8.8, § 9) : la
// charge neuve ; l'analyse d'un texte, ce qu'elle lit et chacun de ses
// refus, nommé par sa raison et son chemin ; la lecture d'un format plus
// récent ; la configuration que reçoit le moteur ; l'état qu'impose le
// contenu ; la capacité d'une table ; l'erreur de stockage ; le parcours
// de forme que partagent l'analyse, le contrôle des placements et les
// correctifs. Les textes d'épreuve sont du JSON compact : l'analyse ne
// Épreuves du document d'événement (§ 4, § 4.5, § 6.1, § 8.1, § 8.8, § 8.9,
// § 9) : la charge neuve ; l'analyse d'un texte, ce qu'elle lit et chacun
// de ses refus, nommé par sa raison et son chemin ; la lecture d'un format
// plus ancien et d'un format plus récent ; les champs facultatifs du format
// 2 et leurs lecteurs ; la configuration que reçoit le moteur ; l'état
// qu'impose le contenu ; la capacité d'une table ; l'erreur de stockage ; le
// parcours de forme que partagent l'analyse, le contrôle des placements et
// les 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';
@ -17,7 +18,7 @@ import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import { serialiser, serialiserCharge } from './canonique.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
FORMAT,
@ -30,9 +31,12 @@ import {
cibleRedondance,
configurationDepuisCharge,
creerCharge,
critereCoche,
etatDeduit,
modeObjectifs,
premiereFaute,
premiereFauteEnPlace,
regleObjectifs,
} from './document.js';
import { ErreurStockage } from './erreurs.js';
import { fautes as fautesDePlacement } from './placements.js';
@ -45,7 +49,7 @@ import { fautes as fautesDePlacement } from './placements.js';
function documentValide() {
return {
entete: {
format: 1,
format: FORMAT,
produitVersion: VERSION.affichee,
revision: 12,
comptes: { participants: 4, tables: 2, reservations: 2, titres: 1, propositions: 1, retenu: 0 },
@ -251,8 +255,8 @@ describe('ErreurStockage (erreurs.js)', () => {
});
describe('creerCharge (§ 4, § 6.1, § 9)', () => {
test('une charge neuve est un brouillon sans participant ni table, au format 1, aux réglages par défaut', () => {
assert.equal(FORMAT, 1);
test('une charge neuve est un brouillon sans participant ni table, au format 2, aux réglages par défaut', () => {
assert.equal(FORMAT, 2);
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', date: '2026-11-12', siegesParDefaut: 8, tours: 3 });
assert.deepEqual(charge, {
evenement: {
@ -817,13 +821,13 @@ describe('analyser : ordre des refus', () => {
});
describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
test("un format 2 se lit, formatPlusRecent vrai, ses clés inconnues ignorées : ni l'en-tête ni la charge rendus ne les portent", () => {
const simple = analyser(abime((d) => { d.entete.format = 2; }));
test("un format plus récent se lit, formatPlusRecent vrai, ses clés inconnues ignorées : ni l'en-tête ni la charge rendus ne les portent", () => {
const simple = analyser(abime((d) => { d.entete.format = FORMAT + 1; }));
assert.equal(simple.formatPlusRecent, true);
assert.equal(simple.entete.format, 2);
assert.equal(simple.entete.format, FORMAT + 1);
const { entete, charge } = documentValide();
const etendu = analyser(abime((d) => {
d.entete.format = 2;
d.entete.format = FORMAT + 1;
d.annexe = { couleur: 'vert' };
d.entete.signature = null;
d.entete.comptes.lieux = 1;
@ -834,7 +838,7 @@ describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
d.charge.propositions[0].placement[0].commentaire = 'tour calme';
}));
assert.deepEqual(etendu, {
entete: { ...entete, format: 2 },
entete: { ...entete, format: FORMAT + 1 },
charge,
formatPlusRecent: true,
manquants: { propositions: 0, retenu: false },
@ -848,40 +852,41 @@ describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
modifier(d);
});
assert.deepEqual(ecartsDeRefus([
['état « archive »', plusRecent(2, (d) => { d.charge.evenement.etat = 'archive'; }),
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: 2 }],
['état « archive », format 3', plusRecent(3, (d) => { d.charge.evenement.etat = 'archive'; }),
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: 3 }],
['nom vide', plusRecent(2, (d) => { d.charge.participants[0].nom = ''; }),
'FORMAT_PLUS_RECENT', 'charge.participants[0].nom', { format: 2 }],
['clé connue absente', plusRecent(2, (d) => { delete d.charge.tables[0].forme; }),
'FORMAT_PLUS_RECENT', 'charge.tables[0].forme', { format: 2 }],
['charge qui est une liste', plusRecent(2, (d) => { d.charge = []; }),
'FORMAT_PLUS_RECENT', 'charge', { format: 2 }],
['identifiant en double', plusRecent(2, (d) => { d.charge.participants[2].id = 1; }),
'FORMAT_PLUS_RECENT', 'charge.participants[2].id', { format: 2 }],
['tour au-delà du nombre de tours', plusRecent(2, (d) => { d.charge.reservations[1].tour = 3; }),
'FORMAT_PLUS_RECENT', 'charge.reservations[1].tour', { format: 2 }],
['comptes faux', plusRecent(2, (d) => { d.entete.comptes.tables = 1; }),
'COMPTES', 'entete.comptes.tables', { format: 2 }],
['référence absente', plusRecent(2, (d) => { d.charge.titres[0].table = 7; }),
'REFERENCE', 'charge.titres[0].table', { format: 2 }],
['comptes faux, format 3', plusRecent(3, (d) => { d.entete.comptes.titres = 0; }),
'COMPTES', 'entete.comptes.titres', { format: 3 }],
['référence absente, format 3', plusRecent(3, (d) => { d.charge.reservations[1].participant = 4; }),
'REFERENCE', 'charge.reservations[1].participant', { format: 3 }],
['siège au-delà de la capacité', plusRecent(2, (d) => { d.charge.reservations[1].siege = 4; }),
'REFERENCE', 'charge.reservations[1].siege', { format: 2 }],
['état « archive »', plusRecent(FORMAT + 1, (d) => { d.charge.evenement.etat = 'archive'; }),
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: FORMAT + 1 }],
['état « archive », deux formats plus loin', plusRecent(FORMAT + 2, (d) => { d.charge.evenement.etat = 'archive'; }),
'FORMAT_PLUS_RECENT', 'charge.evenement.etat', { format: FORMAT + 2 }],
['nom vide', plusRecent(FORMAT + 1, (d) => { d.charge.participants[0].nom = ''; }),
'FORMAT_PLUS_RECENT', 'charge.participants[0].nom', { format: FORMAT + 1 }],
['clé connue absente', plusRecent(FORMAT + 1, (d) => { delete d.charge.tables[0].forme; }),
'FORMAT_PLUS_RECENT', 'charge.tables[0].forme', { format: FORMAT + 1 }],
['charge qui est une liste', plusRecent(FORMAT + 1, (d) => { d.charge = []; }),
'FORMAT_PLUS_RECENT', 'charge', { format: FORMAT + 1 }],
['identifiant en double', plusRecent(FORMAT + 1, (d) => { d.charge.participants[2].id = 1; }),
'FORMAT_PLUS_RECENT', 'charge.participants[2].id', { format: FORMAT + 1 }],
['tour au-delà du nombre de tours', plusRecent(FORMAT + 1, (d) => { d.charge.reservations[1].tour = 3; }),
'FORMAT_PLUS_RECENT', 'charge.reservations[1].tour', { format: FORMAT + 1 }],
['comptes faux', plusRecent(FORMAT + 1, (d) => { d.entete.comptes.tables = 1; }),
'COMPTES', 'entete.comptes.tables', { format: FORMAT + 1 }],
['référence absente', plusRecent(FORMAT + 1, (d) => { d.charge.titres[0].table = 7; }),
'REFERENCE', 'charge.titres[0].table', { format: FORMAT + 1 }],
['comptes faux, deux formats plus loin', plusRecent(FORMAT + 2, (d) => { d.entete.comptes.titres = 0; }),
'COMPTES', 'entete.comptes.titres', { format: FORMAT + 2 }],
['référence absente, deux formats plus loin', plusRecent(FORMAT + 2, (d) => { d.charge.reservations[1].participant = 4; }),
'REFERENCE', 'charge.reservations[1].participant', { format: FORMAT + 2 }],
['siège au-delà de la capacité', plusRecent(FORMAT + 1, (d) => { d.charge.reservations[1].siege = 4; }),
'REFERENCE', 'charge.reservations[1].siege', { format: FORMAT + 1 }],
['au format courant, la même faute reste FORME',
abime((d) => { d.charge.evenement.etat = 'archive'; }), 'FORME', 'charge.evenement.etat'],
]), []);
});
test("une collection qu'un format plus récent ajoute, ignorée à la lecture, laisse une référence ou un compte sans objet : la raison reste REFERENCE ou COMPTES, et les détails portent le format lu", () => {
// Le format 2 de l'épreuve range une table d'appoint hors de tables, et
// une réservation la désigne ; la lecture ne garde que les clés connues.
// Le format plus récent de l'épreuve range une table d'appoint hors de
// tables, et une réservation la désigne ; la lecture ne garde que les
// clés connues.
const avecAppoint = (modifier) => abime((d) => {
d.entete.format = 2;
d.entete.format = FORMAT + 1;
d.charge.tablesAppoint = [{ id: 4, numero: 3, sieges: 4, forme: 'ronde', position: { x: 0, y: 200 } }];
d.charge.prochainsIds.table = 5;
d.charge.reservations.push({ participant: 5, table: 4, siege: 1, portee: 'tous', tour: null });
@ -890,9 +895,9 @@ describe('analyser : format plus récent (§ 8.8, § 18.6)', () => {
});
assert.deepEqual(ecartsDeRefus([
["la réservation de la table d'appoint", avecAppoint(() => {}),
'REFERENCE', 'charge.reservations[2].table', { format: 2 }],
'REFERENCE', 'charge.reservations[2].table', { format: FORMAT + 1 }],
["la table d'appoint comptée parmi les tables", avecAppoint((d) => { d.entete.comptes.tables = 3; }),
'COMPTES', 'entete.comptes.tables', { format: 2 }],
'COMPTES', 'entete.comptes.tables', { format: FORMAT + 1 }],
]), []);
});
});
@ -1290,3 +1295,385 @@ describe('le placement daté : un champ facultatif du retenu et des propositions
assert.equal(Object.hasOwn(lecture.charge.retenu, 'autreCle'), false);
});
});
// --- Les champs facultatifs du format 2 (§ 4.5, § 8.9, A16) ---------------
// Le document valide portant chaque champ facultatif du format 2 hors de son
// défaut : des étiquettes à deux personnes, deux objectifs à la table 1 — la
// soirée, qui suit le défaut, et le tour 2, de règle propre —, la règle par
// défaut « et », les objectifs non respectés, le mode « l'équité d'abord »,
// la rencontre des animateurs cochée. Une copie neuve à chaque appel.
function documentComplet() {
const d = documentValide();
d.charge.reglages.regleObjectifs = 'et';
d.charge.reglages.respecterObjectifs = false;
d.charge.reglages.modeObjectifs = 'minimum';
d.charge.reglages.rencontrerAnimateurs = true;
d.charge.participants[0].etiquettes = ['Montréal', 'finance'];
d.charge.participants[3].etiquettes = ['bénévole'];
d.charge.tables[0].objectifs = [
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
{ portee: 'tour', tour: 2, regle: 'ou', etiquettes: ['Montréal', 'ressources humaines'] },
];
return d;
}
// Texte du document complet, une fois modifier passé sur lui.
function abimeComplet(modifier) {
const copie = documentComplet();
modifier(copie);
return JSON.stringify(copie);
}
// Chemins, rangs effacés, des valeurs nouvelles du format 2 que le contrat
// admet nulles.
const ADMETTENT_NULL_FORMAT_2 = new Set([...ADMETTENT_NULL, 'charge.tables[].objectifs[].tour', 'charge.tables[].objectifs[].regle']);
// Vrai pour les étapes d'une valeur que le format 2 ajoute : sous les
// étiquettes d'une personne, les objectifs d'une table, ou l'un des quatre
// réglages nouveaux.
const REGLAGES_FORMAT_2 = ['regleObjectifs', 'respecterObjectifs', 'modeObjectifs', 'rencontrerAnimateurs'];
const estDuFormat2 = (etapes) =>
etapes.includes('etiquettes') ||
etapes.includes('objectifs') ||
(etapes[1] === 'reglages' && REGLAGES_FORMAT_2.includes(etapes[2]));
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE_SCHEMA = regleDuChamp(SCHEMA, 'charge');
describe('les champs facultatifs du format 2 : forme et place (§ 8.9, A16)', () => {
test('chacun est facultatif, écrit après les clés du format 1 de son objet, les réglages dans l’ordre de A16', () => {
const REGLAGES = regleDuChamp(CHARGE_SCHEMA, 'reglages');
const PARTICIPANT = regleDuChamp(CHARGE_SCHEMA, 'participants').element;
const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element;
assert.deepEqual(
REGLAGES.champs.slice(-5).map(([cle, regle]) => [cle, regle.optionnel]),
[
['cibleRedondance', true],
['regleObjectifs', true],
['respecterObjectifs', true],
['modeObjectifs', true],
['rencontrerAnimateurs', true],
],
);
assert.deepEqual(PARTICIPANT.champs.slice(-2).map(([cle, regle]) => [cle, regle.optionnel]), [['exclu', false], ['etiquettes', true]]);
assert.deepEqual(TABLE.champs.slice(-2).map(([cle, regle]) => [cle, regle.optionnel]), [['position', false], ['objectifs', true]]);
});
test('le document complet se lit tel quel ; il s’écrit dans l’ordre du schéma, listes rangées, et se relit à l’identique', () => {
const { entete, charge } = documentComplet();
assert.deepEqual(analyser(JSON.stringify(documentComplet())), {
entete,
charge,
formatPlusRecent: false,
manquants: { propositions: 0, retenu: false },
});
const texte = serialiser(charge, { revision: 12, produitVersion: VERSION.affichee });
const attendus = [
'"generation":{"nombre":3,"arret":50000,"historique":500},"regleObjectifs":"et","respecterObjectifs":false,"modeObjectifs":"minimum","rencontrerAnimateurs":true}',
'"notes":null,"exclu":false,"etiquettes":["Montréal","finance"]}',
'"exclu":false,"etiquettes":["bénévole"]}',
'"position":{"x":0,"y":0},"objectifs":[{"portee":"tous","tour":null,"regle":null,"etiquettes":["finance"]},{"portee":"tour","tour":2,"regle":"ou","etiquettes":["Montréal","ressources humaines"]}]}',
];
for (const attendu of attendus) assert.ok(texte.includes(attendu), `${attendu}\n${texte}`);
const relue = analyser(texte);
assert.deepEqual(relue.charge, charge);
assert.equal(serialiser(relue.charge, relue.entete), texte);
const avecCible = structuredClone(charge);
avecCible.reglages.cibleRedondance = 900;
assert.ok(
serialiser(avecCible, relue.entete).includes('"historique":500},"cibleRedondance":900,"regleObjectifs":"et",'),
'la cible avant la règle',
);
});
test('des étiquettes hors de l’ordre des unités de code et des objectifs hors de l’ordre soirée puis tours se lisent, et s’écrivent rangés', () => {
const lu = analyser(abimeComplet((d) => {
d.charge.participants[0].etiquettes = ['finance', 'Montréal', 'Été', 'été'];
d.charge.tables[0].objectifs.reverse();
d.charge.tables[0].objectifs[0].etiquettes = ['ressources humaines', 'Montréal'];
}));
const texte = serialiser(lu.charge, { revision: 12, produitVersion: VERSION.affichee });
const relue = analyser(texte).charge;
assert.deepEqual(relue.participants[0].etiquettes, ['Montréal', 'finance', 'Été', 'été']);
assert.deepEqual(relue.tables[0].objectifs, documentComplet().charge.tables[0].objectifs);
});
test('variantes admises : deux orthographes d’une même clé, une étiquette égale à une appartenance, 20 étiquettes de 40 points de code, la règle propre « et », un objectif à chaque tour sans soirée', () => {
const vingt = Array.from({ length: 20 }, (_, rang) => `${String.fromCharCode(65 + rang)}${'x'.repeat(39)}`);
const variantes = [
['Finance et finance', abimeComplet((d) => { d.charge.participants[0].etiquettes = ['Finance', 'finance']; })],
['une étiquette égale à une appartenance', abimeComplet((d) => { d.charge.participants[0].etiquettes = ['Chorale du Vallon']; })],
['20 étiquettes de 40 points de code', abimeComplet((d) => { d.charge.participants[3].etiquettes = vingt; })],
['un objectif de 20 étiquettes', abimeComplet((d) => { d.charge.tables[0].objectifs[0].etiquettes = vingt; })],
['règle propre « et »', abimeComplet((d) => { d.charge.tables[0].objectifs[1].regle = 'et'; })],
['un objectif à chaque tour, aucun de soirée', abimeComplet((d) => {
d.charge.tables[1].objectifs = [
{ portee: 'tour', tour: 1, regle: null, etiquettes: ['finance'] },
{ portee: 'tour', tour: 2, regle: null, etiquettes: ['bénévole'] },
];
})],
['la règle par défaut seule, sans objectif', abime((d) => { d.charge.reglages.regleObjectifs = 'et'; })],
['le mode « chacun » sans objectif', abime((d) => { d.charge.reglages.modeObjectifs = 'chacun'; })],
];
const echecs = variantes.flatMap(([libelle, texte]) => {
try {
analyser(texte);
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.message}`];
}
});
assert.deepEqual(echecs, []);
});
test('un champ écrit à son défaut est une faute de forme, nommée par son chemin : « ou », vrai, « places », faux', () => {
assert.deepEqual(ecartsDeRefus([
['règle par défaut « ou » écrite', abime((d) => { d.charge.reglages.regleObjectifs = 'ou'; }), 'FORME', 'charge.reglages.regleObjectifs'],
['objectifs respectés écrits', abime((d) => { d.charge.reglages.respecterObjectifs = true; }), 'FORME', 'charge.reglages.respecterObjectifs'],
['mode « places » écrit', abime((d) => { d.charge.reglages.modeObjectifs = 'places'; }), 'FORME', 'charge.reglages.modeObjectifs'],
['animateurs décochés écrits', abime((d) => { d.charge.reglages.rencontrerAnimateurs = false; }), 'FORME', 'charge.reglages.rencontrerAnimateurs'],
['étiquettes vides', abime((d) => { d.charge.participants[1].etiquettes = []; }), 'FORME', 'charge.participants[1].etiquettes'],
['objectifs vides', abime((d) => { d.charge.tables[1].objectifs = []; }), 'FORME', 'charge.tables[1].objectifs'],
]), []);
});
test('une valeur hors de son domaine est une faute de forme : un mode inconnu, une règle inconnue, une étiquette mal formée, un doublon exact, 21 étiquettes', () => {
const vingtEtUne = Array.from({ length: 21 }, (_, rang) => `groupe ${rang + 1}`);
const cas = [
['mode inconnu', (d) => { d.charge.reglages.modeObjectifs = 'aleatoire'; }, 'charge.reglages.modeObjectifs'],
['mode en capitales', (d) => { d.charge.reglages.modeObjectifs = 'CHACUN'; }, 'charge.reglages.modeObjectifs'],
['règle par défaut en capitales', (d) => { d.charge.reglages.regleObjectifs = 'ET'; }, 'charge.reglages.regleObjectifs'],
['objectifs respectés en texte', (d) => { d.charge.reglages.respecterObjectifs = 'false'; }, 'charge.reglages.respecterObjectifs'],
['animateurs en texte', (d) => { d.charge.reglages.rencontrerAnimateurs = 'true'; }, 'charge.reglages.rencontrerAnimateurs'],
['une étiquette vide', (d) => { d.charge.participants[0].etiquettes = ['']; }, 'charge.participants[0].etiquettes[0]'],
['une étiquette à blanc en bord', (d) => { d.charge.participants[0].etiquettes = ['finance', ' RH']; }, 'charge.participants[0].etiquettes[1]'],
['deux espaces', (d) => { d.charge.participants[0].etiquettes = ['ressources humaines']; }, 'charge.participants[0].etiquettes[0]'],
['une tabulation', (d) => { d.charge.participants[0].etiquettes = ['ressources\u{0009}humaines']; }, 'charge.participants[0].etiquettes[0]'],
['une fin de ligne', (d) => { d.charge.participants[0].etiquettes = ['a\u{000A}b']; }, 'charge.participants[0].etiquettes[0]'],
['une barre', (d) => { d.charge.participants[0].etiquettes = ['a|b']; }, 'charge.participants[0].etiquettes[0]'],
['une virgule', (d) => { d.charge.participants[0].etiquettes = ['a,b']; }, 'charge.participants[0].etiquettes[0]'],
['un point-virgule', (d) => { d.charge.participants[0].etiquettes = ['a;b']; }, 'charge.participants[0].etiquettes[0]'],
['« é » décomposé', (d) => { d.charge.participants[0].etiquettes = ['Montre\u{0301}al']; }, 'charge.participants[0].etiquettes[0]'],
['41 points de code', (d) => { d.charge.participants[0].etiquettes = ['a'.repeat(41)]; }, 'charge.participants[0].etiquettes[0]'],
['une étiquette numérique', (d) => { d.charge.participants[0].etiquettes = [42]; }, 'charge.participants[0].etiquettes[0]'],
['un doublon exact', (d) => { d.charge.participants[0].etiquettes = ['finance', 'finance']; }, 'charge.participants[0].etiquettes'],
['21 étiquettes', (d) => { d.charge.participants[0].etiquettes = vingtEtUne; }, 'charge.participants[0].etiquettes'],
['une portée inconnue', (d) => { d.charge.tables[0].objectifs[0].portee = 'soiree'; }, 'charge.tables[0].objectifs[0].portee'],
['une règle propre inconnue', (d) => { d.charge.tables[0].objectifs[1].regle = 'peut-etre'; }, 'charge.tables[0].objectifs[1].regle'],
['un tour 0', (d) => { d.charge.tables[0].objectifs[1].tour = 0; }, 'charge.tables[0].objectifs[1].tour'],
['un tour non entier', (d) => { d.charge.tables[0].objectifs[1].tour = 1.5; }, 'charge.tables[0].objectifs[1].tour'],
['un objectif sans étiquette', (d) => { d.charge.tables[0].objectifs[0].etiquettes = []; }, 'charge.tables[0].objectifs[0].etiquettes'],
['un objectif de 21 étiquettes', (d) => { d.charge.tables[0].objectifs[0].etiquettes = vingtEtUne; }, 'charge.tables[0].objectifs[0].etiquettes'],
['un objectif à doublon exact', (d) => { d.charge.tables[0].objectifs[0].etiquettes = ['RH', 'RH']; }, 'charge.tables[0].objectifs[0].etiquettes'],
['un objectif à étiquette mal formée', (d) => { d.charge.tables[0].objectifs[0].etiquettes = ['finance', 'x\u{0007}']; }, 'charge.tables[0].objectifs[0].etiquettes[1]'],
['un objectif sans sa règle', (d) => { delete d.charge.tables[0].objectifs[0].regle; }, 'charge.tables[0].objectifs[0].regle'],
];
assert.deepEqual(ecartsDeRefus(cas.map(([libelle, modifier, chemin]) => [libelle, abimeComplet(modifier), 'FORME', chemin])), []);
});
test('la portée et le tour d’un objectif se répondent, le tour reste dans la soirée, et une table porte au plus un objectif par portée et tour', () => {
assert.deepEqual(ecartsDeRefus([
['portée « tous » avec un tour', abimeComplet((d) => { d.charge.tables[0].objectifs[0].tour = 1; }), 'FORME', 'charge.tables[0].objectifs[0].tour'],
['portée « tour » sans tour', abimeComplet((d) => { d.charge.tables[0].objectifs[1].tour = null; }), 'FORME', 'charge.tables[0].objectifs[1].tour'],
['tour au-delà du nombre de tours', abimeComplet((d) => { d.charge.tables[0].objectifs[1].tour = 3; }), 'FORME', 'charge.tables[0].objectifs[1].tour'],
['deux objectifs de soirée, le second fautif', abimeComplet((d) => {
d.charge.tables[0].objectifs.splice(1, 0, { portee: 'tous', tour: null, regle: 'et', etiquettes: ['RH'] });
}), 'FORME', 'charge.tables[0].objectifs[1]'],
['deux objectifs du tour 2', abimeComplet((d) => {
d.charge.tables[0].objectifs.push({ portee: 'tour', tour: 2, regle: null, etiquettes: ['RH'] });
}), 'FORME', 'charge.tables[0].objectifs[2]'],
['la seconde table fautive après la première admise', abimeComplet((d) => {
d.charge.tables[1].objectifs = [{ portee: 'tour', tour: 5, regle: null, etiquettes: ['RH'] }];
}), 'FORME', 'charge.tables[1].objectifs[0].tour'],
['la forme avant la portée', abimeComplet((d) => {
d.charge.tables[0].objectifs[0].tour = 1;
d.charge.tables[1].objectifs = [{ portee: 'tour', tour: 2, regle: null, etiquettes: [] }];
}), 'FORME', 'charge.tables[1].objectifs[0].etiquettes'],
]), []);
});
test('sur chaque valeur nouvelle : une clé inconnue, une valeur d’une autre sorte, null où le contrat ne l’admet pas, sont nommés par leur chemin', () => {
const etapes = etapesDe(documentComplet()).filter(estDuFormat2);
assert.ok(etapes.length >= 20, `${etapes.length} valeurs seulement`);
const objets = etapes.filter((e) => {
const valeur = valeurEn(documentComplet(), e);
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
});
assert.equal(objets.length, 2);
const cas = [
...objets.map((e) => [
`inconnue sous ${cheminDe(e)}`,
abimeComplet((d) => { valeurEn(d, e).inconnue = 1; }),
'FORME',
cheminDe([...e, 'inconnue']),
]),
...etapes.map((e) => [
`${cheminDe(e)} d'une autre sorte`,
abimeComplet((d) => {
const porteur = valeurEn(d, e.slice(0, -1));
porteur[e.at(-1)] = Array.isArray(porteur[e.at(-1)]) ? {} : [];
}),
'FORME',
cheminDe(e),
]),
...etapes.filter((e) => !ADMETTENT_NULL_FORMAT_2.has(sansRangs(e))).map((e) => [
`${cheminDe(e)} nul`,
abimeComplet((d) => { valeurEn(d, e.slice(0, -1))[e.at(-1)] = null; }),
'FORME',
cheminDe(e),
]),
];
assert.deepEqual(ecartsDeRefus(cas), []);
});
});
describe('un fichier du format 1 se lit comme un fichier du format 2 qui ne porte aucun champ nouveau (§ 8.8, A15)', () => {
test('il se lit, formatPlusRecent faux, sa charge telle qu’écrite ; réécrit, seul l’en-tête change, au format courant', () => {
const formatUn = documentValide();
formatUn.entete.format = 1;
const lu = analyser(JSON.stringify(formatUn));
assert.equal(lu.formatPlusRecent, false);
assert.equal(lu.entete.format, 1);
assert.deepEqual(lu.charge, documentValide().charge);
const reecrit = serialiser(lu.charge, lu.entete);
assert.equal(analyser(reecrit).entete.format, FORMAT);
assert.equal(serialiserCharge(analyser(reecrit).charge), serialiserCharge(lu.charge));
assert.equal(regleObjectifs(lu.charge), 'ou');
assert.equal(modeObjectifs(lu.charge), 'places');
});
test('ses refus gardent leur raison, sans format dans les détails : il n’est pas plus récent', () => {
assert.deepEqual(ecartsDeRefus([
['nom vide', abime((d) => { d.entete.format = 1; d.charge.participants[0].nom = ''; }), 'FORME', 'charge.participants[0].nom'],
['comptes faux', abime((d) => { d.entete.format = 1; d.entete.comptes.tables = 1; }), 'COMPTES', 'entete.comptes.tables'],
['mode « places » écrit', abime((d) => { d.entete.format = 1; d.charge.reglages.modeObjectifs = 'places'; }), 'FORME', 'charge.reglages.modeObjectifs'],
]), []);
});
});
describe('un fichier plus récent porte les champs du format 2 (§ 8.8)', () => {
test('ils se lisent, réduits aux clés connues ; une valeur que le format courant refuse lève FORMAT_PLUS_RECENT', () => {
const lu = analyser(abimeComplet((d) => {
d.entete.format = FORMAT + 1;
d.charge.tables[0].objectifs[0].poids = 3;
d.charge.participants[0].couleurEtiquettes = 'ocre';
}));
assert.equal(lu.formatPlusRecent, true);
assert.deepEqual(lu.charge, documentComplet().charge);
assert.deepEqual(ecartsDeRefus([
['un mode à venir', abimeComplet((d) => {
d.entete.format = FORMAT + 1;
d.charge.reglages.modeObjectifs = 'pondere';
}), 'FORMAT_PLUS_RECENT', 'charge.reglages.modeObjectifs', { format: FORMAT + 1 }],
]), []);
});
});
describe('les lecteurs des réglages facultatifs (§ 8.9)', () => {
test('absents, ils rendent leur défaut : « ou », coché, décoché, « places »', () => {
const { charge } = documentValide();
assert.equal(regleObjectifs(charge), 'ou');
assert.equal(critereCoche(charge, 'respecterObjectifs'), true);
assert.equal(critereCoche(charge, 'rencontrerAnimateurs'), false);
assert.equal(modeObjectifs(charge), 'places');
const neuve = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 });
assert.deepEqual(
[regleObjectifs(neuve), critereCoche(neuve, 'respecterObjectifs'), critereCoche(neuve, 'rencontrerAnimateurs'), modeObjectifs(neuve)],
['ou', true, false, 'places'],
);
});
test('présents, ils rendent la valeur portée', () => {
const { charge } = documentComplet();
assert.equal(regleObjectifs(charge), 'et');
assert.equal(critereCoche(charge, 'respecterObjectifs'), false);
assert.equal(critereCoche(charge, 'rencontrerAnimateurs'), true);
assert.equal(modeObjectifs(charge), 'minimum');
charge.reglages.modeObjectifs = 'chacun';
assert.equal(modeObjectifs(charge), 'chacun');
});
test('critereCoche lit aussi les quatre cases du format 1, et lève RangeError sur une clé qu’il ne connaît pas', () => {
const { charge } = documentValide();
assert.deepEqual(
['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances'].map((cle) => critereCoche(charge, cle)),
[true, false, true, false],
);
for (const cle of ['attribuerSieges', 'generation', 'cibleRedondance', 'modeObjectifs', 'inconnue', '__proto__', 'toString']) {
assert.throws(() => critereCoche(charge, cle), RangeError, cle);
}
});
});
describe('configurationDepuisCharge : étiquettes, objectifs, mode et cases (contrat de données)', () => {
test('sans champ nouveau, la configuration n’en porte aucun', () => {
const configuration = configurationDepuisCharge(documentValide().charge);
assert.deepEqual(Object.keys(configuration).sort(), ['contraintes', 'participants', 'reservations', 'tables', 'tours']);
assert.deepEqual(Object.keys(configuration.contraintes).sort(), ['nouveauxVoisins', 'nouvelleTable', 'separerAppartenances', 'varierAppartenances']);
assert.ok(configuration.participants.every((participant) => !Object.hasOwn(participant, 'etiquettes')));
});
test('chaque champ nouveau se transporte, la règle de chaque objectif résolue ; la table par identifiant, puis la soirée et les tours croissants', () => {
const { charge } = documentComplet();
charge.tables.reverse();
charge.tables[0].objectifs = [{ portee: 'tour', tour: 1, regle: 'et', etiquettes: ['bénévole'] }];
charge.tables[1].objectifs.reverse();
const configuration = configurationDepuisCharge(charge);
assert.deepEqual(configuration.participants.map(({ id, etiquettes }) => [id, etiquettes]), [
[1, ['Montréal', 'finance']],
[2, undefined],
[3, undefined],
[5, ['bénévole']],
]);
assert.deepEqual(configuration.objectifs, [
{ table: 1, tour: null, regle: 'et', etiquettes: ['finance'] },
{ table: 1, tour: 2, regle: 'ou', etiquettes: ['Montréal', 'ressources humaines'] },
{ table: 3, tour: 1, regle: 'et', etiquettes: ['bénévole'] },
]);
assert.equal(configuration.modeObjectifs, 'minimum');
assert.deepEqual(configuration.contraintes, {
separerAppartenances: true,
nouveauxVoisins: false,
nouvelleTable: true,
varierAppartenances: false,
respecterObjectifs: false,
rencontrerAnimateurs: true,
});
});
test('une règle qui suit le défaut prend « ou » tant que le défaut n’est pas réglé ; une règle propre ne change pas avec lui', () => {
const { charge } = documentComplet();
delete charge.reglages.regleObjectifs;
charge.tables[0].objectifs[1].regle = 'et';
assert.deepEqual(configurationDepuisCharge(charge).objectifs.map(({ regle }) => regle), ['ou', 'et']);
charge.reglages.regleObjectifs = 'et';
charge.tables[0].objectifs[1].regle = 'ou';
assert.deepEqual(configurationDepuisCharge(charge).objectifs.map(({ regle }) => regle), ['et', 'ou']);
});
test('chaque champ reste absent à son défaut, un par un', () => {
const cas = [
['mode', (c) => { delete c.reglages.modeObjectifs; }, (k) => !Object.hasOwn(k, 'modeObjectifs')],
['objectifs respectés', (c) => { delete c.reglages.respecterObjectifs; }, (k) => !Object.hasOwn(k.contraintes, 'respecterObjectifs')],
['animateurs décochés', (c) => { delete c.reglages.rencontrerAnimateurs; }, (k) => !Object.hasOwn(k.contraintes, 'rencontrerAnimateurs')],
['aucun objectif', (c) => { delete c.tables[0].objectifs; }, (k) => !Object.hasOwn(k, 'objectifs')],
['aucune étiquette', (c) => { for (const p of c.participants) delete p.etiquettes; }, (k) => k.participants.every((p) => !Object.hasOwn(p, 'etiquettes'))],
];
for (const [libelle, retirer, absent] of cas) {
const { charge } = documentComplet();
retirer(charge);
assert.ok(absent(configurationDepuisCharge(charge)), libelle);
}
});
test('la configuration ne partage aucune liste avec la charge, qui reste intacte', () => {
const charge = geler(documentComplet().charge);
const configuration = configurationDepuisCharge(charge);
configuration.participants[0].etiquettes.push('RH');
configuration.objectifs[0].etiquettes.push('RH');
assert.deepEqual(charge.participants[0].etiquettes, ['Montréal', 'finance']);
assert.deepEqual(charge.tables[0].objectifs[0].etiquettes, ['finance']);
assert.deepEqual(normaliser(configuration).ids, [1, 2, 5]);
});
});

123
src/stockage/etiquettes.js Normal file
View file

@ -0,0 +1,123 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La forme d'une étiquette et la découpe d'une saisie (§ 4.5). C'est le seul
// lieu de cette règle : le schéma du fichier, le CSV, les commandes et
// l'écran l'importent d'ici. L'identité de deux orthographes — la clé
// normalisée — et leur réconciliation vivent dans src/csv/etiquettes.js.
//
// Une étiquette est un texte en NFC, sans blanc en bord, ses blancs
// intérieurs réduits à une espace U+0020, de 1 à ETIQUETTE_LONGUEUR_MAX
// points de code. Elle ne porte aucun séparateur d'une cellule, aucun
// caractère de contrôle ou invisible ni moitié de paire de substitution
// isolée. Un caractère invisible ne se voit pas, ou retourne l'affichage du
// texte qui le suit : gardé, il ferait deux étiquettes d'apparence égale que
// le moteur, qui compare par égalité de chaînes, tiendrait pour deux. Ce sont
// les caractères de contrôle (catégorie Cc, tabulation comprise), de format
// (Cf : trait d'union conditionnel, espace, liant et antiliant sans chasse,
// gluon de mots, enchâssements, forçages et isolats bidirectionnels, U+FEFF)
// et ignorables par défaut (Default_Ignorable_Code_Point, dont remplisseurs
// hangul, lien de graphèmes et sélecteurs de variante), la même famille que
// celle qu'un nom de fichier perd (src/stockage/noms.js). Un blanc est un
// caractère d'espacement Unicode qui n'est pas un caractère de contrôle :
// aucun n'est de format ni ignorable, la tabulation n'en est jamais un, et la
// saisie qui la porte, au bord comme au milieu, est fautive au lieu d'être
// nettoyée.
/** Le plus grand nombre de points de code d'une étiquette (§ 4.5). */
export const ETIQUETTE_LONGUEUR_MAX = 40;
/** Le plus grand nombre d'étiquettes d'une personne ou d'un objectif (§ 4.5). */
export const ETIQUETTES_MAX = 20;
/**
* Les caractères qui séparent deux étiquettes d'une saisie ou d'une cellule
* CSV (§ 4.5, § 10.1) : la virgule, le point-virgule, la barre verticale, et
* les deux caractères d'une fin de ligne, CR et LF.
* @type {ReadonlyArray<string>}
*/
export const SEPARATEURS_ETIQUETTES = Object.freeze([',', ';', '|', '\u{000D}', '\u{000A}']);
// La découpe : une classe de chaque séparateur, chacun écrit par son point
// de code, que le drapeau u admet quel que soit le caractère.
const DECOUPE = new RegExp(
`[${SEPARATEURS_ETIQUETTES.map((separateur) => `\\u{${separateur.codePointAt(0).toString(16)}}`).join('')}]`,
'u',
);
// Une suite de blancs : espacement Unicode hors des caractères de contrôle.
const BLANCS = /(?:(?!\p{Cc})\p{White_Space})+/gu;
// Un caractère de contrôle ou invisible, au sens de l'en-tête.
const CONTROLE = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
// Le texte en NFC, chaque suite de blancs réduite à une espace, sans espace
// en bord.
function mettreEnForme(texte) {
const reduit = texte.normalize('NFC').replace(BLANCS, ' ');
return reduit.slice(reduit.startsWith(' ') ? 1 : 0, reduit.endsWith(' ') ? -1 : undefined);
}
// La raison qui rend fautive une valeur déjà mise en forme et non vide, ou
// null : 'controle' pour un caractère de contrôle ou invisible ou une moitié
// de paire isolée, qui passe avant 'longueur', au-delà
// d'ETIQUETTE_LONGUEUR_MAX points de code.
function raisonDeFaute(valeur) {
if (CONTROLE.test(valeur) || !valeur.isWellFormed()) return 'controle';
if ([...valeur].length > ETIQUETTE_LONGUEUR_MAX) return 'longueur';
return null;
}
/**
* Vrai quand texte a la forme d'une étiquette (§ 4.5) : un texte en NFC,
* déjà mis en forme — ni blanc en bord, ni blanc autre qu'une espace seule
* —, sans séparateur, sans caractère de contrôle ou invisible ni moitié de
* paire isolée, de 1 à ETIQUETTE_LONGUEUR_MAX points de code. Faux pour ce
* qui n'est pas un texte.
*
* @param {unknown} texte
* @returns {boolean}
*/
export function estEtiquetteBienFormee(texte) {
return (
typeof texte === 'string' &&
texte !== '' &&
!DECOUPE.test(texte) &&
mettreEnForme(texte) === texte &&
raisonDeFaute(texte) === null
);
}
/**
* Découpe une saisie en étiquettes (§ 4.5) : coupée à chaque séparateur
* (SEPARATEURS_ETIQUETTES), chaque morceau mis en NFC, ses blancs réduits à
* une espace et ses bords nettoyés ; un morceau vide s'écarte sans faute.
* Rend etiquettes, les morceaux bien formés dans l'ordre de la saisie, un
* doublon exact fondu à sa première place, et fautes, les morceaux fautifs
* mis en forme { valeur, raison } dans le même ordre, chaque valeur une
* fois ; raison vaut 'controle' pour un caractère de contrôle ou invisible ou
* une moitié de paire isolée, 'longueur' au-delà d'ETIQUETTE_LONGUEUR_MAX
* points de code. Le nombre d'étiquettes ne se borne pas ici : l'appelant le
* compare à ETIQUETTES_MAX. Deux orthographes de même clé restent deux
* étiquettes : leur réconciliation appartient à src/csv/etiquettes.js. Chaque
* étiquette rendue satisfait estEtiquetteBienFormee.
*
* @param {string} texte
* @returns {{etiquettes: string[], fautes: Array<{valeur: string, raison: 'controle'|'longueur'}>}}
* @throws {TypeError} quand texte n'est pas un texte
*/
export function lireEtiquettes(texte) {
if (typeof texte !== 'string') {
throw new TypeError(`lireEtiquettes : texte attendu, reçu ${JSON.stringify(texte) ?? String(texte)}`);
}
const etiquettes = [];
const fautes = [];
const vues = new Set();
for (const morceau of texte.split(DECOUPE)) {
const valeur = mettreEnForme(morceau);
if (valeur === '' || vues.has(valeur)) continue;
vues.add(valeur);
const raison = raisonDeFaute(valeur);
if (raison === null) etiquettes.push(valeur);
else fautes.push({ valeur, raison });
}
return { etiquettes, fautes };
}

View file

@ -0,0 +1,762 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la forme d'une étiquette et de la découpe d'une saisie
// (§ 4.5) : les bornes, les séparateurs, la mise en forme — NFC, blancs —,
// les fautes nommées par leur raison, et la garde de la règle unique : hors
// de src/stockage/etiquettes.js, aucun module ne définit les séparateurs ni
// la longueur, et aucun ne découpe une liste d'étiquettes. Les étiquettes
// d'épreuve sont des mots génériques ou inventés.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import {
ETIQUETTES_MAX,
ETIQUETTE_LONGUEUR_MAX,
SEPARATEURS_ETIQUETTES,
estEtiquetteBienFormee,
lireEtiquettes,
} from './etiquettes.js';
// Un texte de n points de code, chacun la lettre donnée.
const repete = (lettre, n) => lettre.repeat(n);
// Une lettre hors du plan de base : deux unités UTF-16, un point de code.
const ASTRALE = '\u{1D538}';
describe('les bornes et les séparateurs (§ 4.5)', () => {
test('40 points de code, 20 étiquettes ; les séparateurs sont la virgule, le point-virgule, la barre et les fins de ligne', () => {
assert.equal(ETIQUETTE_LONGUEUR_MAX, 40);
assert.equal(ETIQUETTES_MAX, 20);
assert.deepEqual(SEPARATEURS_ETIQUETTES, [',', ';', '|', '\u{000D}', '\u{000A}']);
assert.ok(Object.isFrozen(SEPARATEURS_ETIQUETTES));
});
});
describe('lireEtiquettes : la découpe et la mise en forme', () => {
test('chaque séparateur coupe, dans l’ordre de la saisie ; « a|b » saisi d’un bloc fait deux étiquettes', () => {
assert.deepEqual(lireEtiquettes('finance, ressources humaines; Montréal|bénévole'), {
etiquettes: ['finance', 'ressources humaines', 'Montréal', 'bénévole'],
fautes: [],
});
assert.deepEqual(lireEtiquettes('a|b').etiquettes, ['a', 'b']);
assert.deepEqual(lireEtiquettes('lune\u{000D}\u{000A}soleil\u{000A}étoile\u{000D}comète').etiquettes, [
'lune',
'soleil',
'étoile',
'comète',
]);
});
test('bords nettoyés, blancs intérieurs réduits à une espace, insécables et cadratins compris', () => {
assert.deepEqual(lireEtiquettes(' ressources humaines ').etiquettes, ['ressources humaines']);
assert.deepEqual(lireEtiquettes('\u{00A0}ressources\u{2003}\u{00A0}humaines\u{202F}').etiquettes, [
'ressources humaines',
]);
assert.deepEqual(lireEtiquettes('\u{3000}grand\u{2028}salon\u{0020}').etiquettes, ['grand salon']);
});
test('la saisie se met en NFC : « é » composé et décomposé donnent la même étiquette, fondue', () => {
const decompose = 'Montre\u{0301}al';
const compose = 'Montr\u{00E9}al';
assert.deepEqual(lireEtiquettes(decompose).etiquettes, [compose]);
assert.deepEqual(lireEtiquettes(`${decompose}, ${compose}`).etiquettes, [compose]);
});
test('les morceaux vides s’écartent sans faute : une saisie vide, ou de séparateurs et de blancs seuls, ne donne rien', () => {
for (const saisie of ['', ' ', ',', ',,; ;| |', '\u{000A}\u{000D}\u{000A}', '\u{00A0},\u{2003}']) {
assert.deepEqual(lireEtiquettes(saisie), { etiquettes: [], fautes: [] }, JSON.stringify(saisie));
}
});
test('un doublon exact se fond à sa première place ; deux casses différentes restent deux étiquettes, la réconciliation est ailleurs', () => {
assert.deepEqual(lireEtiquettes('finance, Montréal, finance,finance').etiquettes, ['finance', 'Montréal']);
assert.deepEqual(lireEtiquettes('Finance, finance, FINANCE ').etiquettes, ['Finance', 'finance', 'FINANCE']);
});
test('40 points de code se lisent ; 41 font une faute « longueur » ; une lettre hors du plan de base compte pour un', () => {
const quarante = repete('a', 40);
const quaranteEtUn = repete('a', 41);
const astrales = repete(ASTRALE, 40);
assert.equal(astrales.length, 80);
assert.deepEqual(lireEtiquettes(`${quarante}, ${astrales}`), { etiquettes: [quarante, astrales], fautes: [] });
assert.deepEqual(lireEtiquettes(`finance, ${quaranteEtUn}`), {
etiquettes: ['finance'],
fautes: [{ valeur: quaranteEtUn, raison: 'longueur' }],
});
assert.deepEqual(lireEtiquettes(repete(ASTRALE, 41)).fautes, [{ valeur: repete(ASTRALE, 41), raison: 'longueur' }]);
// La longueur se compte après la mise en forme : des blancs réduits ne
// comptent plus.
assert.deepEqual(lireEtiquettes(` ${repete('b', 20)} ${repete('c', 19)} `).fautes, []);
});
test('une tabulation, au milieu comme au bord, fait une faute « contrôle » : un caractère de contrôle n’est jamais un blanc', () => {
assert.deepEqual(lireEtiquettes('ressources\u{0009}humaines'), {
etiquettes: [],
fautes: [{ valeur: 'ressources\u{0009}humaines', raison: 'controle' }],
});
assert.deepEqual(lireEtiquettes('\u{0009}finance, Montréal').fautes, [
{ valeur: '\u{0009}finance', raison: 'controle' },
]);
assert.deepEqual(lireEtiquettes('finance\u{0009}').fautes, [{ valeur: 'finance\u{0009}', raison: 'controle' }]);
});
test('tout caractère de contrôle, C0, DEL ou C1, et une moitié de paire isolée font une faute « contrôle »', () => {
const fautifs = ['a\u{0000}b', 'a\u{0007}b', 'a\u{001B}b', 'a\u{007F}b', 'a\u{0085}b', 'a\u{009F}b', 'a\u{000B}b', 'a\u{000C}b', 'a\u{D800}b', 'a\u{DFFF}'];
for (const valeur of fautifs) {
assert.deepEqual(lireEtiquettes(valeur), { etiquettes: [], fautes: [{ valeur, raison: 'controle' }] }, JSON.stringify(valeur));
}
});
test('un caractère invisible — format (Cf) ou ignorable par défaut — fait une faute « contrôle », au milieu comme au bord : un trait d’union conditionnel dans « finance » ne se voit pas', () => {
const invisibles = [
'\u{00AD}', // trait d'union conditionnel
'\u{200B}', // espace sans chasse
'\u{200C}', // antiliant sans chasse
'\u{200D}', // liant sans chasse
'\u{2060}', // gluon de mots
'\u{FEFF}', // gluon sans chasse, indicateur d'ordre des octets
'\u{202A}', // enchâssement de gauche à droite
'\u{202E}', // forçage de droite à gauche
'\u{2066}', // isolat de gauche à droite
'\u{2069}', // fin d'isolat
'\u{061C}', // marque de lettre arabe
'\u{180E}', // séparateur de voyelle mongol
'\u{115F}', // remplisseur de début hangul, ignorable hors de Cf
'\u{034F}', // lien de graphèmes, ignorable hors de Cf
'\u{FE0F}', // sélecteur de variante, ignorable hors de Cf
'\u{E0061}', // étiquette Unicode, hors du plan de base
];
for (const invisible of invisibles) {
for (const valeur of [`fin${invisible}ance`, `${invisible}finance`, `finance${invisible}`]) {
assert.deepEqual(lireEtiquettes(valeur), { etiquettes: [], fautes: [{ valeur, raison: 'controle' }] }, JSON.stringify(valeur));
assert.equal(estEtiquetteBienFormee(valeur), false, JSON.stringify(valeur));
}
}
// Les blancs restent des blancs : aucun n'est invisible au sens de la
// règle, et chacun se réduit à une espace.
assert.deepEqual(lireEtiquettes('grand\u{00A0}\u{2007}\u{205F}\u{3000}salon').etiquettes, ['grand salon']);
});
test('un caractère de contrôle l’emporte sur la longueur ; une même faute répétée se nomme une fois, à sa première place', () => {
const longueEtControle = `${repete('a', 41)}\u{0007}`;
assert.deepEqual(lireEtiquettes(longueEtControle).fautes, [{ valeur: longueEtControle, raison: 'controle' }]);
const longue = repete('z', 45);
assert.deepEqual(lireEtiquettes(`a\u{0007}, ${longue}, finance, a\u{0007}, ${longue}`), {
etiquettes: ['finance'],
fautes: [
{ valeur: 'a\u{0007}', raison: 'controle' },
{ valeur: longue, raison: 'longueur' },
],
});
});
test('le nombre d’étiquettes ne se borne pas ici : 21 étiquettes distinctes se lisent toutes, l’appelant compare à ETIQUETTES_MAX', () => {
const saisie = Array.from({ length: 21 }, (_, rang) => `groupe ${rang + 1}`).join(', ');
assert.equal(lireEtiquettes(saisie).etiquettes.length, 21);
});
test('chaque étiquette rendue est bien formée, et une étiquette bien formée se relit seule et telle quelle', () => {
const saisies = [
' Finance ,finance; RH| Montre\u{0301}al ',
`\u{00A0}${repete('é', 40)}\u{00A0}`,
`${repete(ASTRALE, 39)} x`,
'a b , c\u{2003}\u{2003}d',
];
for (const saisie of saisies) {
for (const motCle of lireEtiquettes(saisie).etiquettes) {
assert.equal(estEtiquetteBienFormee(motCle), true, JSON.stringify(motCle));
assert.deepEqual(lireEtiquettes(motCle), { etiquettes: [motCle], fautes: [] }, JSON.stringify(motCle));
}
}
});
test('une saisie qui n’est pas un texte lève TypeError', () => {
for (const valeur of [null, undefined, 12, ['finance']]) {
assert.throws(() => lireEtiquettes(valeur), TypeError, String(valeur));
}
});
});
describe('estEtiquetteBienFormee', () => {
test('admet un texte en NFC, sans blanc en bord, ses blancs réduits à une espace, de 1 à 40 points de code', () => {
const admises = ['a', 'finance', 'ressources humaines', 'Montréal', repete('a', 40), repete(ASTRALE, 40), 'été 2031', '=SOMME(A1)'];
for (const valeur of admises) assert.equal(estEtiquetteBienFormee(valeur), true, JSON.stringify(valeur));
});
test('refuse le vide, les bords, les blancs doubles ou insécables, le texte décomposé, 41 points de code, un séparateur, un caractère de contrôle, une moitié de paire et ce qui n’est pas un texte', () => {
const refusees = [
'',
' finance',
'finance ',
'ressources humaines',
'ressources\u{00A0}humaines',
'ressources\u{2003}humaines',
'Montre\u{0301}al',
repete('a', 41),
'a,b',
'a;b',
'a|b',
'a\u{000A}b',
'a\u{000D}b',
'a\u{0009}b',
'a\u{0085}b',
'fin\u{00AD}ance',
'fin\u{200B}ance',
'\u{202E}gauche',
'\u{2066}finance\u{2069}',
'a\u{D800}',
null,
undefined,
40,
['finance'],
];
for (const valeur of refusees) assert.equal(estEtiquetteBienFormee(valeur), false, JSON.stringify(valeur));
});
});
// --- La garde de la règle unique (contrainte 34) ----------------------------
// Le module qui seul définit la forme et la découpe, relatif à la racine.
const MODULE_DE_LA_REGLE = 'src/stockage/etiquettes.js';
// Les deux constantes de la règle, et les définitions qui les visent, lues
// sur le texte entier : une déclaration ; un nom donné à l'export ou à
// l'import ; une affectation, au nom seul ou à une propriété ; une clé d'objet
// littéral ; une déstructuration, sur une ou plusieurs lignes, qui lie le nom.
// L'indice du groupe nom est celui de la ligne relevée.
const NOM = '(?<nom>SEPARATEURS_ETIQUETTES|ETIQUETTE_LONGUEUR_MAX)\\b';
const DEFINITIONS = [
new RegExp(`\\b(?:const|let|var|function|class)\\s+${NOM}`, 'g'),
new RegExp(`\\bas\\s+${NOM}`, 'g'),
new RegExp(`(?<![\\w$])${NOM}\\s*=(?![=>])`, 'g'),
new RegExp(`(?<=[{,]\\s*)${NOM}\\s*:(?!:)`, 'g'),
new RegExp(`[{[][^{}\\[\\]();=]*?(?<![\\w$])${NOM}[^{}\\[\\]();=]*[}\\]]\\s*=(?![=>])`, 'g'),
];
// Le seul emploi admis de la liste des séparateurs hors du module de la
// règle : son nom seul, sans alias, dans la clause { … } d'un import, à côté
// ou non d'un import par défaut. Tout autre mot SEPARATEURS_ETIQUETTES du
// code — propriété, substitution d'un gabarit, alias, réexportation — sert à
// découper hors de lireEtiquettes ou y prépare.
const CLAUSE_D_IMPORT = /\bimport\s+(?:[\p{L}\p{N}_$]+\s*,\s*)?\{([^}]*)\}\s*from\b/gu;
const SEPARATEURS = 'SEPARATEURS_ETIQUETTES';
// Les indices, dans texte, des noms SEPARATEURS_ETIQUETTES que
// CLAUSE_D_IMPORT admet : chaque membre de la clause qui, blancs retirés, se
// réduit au nom.
function importsAdmis(texte) {
const admis = new Set();
for (const clause of texte.matchAll(CLAUSE_D_IMPORT)) {
let indice = clause.index + clause[0].indexOf('{') + 1;
for (const membre of clause[1].split(',')) {
if (membre.trim() === SEPARATEURS) admis.add(indice + membre.indexOf(SEPARATEURS));
indice += membre.length + 1;
}
}
return admis;
}
// Les séparateurs d'une cellule (§ 10.1), sans les fins de ligne : une
// expression qui coupe aux fins de ligne seules découpe des lignes, non une
// liste d'étiquettes.
const SEPARATEURS_DE_CELLULE = Object.freeze([',', ';', '|']);
// Le caractère que désigne une séquence d'échappement d'une source
// d'expression, \xHH, \uHHHH ou \u{H…}, ou le caractère échappé lui-même ;
// null pour une classe (\s, \d, \w et leurs contraires), une ancre (\b, \B)
// ou une référence. Rend { car, fin }, fin l'indice qui suit la séquence.
function echappement(source, debut) {
const suite = source.slice(debut + 1);
const code = /^(?:x([0-9a-fA-F]{2})|u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4}))/.exec(suite);
if (code !== null) {
return { car: String.fromCodePoint(Number.parseInt(code[1] ?? code[2] ?? code[3], 16)), fin: debut + 1 + code[0].length };
}
const lettre = suite.charAt(0);
if (lettre === '') return { car: null, fin: debut + 1 };
if (/[sSdDwWbBkpPc0-9]/.test(lettre)) return { car: null, blanc: lettre === 's', fin: debut + 2 };
return { car: lettre, fin: debut + 2 };
}
// Les séparateurs d'une cellule qu'une classe [ … ] admet, négation ou non,
// intervalles compris : { ensemble, fin }, fin l'indice qui suit le « ] ».
function classeDe(source, debut) {
const ensemble = new Set();
const caracteres = [];
let i = debut + 1;
if (source[i] === '^') i += 1;
while (i < source.length && source[i] !== ']') {
if (source[i] === '\\') {
const { car, fin } = echappement(source, i);
caracteres.push(car);
i = fin;
} else {
caracteres.push(String.fromCodePoint(source.codePointAt(i)));
i += caracteres.at(-1).length;
}
}
caracteres.forEach((car, rang) => {
if (car !== null && SEPARATEURS_DE_CELLULE.includes(car)) ensemble.add(car);
const fin = caracteres[rang + 2];
if (car !== null && caracteres[rang + 1] === '-' && fin !== null && fin !== undefined) {
for (const separateur of SEPARATEURS_DE_CELLULE) {
if (car.codePointAt(0) <= separateur.codePointAt(0) && separateur.codePointAt(0) <= fin.codePointAt(0)) ensemble.add(separateur);
}
}
});
return { ensemble, fin: i + 1 };
}
// Vrai quand une source d'expression régulière découpe aux séparateurs d'une
// cellule : une classe en admet au moins deux, ou une alternance — au
// premier niveau ou dans un groupe — en offre au moins deux par des branches
// qui ne portent, blancs et ancres mis à part, qu'un séparateur, une classe
// ou un groupe de séparateurs. Une source mal formée se lit sans lever.
function decoupeAuxSeparateurs(source) {
let i = 0;
let releve = false;
const alternance = () => {
const branches = [[]];
while (i < source.length && source[i] !== ')') {
const c = source[i];
let atome;
if (c === '|') {
branches.push([]);
i += 1;
continue;
} else if (c === '\\') {
const { car, blanc, fin } = echappement(source, i);
atome = blanc ? { sorte: 'neutre' } : { sorte: 'separateurs', ensemble: new Set(SEPARATEURS_DE_CELLULE.includes(car) ? [car] : []) };
if (car === null && !blanc) atome = { sorte: 'autre' };
i = fin;
} else if (c === '[') {
const { ensemble, fin } = classeDe(source, i);
if (ensemble.size >= 2) releve = true;
atome = { sorte: 'separateurs', ensemble };
i = fin;
} else if (c === '(') {
i += 1;
const prefixe = /^\?(?::|=|!|<=|<!|<[^>]*>)/.exec(source.slice(i));
if (prefixe !== null) i += prefixe[0].length;
atome = { sorte: 'separateurs', ensemble: alternance() };
i += 1;
} else {
atome = c === ' ' || c === '^' || c === '$' ? { sorte: 'neutre' } : { sorte: 'separateurs', ensemble: new Set(SEPARATEURS_DE_CELLULE.includes(c) ? [c] : []) };
if (c === '.') atome = { sorte: 'autre' };
i += String.fromCodePoint(source.codePointAt(i)).length;
}
const quantificateur = /^(?:[*+?]|\{\d+(?:,\d*)?\})\??/.exec(source.slice(i));
if (quantificateur !== null) i += quantificateur[0].length;
branches.at(-1).push(atome);
}
const offerts = new Set();
for (const branche of branches) {
const atomes = branche.filter(({ sorte }) => sorte !== 'neutre');
if (atomes.length === 1 && atomes[0].sorte === 'separateurs') for (const s of atomes[0].ensemble) offerts.add(s);
}
if (offerts.size >= 2) releve = true;
return offerts;
};
while (i < source.length) {
alternance();
i += 1;
}
return releve;
}
// La valeur d'un texte littéral JavaScript, échappements résolus, telle
// qu'une source d'expression la reçoit de RegExp.
function valeurDuTexte(brut) {
return brut.replace(/\\(?:x([0-9a-fA-F]{2})|u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|\r?\n|([\s\S]))/g, (_, x, u1, u2, autre) => {
if (x ?? u1 ?? u2) return String.fromCodePoint(Number.parseInt(x ?? u1 ?? u2, 16));
if (autre === undefined) return '';
return { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }[autre] ?? autre;
});
}
// Les mots après lesquels une barre oblique ouvre une expression régulière
// et non une division.
const MOTS_AVANT_EXPRESSION = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']);
// Lit le code JavaScript de texte entre debut et fin et range dans sortie
// chaque littéral { indice, source, texte } — une expression régulière, sa
// source ; un texte, sa valeur, et un gabarit, la valeur de ses morceaux mis
// bout à bout, ses substitutions retirées, chacun avec texte vrai — et
// chaque mot { indice, mot }, identifiant, mot réservé ou nombre. Les
// lexèmes des substitutions se rangent aussi. Les commentaires sont sautés.
// Une barre oblique ouvre une expression au début, après un mot de
// MOTS_AVANT_EXPRESSION ou une ponctuation autre que « ) » et « ] », et une
// division après une valeur ; une expression qui ne se ferme pas sur sa ligne
// n'en est pas une. S'arrête à la première accolade fermante sans ouvrante, et
// rend son indice, ou fin.
function lireLexemes(texte, debut, fin, sortie) {
let i = debut;
let regexPossible = true;
let profondeur = 0;
while (i < fin) {
const c = texte[i];
if (c === '/' && texte[i + 1] === '/') {
const ligne = texte.indexOf('\n', i);
i = ligne < 0 ? fin : ligne;
} else if (c === '/' && texte[i + 1] === '*') {
const ferme = texte.indexOf('*/', i + 2);
i = ferme < 0 ? fin : ferme + 2;
} else if (/\s/.test(c)) {
i += 1;
} else if (c === "'" || c === '"') {
let j = i + 1;
while (j < fin && texte[j] !== c && texte[j] !== '\n') j += texte[j] === '\\' ? 2 : 1;
sortie.push({ indice: i, source: valeurDuTexte(texte.slice(i + 1, j)), texte: true });
i = j + 1;
regexPossible = false;
} else if (c === '`') {
let j = i + 1;
let morceau = j;
let brut = '';
while (j < fin && texte[j] !== '`') {
if (texte[j] === '\\') {
j += 2;
} else if (texte[j] === '$' && texte[j + 1] === '{') {
brut += texte.slice(morceau, j);
j = lireLexemes(texte, j + 2, fin, sortie) + 1;
morceau = j;
} else {
j += 1;
}
}
sortie.push({ indice: i, source: valeurDuTexte(brut + texte.slice(morceau, j)), texte: true });
i = j + 1;
regexPossible = false;
} else if (c === '/' && regexPossible) {
let j = i + 1;
let dansClasse = false;
while (j < fin && texte[j] !== '\n' && (dansClasse || texte[j] !== '/')) {
if (texte[j] === '\\') j += 1;
else if (texte[j] === '[') dansClasse = true;
else if (texte[j] === ']') dansClasse = false;
j += 1;
}
if (texte[j] === '/') {
sortie.push({ indice: i, source: texte.slice(i + 1, j) });
i = j + 1;
while (/[a-z]/.test(texte[i] ?? '')) i += 1;
regexPossible = false;
} else {
i += 1;
}
} else if (/[\p{L}\p{N}_$]/u.test(c)) {
const mot = /^[\p{L}\p{N}_$]+/u.exec(texte.slice(i, Math.min(fin, i + 200)))[0];
sortie.push({ indice: i, mot });
i += mot.length;
regexPossible = MOTS_AVANT_EXPRESSION.has(mot);
} else if (c === '}' && profondeur === 0) {
return i;
} else {
if (c === '{') profondeur += 1;
if (c === '}') profondeur -= 1;
regexPossible = c !== ')' && c !== ']';
i += 1;
}
}
return fin;
}
// Les lexèmes du code d'une source : d'un module, son texte entier ; d'un
// composant Svelte, ses blocs de script et les expressions { … } de son
// gabarit — un bloc {#each …}, {:else …}, {@html …} se lit après son mot
// —, le texte du balisage, les commentaires HTML et la feuille de style
// exclus.
function lexemesDe(chemin, texte) {
const sortie = [];
if (!chemin.endsWith('.svelte')) {
for (let i = 0; i < texte.length; i = lireLexemes(texte, i, texte.length, sortie) + 1);
return sortie;
}
let i = 0;
while (i < texte.length) {
const ouverture = /^<(script|style)\b[^>]*>/.exec(texte.slice(i, i + 200));
if (texte.startsWith('<!--', i)) {
const ferme = texte.indexOf('-->', i);
i = ferme < 0 ? texte.length : ferme + 3;
} else if (ouverture !== null) {
const debut = i + ouverture[0].length;
const ferme = texte.indexOf(`</${ouverture[1]}`, debut);
const fin = ferme < 0 ? texte.length : ferme;
if (ouverture[1] === 'script') for (let j = debut; j < fin; j = lireLexemes(texte, j, fin, sortie) + 1);
i = fin + 1;
} else if (texte[i] === '{') {
const mot = /^\{\s*[#:@/][a-z]*/.exec(texte.slice(i, i + 40));
i = lireLexemes(texte, i + (mot === null ? 1 : mot[0].length), texte.length, sortie) + 1;
} else {
i += 1;
}
}
return sortie;
}
// Vrai quand un texte peut servir de source à RegExp, avec ou sans le
// drapeau u. Un texte qui ne le peut pas — un jeu de caractères où un « ( »
// ou un « [ » reste ouvert — ne découpe rien.
function estSourceDExpression(source) {
for (const drapeaux of ['', 'u']) {
try {
new RegExp(source, drapeaux);
return true;
} catch {
// Essai suivant.
}
}
return false;
}
// Les littéraux qui coupent aux séparateurs d'une cellule pour lire autre
// chose qu'une liste d'étiquettes, chacun par son module et sa source exacte :
// une source changée n'y répond plus et se relit.
const DECOUPES_ADMISES = Object.freeze([
{
chemin: 'src/polices/chargement.js',
source: '^data:[^,;]*(?:;[^,;=]+=[^,;]*)*;base64,([A-Za-z0-9+/]*={0,2})$',
raison: 'la syntaxe d’une URL data:, où « , » et « ; » séparent type, paramètres et données',
},
]);
/**
* Les écarts à la règle unique dans des sources { chemin, texte } — modules
* JavaScript et composants Svelte : pour chaque source autre que le module
* de la règle, chaque définition des constantes, chaque emploi de
* SEPARATEURS_ETIQUETTES dans le code hors de ce qu'importsAdmis admet, et
* chaque littéral — expression régulière, ou texte et gabarit qui peuvent
* servir de source à RegExp, quel que soit l'appel qui l'emploie — dont la
* source découpe aux séparateurs d'une cellule (decoupeAuxSeparateurs), hors
* de DECOUPES_ADMISES, « chemin : ligne n », une fois par ligne, triés.
*
* La garde lit des lexèmes, sans analyse du programme, et ne voit pas :
* une expression régulière qui ouvre une instruction après « ) », comme
* `if (ok) /[,;]/.test(x)`, que la lecture prend pour une division — à
* raison dans le cas général, où « ) » clôt une valeur ; des découpes
* enchaînées d'un séparateur chacune, dont aucun littéral n'en porte deux ;
* la liste lue par une clé entre crochets, dont le nom est un texte.
*
* @param {Array<{chemin: string, texte: string}>} sources
* @returns {string[]}
*/
function ecartsALaRegleUnique(sources) {
const ecarts = new Set();
for (const { chemin, texte } of sources) {
if (chemin === MODULE_DE_LA_REGLE) continue;
const ligneDe = (indice) => texte.slice(0, indice).split('\n').length;
const relever = (indice) => ecarts.add(`${chemin} : ligne ${ligneDe(indice)}`);
for (const motif of DEFINITIONS) {
for (const trouve of texte.matchAll(motif)) relever(trouve.index + trouve[0].lastIndexOf(trouve.groups.nom));
}
const admis = importsAdmis(texte);
for (const lexeme of lexemesDe(chemin, texte)) {
if (lexeme.mot !== undefined) {
if (lexeme.mot === SEPARATEURS && !admis.has(lexeme.indice)) relever(lexeme.indice);
continue;
}
if (lexeme.texte && !estSourceDExpression(lexeme.source)) continue;
if (DECOUPES_ADMISES.some((admise) => admise.chemin === chemin && admise.source === lexeme.source)) continue;
if (decoupeAuxSeparateurs(lexeme.source)) relever(lexeme.indice);
}
}
return [...ecarts].sort();
}
// Les sources du projet que la garde balaie : sous src et scripts, chaque
// module JavaScript qui n'est pas une épreuve et chaque composant Svelte,
// triés.
function modulesDuProjet() {
const racine = fileURLToPath(new URL('../..', import.meta.url));
return ['src', 'scripts']
.flatMap((dossier) =>
readdirSync(join(racine, dossier), { recursive: true }).map((nom) => `${dossier}/${String(nom).replaceAll('\\', '/')}`),
)
.filter((chemin) => (chemin.endsWith('.js') && !chemin.endsWith('.test.js')) || chemin.endsWith('.svelte'))
.sort()
.map((chemin) => ({ chemin, texte: readFileSync(join(racine, chemin), 'utf8') }));
}
describe('une seule règle d’étiquette (contrainte 34)', () => {
test('hors de src/stockage/etiquettes.js, aucun module ne définit les séparateurs ni la longueur, ni ne découpe une liste d’étiquettes', () => {
const modules = modulesDuProjet();
assert.ok(modules.length > 100, `${modules.length} modules seulement`);
assert.ok(modules.some(({ chemin }) => chemin === MODULE_DE_LA_REGLE), 'le module de la règle manque au balayage');
assert.ok(modules.some(({ chemin }) => chemin === 'src/interface/App.svelte'), 'les composants manquent au balayage');
assert.deepEqual(ecartsALaRegleUnique(modules), []);
// Chaque découpe admise désigne un littéral qui existe encore.
for (const { chemin, source } of DECOUPES_ADMISES) {
const module = modules.find((candidat) => candidat.chemin === chemin);
assert.ok(module !== undefined && lexemesDe(chemin, module.texte).some((litteral) => litteral.source === source), `${chemin} : ${source}`);
assert.deepEqual(ecartsALaRegleUnique([{ chemin: 'src/autre.js', texte: `const x = /${source}/;\n` }]), ['src/autre.js : ligne 1'], source);
}
});
test('la garde relève une copie inventée : une constante redéfinie, renommée à l’import, une découpe par la classe des séparateurs ou par la constante', () => {
const inventees = [
{ chemin: 'src/csv/inventee.js', texte: "// en tête\nexport const SEPARATEURS_ETIQUETTES = [',', ';'];\n" },
{ chemin: 'src/application/inventee.js', texte: 'let ETIQUETTE_LONGUEUR_MAX = 41;\n' },
{ chemin: 'src/interface/inventee.js', texte: "import { LIMITE as ETIQUETTE_LONGUEUR_MAX } from './ailleurs.js';\n" },
{ chemin: 'src/csv/decoupe.js', texte: 'const morceaux = cellule.split(/[,;|]/);\n' },
{ chemin: 'scripts/decoupe.js', texte: "x\ny\nconst morceaux = saisie.split(new RegExp(`[${SEPARATEURS_ETIQUETTES.join('')}]`));\n" },
{ chemin: 'src/csv/expression.js', texte: "const coupe = new RegExp('[;|]');\nconst autre = texte.split(/\\s*[;,]\\s*/);\n" },
];
assert.deepEqual(ecartsALaRegleUnique(inventees), [
'scripts/decoupe.js : ligne 3',
'src/application/inventee.js : ligne 1',
'src/csv/decoupe.js : ligne 1',
'src/csv/expression.js : ligne 1',
'src/csv/expression.js : ligne 2',
'src/csv/inventee.js : ligne 2',
'src/interface/inventee.js : ligne 1',
]);
});
test('la garde relève une découpe rangée dans une constante, une alternance sans classe, un séparateur échappé, une redéfinition par déstructuration et une découpe dans un composant', () => {
const inventees = [
// L'expression rangée dans une constante, puis employée plus loin.
{ chemin: 'src/csv/constante.js', texte: 'const COUPURES = /[,;|]/u;\nconst morceaux = cellule.split(COUPURES);\n' },
// Une alternance sans classe, au premier niveau ou dans un groupe.
{ chemin: 'src/csv/alternance.js', texte: "const a = cellule.split(/,|;|\\|/);\nconst b = saisie.replace(/\\s*(?:;|,)\\s*/g, '·');\n" },
// Une source rangée dans un texte, passée à RegExp par son nom.
{ chemin: 'src/application/motif.js', texte: "const MOTIF = '[,|]';\n\nconst coupe = new RegExp(MOTIF, 'u');\n" },
// Des séparateurs écrits par leur point de code, ou pris dans un
// intervalle.
{ chemin: 'src/csv/echappes.js', texte: 'const x = /[\\x2c\\u{3b}]/u;\nconst y = /[+-;]/;\nconst z = texte.match(/[^\\u002c|]+/g);\n' },
// Les constantes de la règle redéfinies par déstructuration.
{ chemin: 'src/application/destructure.js', texte: 'const { SEPARATEURS_ETIQUETTES } = autre;\nconst {\n premier,\n ETIQUETTE_LONGUEUR_MAX,\n} = autre;\n' },
// Un composant : dans son bloc de script, puis dans une expression du
// gabarit ; le texte du balisage n'est pas du code.
{
chemin: 'src/interface/fiche/Inventee.svelte',
texte: [
'<script>',
" let { saisie } = $props();",
' const morceaux = $derived(saisie.split(/[;|]/));',
'</script>',
"<p>Séparez les mots par [, ; |] : l'écran les lit.</p>",
'{#each saisie.split(/,|;/) as motCle}<span>{motCle}</span>{/each}',
'',
].join('\n'),
},
];
assert.deepEqual(ecartsALaRegleUnique(inventees), [
'src/application/destructure.js : ligne 1',
'src/application/destructure.js : ligne 4',
'src/application/motif.js : ligne 1',
'src/csv/alternance.js : ligne 1',
'src/csv/alternance.js : ligne 2',
'src/csv/constante.js : ligne 1',
'src/csv/echappes.js : ligne 1',
'src/csv/echappes.js : ligne 2',
'src/csv/echappes.js : ligne 3',
'src/interface/fiche/Inventee.svelte : ligne 3',
'src/interface/fiche/Inventee.svelte : ligne 6',
]);
// Ne sont pas des écarts : une division, un commentaire ou un texte qui
// citent les séparateurs, une classe d'un seul séparateur, une alternance
// dont les branches ne sont pas des séparateurs seuls, le texte d'un
// composant hors de ses expressions.
assert.deepEqual(
ecartsALaRegleUnique([
{
chemin: 'src/interface/mesure.js',
texte: [
'const r = [a / 2, b / 3];',
'// cellule.split(/[,;|]/) découperait ici',
"const phrase = 'Séparez par une virgule, un point-virgule ; ou une barre |';",
'const x = texte.split(/[,.]/);',
'const y = /a,|;b/.test(texte);',
"const z = `${r[0] / 2}, ${r[1] / 3}`;",
].join('\n'),
},
{ chemin: 'src/interface/Aide.svelte', texte: "<p>Les mots se séparent par [, ; |], l'écran les découpe.</p>\n" },
]),
[],
);
// Le module de la règle y échappe seul ; une découpe sur un autre
// séparateur, ou l'import du nom, ne sont pas des écarts.
assert.deepEqual(
ecartsALaRegleUnique([
{ chemin: MODULE_DE_LA_REGLE, texte: "export const SEPARATEURS_ETIQUETTES = [',', ';'];\n" },
{ chemin: 'src/interface/palette.js', texte: "texte.split(';');\ntexte.split(',');\nchemin.split('/');\n" },
{ chemin: 'src/csv/import.js', texte: "import { SEPARATEURS_ETIQUETTES, lireEtiquettes } from '../stockage/etiquettes.js';\n" },
]),
[],
);
});
test('la garde relève tout emploi des séparateurs de la règle hors d’une clause d’import sans alias', () => {
const IMPORT = "from '../stockage/etiquettes.js';";
const inventees = [
// Une source bâtie de la liste dans un gabarit, passée à RegExp
// plus loin.
{
chemin: 'src/csv/gabarit.js',
texte: `import { SEPARATEURS_ETIQUETTES } ${IMPORT}\nconst motif = \`[\${SEPARATEURS_ETIQUETTES}]\`;\nconst m = cellule.split(new RegExp(motif));\n`,
},
// La liste renommée à l'import, puis employée sous son alias.
{
chemin: 'src/csv/alias.js',
texte: `import { SEPARATEURS_ETIQUETTES as S } ${IMPORT}\nconst m = cellule.split(new RegExp(\`[\${S.join('')}]\`));\n`,
},
// Une découpe caractère par caractère, sans expression.
{ chemin: 'src/csv/boucle.js', texte: 'for (const car of cellule) if (SEPARATEURS_ETIQUETTES.includes(car)) couper();\n' },
// La liste lue par l'espace de noms du module.
{ chemin: 'src/application/espace.js', texte: `import * as regle ${IMPORT}\nconst coupe = (c) => regle.SEPARATEURS_ETIQUETTES.includes(c);\n` },
// Une réexportation, renommée ou non, et un alias dans un import sur
// plusieurs lignes.
{
chemin: 'src/csv/relais.js',
texte: `export { SEPARATEURS_ETIQUETTES as COUPURES } ${IMPORT}\nexport { SEPARATEURS_ETIQUETTES } ${IMPORT}\nimport {\n lireEtiquettes,\n SEPARATEURS_ETIQUETTES as S,\n} ${IMPORT}\n`,
},
];
assert.deepEqual(ecartsALaRegleUnique(inventees), [
'src/application/espace.js : ligne 2',
'src/csv/alias.js : ligne 1',
'src/csv/boucle.js : ligne 1',
'src/csv/gabarit.js : ligne 2',
'src/csv/relais.js : ligne 1',
'src/csv/relais.js : ligne 2',
'src/csv/relais.js : ligne 5',
]);
// Ne sont pas des écarts : le nom dans une clause d'import sans alias,
// sur une ou plusieurs lignes, à côté d'un import par défaut, et le nom
// cité dans un commentaire ou un texte.
assert.deepEqual(
ecartsALaRegleUnique([
{
chemin: 'src/csv/lecteur.js',
texte: [
`import defaut, { SEPARATEURS_ETIQUETTES } ${IMPORT}`,
'import {',
' lireEtiquettes,',
' SEPARATEURS_ETIQUETTES,',
`} ${IMPORT}`,
'// lireEtiquettes coupe aux SEPARATEURS_ETIQUETTES.',
"const aide = 'voir SEPARATEURS_ETIQUETTES';",
'',
].join('\n'),
},
]),
[],
);
});
test('la garde ne voit pas ce que son commentaire nomme comme ses limites', () => {
// Chaque forme découpe une liste d'étiquettes et passe la garde : une
// expression qui ouvre une instruction après « ) », lue comme une
// division ; des découpes enchaînées d'un séparateur chacune ; la liste
// lue par une clé entre crochets. Une garde qui en relève une met ce
// commentaire et celui d'ecartsALaRegleUnique à jour.
const limites = [
'if (ok) /[,;]/.test(x);',
"const m = cellule.split(',').flatMap((x) => x.split(';'));",
"const m = cellule.replaceAll(';', ',').replaceAll('|', ',').split(',');",
"const s = regle['SEPARATEURS_ETIQUETTES'];",
];
for (const texte of limites) assert.deepEqual(ecartsALaRegleUnique([{ chemin: 'src/csv/limite.js', texte }]), [], texte);
});
});

View file

@ -0,0 +1,187 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Un événement écrit par le code du format 1 (§ 8.8, A15) : l'état, son
// .precedent et le journal de test/fixtures/stockage/format1/, tels que les a
// écrits le dépôt du format 1 pour un événement inventé — une création et son
// jalon, une inscription, une cible de redondance posée puis retirée, une
// réservation, un second jalon, une note. empreintes.json y porte, par
// révision, l'empreinte SHA-256 du texte de serialiserCharge que restituait
// le journal sous ce même code. Chaque épreuve travaille sur une copie de ces
// fichiers dans un dossier temporaire neuf, retiré à la fin, par le système
// de fichiers de la plateforme electron (fichiers_electron.js devant les
// primitives de la coquille, electron/fichiers.js) : l'ouverture n'écrit
// rien ; chaque révision se restitue comme le format 1 la restituait ; le
// premier geste réécrit le journal au format courant, chaque ligne
// d'historique gardée ; défaire, d'un geste à l'autre au-delà de la
// conversion, rend chaque charge d'avant. L'épreuve est de la série longue :
// elle charge les primitives de la coquille, et le motif de surveillance
// d'electron/ (vitest.config.js) ne relance, dans la série node, que les
// épreuves qu'il nomme.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PREFIXE_CANAL, PRIMITIVES, creerFichiers, decrireSysteme, servir } from '../../electron/fichiers.js';
import { describe, test } from '../../test/lanceur.js';
import { VERSION } from '../version.genere.js';
import { serialiserCharge } from './canonique.js';
import { enregistrer, lister, ouvrir } from './depot.js';
import { FORMAT, analyser } from './document.js';
import { creerFichiersElectron } from './fichiers_electron.js';
import { fil, lireJournal, reconstruire } from './journal.js';
import { SUFFIXES } from './noms.js';
const FIXTURE = fileURLToPath(new URL('../../test/fixtures/stockage/format1/', import.meta.url));
const { base: BASE, revisions: EMPREINTES } = JSON.parse(readFileSync(join(FIXTURE, 'empreintes.json'), 'utf8'));
const NOMS = [SUFFIXES.journal, SUFFIXES.etat, SUFFIXES.precedent].map((suffixe) => `${BASE}${suffixe}`);
const [JOURNAL, ETAT, PRECEDENT] = NOMS;
const DERNIERE = Object.keys(EMPREINTES).length;
const empreinte = (charge) => createHash('sha256').update(serialiserCharge(charge), 'utf8').digest('hex');
const lignesDe = (texte) => texte.replace(/\n$/, '').split('\n');
const geste = (n, plus = {}) => ({
libelle: `Geste ${n}`,
horodatage: `2031-06-21T20:${String(n).padStart(2, '0')}:00-04:00`,
produitVersion: VERSION.affichee,
...plus,
});
// Le système de fichiers de la plateforme electron sous dossier : la page
// devant les primitives de la coquille, que servir offre canal par canal ;
// l'IPC d'Electron y est remplacé par une copie structurée des arguments et
// des réponses. La racine documents reçoit une copie des fichiers de la
// fixture. Rend { fs, racine, chemin }, chemin(nom) étant le chemin sur le
// disque d'un fichier de la racine.
async function copieDeLaFixture(dossier) {
const systeme = decrireSysteme({
env: {},
plateforme: process.platform,
cheminSysteme: (nom) => (nom === 'temp' ? tmpdir() : join(dossier, nom)),
nomProduit: 'Gestion table tournante Libre',
});
const gestionnaires = new Map();
servir(creerFichiers({ ...systeme, dialog: {}, shell: {} }), (canal, gestionnaire) =>
gestionnaires.set(canal, gestionnaire),
);
const pont = Object.fromEntries(
PRIMITIVES.map((primitive) => {
const gestionnaire = gestionnaires.get(`${PREFIXE_CANAL}${primitive}`);
const appeler = async (...parametres) =>
structuredClone(await gestionnaire({ sender: null }, ...structuredClone(parametres)));
return [primitive, appeler];
}),
);
const fs = creerFichiersElectron(pont);
const { documents: racine } = await fs.racines();
mkdirSync(racine.chemin, { recursive: true });
for (const nom of NOMS) writeFileSync(join(racine.chemin, nom), readFileSync(join(FIXTURE, nom)));
return { fs, racine, chemin: (nom) => join(racine.chemin, nom) };
}
// Exécute epreuve(copie) sur une copie neuve de la fixture, sous un dossier
// temporaire retiré ensuite, que l'épreuve passe ou lève.
async function surUneCopie(epreuve) {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-format1-'));
try {
await epreuve(await copieDeLaFixture(dossier));
} finally {
rmSync(dossier, { recursive: true, force: true });
}
}
// Chaque fichier de la racine, par nom : ses octets et sa date de
// modification.
function releve(racine) {
return Object.fromEntries(
readdirSync(racine.chemin)
.sort()
.map((nom) => {
const chemin = join(racine.chemin, nom);
return [nom, { octets: readFileSync(chemin).toString('base64'), modifie: statSync(chemin).mtimeMs }];
}),
);
}
const fixture = (nom) => readFileSync(join(FIXTURE, nom), 'utf8');
describe('stockage : un événement écrit par le code du format 1 (§ 8.8, A15)', () => {
test('la fixture est du format 1 : l’ouverture du journal et l’en-tête de l’état et du .precedent le déclarent', () => {
assert.equal(lireJournal(fixture(JOURNAL)).format, 1);
assert.equal(analyser(fixture(ETAT)).entete.format, 1);
assert.equal(analyser(fixture(PRECEDENT)).entete.format, 1);
assert.ok(FORMAT > 1, String(FORMAT));
});
test('ouvrir et lister n’écrivent rien : chaque fichier garde ses octets et sa date, et aucun ne s’ajoute', () =>
surUneCopie(async ({ fs, racine }) => {
const avant = releve(racine);
const ouvert = await ouvrir(fs, racine, BASE);
await lister(fs, racine);
assert.deepEqual(releve(racine), avant);
assert.deepEqual(ouvert.avertissements, []);
assert.deepEqual([ouvert.formatPlusRecent, ouvert.journalAReecrire, ouvert.journal.format], [false, true, 1]);
assert.equal(ouvert.revision, DERNIERE);
}));
test('chaque révision se restitue telle que le code du format 1 l’enregistrait ; l’état et le .precedent portent les deux dernières', () => {
const journal = lireJournal(fixture(JOURNAL));
assert.equal(journal.ecartees, 0);
const lues = Object.fromEntries(journal.entrees.map(({ revision }) => [revision, empreinte(reconstruire(journal, revision))]));
assert.deepEqual(lues, EMPREINTES);
assert.equal(empreinte(analyser(fixture(ETAT)).charge), EMPREINTES[DERNIERE]);
assert.equal(empreinte(analyser(fixture(PRECEDENT)).charge), EMPREINTES[DERNIERE - 1]);
assert.deepEqual(
journal.jalons.map(({ revision, nom }) => [revision, nom]),
[
[1, 'Création'],
[4, 'Avant le défaut'],
],
);
});
test('le premier geste réécrit le journal au format courant, chaque ligne d’historique gardée octet pour octet, et chaque révision se restitue encore ; l’état passe au format courant', () =>
surUneCopie(async ({ fs, racine, chemin }) => {
const ouvert = await ouvrir(fs, racine, BASE);
const apres = structuredClone(ouvert.charge);
apres.participants[1].notes = 'converti';
const suite = await enregistrer(ouvert, apres, geste(1));
const texte = readFileSync(chemin(JOURNAL), 'utf8');
const journal = lireJournal(texte);
assert.deepEqual([journal.format, journal.ecartees], [FORMAT, 0]);
const anciennes = lignesDe(fixture(JOURNAL));
const nouvelles = lignesDe(texte);
assert.deepEqual(nouvelles.slice(1, anciennes.length), anciennes.slice(1));
assert.equal(nouvelles.length, anciennes.length + 1);
for (const [revision, attendue] of Object.entries(EMPREINTES)) {
assert.equal(empreinte(reconstruire(journal, Number(revision))), attendue, `révision ${revision}`);
}
assert.equal(empreinte(reconstruire(journal, DERNIERE + 1)), empreinte(apres));
assert.equal(journal.jalons.length, 2);
assert.equal(analyser(readFileSync(chemin(ETAT), 'utf8')).entete.format, FORMAT);
assert.equal(readFileSync(chemin(PRECEDENT), 'utf8'), fixture(ETAT));
assert.deepEqual([suite.journalAReecrire, suite.journal.format], [false, FORMAT]);
}));
test('défaire, geste après geste depuis le premier geste, revient à chaque charge du format 1 jusqu’à la création ; rouvert, l’événement est à la première', () =>
surUneCopie(async ({ fs, racine }) => {
let ouvert = await ouvrir(fs, racine, BASE);
const apres = structuredClone(ouvert.charge);
apres.participants[1].notes = 'converti';
ouvert = await enregistrer(ouvert, apres, geste(1));
const atteintes = [];
for (let n = 2; fil(ouvert.journal).cibleDefaire !== null; n += 1) {
const cible = fil(ouvert.journal).cibleDefaire;
ouvert = await enregistrer(ouvert, reconstruire(ouvert.journal, cible), geste(n, { sens: 'defaire', retour: cible }));
assert.equal(empreinte(ouvert.charge), EMPREINTES[cible], `retour à ${cible}`);
atteintes.push(cible);
}
assert.deepEqual(atteintes, [6, 5, 4, 3, 2, 1]);
const rouvert = await ouvrir(fs, racine, BASE);
assert.equal(empreinte(rouvert.charge), EMPREINTES[1]);
assert.equal(rouvert.journal.format, FORMAT);
assert.equal(fil(rouvert.journal).cibleRefaire, 2);
}));
});

View file

@ -9,6 +9,15 @@
// charge. L'ordre est celui des lignes, jamais celui des horodatages, qui ne
// servent qu'à l'affichage.
//
// L'ouverture déclare le format des charges que le journal porte. Le
// journal s'écrit au format courant, FORMAT, et se lit à tout format de 1 à
// FORMAT : un format n'ajoute au précédent que des champs facultatifs, et
// les charges d'un journal plus ancien sont des charges du format courant
// qui ne les portent pas (§ 8.8). La lecture rend le format lu ; le dépôt
// réécrit en entier, au premier geste, un journal d'un format plus ancien.
// Un format plus récent rend l'ouverture illisible : ce code ne sait pas
// lire ses charges.
//
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
// texte entier d'un journal, en décode les octets ligne par ligne, restitue
@ -44,14 +53,15 @@ export const PLANCHER_ENTREES = 500;
export const PLAFOND_ENTREES = 600;
/**
* Un journal lu (lireJournal) : l'identifiant de l'événement, null quand
* l'ouverture est illisible ; les entrées, puis les jalons, chacun dans
* l'ordre des lignes, tels que le texte les porte ; et le nombre de lignes
* écartées. Les révisions des entrées se suivent ; la première porte un
* instantané et une version.
* Un journal lu (lireJournal) : l'identifiant de l'événement et le format
* que déclare l'ouverture, null l'un et l'autre quand elle est illisible ;
* les entrées, puis les jalons, chacun dans l'ordre des lignes, tels que le
* texte les porte ; et le nombre de lignes écartées. Les révisions des
* entrées se suivent ; la première porte un instantané et une version.
*
* @typedef {Object} Journal
* @property {string|null} evenement
* @property {number|null} format de 1 à FORMAT
* @property {import('./types.js').LigneEntree[]} entrees
* @property {import('./types.js').LigneJalon[]} jalons
* @property {number} ecartees
@ -99,9 +109,11 @@ function fauteDeCharge(charge) {
// est admise à sa clé ; il peut lire les clés qui la précèdent, déjà admises.
const sorte = (champs) => ({ champs, cles: new Set(champs.map(([cle]) => cle)) });
// Le format d'une ouverture : un entier de 1 à FORMAT. L'écriture n'en pose
// qu'un, FORMAT (ligneOuverture).
const OUVERTURE = sorte([
['type', (valeur) => valeur === 'journal'],
['format', (valeur) => valeur === FORMAT],
['format', (valeur) => Number.isSafeInteger(valeur) && valeur >= 1 && valeur <= FORMAT],
['evenement', estTexte],
]);
@ -287,21 +299,22 @@ function jalonDe(ligne, entrees) {
);
}
// Lecture d'un texte de journal : le journal lu ; les lignes lues après
// l'ouverture, entrées et jalons dans l'ordre du texte ; et les segments du
// texte coupé à chaque fin de ligne, la marque d'ordre d'octets retirée, le
// dernier segment suivant la dernière fin de ligne. Chaque segment suivi
// d'une fin de ligne est une ligne ; le dernier aussi quand il n'est pas
// vide, et la lecture l'écarte.
// Lecture d'un texte de journal : le journal lu, son format compris ; les
// lignes lues après l'ouverture, entrées et jalons dans l'ordre du texte ; et
// les segments du texte coupé à chaque fin de ligne, la marque d'ordre
// d'octets retirée, le dernier segment suivant la dernière fin de ligne.
// Chaque segment suivi d'une fin de ligne est une ligne ; le dernier aussi
// quand il n'est pas vide, et la lecture l'écarte.
function lire(texte) {
const segments = (texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte).split('\n');
const completes = segments.length - 1;
const lignes = segments[completes] === '' ? completes : completes + 1;
const journal = { evenement: null, entrees: [], jalons: [], ecartees: lignes };
const journal = { evenement: null, format: null, entrees: [], jalons: [], ecartees: lignes };
const lues = [];
const ouverture = completes > 0 ? analyserLigne(segments[0]) : undefined;
if (champFautif(ouverture, OUVERTURE) !== null) return { journal, lues, segments };
journal.evenement = ouverture.evenement;
journal.format = ouverture.format;
for (let rang = 1; rang < completes; rang += 1) {
const ligne = analyserLigne(segments[rang]);
if (entreeSuivante(ligne, journal.entrees)) journal.entrees.push(ligne);
@ -319,15 +332,17 @@ function lire(texte) {
* ligne aussi, que JSON tient pour un blanc.
*
* La lecture s'arrête à la première ligne illisible : JSON invalide ; forme
* fausse, toute clé absente, refusée ou inconnue, et l'instantané d'une
* forme que l'analyse du fichier d'état refuse ; une ouverture d'un autre
* format ; une entrée dont la révision ne suit pas la précédente, ou dont
* l'instantané manque ou abonde au regard de la cadence (estInstantane) ;
* une première entrée sans version ; un jalon d'une révision qui n'est pas
* encore lue ; une ligne finale sans fin de ligne. Cette ligne et toutes
* celles qui la suivent, entrées et jalons confondus — une ligne illisible
* ne dit pas sa sorte —, sont écartées et comptées dans ecartees. Une
* ouverture illisible écarte tout, et evenement vaut null.
* fausse, toute clé absente, refusée ou inconnue, et l'instantané d'une forme
* que l'analyse du fichier d'état refuse ; une ouverture dont le format n'est
* pas un entier de 1 à FORMAT ; une entrée dont la révision ne suit pas la
* précédente, ou dont l'instantané manque ou abonde au regard de la cadence
* (estInstantane) ; une première entrée sans version ; un jalon d'une
* révision qui n'est pas encore lue ; une ligne finale sans fin de ligne.
* Cette ligne et toutes celles qui la suivent, entrées et jalons confondus —
* une ligne illisible ne dit pas sa sorte —, sont écartées et comptées dans
* ecartees. Une ouverture illisible écarte tout, et evenement et format
* valent null. Un journal d'un format plus ancien se lit entier, son format
* rendu.
*
* Un correctif se lit sans s'appliquer : reconstruire lève CORRECTIF sur
* celui qui ne s'applique pas.
@ -473,15 +488,16 @@ export function versionDe({ entrees }, revision) {
* Texte du journal élagué (§ 8.6), ou null quand ses entrées lisibles ne
* dépassent pas PLAFOND_ENTREES. La coupe tombe sur l'instantané le plus
* récent qui garde au moins PLANCHER_ENTREES entrées : le plancher l'emporte
* sur le plafond. Le texte rendu porte l'ouverture, puis les lignes lues dont
* la révision atteint la coupe — les jalons des entrées retirées partent
* avec elles —, puis les lignes que la lecture écarte, telles quelles.
* L'entrée de la coupe porte la version de la construction qui l'a produite
* (versionDe), qu'elle hérite sinon d'une entrée retirée ; toute autre
* ligne gardée se recopie octet pour octet. La marque d'ordre d'octets ne
* se recopie pas. Les instantanés gardés suivent la cadence comptée depuis
* la coupe, celle d'avant : le texte rendu se relit, et chaque instant gardé
* s'y restitue comme avant.
* sur le plafond. Le texte rendu porte l'ouverture au format courant —
* réécrit, un journal d'un format plus ancien s'y convertit —, puis les
* lignes lues dont la révision atteint la coupe — les jalons des entrées
* retirées partent avec elles —, puis les lignes que la lecture écarte,
* telles quelles. L'entrée de la coupe porte la version de la construction
* qui l'a produite (versionDe), qu'elle hérite sinon d'une entrée retirée ;
* toute autre ligne gardée se recopie octet pour octet. La marque d'ordre
* d'octets ne se recopie pas. Les instantanés gardés suivent la cadence
* comptée depuis la coupe, celle d'avant : le texte rendu se relit, et chaque
* instant gardé s'y restitue comme avant.
*
* @param {string} texte
* @returns {string|null}

View file

@ -6,12 +6,13 @@
// chaque instant d'une séance de 120 gestes sur la petite démonstration,
// restitué tel qu'il s'est écrit ; la lecture qui s'arrête à la première
// ligne illisible et compte ce qu'elle écarte ; le fil courant, défaire et
// refaire ; la construction notée ; l'élagage. Les journaux s'écrivent comme
// les écrit l'enregistreur, une ligne après l'autre, par les fonctions du
// module ; chaque charge de la séance passe l'analyse du fichier d'état et le
// contrôle des placements. Les noms des personnes ajoutées sont inventés,
// hors des réservoirs des démonstrations ; les versions viennent du module
// engendré, seul à en porter le texte.
// refaire ; la construction notée ; l'élagage ; le journal d'un format
// plus ancien, qui se lit. Les journaux s'écrivent comme les écrit
// l'enregistreur, une ligne après l'autre, par les fonctions du module ;
// chaque charge de la séance passe l'analyse du fichier d'état et le contrôle
// des placements. Les noms des personnes ajoutées sont inventés, hors des
// réservoirs des démonstrations ; les versions viennent du module engendré,
// seul à en porter le texte.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
@ -19,7 +20,7 @@ import { CATALOGUE } from '../demo/catalogue.js';
import { rechercher } from '../moteur/recherche.js';
import { VERSION } from '../version.genere.js';
import { serialiser, serialiserCharge } from './canonique.js';
import { analyser, configurationDepuisCharge, creerCharge, etatDeduit } from './document.js';
import { FORMAT, analyser, configurationDepuisCharge, creerCharge, etatDeduit } from './document.js';
import { ErreurStockage } from './erreurs.js';
import {
INTERVALLE_INSTANTANE,
@ -403,7 +404,7 @@ describe('les lignes du journal : le texte exact, et ce que l’écriture refuse
instantane: false,
}),
];
assert.equal(lignes[0], '{"type":"journal","format":1,"evenement":"evt-essai"}');
assert.equal(lignes[0], '{"type":"journal","format":2,"evenement":"evt-essai"}');
assert.equal(
lignes[1],
`{"type":"entree","revision":1,"libelle":"Création de l'événement « Soirée d'essai »",` +
@ -822,8 +823,13 @@ describe('la lecture s’arrête à la première ligne illisible et compte ce qu
const avaries = [
['JSON invalide', (ligne) => ligne.slice(1)],
['type inconnu', retouche((valeur) => ({ ...valeur, type: 'jornal' }))],
['format plus récent', retouche((valeur) => ({ ...valeur, format: 2 }))],
['format plus récent', retouche((valeur) => ({ ...valeur, format: FORMAT + 1 }))],
['format en texte', retouche((valeur) => ({ ...valeur, format: '1' }))],
['format 0', retouche((valeur) => ({ ...valeur, format: 0 }))],
['format négatif', retouche((valeur) => ({ ...valeur, format: -1 }))],
['format non entier', retouche((valeur) => ({ ...valeur, format: 1.5 }))],
['format nul', retouche((valeur) => ({ ...valeur, format: null }))],
['format absent', retouche(({ format, ...reste }) => reste)],
['événement vide', retouche((valeur) => ({ ...valeur, evenement: '' }))],
['événement absent', retouche(({ evenement, ...reste }) => reste)],
['clé inconnue', retouche((valeur) => ({ ...valeur, nom: "Soirée d'essai" }))],
@ -831,6 +837,7 @@ describe('la lecture s’arrête à la première ligne illisible et compte ce qu
];
for (const [nom, remplacer] of avaries) {
assert.deepEqual(bilan(abime(0, remplacer)), { evenement: null, entrees: 0, derniere: null, jalons: 0, ecartees: 101 }, nom);
assert.equal(lireJournal(abime(0, remplacer)).format, null, nom);
}
});
@ -861,15 +868,16 @@ describe('la lecture s’arrête à la première ligne illisible et compte ce qu
});
test('texte vide, ouverture seule ou coupée ; marque d’ordre d’octets, fins de ligne CRLF, horloge qui recule', () => {
assert.deepEqual(lireJournal(''), { evenement: null, entrees: [], jalons: [], ecartees: 0 });
assert.deepEqual(lireJournal(''), { evenement: null, format: null, entrees: [], jalons: [], ecartees: 0 });
assert.deepEqual(lireJournal(texteDe([ligneOuverture(EVENEMENT)])), {
evenement: EVENEMENT,
format: FORMAT,
entrees: [],
jalons: [],
ecartees: 0,
});
assert.deepEqual(lireJournal(ligneOuverture(EVENEMENT)), { evenement: null, entrees: [], jalons: [], ecartees: 1 });
assert.deepEqual(lireJournal('\n'), { evenement: null, entrees: [], jalons: [], ecartees: 1 });
assert.deepEqual(lireJournal(ligneOuverture(EVENEMENT)), { evenement: null, format: null, entrees: [], jalons: [], ecartees: 1 });
assert.deepEqual(lireJournal('\n'), { evenement: null, format: null, entrees: [], jalons: [], ecartees: 1 });
assert.deepEqual(lireJournal(`\u{FEFF}${cent()}`), lireJournal(cent()));
assert.deepEqual(lireJournal(cent().replaceAll('\n', '\r\n')), lireJournal(cent()));
// L'ordre est celui des lignes : un horodatage qui recule n'y change rien.
@ -1106,3 +1114,53 @@ describe('l’élagage : au-delà du plafond, depuis la frontière d’instantan
assert.equal(texteCanonique(reconstruire(journal, 590)), texteCanonique(etapes[59].apres));
});
});
describe('le journal d’un format plus ancien se lit (§ 8.8, A15)', () => {
// Le texte d'un journal dont la ligne d'ouverture déclare le format donné,
// le reste inchangé : ce qu'une construction de ce format a écrit, le
// format n'ajoutant que des champs facultatifs.
const auFormat = (texte, format) => {
const lignes = lignesDe(texte);
lignes[0] = JSON.stringify({ ...JSON.parse(lignes[0]), format });
return texteDe(lignes);
};
const cent = memoire(() => journalDe(suite(100)));
test('ligneOuverture écrit FORMAT, et la lecture le rend', () => {
assert.equal(ligneOuverture(EVENEMENT), `{"type":"journal","format":${FORMAT},"evenement":"${EVENEMENT}"}`);
assert.equal(lireJournal(cent()).format, FORMAT);
});
test('chaque format de 1 à FORMAT s’ouvre : toutes ses entrées se lisent, se restituent et suivent le même fil, et la lecture rend le format lu', () => {
const courant = lireJournal(cent());
const formats = Array.from({ length: FORMAT }, (_, rang) => rang + 1);
assert.ok(formats.includes(1) && formats.length >= 2);
for (const format of formats) {
const lu = lireJournal(auFormat(cent(), format));
assert.equal(lu.format, format);
assert.equal(lu.evenement, EVENEMENT);
assert.equal(lu.ecartees, 0);
assert.deepEqual(lu.entrees, courant.entrees);
assert.deepEqual(lu.jalons, courant.jalons);
assert.deepEqual(fil(lu), fil(courant));
for (const revision of [1, 2, 49, 50, 51, 99, 100]) {
assert.equal(texteCanonique(reconstruire(lu, revision)), texteCanonique(reconstruire(courant, revision)), `${format}, ${revision}`);
}
}
});
test('une ligne illisible arrête la lecture d’un journal du format 1 comme d’un autre', () => {
const lignes = lignesDe(auFormat(cent(), 1));
lignes[37] = lignes[37].slice(0, 30);
const lu = lireJournal(texteDe(lignes));
assert.deepEqual([lu.format, lu.entrees.length, lu.ecartees], [1, 36, 64]);
});
test('élagué, un journal du format 1 se réécrit au format courant : seule son ouverture change', () => {
const etapes = suite(650);
const texte = journalDe(etapes);
const elague = elaguer(auFormat(texte, 1));
assert.equal(elague, elaguer(texte));
assert.equal(lireJournal(elague).format, FORMAT);
});
});

View file

@ -6,8 +6,10 @@
// écritures : copier, renommer, importer un fichier d'état ou les paires de
// l'autre emplacement, supprimer vers une corbeille datée, la lister, charger
// une démonstration livrée. Une coupure à chaque écriture d'un renommage ou
// d'une suppression laisse le dossier sans perte. Les noms d'épreuve sont
// inventés ; les versions viennent du module engendré.
// d'une suppression laisse le dossier sans perte. Un fichier d'un format
// plus récent ne se réécrit sous aucun nom ; un fichier du format 1 venu
// d'ailleurs s'importe tel quel et se convertit au premier geste. Les noms
// d'épreuve sont inventés ; les versions viennent du module engendré.
import assert from 'node:assert/strict';
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
@ -15,7 +17,7 @@ import { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js';
import { serialiser, serialiserCharge } from './canonique.js';
import { creer, enregistrer, lister, ouvrir, ouvrirSecours, verrouiller } from './depot.js';
import { analyser, creerCharge } from './document.js';
import { FORMAT, analyser, creerCharge } from './document.js';
import { ErreurStockage } from './erreurs.js';
import { lireJournal, reconstruire } from './journal.js';
import { SUFFIXES, deriverBase } from './noms.js';
@ -157,11 +159,12 @@ function bloquer(charge) {
const texteDeCharge = (charge) => serialiserCharge(charge);
const etatTexte = (charge, revision = 1) => serialiser(charge, { revision, produitVersion: V1 });
// Le fichier d'état de charge au format 2 : l'événement porte une clé que le
// format 1 ne connaît pas ; sansNom lui retire son nom.
function formatDeux(charge, { sansNom = false } = {}) {
// Le fichier d'état de charge d'un format plus récent que FORMAT :
// l'événement porte une clé que le format courant ne connaît pas ; sansNom
// lui retire son nom.
function formatPlusRecent(charge, { sansNom = false } = {}) {
const fichier = JSON.parse(etatTexte(charge));
fichier.entete.format = 2;
fichier.entete.format = FORMAT + 1;
fichier.charge.evenement.couleur = 'ocre';
if (sansNom) delete fichier.charge.evenement.nom;
return `${JSON.stringify(fichier, null, 2)}\n`;
@ -1016,9 +1019,9 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
}
});
test("un fichier au format 2 s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => {
test("un fichier d'un format plus récent s'importe tel quel et s'ouvre en lecture seule ; sans nom lisible, il est refusé avec le code de la lecture", async () => {
const { disque, fs } = nouveauDisque();
const texte = formatDeux(chargeEssai());
const texte = formatPlusRecent(chargeEssai());
assert.deepEqual(await importer(fs, DOC, UTF8.encode(texte)), { base: BASE });
assert.equal(fs.contenu('documents', ETAT), texte);
const ouvert = await rouvrir(disque, BASE);
@ -1026,11 +1029,30 @@ describe('opérations : importer un fichier venu d’ailleurs (§ 8.7)', () => {
await echec(enregistrer(ouvert, noter(ouvert.charge, 2), geste(2, { libelleReprise: REPRISE })), 'LECTURE_SEULE');
const sansNom = nouveauDisque();
const octets = UTF8.encode(formatDeux(chargeEssai(), { sansNom: true }));
const octets = UTF8.encode(formatPlusRecent(chargeEssai(), { sansNom: true }));
const erreur = await echec(importer(sansNom.fs, DOC, octets), 'ETAT_ILLISIBLE');
assert.deepEqual(erreur.details, { raison: 'FORMAT_PLUS_RECENT', chemin: 'charge.evenement.nom', format: 2 });
assert.deepEqual(erreur.details, { raison: 'FORMAT_PLUS_RECENT', chemin: 'charge.evenement.nom', format: FORMAT + 1 });
assert.equal(sansNom.fs.compteurs.ecritures, 0);
});
test("un fichier du format 1 venu d'ailleurs s'importe tel quel et s'ouvre en écriture ; le premier geste l'écrit au format courant et commence son journal", async () => {
const { disque, fs } = nouveauDisque();
const texte = etatAuFormat1(etatTexte(chargeEssai(), 4));
assert.deepEqual(await importer(fs, DOC, UTF8.encode(texte)), { base: BASE });
assert.equal(fs.contenu('documents', ETAT), texte);
assert.equal(fs.contenu('documents', JOURNAL), null);
const ouvert = await rouvrir(disque, BASE);
assert.deepEqual([ouvert.formatPlusRecent, ouvert.revision, ouvert.journal], [false, 4, null]);
await enregistrer(ouvert, noter(ouvert.charge, 5), geste(5, { libelleReprise: REPRISE }));
assert.equal(analyser(fs.contenu('documents', ETAT)).entete.format, FORMAT);
assert.equal(fs.contenu('documents', PRECEDENT), texte);
const journal = lireJournal(fs.contenu('documents', JOURNAL));
assert.deepEqual(
[journal.format, journal.entrees.map(({ revision, libelle }) => [revision, libelle])],
[FORMAT, [[4, REPRISE], [5, 'Geste 5']]],
);
assert.equal(serialiserCharge(reconstruire(journal, 4)), serialiserCharge(chargeEssai()));
});
});
describe("opérations : importer depuis l'autre emplacement (§ 8.6)", () => {
@ -1770,9 +1792,81 @@ describe('opérations : charger une démonstration (§ 15)', () => {
});
});
// Le texte d'état au format 1 : ce qu'une construction du format 1 écrit
// pour la même charge.
function etatAuFormat1(texte) {
const avant = `"entete": {"format":${FORMAT},`;
assert.equal(texte.split(avant).length, 2, 'en-tête au format courant attendu');
return texte.replace(avant, '"entete": {"format":1,');
}
// Le texte de journal au format 1 : son ouverture déclare le format 1.
function journalAuFormat1(texte) {
const fin = texte.indexOf('\n');
return JSON.stringify({ ...JSON.parse(texte.slice(0, fin)), format: 1 }) + texte.slice(fin);
}
describe('opérations : un événement du format 1 (§ 8.7, § 8.8)', () => {
test("importerDepuis copie l'état et le journal du format 1 tels quels ; là-bas, ils se lisent, et le premier geste les convertit, historique entier", async () => {
const disque = new Map();
const PORTABLE = { executable: 'E:\\soirees' };
const fs = creerFichiersSimules({ disque, ...PORTABLE });
fs.deposerDossier('portable');
fs.deposerDossier('documents');
const { portable, documents } = await fs.racines();
const paire = await paireDe(chargeEssai());
const etat = etatAuFormat1(paire.etat);
const journal = journalAuFormat1(paire.journal);
fs.deposer('portable', ETAT, etat);
fs.deposer('portable', JOURNAL, journal);
assert.deepEqual(await importerDepuis(fs, portable, documents, [BASE]), {
importes: [{ base: BASE, nouvelle: BASE }],
refuses: [],
});
assert.deepEqual([fs.contenu('documents', ETAT), fs.contenu('documents', JOURNAL)], [etat, journal]);
const ouvert = await rouvrir(disque, BASE);
assert.deepEqual([ouvert.journalAReecrire, ouvert.journal.format, ouvert.revision], [true, 1, 2]);
await enregistrer(ouvert, noter(ouvert.charge, 3), geste(3));
const relu = lireJournal(fs.contenu('documents', JOURNAL));
assert.deepEqual([relu.format, relu.entrees.map(({ revision }) => revision)], [FORMAT, [1, 2, 3]]);
assert.equal(fs.contenu('documents', JOURNAL).split('\n').slice(1, 3).join('\n'), journal.split('\n').slice(1, 3).join('\n'));
assert.deepEqual([fs.contenu('portable', ETAT), fs.contenu('portable', JOURNAL)], [etat, journal]);
});
test('copier et renommer un événement du format 1 écrivent au format courant ; l’original copié reste tel quel', async () => {
const { fs: source, ouvert: depart } = await deuxRevisions();
const fichiers = [
[ETAT, etatAuFormat1(source.contenu('documents', ETAT))],
[PRECEDENT, etatAuFormat1(source.contenu('documents', PRECEDENT))],
[JOURNAL, journalAuFormat1(source.contenu('documents', JOURNAL))],
];
const { disque } = disqueAvec(fichiers);
const ouvert = await rouvrir(disque, BASE);
const copie = await copier(ouvert, optionsCopie('Variante des Lucioles'));
const { fs } = ouvert;
assert.equal(analyser(fs.contenu('documents', `${copie.base}${SUFFIXES.etat}`)).entete.format, FORMAT);
assert.equal(lireJournal(fs.contenu('documents', `${copie.base}${SUFFIXES.journal}`)).format, FORMAT);
for (const [nom, texte] of fichiers) assert.equal(fs.contenu('documents', nom), texte, nom);
const renomme = await renommer(ouvert, renommage('Gala des Saules', 3));
const journal = lireJournal(fs.contenu('documents', `${renomme.base}${SUFFIXES.journal}`));
assert.deepEqual([journal.format, journal.entrees.map(({ revision }) => revision)], [FORMAT, [1, 2, 3]]);
assert.equal(analyser(fs.contenu('documents', `${renomme.base}${SUFFIXES.etat}`)).entete.format, FORMAT);
assert.equal(serialiserCharge(reconstruire(journal, 2)), serialiserCharge(depart.charge));
});
test('une démonstration du format 1 se charge en un événement du format courant', async () => {
const { fs } = nouveauDisque();
const texte = etatAuFormat1(texteLivre('petite'));
const ouvert = await chargerDemonstration(fs, DOC, texte, chargement('evt-demo-1', 2));
assert.equal(analyser(fs.contenu('documents', `${ouvert.base}${SUFFIXES.etat}`)).entete.format, FORMAT);
const livree = analyser(texte).charge;
assert.deepEqual([ouvert.charge.participants, ouvert.charge.tables], [livree.participants, livree.tables]);
});
});
describe("opérations : ce qu'une construction plus récente a écrit (§ 8.8)", () => {
test("copier, renommer et charger refusent un fichier d'un format plus récent, LECTURE_SEULE, sans rien écrire", async () => {
const { disque } = disqueAvec([[ETAT, formatDeux(chargeEssai())]]);
const { disque } = disqueAvec([[ETAT, formatPlusRecent(chargeEssai())]]);
const ouvert = await rouvrir(disque, BASE);
assert.equal(ouvert.formatPlusRecent, true);
const refus = [
@ -1783,7 +1877,7 @@ describe("opérations : ce qu'une construction plus récente a écrit (§ 8.8)",
chargerDemonstration(
ouvert.fs,
DOC,
formatDeux(chargeEssai({ id: 'evt-demo', nom: 'Démonstration' })),
formatPlusRecent(chargeEssai({ id: 'evt-demo', nom: 'Démonstration' })),
chargement('evt-demo-1', 2),
),
],

View file

@ -20,20 +20,27 @@
// prochainsIds, au plus 2^31 − 1.
/**
* Le fichier d'état, format 1 (§ 8.8, § 8.9). Deux régions : l'en-tête et la
* charge. Les comparaisons qui traversent les constructions — empreinte des
* démonstrations, aller-retour des fichiers livrés — portent sur la charge
* seule. Toute clé hors de ces formes est une faute de forme. Le texte est
* en UTF-8 sans marque d'ordre d'octets, fin de ligne LF, une seule fin de
* ligne finale, dans la forme canonique qu'écrit canonique.js.
* Le fichier d'état, format 2 (§ 8.8, § 8.9). Deux régions : l'en-tête et la
* charge. Le format 2 ajoute au format 1 des champs facultatifs seulement,
* chacun absent à son défaut et écrit après les clés du format 1 de son
* objet : les étiquettes d'un participant, les objectifs d'une table, et
* quatre réglages. Un fichier du format 1 se lit comme un fichier du format 2
* qui n'en porte aucun. Les comparaisons qui traversent les constructions —
* empreinte des démonstrations, aller-retour des fichiers livrés — portent
* sur la charge seule. Toute clé hors de ces formes est une faute de forme.
* Le texte est en UTF-8 sans marque d'ordre d'octets, fin de ligne LF, une
* seule fin de ligne finale, dans la forme canonique qu'écrit canonique.js.
*
* @typedef {Object} Fichier
* @property {Entete} entete
* @property {Charge} charge
*
* @typedef {Object} Entete
* @property {number} format FORMAT ; un format plus récent se lit et
* s'ouvre en lecture seule (§ 8.8)
* @property {number} format FORMAT à l'écriture ; un format plus
* ancien se lit tel quel et se convertit
* au premier geste, un format plus récent
* se lit et s'ouvre en lecture seule
* (§ 8.8)
* @property {string} produitVersion non vide : version affichée de la
* construction qui a écrit en dernier (§ 18.6)
* @property {number} revision entier ≥ 1 : révision de la dernière
@ -100,6 +107,18 @@
* de la somme de redondance ; absent,
* elle vaut CIBLE_REDONDANCE_PAR_DEFAUT,
* que rend la fonction cibleRedondance
* @property {'et'} [regleObjectifs] facultatif, présent seulement à
* « et » : la règle que suit un
* objectif de règle nulle ; absent,
* « ou » (regleObjectifs)
* @property {false} [respecterObjectifs] facultatif, présent seulement
* décoché ; absent, coché (critereCoche)
* @property {'chacun'|'minimum'} [modeObjectifs] facultatif, présent
* seulement hors du défaut ; absent,
* « places », le plus de places
* servies (modeObjectifs)
* @property {true} [rencontrerAnimateurs] facultatif, présent seulement
* coché ; absent, décoché (critereCoche)
*
* @typedef {Object} ProchainsIds entiers de 1 à 2^31 − 1, au-delà de tout
* identifiant attribué ; ils ne reculent
@ -134,6 +153,11 @@
* @property {string|null} titrePressenti informatif (§ 4)
* @property {string|null} notes
* @property {boolean} exclu (§ 4.4)
* @property {string[]} [etiquettes] facultatif : de 1 à ETIQUETTES_MAX
* étiquettes bien formées
* (etiquettes.js), sans doublon
* exact, rangées par unités UTF-16 ;
* absent, aucune étiquette (§ 4.5)
*
* @typedef {Object} Table
* @property {number} id identifiant, unique
@ -142,6 +166,17 @@
* surcharge (§ 6.1)
* @property {'ronde'|'carree'} forme
* @property {{x: number, y: number}} position nombres finis, en cm
* @property {Objectif[]} [objectifs] facultatif : une liste non vide, au
* plus un objectif par portée et tour, la
* soirée d'abord puis les tours
* croissants ; absent, aucun objectif
*
* @typedef {Object} Objectif un objectif de table (§ 4.5)
* @property {'tous'|'tour'} portee pour la soirée, ou pour un tour désigné
* @property {number|null} tour null pour la portée tous, de 1 à R sinon
* @property {'ou'|'et'|null} regle la règle propre ; null, l'objectif suit
* la règle par défaut de l'événement
* @property {string[]} etiquettes comme celles d'un participant
*
* @typedef {Object} Reservation
* @property {number} participant id d'un participant de la charge, exclu
@ -231,9 +266,11 @@
* Une règle de SCHEMA, qui décrit une valeur du fichier d'état.
*
* @typedef {Object} Regle
* @property {'objet'|'liste'|'chaine'|'entier'|'nombre'|'booleen'|'parmi'|'date'} genre
* @property {'objet'|'liste'|'chaine'|'entier'|'nombre'|'booleen'|'parmi'|'date'|'horodatage'|'etiquettes'} genre
* chaine : non vide ; nombre : fini ; parmi : l'une de valeurs ;
* date : AAAA-MM-JJ d'un jour du calendrier
* date : AAAA-MM-JJ d'un jour du calendrier ; horodatage :
* AAAA-MM-JJTHH:MM:SS±HH:MM ; etiquettes : une étiquette bien
* formée (estEtiquetteBienFormee)
* @property {boolean} nul null est admis
* @property {boolean} optionnel le champ peut manquer à son
* objet : absent, il ne s'écrit pas et ne se compare pas
@ -241,8 +278,11 @@
* l'ordre des clés du texte canonique
* @property {Set<string>} [cles] objet : les clés de ses champs
* @property {Regle} [element] liste : la règle de ses éléments
* @property {number} [min] entier : bornes incluses
* @property {number} [min] entier : bornes incluses ;
* liste : bornes incluses de sa longueur
* @property {number} [max]
* @property {boolean} [distincts] liste : aucun élément répété ;
* un correctif la pose entière (correctifs.js)
* @property {string[]} [valeurs] parmi
* @property {'lignes'|'ouverte'} [mise] absente : la valeur s'écrit
* compacte. lignes : un champ, ou un élément, par ligne. ouverte :
@ -266,7 +306,9 @@
*
* @typedef {Object} LigneOuverture
* @property {'journal'} type
* @property {number} format
* @property {number} format FORMAT à l'écriture ; de 1 à FORMAT à la
* lecture : le journal d'un format plus ancien se lit, et le
* premier geste le réécrit en entier au format courant
* @property {string} evenement id de l'événement
*
* @typedef {Object} LigneEntree
@ -411,8 +453,10 @@
* domaine — un identifiant ou un compteur au-delà de
* 2^31 − 1 compris —, identifiant de participant ou de
* table en double ou atteignant prochainsIds, tour d'une
* réservation hors de sa portée ; des propositions et du
* retenu, seul le conteneur
* réservation ou d'un objectif hors de sa portée, deux
* objectifs d'une table de même portée et même tour, un
* champ facultatif écrit à son défaut ; des propositions
* et du retenu, seul le conteneur
* COMPTES un compte de la saisie — participants, tables,
* réservations, titres — contredit sa liste ; ceux des
* propositions et du retenu ne refusent jamais (Lecture,

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":8,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":1}},
"entete": {"format":2,"produitVersion":"epreuve","revision":1,"comptes":{"participants":8,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":1}},
"charge": {
"evenement": {"id":"evt-conflit-table-un","nom":"Club des Merles","date":null,"siegesParDefaut":2,"tours":2,"unite":"cm","etat":"retenu","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":20,"tables":8,"reservations":0,"titres":0,"propositions":1,"retenu":1}},
"entete": {"format":2,"produitVersion":"epreuve","revision":1,"comptes":{"participants":20,"tables":8,"reservations":0,"titres":0,"propositions":1,"retenu":1}},
"charge": {
"evenement": {"id":"epreuve:huit-tables-retenu","nom":"Club des Merles","date":null,"siegesParDefaut":3,"tours":3,"unite":"cm","etat":"retenu","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -1,5 +1,5 @@
{
"entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":0}},
"entete": {"format":2,"produitVersion":"epreuve","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":0}},
"charge": {
"evenement": {"id":"epreuve:toutes-en-conflit","nom":"Toutes en conflit","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"propose","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},

View file

@ -0,0 +1,9 @@
{"type":"journal","format":1,"evenement":"evt-hiboux"}
{"type":"entree","revision":1,"libelle":"Création de l'événement","horodatage":"2031-06-21T19:00:00-04:00","sens":null,"retour":null,"produitVersion":"2026.10.05.01","instantane":{"evenement":{"id":"evt-hiboux","nom":"Bal-des-Hiboux","date":null,"siegesParDefaut":3,"tours":2,"unite":"cm","etat":"brouillon","filiation":null},"reglages":{"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},"prochainsIds":{"participant":4,"table":3,"proposition":1},"participants":[{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Cercle des Hiboux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},{"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},{"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":"Cercle des Hiboux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}],"tables":[{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},{"id":2,"numero":2,"sieges":4,"forme":"ronde","position":{"x":250,"y":0}}],"reservations":[],"titres":[{"table":1,"siege":1,"libelle":"animation"}],"propositions":[],"retenu":null},"correctif":null}
{"type":"jalon","revision":1,"nom":"Création","horodatage":"2031-06-21T19:00:00-04:00"}
{"type":"entree","revision":2,"libelle":"Inscription de Brunelle","horodatage":"2031-06-21T19:01:00-04:00","sens":null,"retour":null,"produitVersion":null,"instantane":null,"correctif":[{"op":"poser","chemin":["prochainsIds","participant"],"valeur":5},{"op":"poser","chemin":["participants",{"id":4}],"valeur":{"id":4,"nom":"Brunelle","prenom":null,"appartenance":null,"courriel":null,"titrePressenti":null,"notes":"arrive au second tour","exclu":false}}]}
{"type":"entree","revision":3,"libelle":"Cible de redondance","horodatage":"2031-06-21T19:02:00-04:00","sens":null,"retour":null,"produitVersion":null,"instantane":null,"correctif":[{"op":"poser","chemin":["reglages","cibleRedondance"],"valeur":800}]}
{"type":"entree","revision":4,"libelle":"Réservation","horodatage":"2031-06-21T19:03:00-04:00","sens":null,"retour":null,"produitVersion":null,"instantane":null,"correctif":[{"op":"poser","chemin":["reservations"],"valeur":[{"participant":2,"table":2,"siege":null,"portee":"tour","tour":1}]}]}
{"type":"jalon","revision":4,"nom":"Avant le défaut","horodatage":"2031-06-21T19:04:00-04:00"}
{"type":"entree","revision":5,"libelle":"Cible par défaut","horodatage":"2031-06-21T19:05:00-04:00","sens":null,"retour":null,"produitVersion":null,"instantane":null,"correctif":[{"op":"retirer","chemin":["reglages","cibleRedondance"]}]}
{"type":"entree","revision":6,"libelle":"Note","horodatage":"2031-06-21T19:06:00-04:00","sens":null,"retour":null,"produitVersion":null,"instantane":null,"correctif":[{"op":"poser","chemin":["participants",{"id":1},"notes"],"valeur":"à la table d’accueil"}]}

View file

@ -0,0 +1,26 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":6,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"evt-hiboux","nom":"Bal-des-Hiboux","date":null,"siegesParDefaut":3,"tours":2,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":5,"table":3,"proposition":1},
"participants": [
{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Cercle des Hiboux","courriel":null,"titrePressenti":null,"notes":"à la table d’accueil","exclu":false},
{"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":"Cercle des Hiboux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Brunelle","prenom":null,"appartenance":null,"courriel":null,"titrePressenti":null,"notes":"arrive au second tour","exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":4,"forme":"ronde","position":{"x":250,"y":0}}
],
"reservations": [
{"participant":2,"table":2,"siege":null,"portee":"tour","tour":1}
],
"titres": [
{"table":1,"siege":1,"libelle":"animation"}
],
"propositions": [],
"retenu": null
}
}

View file

@ -0,0 +1,26 @@
{
"entete": {"format":1,"produitVersion":"2026.10.05.01","revision":5,"comptes":{"participants":4,"tables":2,"reservations":1,"titres":1,"propositions":0,"retenu":0}},
"charge": {
"evenement": {"id":"evt-hiboux","nom":"Bal-des-Hiboux","date":null,"siegesParDefaut":3,"tours":2,"unite":"cm","etat":"brouillon","filiation":null},
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
"prochainsIds": {"participant":5,"table":3,"proposition":1},
"participants": [
{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Cercle des Hiboux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":"Cercle des Hiboux","courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
{"id":4,"nom":"Brunelle","prenom":null,"appartenance":null,"courriel":null,"titrePressenti":null,"notes":"arrive au second tour","exclu":false}
],
"tables": [
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
{"id":2,"numero":2,"sieges":4,"forme":"ronde","position":{"x":250,"y":0}}
],
"reservations": [
{"participant":2,"table":2,"siege":null,"portee":"tour","tour":1}
],
"titres": [
{"table":1,"siege":1,"libelle":"animation"}
],
"propositions": [],
"retenu": null
}
}

View file

@ -0,0 +1,12 @@
{
"calcul": "sha256 du texte UTF-8 de serialiserCharge(reconstruire(journal, revision)), par le code du format 1",
"base": "Bal-des-Hiboux",
"revisions": {
"1": "1cc9e6738c0eea7eb0afeefcc4a6cb7423509dfaac045173d7185cd39ef85d25",
"2": "ce6f7f49520b74d4e1a4313c3a31f8e7a9262ad63e3fdb787313292fdd0648d3",
"3": "6700fc683ed730ae41dd0778c486ea11a7d1ef607c3c7cb4f8ff852817c54ab8",
"4": "86519fc9f2dccc95658adda676b6dc499f0bf4df54f16aeb25ef04aa83e9e935",
"5": "13ca4cf502cda8ef905e5c90e9875384e2e2cdc704cde2e751fcad982d35908e",
"6": "694d215b6c6d571dec083246987f9961b650dd829000f91bc3d93f44270878ef"
}
}

View file

@ -46,17 +46,18 @@ const COMMANDES_NAVIGATEUR = Object.fromEntries(
const SERIES = reglagesSeries(coeursDisponibles(), { couverture: process.argv.includes('--coverage') });
const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers };
// Les épreuves node qui lisent les composants de src/interface par le
// système de fichiers — chaînes, couleurs, en-tête, jetons lus, écouteurs du
// plan, version affichée, graphe des couches, version qu'App reçoit par ses
// propriétés —, celle qui importe App.svelte pour en voir l'échec sous
// node, et la garde du banc, qui balaie l'arbre à côté des motifs de ce
// fichier. S'y ajoutent les épreuves qui chargent ce fichier : il nomme les
// composants dans son motif, et son graphe porte les commandes des captures
// d'écran, qui balaient doc/captures ; la garde des lecteurs par le disque
// (test/arborescence.test.js), textuelle, les compte parmi les lecteurs des
// composants. test/documentation.test.js en est : elle lit ces motifs pour
// les fichiers que scripts/documentation.js lit par le disque.
// Les épreuves node qui lisent les composants de src/interface par le système
// de fichiers — chaînes, couleurs, en-tête, jetons lus, écouteurs du plan,
// version affichée, graphe des couches, version qu'App reçoit par ses
// propriétés, découpe des étiquettes hors de leur module —, celle qui importe
// App.svelte pour en voir l'échec sous node, et la garde du banc, qui balaie
// l'arbre à côté des motifs de ce fichier. S'y ajoutent les épreuves qui
// chargent ce fichier : il nomme les composants dans son motif, et son graphe
// porte les commandes des captures d'écran, qui balaient doc/captures ; la
// garde des lecteurs par le disque (test/arborescence.test.js), textuelle,
// les compte parmi les lecteurs des composants. test/documentation.test.js en
// est : elle lit ces motifs pour les fichiers que scripts/documentation.js
// lit par le disque.
const RELANCES_D_UN_COMPOSANT = Object.freeze([
'test/arborescence.test.js',
'test/sources_interface.test.js',
@ -71,6 +72,7 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([
'test/machine.test.js',
'test/rapporteur.test.js',
'test/documentation.test.js',
'src/stockage/etiquettes.test.js',
]);
// Les épreuves node qui lisent la feuille des jetons par le système de