[ADD] storage: event document, canonical form, consistency check

An event lives in one state file: a header (format, writing build,
revision, counts) and a payload. The serializer orders keys by one
schema table and records by id, sorts the sets — reserves, placed
participants, unattributed seats — and reads no clock nor randomness:
one state gives one text, byte for byte. Reading refuses an empty,
truncated or self-contradicting file by reason and path, reads a newer
format with its unknown keys ignored, never refuses a whole file for
one proposition. The engine exports its default history length.

Checked: 58 tests and the golden text; two reviews, mutants killed.

--- FR ---

[ADD] stockage : document d'événement, forme canonique, contrôle

Un événement vit dans un fichier d'état : un en-tête (format,
construction qui écrit, révision, comptes) et une charge. Le sérialiseur
range les clés par une seule table de schéma et les enregistrements par
identifiant, trie les ensembles — réserves, participants placés, sièges
non attribués — et ne lit ni horloge ni aléa : un état, un texte, octet
pour octet. La lecture refuse un fichier vide, tronqué ou contradictoire
par raison et chemin, lit un format plus récent en ignorant ses clés
inconnues, et ne refuse jamais un fichier pour une proposition.

Vérifié : 58 épreuves et le texte exact ; deux revues, mutants tués.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 13:11:34 -04:00
parent dd088f2dd9
commit 1d15ebaf68
7 changed files with 2932 additions and 1 deletions

View file

@ -148,7 +148,7 @@ const ORDRE_CONTRAINTES = Object.freeze([2, 3, 4, 0, 1, 5]);
// Itérations entre deux appels de progression, et entre deux lectures du
// signal.
const CADENCE = 1_000;
const HISTORIQUE_PAR_DEFAUT = 1_000;
export const HISTORIQUE_PAR_DEFAUT = 1_000;
// Longueurs d'historique sans changement du score courant au-delà desquelles
// une descente se relance, dans l'ordre des contraintes seulement.
const PATIENCE = 10;

187
src/stockage/canonique.js Normal file
View file

@ -0,0 +1,187 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le texte canonique du fichier d'état (§ 8.8, § 8.9), par le seul
// sérialiseur du stockage, qui suit le schéma du document. L'ordre des clés
// est celui des champs du schéma, jamais celui dans lequel un objet a reçu
// les siennes ; chaque liste se trie selon sa règle ; les valeurs s'écrivent
// par JSON.stringify. Le texte ne dépend que des arguments : ni horloge ni
// tirage n'y entrent au moment d'écrire.
//
// Mise en page, deux espaces par niveau. Un objet de mise « lignes » — le
// fichier, la charge — porte un champ par ligne, « "clé": valeur ». Une
// liste de mise « lignes » — les collections de la charge, le placement —
// porte un élément par ligne, et s'écrit [] sur la ligne de sa clé quand
// elle est vide. Un objet de mise « ouverte » — une proposition, le retenu —
// s'écrit compact sur la ligne qui l'ouvre, chaque champ selon sa propre
// règle : son placement y ouvre un crochet, porte un tour par ligne un
// niveau plus bas, et le referme au niveau de l'objet. Toute autre valeur
// s'écrit compacte, sur la ligne de sa clé.
//
// Un retenu que sa règle refuse, l'analyse l'admet, puisqu'elle n'en lit
// que le conteneur, et le contrôle des placements le garde en le signalant
// (§ 8.9, point 3). Le schéma ne décrit pas une telle forme : le retenu se
// recopie hors du schéma, ses clés rangées, ses listes dans l'ordre écrit,
// et s'écrit compact à la clé retenu. Le texte se relit ainsi au même
// retenu, et les gestes qui suivent s'enregistrent.
import { FORMAT, SCHEMA, clesRangees, comptesDe, premiereFaute } from './document.js';
// Règle du champ cle d'un objet du schéma.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const ENTETE = regleDuChamp(SCHEMA, 'entete');
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const RETENU = regleDuChamp(CHARGE, 'retenu');
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
/**
* Vrai quand valeur, lue à la place que regle décrit, est un retenu que sa
* règle refuse (premiereFaute) : un objet, que l'analyse admet et que le
* contrôle des placements garde (§ 8.9, point 3). canoniser le recopie hors
* du schéma, le texte l'écrit compact, et difference le pose entier. Faux
* pour toute autre règle, pour null et pour un retenu qui n'est pas un
* objet, que l'analyse refuse.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @returns {boolean}
*/
export function retenuHorsDeSaRegle(valeur, regle) {
return regle === RETENU && estObjet(valeur) && premiereFaute(valeur, RETENU) !== null;
}
// Copie de valeur hors du schéma : chaque objet refait, ses clés posées par
// unités UTF-16 croissantes, chaque liste dans son ordre, toute autre valeur
// telle quelle. JSON.stringify écrit les clés de la copie dans cet ordre,
// les clés d'index entier en tête, croissantes, comme JavaScript les
// énumère : le texte ne dépend que de l'ensemble des clés. Object.fromEntries
// pose chaque clé en propre, « __proto__ » comprise.
function copierHorsDuSchema(valeur) {
if (Array.isArray(valeur)) return valeur.map(copierHorsDuSchema);
if (!estObjet(valeur)) return valeur;
return Object.fromEntries(clesRangees(valeur).map((cle) => [cle, copierHorsDuSchema(valeur[cle])]));
}
// Copie de valeur selon sa règle : chaque objet refait dans l'ordre de ses
// champs, chaque liste copiée, puis triée quand sa règle le demande ; l'ordre
// de la règle compare ainsi des copies déjà canoniques. Un objet qui a un
// champ siegesAttribues — une proposition, le retenu — en transmet la valeur
// à ce qu'il contient : ses listes de table ne se trient que quand il vaut
// faux. Le réglage attribuerSieges de la charge n'y entre pas. Les clés hors
// du schéma ne sont pas copiées. Un retenu que sa règle refuse se copie hors
// du schéma, entier. Ailleurs, une clé du schéma absente lève TypeError en
// nommant son chemin : JSON.stringify la tairait, et le texte ne se relirait
// pas.
function copier(valeur, regle, chemin, siegesAttribues) {
if (valeur === null) return null;
if (retenuHorsDeSaRegle(valeur, regle)) return copierHorsDuSchema(valeur);
if (regle.genre === 'objet') {
const drapeau = regle.cles.has('siegesAttribues') ? valeur.siegesAttribues : siegesAttribues;
const copie = {};
for (const [cle, regleDeCle] of regle.champs) {
const cheminDeCle = `${chemin}.${cle}`;
if (valeur[cle] === undefined) throw new TypeError(`${cheminDeCle} absente`);
copie[cle] = copier(valeur[cle], regleDeCle, cheminDeCle, drapeau);
}
return copie;
}
if (regle.genre === 'liste') {
const copie = valeur.map((element, rang) => copier(element, regle.element, `${chemin}[${rang}]`, siegesAttribues));
if (regle.tri !== undefined) copie.sort(regle.tri);
if (regle.triSansAttribution !== undefined && siegesAttribues === false) copie.sort(regle.triSansAttribution);
return copie;
}
return valeur;
}
// Texte de valeur, déjà canonique, selon sa règle, au retrait de sa ligne :
// la première ligne continue celle de la clé, les suivantes portent leur
// propre retrait. Un retenu que sa règle refuse s'écrit compact.
function rendre(valeur, regle, retrait) {
if (valeur === null || regle.mise === undefined || retenuHorsDeSaRegle(valeur, regle)) {
return JSON.stringify(valeur);
}
const dedans = ' '.repeat(retrait + 2);
const fin = ' '.repeat(retrait);
if (regle.genre === 'liste') {
if (valeur.length === 0) return '[]';
const lignes = valeur.map((element) => dedans + rendre(element, regle.element, retrait + 2));
return `[\n${lignes.join(',\n')}\n${fin}]`;
}
if (regle.mise === 'lignes') {
const lignes = regle.champs.map(
([cle, regleDeCle]) => `${dedans}${JSON.stringify(cle)}: ${rendre(valeur[cle], regleDeCle, retrait + 2)}`,
);
return `{\n${lignes.join(',\n')}\n${fin}}`;
}
// Mise « ouverte » : la syntaxe compacte, chaque champ selon sa règle au
// retrait de l'objet.
const champs = regle.champs.map(
([cle, regleDeCle]) => `${JSON.stringify(cle)}:${rendre(valeur[cle], regleDeCle, retrait)}`,
);
return `{${champs.join(',')}}`;
}
/**
* Copie de la charge dans l'ordre canonique (§ 8.8, § 8.9). Les clés de
* chaque objet y sont posées dans l'ordre du schéma : JSON.stringify de la
* copie est canonique lui aussi. Participants et tables s'y rangent par
* identifiant ; propositions par identifiant, puis graine, compte d'arrêt,
* historique et texte canonique ; réservations par participant, table,
* portée — tous avant tour —, tour, siège, null en tête ; titres par table,
* siège, libellé. Une proposition, ou le retenu, dont siegesAttribues est
* faux a chaque liste de table de son placement par identifiant croissant ;
* vrai, l'ordre est celui des sièges et reste tel quel. C'est le drapeau de
* la proposition qui décide, jamais le réglage attribuerSieges : le changer
* ne détruit pas l'ordre des sièges d'une proposition déjà produite. Ses
* participants et chaque réserve, des ensembles, se rangent par identifiant
* croissant quel que soit le drapeau. L'ordre des tables d'une proposition,
* qui apparie chaque liste à sa table, est gardé. La charge reçue n'est pas
* modifiée, et la copie n'en partage aucun objet.
*
* La charge est celle que rend examiner (placements.js) : l'analyse l'admet,
* et chaque proposition suit sa règle ; une clé du schéma absente, ailleurs
* que dans le retenu, lève TypeError. Un retenu que sa règle refuse, et que
* l'analyse admet, se copie hors du schéma (retenuHorsDeSaRegle) : chaque
* objet refait, ses clés rangées par unités UTF-16, chaque liste dans
* l'ordre reçu, ses clés inconnues gardées. Un retenu qui n'est pas un
* objet lève TypeError.
*
* @param {import('./types.js').Charge} charge
* @returns {import('./types.js').Charge}
*/
export function canoniser(charge) {
return copier(charge, CHARGE, 'charge');
}
/**
* Texte canonique du fichier d'état (§ 8.8) : l'en-tête — FORMAT,
* produitVersion, revision et les comptes de la charge —, la charge
* canonique, et une fin de ligne finale. Deux charges qui ne diffèrent que
* par l'ordre de leurs clés, ou par celui d'une liste que canoniser range,
* s'écrivent octet pour octet pareil : chaque ordre est total. revision ou
* produitVersion absents lèvent TypeError.
*
* @param {import('./types.js').Charge} charge
* @param {{revision: number, produitVersion: string}} entete
* @returns {string}
*/
export function serialiser(charge, { revision, produitVersion }) {
const copie = canoniser(charge);
const entete = copier({ format: FORMAT, produitVersion, revision, comptes: comptesDe(copie) }, ENTETE, 'entete');
return `${rendre({ entete, charge: copie }, SCHEMA, 0)}\n`;
}
/**
* Texte canonique de la charge seule, dans la mise en page du fichier, sans
* en-tête, et une fin de ligne finale : ce que comparent l'empreinte des
* démonstrations et l'aller-retour des fichiers livrés, qui ne dépendent pas
* de la construction qui a écrit (§ 8.8, § 15.5).
*
* @param {import('./types.js').Charge} charge
* @returns {string}
*/
export function serialiserCharge(charge) {
return `${rendre(canoniser(charge), CHARGE, 0)}\n`;
}

View file

