diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js index 7f38282..1fad988 100644 --- a/src/moteur/recherche.js +++ b/src/moteur/recherche.js @@ -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; diff --git a/src/stockage/canonique.js b/src/stockage/canonique.js new file mode 100644 index 0000000..23cac4f --- /dev/null +++ b/src/stockage/canonique.js @@ -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`; +} diff --git a/src/stockage/canonique.test.js b/src/stockage/canonique.test.js new file mode 100644 index 0000000..fe4764e --- /dev/null +++ b/src/stockage/canonique.test.js @@ -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()); + }); +}); diff --git a/src/stockage/document.js b/src/stockage/document.js new file mode 100644 index 0000000..e99df46 --- /dev/null +++ b/src/stockage/document.js @@ -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; +} diff --git a/src/stockage/document.test.js b/src/stockage/document.test.js new file mode 100644 index 0000000..cda04a5 --- /dev/null +++ b/src/stockage/document.test.js @@ -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'); + }); +}); diff --git a/src/stockage/erreurs.js b/src/stockage/erreurs.js new file mode 100644 index 0000000..74ed0e4 --- /dev/null +++ b/src/stockage/erreurs.js @@ -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'; diff --git a/src/stockage/types.js b/src/stockage/types.js new file mode 100644 index 0000000..c224d8e --- /dev/null +++ b/src/stockage/types.js @@ -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} [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, .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|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 + */