gestion_table_tournante_libre/src/stockage/canonique.test.js
Mathieu Benoit 15148e3a26 [FIX] storage: compact seat patches, checked poses, 32-bit identifiers
Without seat attribution a table list stays sorted, so swapping two
people shifted every rank between them and a patch grew to over a
kilobyte; a same-length list of ids is now set whole when that is
lighter, and a swap costs two small operations (§ 8.9). A patch that
sets a malformed proposition or retained placement is refused at its
rank, instead of a TypeError at the next application. Identifiers stay
below 2^31, the engine's Int32Array bound: beyond, the record is a
form fault, and a proposition is set aside.

Checked: storage tests; reviewed, remaining findings minor.

--- FR ---

[FIX] stockage : correctifs compacts, poses contrôlées, id sous 2^31

Sans attribution des sièges, une liste de table reste triée : échanger
deux personnes décalait chaque rang entre elles, et un correctif passait
le kilo-octet ; une liste d'identifiants de même longueur se pose
désormais entière quand c'est plus léger, et un échange coûte deux
petites opérations (§ 8.9). Un correctif qui pose une proposition ou un
retenu mal formé est refusé à son rang, au lieu d'une TypeError à
l'application suivante. Les identifiants restent sous 2^31, borne des
Int32Array du moteur : au-delà, faute de forme, et proposition écartée.

Vérifié : épreuves du stockage ; revue, constats restants mineurs.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 19:22:02 -04:00

729 lines
34 KiB
JavaScript

// © 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;
}
// Chaque objet et chaque liste que porte valeur, elle comprise :
// JSON.stringify présente chacun à la fonction de remplacement avant de
// l'écrire.
function objetsDe(valeur) {
const objets = new Set();
JSON.stringify(valeur, (_, enfant) => {
if (typeof enfant === 'object' && enfant !== null) objets.add(enfant);
return enfant;
});
return objets;
}
// 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, qui ne partage aucun objet ni aucune liste avec lui", () => {
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 source = geler(structuredClone(charge));
const copie = canoniser(source).retenu;
assert.deepEqual(copie, charge.retenu);
const recus = objetsDe(source.retenu);
assert.deepEqual([...objetsDe(copie)].filter((objet) => recus.has(objet)), []);
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 que sa règle admet mais qui se contredit — cinq tours déclarés, deux écrits — suit la mise en page d'une proposition : seul un retenu que sa règle refuse s'écrit compact", () => {
const charge = chargeAvecRetenu();
charge.retenu.tours = 5;
const ligne = '"retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":';
const attendu = remplacer(TEXTE_AVEC_RETENU, `${ligne}2,`, `${ligne}5,`);
assert.equal(serialiser(charge, ENTETE), attendu);
assert.equal(serialiser(aRebours(charge), ENTETE), attendu);
const { entete, charge: relue } = analyser(attendu);
assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), attendu);
});
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());
});
});