@ -0,0 +1,703 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la forme canonique du fichier d'état (§ 8.8, § 8.9) : le texte
// 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.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { versionVoisine } from '../../test/version_voisine.js';
import { VERSION } from '../version.genere.js';
import { canoniser, serialiser, serialiserCharge } from './canonique.js';
import { analyser } from './document.js';
// En-tête du texte du contrat.
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}},
"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}},
"prochainsIds": {"participant":5,"table":3,"proposition":2},
"participants": [
{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Club des Merles","courriel":null,"titrePressenti":"animation","notes":null,"exclu":false},
{"id":2,"nom":"Grisaille","prenom":null,"appartenance":"Club des Merles","courriel":"g@exemple.test","titrePressenti":null,"notes":null,"exclu":false},
{"id":3,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":"arrive tard","exclu":false},
{"id":4,"nom":"Lacasse","prenom":"Ondine","appartenance":"Société Alpha","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":null,"forme":"carree","position":{"x":250,"y":0}}
],
"reservations": [
{"participant":1,"table":1,"siege":1,"portee":"tous","tour":null}
],
"titres": [
{"table":1,"siege":1,"libelle":"animation"}
],
"propositions": [
{"id":1,"graine":48271,"arret":200000,"historique":1000,"produitVersion":"${VERSION.affichee}","siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":2,"participants":[1,2,3,4],"placement":[
{"sieges":[[1,3],[2,4]],"reserve":[]},
{"sieges":[[1,4],[2,3]],"reserve":[]}
]}
],
"retenu": null
}
}
`;
// La charge que décrit TEXTE_CONTRAT, une copie neuve à chaque appel.
function chargeContrat() {
return {
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: 200_000, historique: 1_000 },
},
prochainsIds: { participant: 5, table: 3, proposition: 2 },
participants: [
{ id: 1, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles', courriel: null,
titrePressenti: 'animation', notes: null, exclu: false },
{ id: 2, nom: 'Grisaille', prenom: null, appartenance: 'Club des Merles', courriel: 'g@exemple.test',
titrePressenti: null, notes: null, exclu: false },
{ id: 3, nom: 'Pervenche', prenom: 'Théo', appartenance: null, courriel: null,
titrePressenti: null, notes: 'arrive tard', exclu: false },
{ id: 4, nom: 'Lacasse', prenom: 'Ondine', appartenance: 'Société Alpha', 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: null, forme: 'carree', position: { x: 250, y: 0 } },
],
reservations: [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }],
titres: [{ table: 1, siege: 1, libelle: 'animation' }],
propositions: [
{
id: 1,
graine: 48_271,
arret: 200_000,
historique: 1_000,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: [1, 2],
capacites: [2, 2],
tours: 2,
participants: [1, 2, 3, 4],
placement: [
{ sieges: [[1, 3], [2, 4]], reserve: [] },
{ sieges: [[1, 4], [2, 3]], reserve: [] },
],
},
],
retenu: null,
};
}
// texte où avant, qui doit y figurer exactement une fois, devient apres.
// Une épreuve qui dérive son attendu d'un autre texte échoue ainsi quand le
// remplacement ne trouve rien, au lieu de comparer deux textes inchangés.
function remplacer(texte, avant, apres) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
return texte.replace(avant, () => apres);
}
// La charge du contrat avec un retenu à sièges non attribués dont les listes
// de table arrivent non triées, et le texte qui l'écrit : le retenu prend la
// mise en page d'une proposition, à la clé retenu, et l'en-tête le compte.
function chargeAvecRetenu() {
const charge = chargeContrat();
charge.retenu = {
proposition: 1,
siegesAttribues: false,
tables: [1, 2],
capacites: [2, 2],
tours: 2,
participants: [1, 2, 3, 4],
placement: [
{ sieges: [[3, 1], [2, 4]], reserve: [] },
{ sieges: [[4, 1], [3, 2]], reserve: [] },
],
};
return charge;
}
const TEXTE_AVEC_RETENU = remplacer(
remplacer(TEXTE_CONTRAT, '"retenu":0}}', '"retenu":1}}'),
' "retenu": null\n',
[
' "retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":2,"participants":[1,2,3,4],"placement":[',
' {"sieges":[[1,3],[2,4]],"reserve":[]},',
' {"sieges":[[1,4],[2,3]],"reserve":[]}',
' ]}',
'',
].join('\n'),
);
// Une charge dont toutes les listes sont vides, avec une filiation, et dont
// le nom porte des guillemets droits ; puis le texte qui l'écrit, révision 1.
function chargeSansListe() {
return {
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: 1_000, historique: 10 },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
};
}
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}},
"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}},
"prochainsIds": {"participant":1,"table":1,"proposition":1},
"participants": [],
"tables": [],
"reservations": [],
"titres": [],
"propositions": [],
"retenu": null
}
}
`;
// Copie de valeur dont chaque objet reçoit ses clés dans l'ordre inverse :
// un sérialiseur qui suivrait l'ordre d'insertion écrirait un autre texte.
// Object.fromEntries pose chaque clé en propre, « __proto__ » comprise.
function aRebours(valeur) {
if (Array.isArray(valeur)) return valeur.map(aRebours);
if (typeof valeur !== 'object' || valeur === null) return valeur;
return Object.fromEntries(Object.keys(valeur).reverse().map((cle) => [cle, aRebours(valeur[cle])]));
}
// Gèle valeur et tout ce qu'elle contient : une écriture y lève TypeError,
// le module s'exécutant en mode strict.
function geler(valeur) {
if (typeof valeur === 'object' && valeur !== null) {
for (const enfant of Object.values(valeur)) geler(enfant);
Object.freeze(valeur);
}
return valeur;
}
// Exécute fonction pendant que l'horloge et les sources d'aléa lèvent à leur
// lecture, puis les rétablit telles qu'elles étaient, propres ou héritées ;
// rend ce que rend fonction.
function sansHorlogeNiAlea(fonction) {
const interdite = (nom) => () => {
throw new Error(`${nom} lu`);
};
const remplacees = [
[Math, 'random'],
[Date, 'now'],
[performance, 'now'],
[crypto, 'getRandomValues'],
[crypto, 'randomUUID'],
].map(([objet, nom]) => ({ objet, nom, propre: Object.hasOwn(objet, nom), valeur: objet[nom] }));
const DateReelle = globalThis.Date;
for (const { objet, nom } of remplacees) objet[nom] = interdite(nom);
function DateInterdite() {
throw new Error('Date lu');
}
DateInterdite.now = interdite('Date.now');
globalThis.Date = DateInterdite;
try {
return fonction();
} finally {
globalThis.Date = DateReelle;
for (const { objet, nom, propre, valeur } of remplacees) {
if (propre) objet[nom] = valeur;
else delete objet[nom];
}
}
}
describe('serialiser : le texte du contrat de données (§ 8.8)', () => {
test('la charge du contrat, révision 3, rend le texte du contrat octet pour octet, fin de ligne finale comprise', () => {
assert.equal(serialiser(chargeContrat(), ENTETE), TEXTE_CONTRAT);
});
test("un retenu non nul suit la mise en page d'une proposition, à la clé retenu, et l'en-tête le compte", () => {
assert.equal(serialiser(chargeAvecRetenu(), ENTETE), TEXTE_AVEC_RETENU);
});
test('une liste vide s\'écrit [] sur la ligne de sa clé ; les chaînes passent par JSON.stringify', () => {
assert.equal(serialiser(chargeSansListe(), { revision: 1, produitVersion: VERSION.affichee }), TEXTE_SANS_LISTE);
});
test('un placement vide s\'écrit [] sur la ligne qui ouvre sa proposition', () => {
const charge = chargeContrat();
charge.propositions[0].placement = [];
const attendu = remplacer(
TEXTE_CONTRAT,
'"placement":[\n {"sieges":[[1,3],[2,4]],"reserve":[]},\n {"sieges":[[1,4],[2,3]],"reserve":[]}\n ]}',
'"placement":[]}',
);
assert.equal(serialiser(charge, ENTETE), attendu);
});
});
describe('canonicité (§ 8.8, § 8.9)', () => {
test('la même charge construite dans un autre ordre — participants 4, 2, 1, 3, clés à rebours, listes de table non triées — rend le même texte', () => {
const desordre = chargeContrat();
desordre.participants = [4, 2, 1, 3].map((id) => desordre.participants.find((p) => p.id === id));
desordre.propositions[0].placement = [
{ sieges: [[3, 1], [4, 2]], reserve: [] },
{ sieges: [[4, 1], [3, 2]], reserve: [] },
];
const renversee = aRebours(desordre);
// L'épreuve n'a d'objet que si l'ordre a réellement changé.
assert.deepEqual(Object.keys(renversee.evenement), Object.keys(chargeContrat().evenement).reverse());
assert.deepEqual(Object.keys(renversee)[0], 'retenu');
assert.deepEqual(renversee.participants.map((p) => p.id), [4, 2, 1, 3]);
assert.equal(serialiser(renversee, ENTETE), serialiser(chargeContrat(), ENTETE));
assert.equal(serialiser(renversee, ENTETE), TEXTE_CONTRAT);
});
test("une proposition, ou le retenu, à siegesAttribues vrai garde l'ordre de ses listes de table ; à faux, chacune se trie", () => {
const charge = chargeContrat();
charge.propositions[0].siegesAttribues = true;
charge.propositions[0].placement[0].sieges = [[3, 1], [4, 2]];
const attendu = remplacer(
remplacer(
TEXTE_CONTRAT,
`"produitVersion":"${VERSION.affichee}","siegesAttribues":false`,
`"produitVersion":"${VERSION.affichee}","siegesAttribues":true`,
),
'{"sieges":[[1,3],[2,4]],"reserve":[]}',
'{"sieges":[[3,1],[4,2]],"reserve":[]}',
);
assert.equal(serialiser(charge, ENTETE), attendu);
assert.deepEqual(canoniser(charge).propositions[0].placement[0].sieges, [[3, 1], [4, 2]]);
const retenu = chargeAvecRetenu();
assert.deepEqual(canoniser(retenu).retenu.placement[1].sieges, [[1, 4], [2, 3]]);
retenu.retenu.siegesAttribues = true;
assert.deepEqual(canoniser(retenu).retenu.placement[1].sieges, [[4, 1], [3, 2]]);
});
test("basculer le réglage attribuerSieges ne change l'ordre d'aucune proposition déjà écrite", () => {
// Deux propositions, l'une à sièges attribués dont les listes ne sont
// pas croissantes, l'autre à sièges non attribués ; un retenu à sièges
// attribués. Le réglage vaut tour à tour faux et vrai.
const charge = chargeAvecRetenu();
charge.retenu.siegesAttribues = true;
charge.propositions.push({
...charge.propositions[0],
id: 2,
siegesAttribues: true,
placement: [
{ sieges: [[3, 1], [4, 2]], reserve: [] },
{ sieges: [[4, 1], [3, 2]], reserve: [] },
],
});
const textes = [false, true].map((attribuerSieges) => {
charge.reglages.attribuerSieges = attribuerSieges;
return serialiser(charge, ENTETE).split('\n');
});
assert.equal(textes[1].length, textes[0].length);
const differentes = textes[0].flatMap((ligne, i) => (ligne === textes[1][i] ? [] : [i]));
assert.deepEqual(differentes.map((i) => textes[0][i].slice(0, 16)), [' "reglages": ']);
for (const lignes of textes) {
assert.ok(lignes.includes(' {"sieges":[[1,3],[2,4]],"reserve":[]},'));
assert.ok(lignes.includes(' {"sieges":[[3,1],[4,2]],"reserve":[]},'));
assert.ok(lignes.includes(' {"sieges":[[4,1],[3,2]],"reserve":[]}'));
}
});
test("l'ordre des tables d'une proposition est gardé : il apparie chaque liste à sa table", () => {
const charge = chargeContrat();
Object.assign(charge.propositions[0], {
tables: [2, 1],
capacites: [2, 2],
placement: [
{ sieges: [[4, 2], [3, 1]], reserve: [] },
{ sieges: [[3, 2], [4, 1]], reserve: [] },
],
});
const proposition = canoniser(charge).propositions[0];
assert.deepEqual(proposition.tables, [2, 1]);
assert.deepEqual(proposition.placement.map((tour) => tour.sieges), [[[2, 4], [1, 3]], [[2, 3], [1, 4]]]);
});
test("les participants d'un plan et chaque réserve sont des ensembles : ils se rangent par identifiant croissant, quel que soit siegesAttribues", () => {
// Au tour 1 du retenu et au tour 2 de la proposition, deux personnes
// attendent hors des tables. croissants faux pose chaque réserve et
// chaque liste de participants à rebours ; les listes de table, elles,
// sont les mêmes dans les deux charges.
const plans = (siegesAttribues, croissants) => {
const ordonner = (ids) => (croissants ? ids : [...ids].reverse());
const charge = chargeAvecRetenu();
for (const plan of [charge.propositions[0], charge.retenu]) {
Object.assign(plan, { siegesAttribues, participants: ordonner([1, 2, 3, 4]) });
}
charge.retenu.placement[0] = { sieges: [[1], [4]], reserve: ordonner([2, 3]) };
charge.propositions[0].placement[1] = { sieges: [[1], [2]], reserve: ordonner([3, 4]) };
return charge;
};
for (const siegesAttribues of [false, true]) {
assert.equal(serialiser(plans(siegesAttribues, false), ENTETE), serialiser(plans(siegesAttribues, true), ENTETE));
const copie = canoniser(plans(siegesAttribues, false));
assert.deepEqual(copie.propositions[0].participants, [1, 2, 3, 4]);
assert.deepEqual(copie.retenu.participants, [1, 2, 3, 4]);
assert.deepEqual(copie.retenu.placement[0].reserve, [2, 3]);
assert.deepEqual(copie.propositions[0].placement[1].reserve, [3, 4]);
}
});
test('tables et propositions se trient par identifiant', () => {
const charge = chargeContrat();
charge.tables.reverse();
const [premiere] = charge.propositions;
charge.propositions = [{ ...premiere, id: 3 }, premiere, { ...premiere, id: 2 }];
const copie = canoniser(charge);
assert.deepEqual(copie.tables.map((table) => table.id), [1, 2]);
assert.deepEqual(copie.propositions.map((proposition) => proposition.id), [1, 2, 3]);
});
test("des propositions de même identifiant se rangent par graine, arrêt, historique, puis texte canonique : ni l'ordre reçu ni l'écriture de chacune n'y entrent", () => {
// L'analyse n'examine pas les propositions : elle admet deux
// propositions de même identifiant. Les voici dans leur ordre canonique,
// chacune ne différant de la précédente que par ce qui les départage.
// Chaque clé numérique oppose 9 à 10, que l'ordre des textes rangerait à
// l'inverse. Les trois égales sur ces clés ne diffèrent que par leur
// placement : seul le texte canonique les départage. Une proposition 2
// de graine 1 les suit toutes.
const [premiere] = chargeContrat().propositions;
const [tourA, tourB] = premiere.placement;
const egales = [
[tourA, tourB],
[tourB, tourA],
[
{ sieges: [[2, 3], [1, 4]], reserve: [] },
{ sieges: [[2, 4], [1, 3]], reserve: [] },
],
].map((placement) => ({ ...premiere, graine: 10, arret: 10, historique: 10, placement }));
const rangees = [
{ ...premiere, graine: 9 },
{ ...premiere, graine: 10, arret: 9 },
{ ...premiere, graine: 10, arret: 10, historique: 9 },
...egales,
{ ...premiere, id: 2, graine: 1 },
];
// Deux écritures qui changent le JSON d'une proposition sans changer sa
// copie canonique : ses clés à rebours ; ou ses participants, ses
// réserves et, à sièges non attribués, ses listes de table à rebours.
const listesARebours = (proposition) => ({
...proposition,
participants: [...proposition.participants].reverse(),
placement: proposition.placement.map(({ sieges, reserve }) => ({
sieges: sieges.map((liste) => [...liste].reverse()),
reserve: [...reserve].reverse(),
})),
});
const avec = (propositions) => {
const charge = chargeContrat();
charge.propositions = propositions;
charge.prochainsIds.proposition = 3;
return charge;
};
const parTexte = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const texte = serialiser(avec(rangees), ENTETE);
for (const ecrire of [aRebours, listesARebours]) {
for (const parite of [0, 1]) {
// Une proposition sur deux change d'écriture, selon la parité de son
// rang canonique.
const ecrites = rangees.map((proposition, rang) => (rang % 2 === parite ? ecrire(proposition) : proposition));
const cas = `${ecrire.name}, parité ${parite}`;
// L'épreuve n'a d'objet que si le JSON des propositions égales, telles
// qu'écrites, les rangerait autrement que leur texte canonique.
const brutes = egales.map((proposition) => JSON.stringify(ecrites[rangees.indexOf(proposition)]));
assert.notDeepEqual([...brutes].sort(parTexte), brutes, cas);
for (const ordre of [[6, 5, 4, 3, 2, 1, 0], [2, 6, 0, 5, 3, 1, 4], [1, 0, 3, 2, 5, 4, 6]]) {
const recues = ordre.map((rang) => ecrites[rang]);
assert.deepEqual(canoniser(avec(recues)).propositions, rangees, `${cas}, ordre reçu ${ordre}`);
assert.equal(serialiser(avec(recues), ENTETE), texte, `${cas}, ordre reçu ${ordre}`);
// Le même désordre dans un texte que l'analyse admet se réécrit pareil.
const fichier = JSON.parse(texte);
fichier.charge.propositions = recues;
const relue = analyser(JSON.stringify(fichier)).charge;
assert.equal(serialiser(relue, ENTETE), texte, `texte lu, ${cas}, ordre ${ordre}`);
}
}
}
});
test('réservations triées par participant, table, portée — tous avant tour —, tour puis siège, null en tête', () => {
const reservation = (participant, table, portee, tour, siege) => ({ participant, table, siege, portee, tour });
const attendues = [
reservation(1, 1, 'tous', null, null),
reservation(1, 1, 'tous', null, 2),
reservation(1, 1, 'tour', 1, null),
reservation(1, 1, 'tour', 1, 1),
reservation(1, 1, 'tour', 1, 2),
reservation(1, 1, 'tour', 2, null),
reservation(1, 2, 'tour', 1, null),
reservation(2, 1, 'tour', 2, null),
];
const charge = chargeContrat();
// Chaque réservation arrive après celle qui la suit : seul le tri les
// remet en ordre.
charge.reservations = [...attendues].reverse();
assert.deepEqual(canoniser(charge).reservations, attendues);
});
test('titres triés par table puis siège, et par libellé à égalité', () => {
const titre = (table, siege, libelle) => ({ table, siege, libelle });
const charge = chargeContrat();
charge.titres = [titre(2, 1, 'b'), titre(1, 3, 'a'), titre(1, 1, 'z'), titre(1, 1, 'm')];
assert.deepEqual(canoniser(charge).titres, [titre(1, 1, 'm'), titre(1, 1, 'z'), titre(1, 3, 'a'), titre(2, 1, 'b')]);
});
test("deux textes se départagent unité UTF-16 par unité, ni selon la langue ni sans égard à la casse : libellés d'une même place, puis propositions qui ne diffèrent que par leur version", () => {
// Les capitales (0x41, 0x43) précèdent les minuscules, et é (0xE9) suit
// f (0x66) ; l'ordre de la langue rangerait a, A, b, C, é, f, et un
// ordre sans casse laisserait l'ordre reçu départager a et A.
const libelles = ['b', 'C', 'a', 'A', 'f', '\u{e9}'];
const [premiere] = chargeContrat().propositions;
for (const ordre of [libelles, [...libelles].reverse()]) {
const charge = chargeContrat();
charge.titres = ordre.map((libelle) => ({ table: 1, siege: 1, libelle }));
assert.deepEqual(canoniser(charge).titres.map(({ libelle }) => libelle), ['A', 'C', 'a', 'b', 'f', '\u{e9}']);
}
for (const ordre of [['b', 'C'], ['C', 'b']]) {
const charge = chargeContrat();
charge.propositions = ordre.map((produitVersion) => ({ ...premiere, produitVersion }));
assert.deepEqual(canoniser(charge).propositions.map(({ produitVersion }) => produitVersion), ['C', 'b']);
}
});
test("canoniser, serialiser et serialiserCharge laissent intacte la charge reçue, et la copie s'en détache", () => {
const desordre = chargeAvecRetenu();
desordre.participants.reverse();
desordre.propositions[0].placement[0].sieges = [[3, 1], [4, 2]];
const charge = geler(aRebours(desordre));
const avant = JSON.stringify(charge);
const copie = canoniser(charge);
serialiser(charge, ENTETE);
serialiserCharge(charge);
copie.participants[0].nom = 'Autre';
copie.propositions[0].placement[0].sieges[0].push(9);
copie.retenu.placement[0].reserve.push(9);
assert.equal(JSON.stringify(charge), avant);
});
test("les clés de la copie suivent l'ordre du schéma : JSON.stringify de la copie est canonique", () => {
const attendu = JSON.stringify(JSON.parse(TEXTE_CONTRAT).charge);
assert.equal(JSON.stringify(canoniser(aRebours(chargeContrat()))), attendu);
});
test('une clé du schéma absente lève TypeError au lieu de disparaître du texte', () => {
const charge = chargeContrat();
delete charge.participants[2].notes;
assert.throws(() => serialiser(charge, ENTETE), {
name: 'TypeError',
message: /charge\.participants\[2\]\.notes/,
});
assert.throws(() => canoniser(charge), { name: 'TypeError', message: /charge\.participants\[2\]\.notes/ });
const sansDrapeau = chargeContrat();
delete sansDrapeau.propositions[0].siegesAttribues;
assert.throws(() => canoniser(sansDrapeau), {
name: 'TypeError',
message: /charge\.propositions\[0\]\.siegesAttribues/,
});
assert.throws(() => serialiser(chargeContrat(), { produitVersion: VERSION.affichee }), {
name: 'TypeError',
message: /entete\.revision/,
});
});
});
describe('serialiserCharge : la charge seule (§ 8.8)', () => {
test("la charge s'écrit dans la mise en page du fichier, sans en-tête, et finit sur une fin de ligne", () => {
// Les lignes de la charge dans le texte du contrat, un niveau plus haut :
// l'accolade qui ouvre la clé charge, puis tout jusqu'à celle qui la ferme.
const lignes = TEXTE_CONTRAT.split('\n');
assert.equal(lignes[2], ' "charge": {');
assert.deepEqual(lignes.slice(-3), [' }', '}', '']);
const attendu = ['{', ...lignes.slice(3, -3).map((ligne) => ligne.slice(2)), '}', ''].join('\n');
assert.equal(serialiserCharge(chargeContrat()), attendu);
assert.equal(serialiserCharge(aRebours(chargeContrat())), attendu);
});
test("révision et produitVersion ne changent que la ligne de l'en-tête", () => {
const autre = versionVoisine(VERSION.affichee);
const avant = serialiser(chargeContrat(), ENTETE).split('\n');
const apres = serialiser(chargeContrat(), { revision: 41, produitVersion: autre }).split('\n');
assert.equal(apres.length, avant.length);
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}},`,
);
});
});
describe('aller-retour par analyser (§ 8.9)', () => {
test("serialiser(analyser(t).charge, en-tête de t) rend t : texte du contrat, retenu, listes vides", () => {
const textes = [TEXTE_CONTRAT, TEXTE_AVEC_RETENU, TEXTE_SANS_LISTE];
for (const texte of textes) {
const { entete, charge } = analyser(texte);
assert.equal(serialiser(charge, { revision: entete.revision, produitVersion: entete.produitVersion }), texte);
}
});
test("le sérialiseur ne normalise aucune chaîne : un nom en NFD s'écrit tel quel, et le texte relu se réécrit octet pour octet", () => {
// La conversion en NFC se fait à l'entrée, jamais à l'écriture : un
// fichier écrit à la main en NFD garde ses octets.
const nfd = 'Re\u{301}glisse';
assert.equal(nfd.length, 9);
assert.notEqual(nfd.normalize('NFC'), nfd);
const charge = chargeContrat();
charge.participants[3].nom = nfd;
const texte = serialiser(charge, ENTETE);
assert.ok(texte.includes(`"nom":"${nfd}"`));
const { entete, charge: relue } = analyser(texte);
assert.equal(relue.participants[3].nom, nfd);
assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), texte);
});
});
describe('un retenu hors de sa règle (§ 8.9, point 3)', () => {
// Façons d'abîmer le retenu de chargeAvecRetenu, chacune en place : sa
// règle le refuse, l'analyse, qui n'en lit que le conteneur, l'admet, et
// examiner le garde en le signalant.
const FACONS = [
['« tours » renommé « toura »', (r) => {
r.toura = r.tours;
delete r.tours;
}],
['une clé du schéma retirée', (r) => { delete r.siegesAttribues; }],
['un placement objet', (r) => { r.placement = {}; }],
['des capacités nombre', (r) => { r.capacites = 2; }],
['un tour devenu la liste de ses tables', (r) => { r.placement[0] = r.placement[0].sieges; }],
['une clé inconnue', (r) => { r.note = 'à revoir'; }],
['une clé inconnue dans un tour', (r) => { r.placement[1].commentaire = 'tour calme'; }],
['tours à 0', (r) => { r.tours = 0; }],
['une clé « __proto__ »', (r) => {
Object.defineProperty(r, '__proto__', { value: 'x', enumerable: true, writable: true, configurable: true });
}],
["des clés d'index entier", (r) => {
r['9'] = 'neuf';
r['10'] = 'dix';
}],
['un objet vide', (r) => {
for (const cle of Object.keys(r)) delete r[cle];
}],
];
const abimee = (facon) => {
const charge = chargeAvecRetenu();
facon(charge.retenu);
return charge;
};
const LIGNE_DU_RETENU = ' "retenu": ';
test("un retenu que sa règle refuse s'écrit compact à la clé retenu, recopié hors du schéma : ses clés rangées par unités UTF-16, ses listes dans l'ordre écrit, ses clés inconnues gardées", () => {
const charge = abimee(FACONS[0][1]);
charge.retenu.note = 'à revoir';
const attendu = remplacer(
remplacer(TEXTE_CONTRAT, '"retenu":0}}', '"retenu":1}}'),
' "retenu": null\n',
`${LIGNE_DU_RETENU}{"capacites":[2,2],"note":"à revoir","participants":[1,2,3,4],"placement":[{"reserve":[],"sieges":[[3,1],[2,4]]},{"reserve":[],"sieges":[[4,1],[3,2]]}],"proposition":1,"siegesAttribues":false,"tables":[1,2],"toura":2}\n`,
);
assert.equal(serialiser(charge, ENTETE), attendu);
assert.equal(serialiser(aRebours(charge), ENTETE), attendu);
});
test("chaque retenu que sa règle refuse et que l'analyse admet s'écrit, se relit au même retenu et se réécrit octet pour octet, quel que soit l'ordre de ses clés ; canoniser en rend une copie détachée", () => {
assert.ok(FACONS.length > 0, 'aucune façon examinée');
const ecarts = FACONS.flatMap(([libelle, facon]) => {
const charge = abimee(facon);
try {
const texte = serialiser(charge, ENTETE);
const ligne = texte.split('\n').find((candidate) => candidate.startsWith(LIGNE_DU_RETENU));
assert.deepEqual(JSON.parse(ligne.slice(LIGNE_DU_RETENU.length)), charge.retenu);
const { entete, charge: relue } = analyser(texte);
assert.deepEqual(relue.retenu, charge.retenu);
assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), texte);
assert.equal(serialiser(aRebours(charge), ENTETE), texte);
assert.equal(serialiserCharge(charge), serialiserCharge(relue));
const avant = JSON.stringify(charge.retenu);
const copie = canoniser(geler(structuredClone(charge))).retenu;
assert.deepEqual(copie, charge.retenu);
assert.equal(JSON.stringify(canoniser(charge).retenu), JSON.stringify(copie));
assert.equal(JSON.stringify(charge.retenu), avant);
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.name} ${erreur.message.split('\n')[0]}`];
}
});
assert.deepEqual(ecarts, []);
});
test("un retenu qui n'est pas un objet, que l'analyse refuse, lève TypeError : aucun texte qu'elle refuserait ne s'écrit", () => {
for (const retenu of [[], 42, 'retenu', false]) {
const charge = chargeContrat();
charge.retenu = retenu;
assert.throws(() => serialiser(charge, ENTETE), TypeError, JSON.stringify(retenu));
assert.throws(() => canoniser(charge), TypeError, JSON.stringify(retenu));
}
});
});
describe('déterminisme (§ 8.8, § 14.7)', () => {
test("serialiser, serialiserCharge et canoniser ne lisent ni horloge ni aléa : leur sortie ne dépend que de leurs arguments", () => {
// Le filet est éprouvé d'abord : chaque source lève pendant qu'il est tendu.
for (const lecture of [
() => Math.random(),
() => Date.now(),
() => new Date(),
() => performance.now(),
() => crypto.getRandomValues(new Uint8Array(1)),
() => crypto.randomUUID(),
]) {
assert.throws(() => sansHorlogeNiAlea(lecture), / lu$/);
}
assert.equal(typeof Date.now(), 'number');
const rendre = () => {
const charge = chargeAvecRetenu();
return [serialiser(charge, ENTETE), serialiserCharge(charge), JSON.stringify(canoniser(charge))];
};
assert.deepEqual(sansHorlogeNiAlea(rendre), rendre());
});
});

641
src/stockage/document.js Normal file
View file

@ -0,0 +1,641 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le document d'un événement (§ 4, § 6.1, § 8.1, § 8.8, § 9).
//
// SCHEMA décrit le fichier d'état en une seule table de règles : l'ordre des
// champs de chaque objet fait l'ordre des clés du texte canonique, et
// l'analyse parcourt la table pour contrôler la forme. Chaque règle porte
// aussi ce que canonique.js lit pour écrire : la mise en page et l'ordre des
// listes. Les formes sont décrites dans types.js. Ce parcours de la forme
// est le seul : premiereFaute le rend au contrôle des placements, et
// premiereFauteEnPlace aux correctifs.
//
// analyser lit un texte sans rien écrire et lève, à la première faute,
// ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), en contrôlant dans
// cet ordre : le texte vide (VIDE) ; le JSON (JSON) ; le format, lu avant
// 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, et le tour des réservations (FORME) ; les
// comptes de l'en-tête (COMPTES) ; les références (REFERENCE). 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).
//
// 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.
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { ErreurStockage } from './erreurs.js';
/** Version de format que ce code écrit (§ 8.8). */
export const FORMAT = 1;
/**
* Réglages de génération d'une charge neuve : cinq propositions de 200 000
* mouvements, valeurs de départ non mesurées (§ 5.10), et l'historique
* d'acceptation que le moteur prend par défaut.
*/
export const GENERATION_PAR_DEFAUT = Object.freeze({ nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
// 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.
function regle(genre, proprietes) {
return Object.freeze({ genre, nul: false, ...proprietes });
}
const chaine = (options) => regle('chaine', options);
const entier = (min, max = Infinity, options = {}) => regle('entier', { min, max, ...options });
const parmi = (...valeurs) => regle('parmi', { valeurs: Object.freeze(valeurs) });
const liste = (element, options) => regle('liste', { element, ...options });
const objet = (champs, options) =>
regle('objet', {
champs: Object.freeze(champs.map((champ) => Object.freeze(champ))),
cles: new Set(champs.map(([cle]) => cle)),
...options,
});
const CHAINE = chaine();
const CHAINE_OU_NUL = chaine({ nul: true });
const BOOLEEN = regle('booleen');
const NOMBRE = regle('nombre');
const IDENTIFIANT = entier(1);
const DATE_OU_NUL = regle('date', { nul: true });
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
// copies canoniques des éléments : clés dans l'ordre du schéma, listes
// intérieures déjà rangées. Chaque ordre est total : deux éléments qu'il
// tient pour égaux s'écrivent pareil, et le tri rend la même liste quel que
// soit l'ordre reçu. Participants et tables se rangent par identifiant,
// unique sur une charge que l'analyse admet ; réservations et titres se
// comparent sur tous leurs champs ; une liste d'identifiants, par valeur.
const croissant = (a, b) => a - b;
const parIdentifiant = (a, b) => a.id - b.id;
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// null avant tout entier, puis les entiers croissants.
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);
const ordreDesReservations = (a, b) =>
a.participant - b.participant ||
a.table - b.table ||
rangDePortee(a.portee) - rangDePortee(b.portee) ||
nulEnTete(a.tour, b.tour) ||
nulEnTete(a.siege, b.siege);
// Deux titres d'une même place se départagent par leur libellé.
const ordreDesTitres = (a, b) => a.table - b.table || a.siege - b.siege || comparerTextes(a.libelle, b.libelle);
// L'analyse n'examine des propositions que leur liste : deux d'entre elles
// peuvent partager un identifiant. À identifiant égal, la graine, le compte
// d'arrêt et l'historique les départagent, puis le JSON de leur copie
// canonique, qui ne coïncide que pour deux propositions écrites pareil.
const ordreDesPropositions = (a, b) =>
a.id - b.id ||
a.graine - b.graine ||
a.arret - b.arret ||
a.historique - b.historique ||
comparerTextes(JSON.stringify(a), JSON.stringify(b));
const COMPTES = objet([
['participants', entier(0)],
['tables', entier(0)],
['reservations', entier(0)],
['titres', entier(0)],
['propositions', entier(0)],
['retenu', entier(0, 1)],
]);
const ENTETE = objet([
['format', entier(1)],
['produitVersion', CHAINE],
['revision', entier(1)],
['comptes', COMPTES],
]);
const FILIATION = objet(
[
['source', objet([['id', CHAINE], ['nom', CHAINE]])],
['instant', objet([['revision', entier(1)], ['libelle', CHAINE]])],
],
{ nul: true },
);
const EVENEMENT = objet([
['id', CHAINE],
['nom', CHAINE],
['date', DATE_OU_NUL],
['siegesParDefaut', entier(2)],
['tours', entier(1)],
['unite', parmi('cm')],
['etat', parmi('brouillon', 'propose', 'retenu', 'bloque')],
['filiation', FILIATION],
]);
const REGLAGES = objet([
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
['nouvelleTable', BOOLEEN],
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
]);
const PARTICIPANT = objet([
['id', IDENTIFIANT],
['nom', CHAINE],
['prenom', CHAINE_OU_NUL],
['appartenance', CHAINE_OU_NUL],
['courriel', CHAINE_OU_NUL],
['titrePressenti', CHAINE_OU_NUL],
['notes', CHAINE_OU_NUL],
['exclu', BOOLEEN],
]);
const TABLE = objet([
['id', IDENTIFIANT],
['numero', entier(1)],
['sieges', entier(2, undefined, { nul: true })],
['forme', parmi('ronde', 'carree')],
['position', objet([['x', NOMBRE], ['y', NOMBRE]])],
]);
const RESERVATION = objet([
['participant', IDENTIFIANT],
['table', IDENTIFIANT],
['siege', entier(1, undefined, { nul: true })],
['portee', parmi('tous', 'tour')],
['tour', entier(1, undefined, { nul: true })],
]);
const TITRE = objet([
['table', IDENTIFIANT],
['siege', entier(1)],
['libelle', CHAINE],
]);
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
// chacun sur sa ligne. sieges porte une liste d'identifiants par table
// déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a
// siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par
// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
// trie par identifiant croissant, quel que soit le drapeau.
const PLACEMENT = liste(
objet([
['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))],
['reserve', liste(IDENTIFIANT, { tri: croissant })],
]),
{ mise: 'lignes' },
);
// Champs qu'une proposition et le retenu partagent, après ce qui les
// identifie : tables et capacités se correspondent rang à rang, et leur
// ordre se garde ; participants est l'ensemble des identifiants que le plan
// place, rangé par identifiant croissant.
const PLAN = [
['tables', liste(IDENTIFIANT)],
['capacites', liste(entier(2))],
['tours', entier(1)],
['participants', liste(IDENTIFIANT, { tri: croissant })],
['placement', PLACEMENT],
];
const PROPOSITION = objet(
[
['id', IDENTIFIANT],
['graine', entier(0, 2 ** 32 - 1)],
['arret', entier(1)],
['historique', entier(1)],
['produitVersion', CHAINE],
['siegesAttribues', BOOLEEN],
...PLAN,
],
{ mise: 'ouverte' },
);
const RETENU = objet([['proposition', IDENTIFIANT], ['siegesAttribues', BOOLEEN], ...PLAN], {
nul: true,
mise: 'ouverte',
aPart: true,
});
const CHARGE = objet(
[
['evenement', EVENEMENT],
['reglages', REGLAGES],
['prochainsIds', objet([['participant', IDENTIFIANT], ['table', IDENTIFIANT], ['proposition', IDENTIFIANT]])],
['participants', liste(PARTICIPANT, { mise: 'lignes', tri: parIdentifiant })],
['tables', liste(TABLE, { mise: 'lignes', tri: parIdentifiant })],
['reservations', liste(RESERVATION, { mise: 'lignes', tri: ordreDesReservations })],
['titres', liste(TITRE, { mise: 'lignes', tri: ordreDesTitres })],
['propositions', liste(PROPOSITION, { mise: 'lignes', tri: ordreDesPropositions, aPart: true })],
['retenu', RETENU],
],
{ mise: 'lignes' },
);
/**
* Le fichier d'état (§ 8.8) : l'en-tête, puis la charge. Une règle par
* valeur, dont types.js décrit la forme (Regle) ; l'ordre des champs de
* chaque objet est l'ordre des clés du texte canonique.
* @type {import('./types.js').Regle}
*/
export const SCHEMA = objet([['entete', ENTETE], ['charge', CHARGE]], { mise: 'lignes' });
/**
* Comptes de l'en-tête (§ 8.8) : pour chaque clé des comptes, la longueur de
* la liste de la charge du même nom, et 0 ou 1 pour le retenu. Les clés
* suivent l'ordre du schéma. canonique.js les écrit ; analyser les compare.
*
* @param {import('./types.js').Charge} charge
* @returns {import('./types.js').Comptes}
*/
export function comptesDe(charge) {
const comptes = {};
for (const [cle] of COMPTES.champs) {
const valeur = charge[cle];
comptes[cle] = Array.isArray(valeur) ? valeur.length : valeur === null ? 0 : 1;
}
return comptes;
}
/**
* Charge neuve (§ 4, § 9) : brouillon, ni participant ni table, aucune
* proposition, identifiants attribués à partir de 1. Les réglages activent
* les quatre contraintes, laissent les sièges non attribués et copient
* GENERATION_PAR_DEFAUT. Chaque appel rend des objets neufs. Les arguments
* ne sont pas contrôlés ici : ce contrôle appartient à la commande qui crée
* l'événement.
*
* @param {{id: string, nom: string, date?: string|null, siegesParDefaut: number, tours: number}} evenement
* @returns {import('./types.js').Charge}
*/
export function creerCharge({ id, nom, date = null, siegesParDefaut, tours }) {
return {
evenement: { id, nom, date, siegesParDefaut, tours, unite: 'cm', etat: 'brouillon', filiation: null },
reglages: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
attribuerSieges: false,
generation: { ...GENERATION_PAR_DEFAUT },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
};
}
const MARQUE_ORDRE_OCTETS = '\uFEFF';
// Lève le refus d'un état illisible ; plus ajoute ses détails après la
// raison et le chemin.
function illisible(raison, chemin, plus = {}) {
throw new ErreurStockage('ETAT_ILLISIBLE', { raison, chemin, ...plus });
}
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
// Chemin de la clé cle sous chemin : après un point quand elle s'écrit comme
// un identifiant, entre crochets en JSON sinon ; la racine est ''.
const IDENTIFIANT_JS = /^[A-Za-z_$][\w$]*$/;
function joindre(chemin, cle) {
if (!IDENTIFIANT_JS.test(cle)) return `${chemin}[${JSON.stringify(cle)}]`;
return chemin === '' ? cle : `${chemin}.${cle}`;
}
// AAAA-MM-JJ d'un jour du calendrier grégorien : mois de 1 à 12, jour de 1
// au dernier du mois, février à 29 jours les années bissextiles.
const AAAA_MM_JJ = /^(\d{4})-(\d{2})-(\d{2})$/;
const JOURS_PAR_MOIS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function estJour(texte) {
const morceaux = AAAA_MM_JJ.exec(texte);
if (morceaux === null) return false;
const [annee, mois, jour] = morceaux.slice(1).map(Number);
const bissextile = annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0);
const dernier = mois === 2 && bissextile ? 29 : JOURS_PAR_MOIS[mois - 1];
return mois >= 1 && mois <= 12 && jour >= 1 && jour <= dernier;
}
// 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 }) {
switch (genre) {
case 'objet':
return estObjet(valeur);
case 'liste':
return Array.isArray(valeur);
case 'chaine':
return typeof valeur === 'string' && valeur !== '';
case 'entier':
return Number.isSafeInteger(valeur) && valeur >= min && valeur <= max;
case 'nombre':
return Number.isFinite(valeur);
case 'booleen':
return typeof valeur === 'boolean';
case 'parmi':
return valeurs.includes(valeur);
case 'date':
return typeof valeur === 'string' && estJour(valeur);
}
}
/**
* Clés propres de l'objet valeur, rangées par unités UTF-16 croissantes :
* leur ordre ne dépend que de leur ensemble, non de l'ordre dans lequel
* l'objet les a reçues. C'est le seul parcours des clés d'un objet du
* stockage : l'analyse y cherche une clé inconnue, et canonique.js recopie
* ainsi ce que le schéma ne décrit pas.
*
* @param {Object} valeur
* @returns {string[]}
*/
export function clesRangees(valeur) {
return Object.keys(valeur).sort(comparerTextes);
}
// Plus petite des clés de valeur absentes de cles, en comparant les unités
// UTF-16, ou undefined.
const cleInconnue = (valeur, cles) => clesRangees(valeur).find((cle) => !cles.has(cle));
// Format du fichier lu, contrôlé avant toute autre règle : il dit comment
// lire le reste. Une racine qui n'est pas un objet, un en-tête ou un format
// absents sont des fautes de forme ; un format qui n'est pas un entier ≥ 1
// est inconnu.
function lireFormat(lu) {
if (!estObjet(lu)) illisible('FORME', '');
if (!estObjet(lu.entete)) illisible('FORME', 'entete');
if (lu.entete.format === undefined) illisible('FORME', 'entete.format');
const { format } = lu.entete;
if (!Number.isSafeInteger(format) || format < 1) illisible('FORMAT_INCONNU', 'entete.format');
return format;
}
// Valeur réduite aux clés que sa règle connaît, en profondeur, chaque objet
// refait dans l'ordre du schéma, propositions et retenu compris : la lecture
// d'un format plus récent ignore les clés qu'elle ne connaît pas, et un tel
// fichier ne se réécrit jamais. Une valeur qui n'a pas la sorte de sa règle
// passe telle quelle : le contrôle de forme la nomme.
function reduire(valeur, regleDeValeur) {
if (regleDeValeur.genre === 'objet' && estObjet(valeur)) {
const reduite = {};
for (const [cle, regleDuChamp] of regleDeValeur.champs) {
if (valeur[cle] !== undefined) reduite[cle] = reduire(valeur[cle], regleDuChamp);
}
return reduite;
}
if (regleDeValeur.genre === 'liste' && Array.isArray(valeur)) {
return valeur.map((element) => reduire(element, regleDeValeur.element));
}
return valeur;
}
// Chemin de la première valeur qui sort de sa règle, ou null. Une règle
// aPart rencontrée sous une autre n'examine que son conteneur ; racine
// vrai fait examiner tout le contenu de la règle donnée, même aPart, et
// faux la lit à sa place sous une autre, comme l'analyse.
function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
if (valeur === null && regleDeValeur.nul) return null;
if (!conforme(valeur, regleDeValeur)) return chemin;
if (regleDeValeur.aPart && !racine) return null;
if (regleDeValeur.genre === 'objet') {
for (const [cle, regleDuChamp] of regleDeValeur.champs) {
const faute = fauteDeForme(valeur[cle], regleDuChamp, joindre(chemin, cle), false);
if (faute !== null) return faute;
}
const inconnue = cleInconnue(valeur, regleDeValeur.cles);
return inconnue === undefined ? null : joindre(chemin, inconnue);
}
if (regleDeValeur.genre === 'liste') {
for (let rang = 0; rang < valeur.length; rang += 1) {
const faute = fauteDeForme(valeur[rang], regleDeValeur.element, `${chemin}[${rang}]`, false);
if (faute !== null) return faute;
}
}
return null;
}
/**
* Chemin de la première valeur qui sort de sa règle, en profondeur, ou null
* quand valeur est conforme ; ne lève jamais. D'un objet, chaque champ dans
* l'ordre du schéma, puis la plus petite de ses clés inconnues ; d'une
* liste, chaque élément dans l'ordre des rangs. null est conforme à une
* règle qui l'admet. Un champ absent se lit undefined, qu'aucune règle
* n'admet, et se nomme par son chemin comme une valeur fautive. Le chemin
* suit la convention d'analyser, à partir de chemin, '' par défaut.
*
* Une règle aPart rencontrée sous une autre n'examine que son conteneur :
* son contenu s'examine à part, la règle donnée ici pour racine. analyser
* contrôle ainsi le fichier sans entrer dans les propositions ni dans le
* retenu ; le contrôle des placements les examine chacun sous leur règle.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @param {string} [chemin]
* @returns {string|null}
*/
export function premiereFaute(valeur, regle, chemin = '') {
return fauteDeForme(valeur, regle, chemin, true);
}
/**
* premiereFaute de valeur lue à la place d'une règle regle sous une autre,
* comme l'analyse la lit : une règle aPart n'y examine que son conteneur,
* même donnée ici. Les correctifs contrôlent ainsi une valeur posée là où
* l'analyse la lirait.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @param {string} [chemin]
* @returns {string|null}
*/
export function premiereFauteEnPlace(valeur, regle, chemin = '') {
return fauteDeForme(valeur, regle, chemin, false);
}
// Identifiants des participants, puis des tables : chacun unique, et sous
// prochainsIds, qui ne recule jamais (§ 4) ; d'un doublon, le second est
// fautif. Ceux des propositions et la proposition d'origine du retenu ne se
// comparent pas ici : une proposition qui contredit prochainsIds.proposition
// tombe seule, et le retenu se signale sans fermer la saisie (§ 8.9, point
// 3). faute(chemin) lève l'écart.
function examinerIdentifiants({ prochainsIds, participants, tables }, faute) {
for (const [cle, enregistrements, prochain] of [
['participants', participants, prochainsIds.participant],
['tables', tables, prochainsIds.table],
]) {
const vus = new Set();
enregistrements.forEach(({ id }, rang) => {
if (id >= prochain || vus.has(id)) faute(`charge.${cle}[${rang}].id`);
vus.add(id);
});
}
}
// Tour de chaque réservation : null pour la portée « tous », de 1 à R pour
// la portée « tour » ; faute(chemin) lève l'écart.
function examinerTours({ evenement, reservations }, faute) {
reservations.forEach(({ portee, tour }, rang) => {
const admis = portee === 'tous' ? tour === null : tour !== null && tour <= evenement.tours;
if (!admis) faute(`charge.reservations[${rang}].tour`);
});
}
// Chaque compte de l'en-tête contre la charge, dans l'ordre du schéma ;
// plus s'ajoute aux détails du refus.
function examinerComptes(comptes, charge, plus) {
const reels = comptesDe(charge);
for (const [cle] of COMPTES.champs) {
if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus);
}
}
// Références des réservations, puis des titres : la personne et la table
// désignées existent, et le siège, quand il est donné, tient dans la
// capacité courante de la table (§ 6.2). La réservation d'une personne
// exclue reste admise : elle est suspendue, non fautive (§ 4.4). plus
// s'ajoute aux détails du refus.
function examinerReferences(charge, plus) {
const personnes = new Set(charge.participants.map(({ id }) => id));
const tables = new Map(charge.tables.map((table) => [table.id, table]));
const examinerPlace = ({ table, siege }, chemin) => {
const designee = tables.get(table);
if (designee === undefined) illisible('REFERENCE', `${chemin}.table`, plus);
if (siege !== null && siege > capacite(charge, designee)) illisible('REFERENCE', `${chemin}.siege`, plus);
};
charge.reservations.forEach((reservation, rang) => {
const chemin = `charge.reservations[${rang}]`;
if (!personnes.has(reservation.participant)) illisible('REFERENCE', `${chemin}.participant`, plus);
examinerPlace(reservation, chemin);
});
charge.titres.forEach((titre, rang) => examinerPlace(titre, `charge.titres[${rang}]`));
}
/**
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge et
* formatPlusRecent, vrai quand le format dépasse FORMAT. Au format courant,
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
* charge rendus sont des copies réduites aux clés que le schéma connaît, et
* une faute de forme y porte la raison FORMAT_PLUS_RECENT ; les comptes et
* les références gardent leur raison, et chaque refus porte le format lu
* dans ses détails. Des propositions et du retenu, seul le conteneur se
* contrôle ici : examiner (placements.js) juge le reste, identifiants
* compris, sans refuser le fichier (§ 8.9, point 3).
*
* Lève ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), et format
* pour un fichier plus récent, à la première faute, dans l'ordre que donne
* l'en-tête du module. chemin désigne l'élément fautif : clés séparées par
* un point, rangs entre crochets, une clé qui ne s'écrit pas comme un
* identifiant entre crochets en JSON ; '' pour la racine ; null quand le
* texte n'a rien à désigner (VIDE, JSON). D'un objet, un champ du schéma
* fautif est nommé avant une clé inconnue, et de plusieurs clés inconnues,
* la plus petite.
*
* @param {string} texte
* @returns {import('./types.js').Lecture}
*/
export function analyser(texte) {
const sansMarque = texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte;
if (sansMarque.trim() === '') illisible('VIDE', null);
let lu;
try {
lu = JSON.parse(sansMarque);
} catch {
illisible('JSON', null);
}
const format = lireFormat(lu);
const formatPlusRecent = format > FORMAT;
// Tout refus d'un fichier plus récent porte le format lu.
const plus = formatPlusRecent ? { format } : {};
const fichier = formatPlusRecent ? reduire(lu, SCHEMA) : lu;
const faute = (chemin) => illisible(formatPlusRecent ? 'FORMAT_PLUS_RECENT' : 'FORME', chemin, plus);
const cheminFautif = premiereFaute(fichier, SCHEMA);
if (cheminFautif !== null) faute(cheminFautif);
const { entete, charge } = fichier;
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus);
return { entete, charge, formatPlusRecent };
}
/**
* 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 listes gardent l'ordre de la charge : le rang d'une
* réservation que nomme une ErreurConfiguration est son rang dans la charge.
*
* @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.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,
},
};
}
/**
* État que le contenu impose à une copie (§ 8.7, § 9) : retenu quand un
* placement est retenu, proposé quand des propositions existent, brouillon
* sinon. L'état inscrit dans l'événement n'est pas lu : la copie d'un plan
* bloqué n'est pas bloquée.
*
* @param {import('./types.js').Charge} charge
* @returns {'brouillon'|'propose'|'retenu'}
*/
export function etatDeduit({ retenu, propositions }) {
if (retenu !== null) return 'retenu';
return propositions.length > 0 ? 'propose' : 'brouillon';
}
/**
* 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).
* table est l'enregistrement de charge.tables : un identifiant lève
* TypeError, au lieu de rendre le défaut sans bruit.
*
* @param {import('./types.js').Charge} charge
* @param {import('./types.js').Table} table
* @returns {number}
*/
export function capacite(charge, table) {
if (!estObjet(table)) {
throw new TypeError(`capacite : enregistrement de table attendu, reçu ${JSON.stringify(table)}`);
}
return table.sieges ?? charge.evenement.siegesParDefaut;
}

View file

@ -0,0 +1,953 @@
// © 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
// dépend pas de la mise en page. Les noms sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { STATUT, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import {
FORMAT,
GENERATION_PAR_DEFAUT,
SCHEMA,
analyser,
capacite,
configurationDepuisCharge,
creerCharge,
etatDeduit,
premiereFaute,
premiereFauteEnPlace,
} from './document.js';
import { ErreurStockage } from './erreurs.js';
// Un document valide : quatre participants, dont un exclu, l'identifiant 4
// retiré et jamais réattribué ; deux tables, dont une surchargée ; une
// réservation de portée « tous » à siège donné et une de tour désigné sans
// siège ; un titre ; une proposition ; une filiation. Une copie neuve à
// chaque appel, que chaque épreuve abîme à sa façon.
function documentValide() {
return {
entete: {
format: 1,
produitVersion: VERSION.affichee,
revision: 12,
comptes: { participants: 4, tables: 2, reservations: 2, titres: 1, propositions: 1, retenu: 0 },
},
charge: {
evenement: {
id: 'evt-lucioles',
nom: 'Veillée des Lucioles',
date: '2026-11-12',
siegesParDefaut: 3,
tours: 2,
unite: 'cm',
etat: 'propose',
filiation: {
source: { id: 'evt-modele', nom: 'Veillée modèle' },
instant: { revision: 7, libelle: 'Tables posées' },
},
},
reglages: {
separerAppartenances: true,
nouveauxVoisins: false,
nouvelleTable: true,
varierAppartenances: false,
attribuerSieges: false,
generation: { nombre: 3, arret: 50_000, historique: 500 },
},
prochainsIds: { participant: 6, table: 4, proposition: 2 },
participants: [
{ id: 1, nom: 'Brindille', prenom: 'Anouk', appartenance: 'Chorale du Vallon',
courriel: 'anouk@exemple.test', titrePressenti: 'animation', notes: null, exclu: false },
{ id: 2, nom: 'Sarrasin', prenom: null, appartenance: 'Chorale du Vallon', courriel: null,
titrePressenti: null, notes: 'part à 21 h', exclu: false },
{ id: 3, nom: 'Coquelicot', prenom: 'Basile', appartenance: null, courriel: null,
titrePressenti: null, notes: null, exclu: true },
{ id: 5, nom: 'Mirabelle', prenom: 'Ysé', appartenance: 'Cercle Gamma', courriel: null,
titrePressenti: null, notes: null, exclu: false },
],
tables: [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
{ id: 3, numero: 2, sieges: 4, forme: 'carree', position: { x: 120.5, y: -40 } },
],
reservations: [
{ participant: 1, table: 3, siege: 4, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: null, portee: 'tour', tour: 2 },
],
titres: [{ table: 3, siege: 4, libelle: 'animation' }],
propositions: [
{
id: 1,
graine: 7,
arret: 50_000,
historique: 500,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: [1, 3],
capacites: [3, 4],
tours: 2,
participants: [1, 2, 5],
placement: [
{ sieges: [[2, 5], [1]], reserve: [] },
{ sieges: [[2], [1, 5]], reserve: [] },
],
},
],
retenu: null,
},
};
}
// Un retenu bien formé pour le document valide.
const retenuValide = () => ({
proposition: 1,
siegesAttribues: false,
tables: [1, 3],
capacites: [3, 4],
tours: 2,
participants: [1, 2, 5],
placement: [
{ sieges: [[2, 5], [1]], reserve: [] },
{ sieges: [[2], [1, 5]], reserve: [] },
],
});
// Texte du document valide, une fois modifier passé sur lui.
function abime(modifier) {
const copie = documentValide();
modifier(copie);
return JSON.stringify(copie);
}
// texte où avant, qui doit y figurer exactement une fois, devient apres :
// pour une valeur que JSON.stringify n'écrit pas. Un remplacement qui ne
// trouve rien échoue au lieu de rendre le texte inchangé.
function remplacerUneFois(texte, avant, apres) {
assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`);
return texte.replace(avant, () => apres);
}
// Écart entre ce que fait analyser(texte) et le refus ETAT_ILLISIBLE dont
// les détails sont la raison et le chemin attendus, puis ceux de plus ; null
// quand ils concordent.
function ecartDeRefus(texte, raison, chemin, plus = {}) {
const attendus = { raison, chemin, ...plus };
try {
analyser(texte);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) {
return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`;
}
if (erreur.code !== 'ETAT_ILLISIBLE') return `code ${erreur.code} au lieu de ETAT_ILLISIBLE`;
try {
assert.deepEqual(erreur.details, attendus);
} catch {
return `détails ${JSON.stringify(erreur.details)} au lieu de ${JSON.stringify(attendus)}`;
}
return null;
}
return 'aucun refus';
}
// Chaque cas [libellé, texte, raison, chemin, détails en plus] dont le refus
// s'écarte de l'attendu, en une ligne lisible.
function ecartsDeRefus(cas) {
assert.ok(cas.length > 0, 'aucun cas examiné');
return cas.flatMap(([libelle, texte, raison, chemin, plus]) => {
const ecart = ecartDeRefus(texte, raison, chemin, plus);
return ecart === null ? [] : [`${libelle} : ${ecart}`];
});
}
// Étapes — clés d'objet et rangs de liste — menant à chaque valeur que
// l'analyse examine dans valeur, parents avant enfants, hors de l'intérieur
// des propositions et du retenu, que l'analyse laisse au contrôle des
// placements.
function etapesDe(valeur, prefixe = []) {
const aPart =
prefixe.length === 2 && prefixe[0] === 'charge' && (prefixe[1] === 'propositions' || prefixe[1] === 'retenu');
if (aPart || typeof valeur !== 'object' || valeur === null) return [];
const suivantes = Array.isArray(valeur) ? valeur.map((_, rang) => rang) : Object.keys(valeur);
return suivantes.flatMap((etape) => [[...prefixe, etape], ...etapesDe(valeur[etape], [...prefixe, etape])]);
}
// Chemin d'ETAT_ILLISIBLE de ces étapes : clés séparées par un point, rangs
// entre crochets.
const cheminDe = (etapes) =>
etapes.map((etape, i) => (typeof etape === 'number' ? `[${etape}]` : i === 0 ? etape : `.${etape}`)).join('');
// Valeur au bout de ces étapes dans racine.
const valeurEn = (racine, etapes) => etapes.reduce((valeur, etape) => valeur[etape], racine);
// Chemin de ces étapes, rangs effacés : charge.participants[].nom.
const sansRangs = (etapes) => cheminDe(etapes).replace(/\[\d+\]/g, '[]');
// Chemins, rangs effacés, des valeurs que le contrat de données admet
// nulles. La liste s'écrit d'après le contrat, sans lire le schéma : un
// champ que le schéma rendrait nullable à tort reste ainsi éprouvé.
const ADMETTENT_NULL = new Set([
'charge.evenement.date',
'charge.evenement.filiation',
'charge.participants[].prenom',
'charge.participants[].appartenance',
'charge.participants[].courriel',
'charge.participants[].titrePressenti',
'charge.participants[].notes',
'charge.tables[].sieges',
'charge.reservations[].siege',
'charge.reservations[].tour',
'charge.retenu',
]);
// Gèle valeur et tout ce qu'elle contient : une écriture y lève TypeError,
// le module s'exécutant en mode strict.
function geler(valeur) {
if (typeof valeur === 'object' && valeur !== null) {
for (const enfant of Object.values(valeur)) geler(enfant);
Object.freeze(valeur);
}
return valeur;
}
describe('ErreurStockage (erreurs.js)', () => {
test('porte son code et ses détails ; le message les écrit, code puis détails en JSON', () => {
const erreur = new ErreurStockage('ABSENT', { chemin: 'veillee.gtt.json' });
assert.ok(erreur instanceof Error);
assert.equal(erreur.name, 'ErreurStockage');
assert.equal(erreur.code, 'ABSENT');
assert.deepEqual(erreur.details, { chemin: 'veillee.gtt.json' });
assert.equal(erreur.message, 'ABSENT {"chemin":"veillee.gtt.json"}');
const sansDetails = new ErreurStockage('CORRECTIF');
assert.deepEqual(sansDetails.details, {});
assert.equal(sansDetails.message, 'CORRECTIF {}');
});
});
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);
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', date: '2026-11-12', siegesParDefaut: 8, tours: 3 });
assert.deepEqual(charge, {
evenement: {
id: 'evt-neuf',
nom: 'Atelier du jeudi',
date: '2026-11-12',
siegesParDefaut: 8,
tours: 3,
unite: 'cm',
etat: 'brouillon',
filiation: null,
},
reglages: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
attribuerSieges: false,
generation: { nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
});
});
test("GENERATION_PAR_DEFAUT : cinq propositions de 200 000 mouvements, l'historique par défaut du moteur ; figée, copiée dans chaque charge neuve", () => {
assert.deepEqual(GENERATION_PAR_DEFAUT, { nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
assert.ok(Object.isFrozen(GENERATION_PAR_DEFAUT));
const { generation } = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 }).reglages;
assert.deepEqual(generation, GENERATION_PAR_DEFAUT);
assert.notEqual(generation, GENERATION_PAR_DEFAUT);
assert.ok(!Object.isFrozen(generation));
});
test('sans date, la date est nulle ; deux charges neuves ne partagent aucun objet', () => {
const premiere = creerCharge({ id: 'evt-a', nom: 'Veillée A', siegesParDefaut: 6, tours: 2 });
const seconde = creerCharge({ id: 'evt-b', nom: 'Veillée B', siegesParDefaut: 6, tours: 2 });
assert.equal(premiere.evenement.date, null);
premiere.reglages.generation.nombre = 9;
premiere.prochainsIds.participant = 2;
premiere.participants.push({ id: 1 });
assert.equal(seconde.reglages.generation.nombre, 5);
assert.equal(seconde.prochainsIds.participant, 1);
assert.deepEqual(seconde.participants, []);
});
test("une charge neuve s'écrit, se relit à l'identique, et son état déduit est brouillon", () => {
const charge = creerCharge({ id: 'evt-neuf', nom: 'Atelier du jeudi', siegesParDefaut: 8, tours: 3 });
const lecture = analyser(serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }));
assert.deepEqual(lecture.charge, charge);
assert.equal(lecture.formatPlusRecent, false);
assert.equal(etatDeduit(lecture.charge), 'brouillon');
});
});
describe('analyser : ce qui se lit (§ 8.8)', () => {
test('le document valide se lit : son en-tête et sa charge tels que le texte les porte, au format courant', () => {
const { entete, charge } = documentValide();
assert.deepEqual(analyser(JSON.stringify(documentValide())), { entete, charge, formatPlusRecent: false });
});
test('variantes admises : chacune se lit sans refus', () => {
const variantes = [
['date nulle', abime((d) => { d.charge.evenement.date = null; })],
["29 février d'une année bissextile", abime((d) => { d.charge.evenement.date = '2024-02-29'; })],
['29 février 2000, séculaire divisible par 400', abime((d) => { d.charge.evenement.date = '2000-02-29'; })],
['31 décembre', abime((d) => { d.charge.evenement.date = '2026-12-31'; })],
['filiation nulle', abime((d) => { d.charge.evenement.filiation = null; })],
['table de deux sièges', abime((d) => {
d.charge.tables[1].sieges = 2;
d.charge.reservations[0].siege = 2;
d.charge.titres[0].siege = 1;
})],
["réservation d'une personne exclue", abime((d) => {
d.charge.reservations.push({ participant: 3, table: 1, siege: null, portee: 'tous', tour: null });
d.entete.comptes.reservations = 3;
})],
['réservation au dernier tour', abime((d) => { d.charge.reservations[1].tour = 2; })],
["texte précédé d'une marque d'ordre d'octets", `\uFEFF${JSON.stringify(documentValide())}`],
['texte mis en page autrement', JSON.stringify(documentValide(), null, 4)],
["propositions fautives, que l'analyse ne contrôle pas", abime((d) => {
const [premiere] = d.charge.propositions;
d.charge.propositions.push(42, { id: 1, inconnue: true }, { ...premiere, placement: [{ sieges: [[9]] }] });
d.entete.comptes.propositions = 4;
})],
['retenu quelconque', abime((d) => {
d.charge.retenu = { inconnue: [] };
d.entete.comptes.retenu = 1;
})],
// Un identifiant de proposition qui n'en est pas un ne s'attribue pas :
// il ne se compare pas à prochainsIds.proposition, et le contrôle des
// placements écarte la proposition ou signale le retenu.
["identifiants de proposition qui n'en sont pas, au-delà de prochainsIds.proposition", abime((d) => {
d.charge.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 53 }, [9]);
d.entete.comptes.propositions = 6;
d.charge.retenu = { ...retenuValide(), proposition: '9' };
d.entete.comptes.retenu = 1;
})],
['deux propositions de même identifiant, sous prochainsIds.proposition', abime((d) => {
d.charge.propositions.push({ ...d.charge.propositions[0], graine: 8 });
d.entete.comptes.propositions = 2;
})],
['retenu bien formé', abime((d) => {
d.charge.retenu = retenuValide();
d.entete.comptes.retenu = 1;
})],
['toutes les listes vides', abime((d) => {
for (const cle of ['participants', 'tables', 'reservations', 'titres', 'propositions']) {
d.charge[cle] = [];
d.entete.comptes[cle] = 0;
}
})],
];
const refusees = variantes.flatMap(([libelle, texte]) => {
try {
analyser(texte);
return [];
} catch (erreur) {
return [`${libelle} : ${erreur.message}`];
}
});
assert.deepEqual(refusees, []);
});
});
describe('analyser : les refus que le plan nomme (§ 8.8)', () => {
test('vide, JSON, clé inconnue, identifiant égal à prochainsIds, comptes, référence, format 0 : chacun avec sa raison et son chemin', () => {
assert.deepEqual(ecartsDeRefus([
['texte vide', '', 'VIDE', null],
['« { »', '{', 'JSON', null],
['clé inconnue dans un participant',
abime((d) => { d.charge.participants[1].surnom = 'Sasa'; }), 'FORME', 'charge.participants[1].surnom'],
['identifiant de participant égal à prochainsIds.participant',
abime((d) => { d.charge.participants[3].id = 6; }), 'FORME', 'charge.participants[3].id'],
['comptes faux',
abime((d) => { d.entete.comptes.participants = 5; }), 'COMPTES', 'entete.comptes.participants'],
['réservation vers la personne 9, absente',
abime((d) => { d.charge.reservations[0].participant = 9; }), 'REFERENCE', 'charge.reservations[0].participant'],
['format 0', abime((d) => { d.entete.format = 0; }), 'FORMAT_INCONNU', 'entete.format'],
]), []);
});
});
describe('analyser : texte vide, JSON, racine et format', () => {
test('chaque refus porte sa raison et son chemin ; null quand aucun élément ne se désigne', () => {
const valide = JSON.stringify(documentValide());
assert.deepEqual(ecartsDeRefus([
['blancs seuls', ' \n\t\r\n ', 'VIDE', null],
["marque d'ordre d'octets suivie de blancs", '\uFEFF \n', 'VIDE', null],
['texte tronqué', valide.slice(0, -20), 'JSON', null],
['deux documents à la suite', `${valide}${valide}`, 'JSON', null],
['racine qui est une liste', '[]', 'FORME', ''],
['racine nulle', 'null', 'FORME', ''],
['racine qui est un nombre', '3', 'FORME', ''],
['en-tête absent', abime((d) => { delete d.entete; }), 'FORME', 'entete'],
['en-tête qui est un nombre', abime((d) => { d.entete = 1; }), 'FORME', 'entete'],
['format absent', abime((d) => { delete d.entete.format; }), 'FORME', 'entete.format'],
['format négatif', abime((d) => { d.entete.format = -1; }), 'FORMAT_INCONNU', 'entete.format'],
['format non entier', abime((d) => { d.entete.format = 1.5; }), 'FORMAT_INCONNU', 'entete.format'],
['format en texte', abime((d) => { d.entete.format = '1'; }), 'FORMAT_INCONNU', 'entete.format'],
['format nul', abime((d) => { d.entete.format = null; }), 'FORMAT_INCONNU', 'entete.format'],
]), []);
});
});
describe('analyser : forme (§ 8.8)', () => {
test('chaque clé du contrat, retirée, est nommée par son chemin', () => {
const etapes = etapesDe(documentValide()).filter((e) => typeof e.at(-1) === 'string');
assert.ok(etapes.length >= 100, `${etapes.length} clés seulement`);
assert.deepEqual(ecartsDeRefus(etapes.map((e) => [
`sans ${cheminDe(e)}`,
abime((d) => { delete valeurEn(d, e.slice(0, -1))[e.at(-1)]; }),
'FORME',
cheminDe(e),
])), []);
});
test('une clé inconnue, dans chaque objet que le contrat décrit, racine comprise, est nommée par son chemin', () => {
const objets = [[], ...etapesDe(documentValide())].filter((e) => {
const valeur = valeurEn(documentValide(), e);
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
});
assert.ok(objets.length >= 20, `${objets.length} objets seulement`);
assert.deepEqual(ecartsDeRefus(objets.map((e) => [
`inconnue sous ${cheminDe(e) || 'la racine'}`,
abime((d) => { valeurEn(d, e).inconnue = 1; }),
'FORME',
cheminDe([...e, 'inconnue']),
])), []);
});
test("chaque valeur remplacée par une valeur d'une autre sorte est nommée par son chemin", () => {
// Une liste devient un objet, toute autre valeur une liste : aucune règle
// du contrat n'admet l'un pour l'autre. Le format, lu avant tout le reste,
// a sa propre raison.
const etapes = etapesDe(documentValide());
assert.deepEqual(ecartsDeRefus(etapes.map((e) => {
const chemin = cheminDe(e);
return [
`${chemin} d'une autre sorte`,
abime((d) => {
const porteur = valeurEn(d, e.slice(0, -1));
porteur[e.at(-1)] = Array.isArray(porteur[e.at(-1)]) ? {} : [];
}),
chemin === 'entete.format' ? 'FORMAT_INCONNU' : 'FORME',
chemin,
];
})), []);
});
test('chaque valeur hors de son domaine est nommée par son chemin', () => {
const cas = [
['produitVersion vide', (d) => { d.entete.produitVersion = ''; }, 'entete.produitVersion'],
['révision 0', (d) => { d.entete.revision = 0; }, 'entete.revision'],
['révision non entière', (d) => { d.entete.revision = 2.5; }, 'entete.revision'],
['compte négatif', (d) => { d.entete.comptes.titres = -1; }, 'entete.comptes.titres'],
['compte de retenu 2', (d) => { d.entete.comptes.retenu = 2; }, 'entete.comptes.retenu'],
["identifiant d'événement vide", (d) => { d.charge.evenement.id = ''; }, 'charge.evenement.id'],
["nom d'événement vide", (d) => { d.charge.evenement.nom = ''; }, 'charge.evenement.nom'],
['30 février', (d) => { d.charge.evenement.date = '2026-02-30'; }, 'charge.evenement.date'],
["29 février d'une année ordinaire", (d) => { d.charge.evenement.date = '2026-02-29'; }, 'charge.evenement.date'],
['29 février 1900, séculaire', (d) => { d.charge.evenement.date = '1900-02-29'; }, 'charge.evenement.date'],
['31 avril', (d) => { d.charge.evenement.date = '2026-04-31'; }, 'charge.evenement.date'],
['mois 13', (d) => { d.charge.evenement.date = '2026-13-01'; }, 'charge.evenement.date'],
['mois 00', (d) => { d.charge.evenement.date = '2026-00-10'; }, 'charge.evenement.date'],
['jour 00', (d) => { d.charge.evenement.date = '2026-11-00'; }, 'charge.evenement.date'],
['date dans un autre ordre', (d) => { d.charge.evenement.date = '12/11/2026'; }, 'charge.evenement.date'],
['date suivie d\'une heure', (d) => { d.charge.evenement.date = '2026-11-12T19:00'; }, 'charge.evenement.date'],
['date vide', (d) => { d.charge.evenement.date = ''; }, 'charge.evenement.date'],
['un seul siège par défaut', (d) => { d.charge.evenement.siegesParDefaut = 1; }, 'charge.evenement.siegesParDefaut'],
['aucun tour', (d) => { d.charge.evenement.tours = 0; }, 'charge.evenement.tours'],
['unité autre que le centimètre', (d) => { d.charge.evenement.unite = 'mm'; }, 'charge.evenement.unite'],
['état inconnu', (d) => { d.charge.evenement.etat = 'archive'; }, 'charge.evenement.etat'],
['source de filiation sans nom', (d) => { d.charge.evenement.filiation.source.nom = ''; },
'charge.evenement.filiation.source.nom'],
['instant de filiation à la révision 0', (d) => { d.charge.evenement.filiation.instant.revision = 0; },
'charge.evenement.filiation.instant.revision'],
['réglage non booléen', (d) => { d.charge.reglages.attribuerSieges = 'non'; }, 'charge.reglages.attribuerSieges'],
["compte d'arrêt nul", (d) => { d.charge.reglages.generation.arret = 0; }, 'charge.reglages.generation.arret'],
['aucune proposition demandée', (d) => { d.charge.reglages.generation.nombre = 0; },
'charge.reglages.generation.nombre'],
['historique de longueur 0', (d) => { d.charge.reglages.generation.historique = 0; },
'charge.reglages.generation.historique'],
['prochain identifiant 0', (d) => { d.charge.prochainsIds.participant = 0; }, 'charge.prochainsIds.participant'],
['prochain identifiant de table 0', (d) => { d.charge.prochainsIds.table = 0; }, 'charge.prochainsIds.table'],
['prochain identifiant de proposition 0', (d) => { d.charge.prochainsIds.proposition = 0; },
'charge.prochainsIds.proposition'],
['identifiant 0', (d) => { d.charge.participants[0].id = 0; }, 'charge.participants[0].id'],
['identifiant en texte', (d) => { d.charge.participants[0].id = '1'; }, 'charge.participants[0].id'],
['révision au-delà des entiers exacts', (d) => { d.entete.revision = 2 ** 53; }, 'entete.revision'],
['nom de participant vide', (d) => { d.charge.participants[1].nom = ''; }, 'charge.participants[1].nom'],
['prénom vide au lieu de null', (d) => { d.charge.participants[1].prenom = ''; }, 'charge.participants[1].prenom'],
['notes vides au lieu de null', (d) => { d.charge.participants[2].notes = ''; }, 'charge.participants[2].notes'],
['courriel numérique', (d) => { d.charge.participants[0].courriel = 42; }, 'charge.participants[0].courriel'],
['exclu en texte', (d) => { d.charge.participants[2].exclu = 'oui'; }, 'charge.participants[2].exclu'],
['exclu nul', (d) => { d.charge.participants[2].exclu = null; }, 'charge.participants[2].exclu'],
['numéro de table 0', (d) => { d.charge.tables[0].numero = 0; }, 'charge.tables[0].numero'],
['table d\'un seul siège', (d) => { d.charge.tables[1].sieges = 1; }, 'charge.tables[1].sieges'],
['sièges non entiers', (d) => { d.charge.tables[1].sieges = 3.5; }, 'charge.tables[1].sieges'],
['forme inconnue', (d) => { d.charge.tables[0].forme = 'ovale'; }, 'charge.tables[0].forme'],
['position en texte', (d) => { d.charge.tables[0].position.x = '0'; }, 'charge.tables[0].position.x'],
['position nulle', (d) => { d.charge.tables[1].position.y = null; }, 'charge.tables[1].position.y'],
['portée inconnue', (d) => { d.charge.reservations[0].portee = 'toujours'; }, 'charge.reservations[0].portee'],
['siège réservé 0', (d) => { d.charge.reservations[0].siege = 0; }, 'charge.reservations[0].siege'],
['tour 0', (d) => { d.charge.reservations[1].tour = 0; }, 'charge.reservations[1].tour'],
['libellé de titre vide', (d) => { d.charge.titres[0].libelle = ''; }, 'charge.titres[0].libelle'],
['titre sans siège', (d) => { d.charge.titres[0].siege = null; }, 'charge.titres[0].siege'],
];
assert.deepEqual(ecartsDeRefus(cas.map(([libelle, modifier, chemin]) => [libelle, abime(modifier), 'FORME', chemin])), []);
});
test("une position que JSON.parse lit infinie, 1e400 ou -1e400, est nommée par son chemin", () => {
// JSON.stringify n'écrit jamais un nombre infini : le cas s'écrit dans le
// texte, et l'épreuve vérifie d'abord qu'il se lit bien infini.
const valide = JSON.stringify(documentValide());
const cas = [
['abscisse 1e400',
remplacerUneFois(valide, '"position":{"x":0,"y":0}', '"position":{"x":1e400,"y":0}'),
'FORME', 'charge.tables[0].position.x'],
['ordonnée -1e400',
remplacerUneFois(valide, '"position":{"x":120.5,"y":-40}', '"position":{"x":120.5,"y":-1e400}'),
'FORME', 'charge.tables[1].position.y'],
];
assert.equal(JSON.parse(cas[0][1]).charge.tables[0].position.x, Infinity);
assert.equal(JSON.parse(cas[1][1]).charge.tables[1].position.y, -Infinity);
assert.deepEqual(ecartsDeRefus(cas), []);
});
test("null, posé à chaque valeur dont le contrat ne l'admet pas, est nommé par son chemin", () => {
const etapes = etapesDe(documentValide());
// Chaque chemin admis nul désigne au moins une valeur du document : la
// liste suit le contrat, sans entrée qui ne désigne plus rien.
for (const chemin of ADMETTENT_NULL) {
assert.ok(etapes.some((e) => sansRangs(e) === chemin), `${chemin} : aucune valeur du document`);
}
const refusent = etapes.filter((e) => !ADMETTENT_NULL.has(sansRangs(e)));
assert.ok(refusent.length >= 80, `${refusent.length} valeurs seulement`);
// Le format, lu avant tout le reste, a sa propre raison.
assert.deepEqual(ecartsDeRefus(refusent.map((e) => {
const chemin = cheminDe(e);
return [
`${chemin} nul`,
abime((d) => { valeurEn(d, e.slice(0, -1))[e.at(-1)] = null; }),
chemin === 'entete.format' ? 'FORMAT_INCONNU' : 'FORME',
chemin,
];
})), []);
});
test("de plusieurs clés inconnues, la plus petite en unités UTF-16 est nommée, quel que soit l'ordre où elles arrivent", () => {
const avec = (cles) => abime((d) => {
for (const cle of cles) d.charge.participants[1][cle] = 1;
});
// Une clé « __proto__ » ne s'écrit que dans le texte : une affectation en
// JavaScript changerait le prototype au lieu d'ajouter une clé.
const avecProto = JSON.stringify(documentValide()).replace('"nom":"Sarrasin"', '"nom":"Sarrasin","__proto__":{}');
assert.deepEqual(ecartsDeRefus([
['zeta puis alpha', avec(['zeta', 'alpha']), 'FORME', 'charge.participants[1].alpha'],
['alpha puis zeta', avec(['alpha', 'zeta']), 'FORME', 'charge.participants[1].alpha'],
['une majuscule précède une minuscule', avec(['alpha', 'Zeta']), 'FORME', 'charge.participants[1].Zeta'],
["une clé qui n'est pas un identifiant", avec(['nom complet']), 'FORME', 'charge.participants[1]["nom complet"]'],
['une clé vide', avec(['']), 'FORME', 'charge.participants[1][""]'],
['une clé entière', avec(['7']), 'FORME', 'charge.participants[1]["7"]'],
['une clé « __proto__ »', avecProto, 'FORME', 'charge.participants[1].__proto__'],
]), []);
});
});
describe('analyser : identifiants et tours (§ 4)', () => {
test('un identifiant en double, ou atteignant prochainsIds, est nommé ; un tour hors de sa portée aussi', () => {
assert.deepEqual(ecartsDeRefus([
['identifiant de participant au-delà de prochainsIds',
abime((d) => { d.charge.participants[3].id = 9; }), 'FORME', 'charge.participants[3].id'],
['identifiant de participant en double, le second fautif',
abime((d) => { d.charge.participants[2].id = 1; }), 'FORME', 'charge.participants[2].id'],
['identifiant de table égal à prochainsIds.table',
abime((d) => { d.charge.tables[1].id = 4; }), 'FORME', 'charge.tables[1].id'],
['identifiant de table en double',
abime((d) => { d.charge.tables[1].id = 1; }), 'FORME', 'charge.tables[1].id'],
['portée « tous » avec un tour',
abime((d) => { d.charge.reservations[0].tour = 1; }), 'FORME', 'charge.reservations[0].tour'],
['portée « tour » sans tour',
abime((d) => { d.charge.reservations[1].tour = null; }), 'FORME', 'charge.reservations[1].tour'],
['tour au-delà du nombre de tours',
abime((d) => { d.charge.reservations[1].tour = 3; }), 'FORME', 'charge.reservations[1].tour'],
]), []);
});
test("l'identifiant d'une proposition et la proposition d'origine du retenu ne se comparent pas à prochainsIds.proposition : le fichier se lit tel qu'écrit, au format courant comme plus récent, et le contrôle des placements les juge (§ 8.9, point 3)", () => {
// La proposition 1 est seule, et prochainsIds.proposition vaut 2.
const avecRetenu = (proposition) => (d) => {
d.charge.retenu = { ...retenuValide(), proposition };
d.entete.comptes.retenu = 1;
};
const seconde = (id) => (d) => {
d.charge.propositions.push({ ...d.charge.propositions[0], id });
d.entete.comptes.propositions = 2;
};
const cas = [
['identifiant de proposition égal à prochainsIds.proposition', (d) => { d.charge.propositions[0].id = 2; }],
['identifiant de proposition au-delà de prochainsIds.proposition', (d) => { d.charge.propositions[0].id = 7; }],
['la seconde proposition au-delà, la première en deçà', seconde(3)],
["proposition d'origine du retenu égale à prochainsIds.proposition", avecRetenu(2)],
["proposition d'origine du retenu au-delà, sans aucune proposition", (d) => {
avecRetenu(5)(d);
d.charge.propositions = [];
d.entete.comptes.propositions = 0;
}],
['compteur abaissé à 1, sous chaque identifiant et sous le retenu', (d) => {
avecRetenu(1)(d);
seconde(3)(d);
d.charge.prochainsIds.proposition = 1;
}],
];
// Chaque cas se lit à chaque format, l'en-tête et la charge rendus tels
// que le document les porte.
const ecarts = cas.flatMap(([libelle, modifier]) =>
[FORMAT, FORMAT + 1].flatMap((format) => {
const attendu = documentValide();
modifier(attendu);
attendu.entete.format = format;
try {
assert.deepEqual(analyser(JSON.stringify(attendu)), { ...attendu, formatPlusRecent: format > FORMAT });
return [];
} catch (erreur) {
return [`${libelle}, format ${format} : ${erreur.message.split('\n')[0]}`];
}
}),
);
assert.ok(cas.length > 0, 'aucun cas examiné');
assert.deepEqual(ecarts, []);
// Un identifiant de table en double reste refusé, quels que soient ceux
// des propositions.
assert.deepEqual(ecartsDeRefus([
['une table en double, une proposition au-delà du compteur', abime((d) => {
seconde(3)(d);
d.charge.tables[1].id = 1;
}), 'FORME', 'charge.tables[1].id'],
]), []);
});
});
describe('analyser : comptes de l\'en-tête (§ 8.8)', () => {
test('chaque compte se compare à sa liste, le retenu à 0 ou 1', () => {
assert.deepEqual(ecartsDeRefus([
['un participant perdu', abime((d) => { d.charge.participants.pop(); }), 'COMPTES', 'entete.comptes.participants'],
['une table annoncée en trop', abime((d) => { d.entete.comptes.tables = 3; }), 'COMPTES', 'entete.comptes.tables'],
['une réservation annoncée en moins',
abime((d) => { d.entete.comptes.reservations = 1; }), 'COMPTES', 'entete.comptes.reservations'],
['un titre annoncé en trop', abime((d) => { d.entete.comptes.titres = 2; }), 'COMPTES', 'entete.comptes.titres'],
['aucune proposition annoncée',
abime((d) => { d.entete.comptes.propositions = 0; }), 'COMPTES', 'entete.comptes.propositions'],
['un retenu annoncé, aucun écrit', abime((d) => { d.entete.comptes.retenu = 1; }), 'COMPTES', 'entete.comptes.retenu'],
['un retenu écrit, aucun annoncé',
abime((d) => { d.charge.retenu = retenuValide(); }), 'COMPTES', 'entete.comptes.retenu'],
]), []);
});
});
describe('analyser : références (§ 4, § 6.2)', () => {
test("une réservation ou un titre vers une table ou une personne absente, ou un siège au-delà de la capacité, est nommé", () => {
assert.deepEqual(ecartsDeRefus([
["réservation d'une personne retirée",
abime((d) => { d.charge.reservations[1].participant = 4; }), 'REFERENCE', 'charge.reservations[1].participant'],
['réservation vers une table absente',
abime((d) => { d.charge.reservations[0].table = 2; }), 'REFERENCE', 'charge.reservations[0].table'],
['siège réservé au-delà de la capacité par défaut',
abime((d) => { d.charge.reservations[1].siege = 4; }), 'REFERENCE', 'charge.reservations[1].siege'],
['siège réservé au-delà de la surcharge',
abime((d) => { d.charge.reservations[0].siege = 5; }), 'REFERENCE', 'charge.reservations[0].siege'],
['titre vers une table absente',
abime((d) => { d.charge.titres[0].table = 7; }), 'REFERENCE', 'charge.titres[0].table'],
['titre au-delà de la capacité',
abime((d) => { d.charge.titres[0].siege = 5; }), 'REFERENCE', 'charge.titres[0].siege'],
]), []);
});
});
describe('analyser : ordre des refus', () => {
test('format, forme de chaque champ, identifiants et tours, comptes, puis références ; dans chacun, l\'ordre du schéma puis des rangs', () => {
assert.deepEqual(ecartsDeRefus([
['le format avant la forme', abime((d) => {
d.entete.format = 0;
d.charge.participants[0].nom = '';
}), 'FORMAT_INCONNU', 'entete.format'],
["l'ordre du schéma dans la forme", abime((d) => {
d.charge.participants[0].nom = '';
d.charge.evenement.nom = '';
}), 'FORME', 'charge.evenement.nom'],
['les rangs dans leur ordre', abime((d) => {
d.charge.participants[2].nom = '';
d.charge.participants[1].nom = '';
}), 'FORME', 'charge.participants[1].nom'],
["un champ connu avant une clé inconnue du même objet", abime((d) => {
d.charge.participants[0].alpha = 1;
d.charge.participants[0].nom = '';
}), 'FORME', 'charge.participants[0].nom'],
['la forme de chaque champ avant les identifiants', abime((d) => {
d.charge.participants[1].id = 1;
d.charge.titres[0].libelle = '';
}), 'FORME', 'charge.titres[0].libelle'],
['la forme avant les comptes', abime((d) => {
d.entete.comptes.participants = 5;
d.charge.participants[0].nom = '';
}), 'FORME', 'charge.participants[0].nom'],
['les comptes avant les références', abime((d) => {
d.charge.participants.shift();
}), 'COMPTES', 'entete.comptes.participants'],
['la forme avant les références, même plus loin dans le texte', abime((d) => {
d.charge.reservations[0].participant = 9;
d.charge.titres[0].libelle = '';
}), 'FORME', 'charge.titres[0].libelle'],
]), []);
});
});
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; }));
assert.equal(simple.formatPlusRecent, true);
assert.equal(simple.entete.format, 2);
const { entete, charge } = documentValide();
const etendu = analyser(abime((d) => {
d.entete.format = 2;
d.annexe = { couleur: 'vert' };
d.entete.signature = null;
d.entete.comptes.lieux = 1;
d.charge.evenement.lieu = 'Salle des fêtes';
d.charge.participants[0].pronom = 'elle';
d.charge.tables[1].position.z = 0;
d.charge.propositions[0].note = 'refaite';
d.charge.propositions[0].placement[0].commentaire = 'tour calme';
}));
assert.deepEqual(etendu, { entete: { ...entete, format: 2 }, charge, formatPlusRecent: true });
});
test("dans un format plus récent, toute autre faute de forme lève FORMAT_PLUS_RECENT avec le format lu ; comptes et références gardent leur raison et portent le format lu", () => {
const plusRecent = (format, modifier) => abime((d) => {
d.entete.format = format;
d.charge.evenement.lieu = 'Salle des fêtes';
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 }],
['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.
const avecAppoint = (modifier) => abime((d) => {
d.entete.format = 2;
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 });
d.entete.comptes.reservations = 3;
modifier(d);
});
assert.deepEqual(ecartsDeRefus([
["la réservation de la table d'appoint", avecAppoint(() => {}),
'REFERENCE', 'charge.reservations[2].table', { format: 2 }],
["la table d'appoint comptée parmi les tables", avecAppoint((d) => { d.entete.comptes.tables = 3; }),
'COMPTES', 'entete.comptes.tables', { format: 2 }],
]), []);
});
});
describe('configurationDepuisCharge (§ 6.1)', () => {
test('une table qui suit le défaut le prend, une table surchargée garde sa valeur, et changer le défaut ne touche pas la surchargée', () => {
const { charge } = documentValide();
assert.deepEqual(configurationDepuisCharge(charge).tables, [
{ id: 1, numero: 1, capacite: 3 },
{ id: 3, numero: 2, capacite: 4 },
]);
charge.evenement.siegesParDefaut = 6;
assert.deepEqual(configurationDepuisCharge(charge).tables.map((table) => table.capacite), [6, 4]);
});
test('la configuration porte les participants, les tours, les réservations sans siège et les quatre contraintes', () => {
assert.deepEqual(configurationDepuisCharge(documentValide().charge), {
participants: [
{ id: 1, nom: 'Brindille', appartenance: 'Chorale du Vallon', exclu: false },
{ id: 2, nom: 'Sarrasin', appartenance: 'Chorale du Vallon', exclu: false },
{ id: 3, nom: 'Coquelicot', appartenance: null, exclu: true },
{ id: 5, nom: 'Mirabelle', appartenance: 'Cercle Gamma', exclu: false },
],
tables: [
{ id: 1, numero: 1, capacite: 3 },
{ id: 3, numero: 2, capacite: 4 },
],
tours: 2,
reservations: [
{ participant: 1, table: 3, portee: 'tous' },
{ participant: 2, table: 1, portee: 'tour', tour: 2 },
],
contraintes: {
separerAppartenances: true,
nouveauxVoisins: false,
nouvelleTable: true,
varierAppartenances: false,
},
});
});
test("les listes gardent l'ordre de la charge : le rang d'une réservation que nomme une ErreurConfiguration est son rang dans la charge", () => {
const { charge } = documentValide();
charge.participants.reverse();
charge.tables.reverse();
charge.reservations.reverse();
const configuration = configurationDepuisCharge(charge);
assert.deepEqual(configuration.participants.map(({ id }) => id), [5, 3, 2, 1]);
assert.deepEqual(configuration.tables.map(({ id }) => id), [3, 1]);
assert.deepEqual(configuration.reservations.map(({ participant }) => participant), [2, 1]);
// La seconde réservation de la charge désigne une table absente : le
// moteur la nomme à son rang dans la charge, 1.
charge.reservations[1].table = 9;
assert.throws(
() => normaliser(configurationDepuisCharge(charge)),
(erreur) =>
erreur instanceof ErreurConfiguration &&
erreur.code === 'RESERVATION_INCONNUE' &&
erreur.details.reservation === 1 &&
erreur.details.table === 9,
);
});
test('le résultat passe normaliser du moteur : exclu écarté, ancrage dérivé de la portée « tous », capacités résolues', () => {
const charge = geler(documentValide().charge);
const instance = normaliser(configurationDepuisCharge(charge));
assert.deepEqual(instance.ids, [1, 2, 5]);
assert.deepEqual([...instance.exclus], [3]);
assert.deepEqual(instance.idsTables, [1, 3]);
assert.deepEqual(instance.capacite, Int32Array.from([3, 4]));
assert.deepEqual(instance.statut, Uint8Array.from([STATUT.ANCRE, STATUT.PARTIELLEMENT_FIXE, STATUT.MOBILE]));
assert.equal(instance.R, 2);
});
});
describe('etatDeduit (§ 8.7, § 9)', () => {
test('sans proposition, brouillon ; avec, proposé ; avec un retenu, retenu', () => {
assert.equal(etatDeduit(creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 })), 'brouillon');
const { charge } = documentValide();
assert.equal(etatDeduit(charge), 'propose');
charge.retenu = retenuValide();
assert.equal(etatDeduit(charge), 'retenu');
charge.propositions = [];
assert.equal(etatDeduit(charge), 'retenu');
});
test("un plan bloqué donne l'un des trois, jamais bloqué : le contenu décide, pas l'état inscrit", () => {
const bloquee = (modifier) => {
const { charge } = documentValide();
charge.evenement.etat = 'bloque';
modifier(charge);
return etatDeduit(charge);
};
assert.deepEqual(
[
bloquee((c) => { c.propositions = []; }),
bloquee(() => {}),
bloquee((c) => { c.retenu = retenuValide(); }),
],
['brouillon', 'propose', 'retenu'],
);
const { charge } = documentValide();
charge.evenement.etat = 'retenu';
charge.propositions = [];
assert.equal(etatDeduit(charge), 'brouillon');
});
});
describe('capacite (§ 6.1)', () => {
test("les sièges de la table, ou le défaut quand elle le suit ; un identifiant au lieu de l'enregistrement lève TypeError", () => {
const { charge } = documentValide();
assert.equal(capacite(charge, charge.tables[0]), 3);
assert.equal(capacite(charge, charge.tables[1]), 4);
charge.evenement.siegesParDefaut = 10;
assert.equal(capacite(charge, charge.tables[0]), 10);
assert.equal(capacite(charge, charge.tables[1]), 4);
assert.throws(() => capacite(charge, 3), TypeError);
assert.throws(() => capacite(charge, null), TypeError);
});
});
describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', () => {
test("le chemin de la première faute, ou null ; une règle aPart n'examine que son conteneur sous une autre règle, et tout son contenu donnée pour racine", () => {
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const RETENU = regleDe(regleDe(SCHEMA, 'charge'), 'retenu');
const valide = documentValide();
valide.charge.retenu = retenuValide();
assert.equal(premiereFaute(valide, SCHEMA), null);
assert.equal(premiereFaute(valide.charge.retenu, RETENU), null);
const fautif = { ...retenuValide(), tours: 0 };
valide.charge.retenu = fautif;
assert.equal(premiereFaute(valide, SCHEMA), null);
assert.equal(premiereFaute(fautif, RETENU), 'tours');
assert.equal(premiereFaute(fautif, RETENU, 'charge.retenu'), 'charge.retenu.tours');
assert.equal(premiereFaute(null, RETENU), null);
valide.charge.retenu = [];
assert.equal(premiereFaute(valide, SCHEMA), 'charge.retenu');
});
test("premiereFauteEnPlace lit une valeur comme l'analyse la lit à sa place : une règle aPart n'y examine que son conteneur, toute autre comme premiereFaute", () => {
const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDe(SCHEMA, 'charge');
const RETENU = regleDe(CHARGE, 'retenu');
const PROPOSITIONS = regleDe(CHARGE, 'propositions');
const EVENEMENT = regleDe(CHARGE, 'evenement');
const fautif = { ...retenuValide(), tours: 0 };
assert.equal(premiereFaute(fautif, RETENU), 'tours');
assert.equal(premiereFauteEnPlace(fautif, RETENU), null);
assert.equal(premiereFauteEnPlace(null, RETENU), null);
assert.equal(premiereFauteEnPlace([], RETENU), '');
assert.equal(premiereFauteEnPlace(5, RETENU, 'charge.retenu'), 'charge.retenu');
assert.equal(premiereFaute([42], PROPOSITIONS), '[0]');
assert.equal(premiereFauteEnPlace([42, { id: 'x' }], PROPOSITIONS), null);
assert.equal(premiereFauteEnPlace({}, PROPOSITIONS), '');
assert.equal(premiereFauteEnPlace(null, PROPOSITIONS), '');
const { evenement } = documentValide().charge;
for (const valeur of [evenement, { ...evenement, nom: '' }, { ...evenement, lieu: 'Salle' }, 5]) {
assert.equal(premiereFauteEnPlace(valeur, EVENEMENT), premiereFaute(valeur, EVENEMENT), JSON.stringify(valeur));
}
assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom');
});
});

22
src/stockage/erreurs.js Normal file
View file

@ -0,0 +1,22 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Erreurs que lève le stockage. Le code d'une ErreurStockage nomme l'échec,
// et ses détails ce que l'appelant en lit — chemin, raison, révisions de
// secours — ; types.js en donne la table, code par code. L'appelant lit le
// code et les détails ; le message, code et détails en JSON, sert aux traces
// de diagnostic et n'est pas un texte affiché, que fournit la table des
// libellés de l'application (§ 14.6).
export class ErreurStockage extends Error {
/**
* @param {string} code
* @param {Object} [details]
*/
constructor(code, details = {}) {
super(`${code} ${JSON.stringify(details)}`);
this.code = code;
this.details = details;
}
}
ErreurStockage.prototype.name = 'ErreurStockage';

425
src/stockage/types.js Normal file
View file

@ -0,0 +1,425 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Contrat de données du stockage : le fichier d'état, le journal, les
// fichiers voisins d'un événement, les erreurs que lève le stockage et les
// codes des fautes de placement. Ce module ne porte que des définitions
// JSDoc et n'exécute rien ; un module cite un type par
// import('./types.js').Charge. Les valeurs que ces formes portent sont
// définies par leurs modules : FORMAT, GENERATION_PAR_DEFAUT et SCHEMA par
// le module du document, les constantes du journal par journal.js, les
// suffixes des fichiers par noms.js ; l'interface du système de fichiers
// est déclarée par systeme_fichiers.js.
//
// Les chaînes de saisie sont en NFC : la conversion se fait à l'entrée — CSV,
// commandes —, jamais à l'écriture. Une chaîne « non vide » compte au moins
// une unité ; un champ « chaîne ou null » porte null plutôt qu'une chaîne
// vide. Un entier est un entier exact, au plus 2^53 − 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.
*
* @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 {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
* entrée de journal que l'état reflète
* @property {Comptes} comptes
*
* @typedef {Object} Comptes longueur de la liste de la charge du même nom
* @property {number} participants
* @property {number} tables
* @property {number} reservations
* @property {number} titres
* @property {number} propositions
* @property {number} retenu 0 ou 1
*
* @typedef {Object} Charge
* @property {Evenement} evenement
* @property {Reglages} reglages
* @property {ProchainsIds} prochainsIds
* @property {Participant[]} participants triés par id
* @property {Table[]} tables triées par id
* @property {Reservation[]} reservations triées par participant, table,
* portée (tous avant tour), tour puis
* siège, null en tête
* @property {Titre[]} titres triés par table, siège puis libellé
* @property {PropositionFichier[]} propositions triées par id, puis
* graine, arret, historique et
* texte canonique : la lecture du
* document n'en contrôle que la
* liste, et admet deux propositions
* de même id ; leur forme, leur
* cohérence et leurs identifiants se
* contrôlent à part (placements.js)
* @property {Retenu|null} retenu le placement en vigueur
*
* @typedef {Object} Evenement
* @property {string} id non vide : identifiant interne, qui
* apparie l'état et le journal (§ 8.6)
* @property {string} nom non vide : l'autorité, dont le nom de
* fichier dérive
* @property {string|null} date AAAA-MM-JJ d'un jour du calendrier
* @property {number} siegesParDefaut entier ≥ 2 (§ 6.1)
* @property {number} tours R, entier ≥ 1
* @property {'cm'} unite (§ 7.2)
* @property {'brouillon'|'propose'|'retenu'|'bloque'} etat (§ 9)
* @property {Filiation|null} filiation (§ 8.7)
*
* @typedef {Object} Filiation
* @property {{id: string, nom: string}} source chaînes non vides
* @property {{revision: number, libelle: string}} instant révision ≥ 1,
* libellé non vide
*
* @typedef {Object} Reglages
* @property {boolean} separerAppartenances
* @property {boolean} nouveauxVoisins
* @property {boolean} nouvelleTable
* @property {boolean} varierAppartenances
* @property {boolean} attribuerSieges le réglage que reçoit la prochaine
* génération ; il ne décide d'aucun
* tri : chaque proposition porte son
* propre siegesAttribues
* @property {{nombre: number, arret: number, historique: number}} generation
* entiers ≥ 1
*
* @typedef {Object} ProchainsIds entiers ≥ 1, au-delà de tout identifiant
* attribué ; ils ne reculent jamais, et un
* identifiant ne se réattribue pas (§ 4)
* @property {number} participant
* @property {number} table
* @property {number} proposition au-delà de chaque identifiant de
* proposition et de retenu.proposition ;
* unique à travers les générations
* accumulées, il ne revient pas après un
* effacement (§ 5.7). Le contrôle des
* placements le compare : une proposition
* qui l'atteint s'écarte, un retenu se
* signale (IDENTIFIANT_HORS_COMPTEUR) ; la
* charge qu'il rend le relève au-delà de
* chaque identifiant lu, écartées et
* origine du retenu comprises : abaissé à
* la main, il ne fait réattribuer aucun
* d'eux
*
* @typedef {Object} Participant
* @property {number} id entier ≥ 1, unique
* @property {string} nom non vide
* @property {string|null} prenom
* @property {string|null} appartenance
* @property {string|null} courriel
* @property {string|null} titrePressenti informatif (§ 4)
* @property {string|null} notes
* @property {boolean} exclu (§ 4.4)
*
* @typedef {Object} Table
* @property {number} id entier ≥ 1, unique
* @property {number} numero affiché, entier ≥ 1
* @property {number|null} sieges null : suit le défaut ; entier ≥ 2 :
* surcharge (§ 6.1)
* @property {'ronde'|'carree'} forme
* @property {{x: number, y: number}} position nombres finis, en cm
*
* @typedef {Object} Reservation
* @property {number} participant id d'un participant de la charge, exclu
* compris : sa réservation est suspendue
* @property {number} table id d'une table
* @property {number|null} siege de 1 à la capacité de la table, ou null
* @property {'tous'|'tour'} portee (§ 4.3)
* @property {number|null} tour null pour la portée tous, de 1 à R sinon
*
* @typedef {Object} Titre
* @property {number} table id d'une table
* @property {number} siege de 1 à la capacité de la table
* @property {string} libelle non vide
*/
/**
* Forme positionnelle d'un placement (§ 8.9). tables et capacites se
* correspondent rang à rang ; placement porte un tour par élément :
* sieges[i] liste les occupants de tables[i] dans l'ordre des sièges —
* triés par identifiant quand siegesAttribues est faux —, reserve ceux qui
* ne sont assis nulle part à ce tour. siegesAttribues appartient à la
* proposition, ou au retenu, et non au réglage courant : changer le réglage
* ne détruit pas l'ordre des sièges d'une proposition déjà produite.
* participants et chaque reserve sont des ensembles, triés par identifiant
* croissant quel que soit siegesAttribues.
*
* @typedef {Object} TourDePlacement
* @property {number[][]} sieges
* @property {number[]} reserve triée par identifiant croissant
*
* @typedef {Object} PropositionFichier
* @property {number} id entier ≥ 1 : place dans la suite des
* graines dérivées (§ 5.7)
* @property {number} graine graine dérivée, 0 ≤ g < 2^32
* @property {number} arret compte d'arrêt, ≥ 1
* @property {number} historique longueur de l'historique d'acceptation, ≥ 1
* @property {string} produitVersion construction qui l'a produite
* @property {boolean} siegesAttribues l'ordre dans une table est un numéro
* de siège ; faux : chaque liste de table
* se trie par identifiant croissant
* @property {number[]} tables identifiants de table, dans l'ordre des
* listes de chaque tour
* @property {number[]} capacites capacité de chacune
* @property {number} tours
* @property {number[]} participants identifiants qu'elle place, triés par
* identifiant croissant
* @property {TourDePlacement[]} placement
*
* @typedef {Object} Retenu le placement en vigueur ; une retouche à la
* main le modifie, jamais la proposition d'origine.
* Un retenu que cette forme refuse, la lecture
* l'admet et le contrôle des placements le garde
* en le signalant (§ 8.9, point 3) : canonique.js
* l'écrit compact à la clé retenu, ses clés
* rangées, ses listes dans l'ordre écrit
* @property {number} proposition id de la proposition d'origine, sous
* prochainsIds.proposition
* @property {boolean} siegesAttribues
* @property {number[]} tables
* @property {number[]} capacites
* @property {number} tours
* @property {number[]} participants triés par identifiant croissant
* @property {TourDePlacement[]} placement
*/
/**
* Ce que rend analyser.
*
* @typedef {Object} Lecture
* @property {Entete} entete
* @property {Charge} charge telle que le texte la porte ;
* réduite aux clés connues quand le
* format est plus récent
* @property {boolean} formatPlusRecent format > FORMAT : lecture seule
*/
/**
* 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
* chaine : non vide ; nombre : fini ; parmi : l'une de valeurs ;
* date : AAAA-MM-JJ d'un jour du calendrier
* @property {boolean} nul null est admis
* @property {Array<[string, Regle]>} [champs] objet : ses champs, dans
* 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} [max]
* @property {string[]} [valeurs] parmi
* @property {'lignes'|'ouverte'} [mise] absente : la valeur s'écrit
* compacte. lignes : un champ, ou un élément, par ligne. ouverte :
* l'objet s'écrit compact sur la ligne qui l'ouvre, chaque champ
* selon sa propre mise
* @property {function(*, *): number} [tri] liste : ordre canonique de ses
* éléments, total, qui compare leurs copies canoniques
* @property {function(*, *): number} [triSansAttribution] liste : ordre
* canonique quand l'objet qui porte le placement a
* siegesAttribues faux
* @property {boolean} [aPart] la lecture du document n'en
* contrôle que le conteneur ; le contenu se contrôle à part
* (placements.js)
*/
/**
* Le journal, <base>.gtt-journal.jsonl (§ 8.2, § 8.3, § 8.6) : un objet JSON
* par ligne, clés dans l'ordre écrit ici, fin de ligne LF. La première ligne
* l'apparie à son état ; suivent des entrées et des jalons, dont l'ordre est
* celui des lignes, jamais celui des horodatages.
*
* @typedef {Object} LigneOuverture
* @property {'journal'} type
* @property {number} format
* @property {string} evenement id de l'événement
*
* @typedef {Object} LigneEntree
* @property {'entree'} type
* @property {number} revision la suivante, consécutive
* @property {string} libelle texte figé à l'écriture, jamais un code
* @property {string} horodatage AAAA-MM-JJTHH:MM:SS±HH:MM, pour l'affichage
* @property {null|'defaire'|'refaire'|'revenir'} sens null pour un geste
* @property {number|null} retour révision visée par une entrée de retour
* @property {string|null} produitVersion non nul à la première entrée et à
* chaque entrée dont la construction diffère de la précédente
* @property {Charge|null} instantane la charge résultante, quand la
* révision ouvre un intervalle : la première entrée du journal,
* puis toutes les INTERVALLE_INSTANTANE révisions comptées depuis
* elle
* @property {Array<Object>|null} correctif sinon : le correctif depuis la
* charge de l'entrée précédente dans l'ordre des lignes
*
* @typedef {Object} LigneJalon nomme l'instant d'une révision ; ne change
* pas la charge, ne prend pas de révision
* @property {'jalon'} type
* @property {number} revision
* @property {string} nom
* @property {string} horodatage
*
* Fil courant (§ 8.3) : la position d'une entrée ordinaire est elle-même,
* celle d'une entrée de retour la position de l'entrée qu'elle vise. Le
* rejeu des lignes tient une position et une pile : une entrée ordinaire
* pose la position et vide la pile ; un retour defaire ou revenir empile la
* position courante et pose celle de sa cible ; un retour refaire dépile.
* Défaire vise la position de l'entrée qui précède, dans l'ordre des lignes,
* l'entrée de la position courante ; refaire vise le sommet de la pile, et
* n'existe que si elle n'est pas vide. Le fil courant est la chaîne des
* positions remontée depuis la position courante ; les autres entrées
* ordinaires forment des fils abandonnés.
*
* Lecture : la première ligne illisible — JSON invalide, forme fausse,
* révision non consécutive, ligne finale sans fin de ligne — arrête la
* lecture ; elle et tout ce qui la suit sont écartés et comptés (§ 8.6).
* Élagage : au-delà de PLAFOND_ENTREES entrées, le journal se réécrit à
* partir de la frontière d'instantané la plus récente qui en garde au moins
* PLANCHER_ENTREES ; les jalons des entrées retirées partent avec elles.
*/
/**
* Fichiers voisins d'un événement, nommés par un suffixe ajouté à sa base :
* .gtt.json, l'état ; .gtt.json.precedent, l'état d'avant le dernier geste
* (§ 8.8) ; .gtt-journal.jsonl, le journal ; .gtt.verrou, présent tant
* qu'une séance est en écriture ; .ecriture, ajouté au nom d'un fichier
* pendant son écriture atomique. Le dossier de travail porte aussi
* reglages_locaux.json (§ 8.5) et corbeille/AAAA-MM-JJ_HH-MM-SS/ (§ 8.7).
*
* @typedef {Object} ContenuVerrou contenu de .gtt.verrou
* @property {string} seance
* @property {number} pid
* @property {string} hote
* @property {string} depuis
*
* @typedef {Object} ReglagesLocaux reglages_locaux.json
* @property {number} format
* @property {null|'A4'|'Lettre'} papier
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
* triés par identifiant d'événement
*/
/**
* Codes d'une ErreurStockage, et ses détails :
*
* ABSENT {chemin} lecture d'un fichier qui
* n'existe pas
* ECRITURE {chemin, dossier, cause} une écriture, un ajout, un
* renommage refusés ; la
* cible est intacte
* EXISTE {chemin} déplacer vers une cible
* existante
* CHEMIN_REFUSE {chemin} chemin absolu, .., racine
* inconnue
* ETAT_ILLISIBLE DetailsIllisible un fichier d'état qui ne
* se lit pas
* CORRECTIF {rang} un correctif qui ne
* s'applique pas
* CHEMIN_TROP_LONG {racine} aucune base ne tient sous
* la borne (§ 8.6)
* IDENTIFIANT_PRESENT {id, base} importer un événement dont
* l'identifiant est déjà
* dans le dossier
* VERROU_PRIS {seance, depuis, vivant} passer en écriture quand
* une autre séance tient le
* verrou
* LECTURE_SEULE {raison} un geste sur un fichier
* d'un format plus récent,
* ou sur un plan bloqué
* ETAT_NON_ECRIT {chemin, dossier, cause} le geste est au journal,
* l'état n'a pas pu s'écrire
* (§ 8.2)
* CORBEILLE_SATUREE {dossier} cent suppressions dans la
* même seconde : le rang de
* collision dépasserait les
* deux chiffres que la borne
* réserve (§ 8.7)
*
* @typedef {'ABSENT'|'VIDE'|'JSON'|'FORME'|'COMPTES'|'REFERENCE'|'FORMAT_INCONNU'|'FORMAT_PLUS_RECENT'} RaisonIllisible
* ABSENT le journal existe sans l'état ; posée par le dépôt, jamais
* par analyser
* VIDE zéro octet, ou des blancs seulement
* JSON le texte n'est pas du JSON
* FORME type, clé manquante ou inconnue, valeur hors de son
* domaine, 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
* COMPTES un compte de l'en-tête contredit sa liste
* REFERENCE réservation ou titre vers une table ou une personne
* absente, siège au-delà de la capacité
* FORMAT_INCONNU format présent, mais non entier ou < 1
* FORMAT_PLUS_RECENT format > FORMAT : ses clés inconnues sont ignorées,
* et une faute qui serait FORME au format courant porte
* cette raison ; COMPTES et REFERENCE gardent la leur, une
* corruption quel que soit le format ; tout refus d'un tel
* fichier porte le format lu
*
* @typedef {Object} DetailsIllisible
* @property {string} base base de l'événement
* @property {RaisonIllisible} raison
* @property {string|null} chemin premier élément fautif, par exemple
* charge.participants[2].nom : clés séparées par un point, rangs
* entre crochets, une clé qui ne s'écrit pas comme un identifiant
* entre crochets en JSON ; '' pour la racine ; null quand rien ne se
* désigne (ABSENT, VIDE, JSON)
* @property {number} [format] le format lu quand il dépasse FORMAT :
* FORMAT_PLUS_RECENT, et COMPTES ou REFERENCE d'un fichier plus
* récent
* @property {{precedent: number|null, journal: number|null}} secours
* révision lisible du .precedent et dernier instant du journal ;
* null pour ce qui ne se lit pas
*
* analyser ne connaît que le texte : il lève {raison, chemin}, et format
* pour un fichier plus récent, que le dépôt complète de base et de secours.
*/
/**
* Codes d'une faute de placement, que rendent fautes et examiner
* (placements.js, Faute), et ses détails. Une proposition fautive s'écarte
* à l'ouverture, et le fichier s'ouvre sans elle ; un retenu fautif est
* signalé et gardé (§ 8.9, point 3). Les raisons de dérive d'un placement
* cohérent sont décrites avec derive (placements.js, Raison).
*
* FORME {chemin} une valeur sort de sa règle, ou la
* déclaration se contredit ; chemin
* dans le placement, '' pour lui-même
* LONGUEUR {tour, table, une longueur écrite contredit celle
* declare, ecrit} que le placement déclare : nombre de
* tours (tour et table null), de listes
* d'un tour (table null), liste d'une
* table au-delà de sa capacité
* DOUBLON {tour, un identifiant écrit plus d'une fois
* participant} dans un tour, réserve comprise
* INCONNU {tour, un identifiant écrit sans être
* participant} déclaré
* MANQUANT {tour, un identifiant déclaré sans être
* participant} écrit
* IDENTIFIANT_REPETE {} une proposition cohérente porte
* l'identifiant d'une proposition
* gardée avant elle dans la liste ;
* examiner seul la nomme, et l'entrée
* de fautives porte son rang et son id
* IDENTIFIANT_HORS_COMPTEUR
* {} une proposition cohérente porte un
* identifiant qui atteint
* prochainsIds.proposition, ou le
* retenu cohérent une proposition
* d'origine qui l'atteint ; examiner
* seul la nomme : la proposition
* s'écarte, le retenu reste et garde
* ses raisons de dérive ; la charge
* qu'examiner rend relève le compteur
* au-delà de l'identifiant, qui ne se
* réattribue pas
*/