From 15148e3a2671d46107cfc3389a184aa4057072e6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 19:22:02 -0400 Subject: [PATCH] [FIX] storage: compact seat patches, checked poses, 32-bit identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without seat attribution a table list stays sorted, so swapping two people shifted every rank between them and a patch grew to over a kilobyte; a same-length list of ids is now set whole when that is lighter, and a swap costs two small operations (§ 8.9). A patch that sets a malformed proposition or retained placement is refused at its rank, instead of a TypeError at the next application. Identifiers stay below 2^31, the engine's Int32Array bound: beyond, the record is a form fault, and a proposition is set aside. Checked: storage tests; reviewed, remaining findings minor. --- FR --- [FIX] stockage : correctifs compacts, poses contrôlées, id sous 2^31 Sans attribution des sièges, une liste de table reste triée : échanger deux personnes décalait chaque rang entre elles, et un correctif passait le kilo-octet ; une liste d'identifiants de même longueur se pose désormais entière quand c'est plus léger, et un échange coûte deux petites opérations (§ 8.9). Un correctif qui pose une proposition ou un retenu mal formé est refusé à son rang, au lieu d'une TypeError à l'application suivante. Les identifiants restent sous 2^31, borne des Int32Array du moteur : au-delà, faute de forme, et proposition écartée. Vérifié : épreuves du stockage ; revue, constats restants mineurs. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- src/stockage/canonique.test.js | 30 ++- src/stockage/correctifs.js | 188 +++++++++------- src/stockage/correctifs.test.js | 367 ++++++++++++++++++++++++++++---- src/stockage/document.js | 18 +- src/stockage/document.test.js | 26 ++- src/stockage/placements.js | 53 +++-- src/stockage/placements.test.js | 146 ++++++++++++- src/stockage/types.js | 45 ++-- 8 files changed, 716 insertions(+), 157 deletions(-) diff --git a/src/stockage/canonique.test.js b/src/stockage/canonique.test.js index fe4764e..127a474 100644 --- a/src/stockage/canonique.test.js +++ b/src/stockage/canonique.test.js @@ -221,6 +221,18 @@ function geler(valeur) { return valeur; } +// Chaque objet et chaque liste que porte valeur, elle comprise : +// JSON.stringify présente chacun à la fonction de remplacement avant de +// l'écrire. +function objetsDe(valeur) { + const objets = new Set(); + JSON.stringify(valeur, (_, enfant) => { + if (typeof enfant === 'object' && enfant !== null) objets.add(enfant); + return enfant; + }); + return objets; +} + // Exécute fonction pendant que l'horloge et les sources d'aléa lèvent à leur // lecture, puis les rétablit telles qu'elles étaient, propres ou héritées ; // rend ce que rend fonction. @@ -644,7 +656,7 @@ describe('un retenu hors de sa règle (§ 8.9, point 3)', () => { 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", () => { + test("chaque retenu que sa règle refuse et que l'analyse admet s'écrit, se relit au même retenu et se réécrit octet pour octet, quel que soit l'ordre de ses clés ; canoniser en rend une copie détachée, qui ne partage aucun objet ni aucune liste avec lui", () => { assert.ok(FACONS.length > 0, 'aucune façon examinée'); const ecarts = FACONS.flatMap(([libelle, facon]) => { const charge = abimee(facon); @@ -658,8 +670,11 @@ describe('un retenu hors de sa règle (§ 8.9, point 3)', () => { 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; + const source = geler(structuredClone(charge)); + const copie = canoniser(source).retenu; assert.deepEqual(copie, charge.retenu); + const recus = objetsDe(source.retenu); + assert.deepEqual([...objetsDe(copie)].filter((objet) => recus.has(objet)), []); assert.equal(JSON.stringify(canoniser(charge).retenu), JSON.stringify(copie)); assert.equal(JSON.stringify(charge.retenu), avant); return []; @@ -670,6 +685,17 @@ describe('un retenu hors de sa règle (§ 8.9, point 3)', () => { assert.deepEqual(ecarts, []); }); + test("un retenu que sa règle admet mais qui se contredit — cinq tours déclarés, deux écrits — suit la mise en page d'une proposition : seul un retenu que sa règle refuse s'écrit compact", () => { + const charge = chargeAvecRetenu(); + charge.retenu.tours = 5; + const ligne = '"retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2],"capacites":[2,2],"tours":'; + const attendu = remplacer(TEXTE_AVEC_RETENU, `${ligne}2,`, `${ligne}5,`); + assert.equal(serialiser(charge, ENTETE), attendu); + assert.equal(serialiser(aRebours(charge), ENTETE), attendu); + const { entete, charge: relue } = analyser(attendu); + assert.equal(serialiser(relue, { revision: entete.revision, produitVersion: entete.produitVersion }), attendu); + }); + test("un retenu qui n'est pas un objet, que l'analyse refuse, lève TypeError : aucun texte qu'elle refuserait ne s'écrit", () => { for (const retenu of [[], 42, 'retenu', false]) { const charge = chargeContrat(); diff --git a/src/stockage/correctifs.js b/src/stockage/correctifs.js index 07b6a83..b274274 100644 --- a/src/stockage/correctifs.js +++ b/src/stockage/correctifs.js @@ -26,19 +26,24 @@ // personne prend le rang de l'autre, ce que garde l'ordre des sièges quand // ils sont attribués. Sans attribution, chaque liste se trie par // identifiant, et l'entrée qui change de rang décale celles qu'elle -// franchit dans sa liste. Une liste qui change de longueur se pose entière. +// franchit ; une liste d'identifiants de même longueur se pose alors +// entière dès que ses poses rang par rang pèsent plus que cette pose, et +// l'échange ne coûte jamais plus que les deux listes posées entières. Une +// liste qui change de longueur se pose entière. // -// Une charge que rend appliquer, l'analyse l'admet à la forme près comme -// celle qu'il reçoit : une valeur posée doit être admise à sa place, lue -// par le parcours même de l'analyse (premiereFauteEnPlace). Dans les -// propositions et le retenu, l'analyse ne lit que le conteneur, et le -// contrôle des placements juge le reste (§ 8.9) ; une valeur posée plus bas -// ne s'examine donc pas. Un retenu que sa règle refuse, que la lecture -// admet et que le contrôle des placements garde, canonique.js le recopie -// hors du schéma, et difference le pose entier : le journal le libère, le -// remet en place ou le remplace tel quel, quelle que soit sa faute. +// Une charge que rend appliquer, l'analyse l'admet comme celle qu'il +// reçoit, et chacune de ses propositions suit sa règle, comme dans la +// charge que rend examiner : une valeur posée suit la règle de sa place, sa +// forme entière (premiereFaute), jusque dans les propositions et le retenu, +// que l'analyse ne lit que comme conteneurs (§ 8.9). Le retenu posé entier +// fait seul exception. Un retenu que sa règle refuse, que la lecture admet +// et que le contrôle des placements garde, canonique.js le recopie hors du +// schéma, et difference le pose entier : à sa place, seul son conteneur se +// contrôle (premiereFauteEnPlace), et le journal le libère, le remet en +// place ou le remplace tel quel, quelle que soit sa faute. Sous un tel +// retenu, rien ne se pose : difference ne descend pas en lui. import { canoniser, retenuHorsDeSaRegle } from './canonique.js'; -import { SCHEMA, premiereFauteEnPlace } from './document.js'; +import { SCHEMA, premiereFaute, premiereFauteEnPlace } from './document.js'; import { ErreurStockage } from './erreurs.js'; /** @@ -59,8 +64,12 @@ import { ErreurStockage } from './erreurs.js'; * @typedef {OperationPoser|OperationRetirer} Operation */ -// Règle de la charge dans le schéma du fichier d'état. -const CHARGE = SCHEMA.champs.find(([cle]) => cle === 'charge')[1]; +// Règles de la charge, du retenu et d'un identifiant dans le schéma du +// fichier d'état. +const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1]; +const CHARGE = regleDuChamp(SCHEMA, 'charge'); +const RETENU = regleDuChamp(CHARGE, 'retenu'); +const IDENTIFIANT = regleDuChamp(regleDuChamp(CHARGE, 'propositions').element, 'id'); // Vrai pour un objet qui n'est ni null ni une liste. const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); @@ -82,6 +91,10 @@ function identifiantsDistincts(liste) { return true; } +// Poids d'opérations dans un correctif : la longueur de leur texte JSON, en +// unités UTF-16, des octets pour les chemins du schéma et les identifiants. +const poids = (operations) => JSON.stringify(operations).length; + // Ajoute à operations ce qui mène de a à b, deux valeurs canoniques de la // règle regle, au bout de chemin. null contre une valeur, et deux scalaires // différents, posent b. Un retenu que sa règle refuse, d'un côté ou de @@ -89,8 +102,10 @@ function identifiantsDistincts(liste) { // les deux copies canoniques s'écrivent autrement, et rien sinon. Deux // objets se comparent champ par champ, dans l'ordre du schéma. Deux listes // d'enregistrements à identifiants distincts se comparent par identifiant ; -// deux autres listes, rang par rang quand elles ont la même longueur, et b -// se pose entière sinon. +// deux autres listes, de longueurs différentes, posent b entière, et de même +// longueur se comparent rang par rang — deux listes d'identifiants +// exceptées, qui posent b entière quand leurs poses rang par rang pèsent +// plus que la sienne. function comparer(a, b, regle, chemin, operations) { if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) { if (a !== b) operations.push({ op: 'poser', chemin, valeur: b }); @@ -100,13 +115,29 @@ function comparer(a, b, regle, chemin, operations) { for (const [cle, regleDeCle] of regle.champs) comparer(a[cle], b[cle], regleDeCle, [...chemin, cle], operations); } else if (identifiantsDistincts(a) && identifiantsDistincts(b)) { comparerParIdentifiant(a, b, regle.element, chemin, operations); - } else if (a.length === b.length) { - a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations)); - } else { + } else if (a.length !== b.length) { operations.push({ op: 'poser', chemin, valeur: b }); + } else if (regle.element === IDENTIFIANT) { + comparerIdentifiants(a, b, chemin, operations); + } else { + a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations)); } } +// Ajoute à operations ce qui mène de a à b, deux listes d'identifiants de +// même longueur, au bout de chemin : une pose par rang qui change, ou b +// entière quand ces poses pèsent plus que la sienne. Une liste triée par +// identifiant — une liste de table sans attribution, une réserve, les +// participants d'un plan — décale d'un rang chaque entrée qu'un identifiant +// remplacé franchit, et ses poses rang par rang se multiplient ; ce qui +// s'en écrit ne pèse jamais plus que la liste posée entière. +function comparerIdentifiants(a, b, chemin, operations) { + const parRang = []; + a.forEach((id, rang) => comparer(id, b[rang], IDENTIFIANT, [...chemin, rang], parRang)); + const entiere = { op: 'poser', chemin, valeur: b }; + operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang)); +} + // Fusion de deux listes d'enregistrements rangées par identifiant croissant, // comme les laisse canoniser : un identifiant de a seul est retiré, un // identifiant de b seul est posé entier, un identifiant commun se compare @@ -145,10 +176,15 @@ function comparerParIdentifiant(a, b, regleElement, chemin, operations) { * par identifiant : retirés, posés entiers, communs comparés ; une telle * liste dont un identifiant se répète, ce que la lecture admet pour deux * propositions, se compare comme les autres listes ; les autres listes de - * même longueur rang par rang, et de longueurs différentes posées - * entières ; null contre une valeur, et deux scalaires différents, posent la - * valeur de b ; un retenu que sa règle refuse, dans a ou dans b, se pose - * entier quand les deux diffèrent, sans rien pour lui sinon. + * longueurs différentes posées entières, et de même longueur rang par rang, + * sauf une liste d'identifiants dont les poses rang par rang pèsent plus, + * en JSON, que sa pose entière : elle se pose entière ; null contre une + * valeur, et deux scalaires différents, posent la valeur de b ; un retenu + * que sa règle refuse, dans a ou dans b, se pose entier quand les deux + * diffèrent, sans rien pour lui sinon. L'échange de deux personnes, au même + * tour d'une proposition, ne touche ainsi que les listes de leurs deux + * tables, et ne pèse jamais plus que ces deux listes posées entières, + * attribution des sièges ou non (§ 8.9). * * @param {import('./types.js').Charge} a * @param {import('./types.js').Charge} b @@ -163,9 +199,8 @@ export function difference(a, b) { // Règle de ce que désigne etape dans une valeur de règle regle, lue dans le // schéma : pour un objet, le champ de cette clé ; pour une liste, son // élément, la valeur refusant toute étape qui n'y est ni un rang ni { id }. -// undefined quand l'étape sort du schéma — une clé qu'une valeur posée dans -// une proposition ou dans le retenu apporte, par exemple —, et pour toute -// étape qui la suit. +// undefined quand l'étape sort du schéma — une clé que porte un retenu +// recopié hors du schéma, par exemple —, et pour toute étape qui la suit. function regleSuivante(regle, etape) { if (regle?.genre === 'objet') return regle.champs.find(([cle]) => cle === etape)?.[1]; return regle?.genre === 'liste' ? regle.element : undefined; @@ -175,49 +210,48 @@ function regleSuivante(regle, etape) { // dont l'élément a un champ id : participants, tables, propositions. const estListeDEnregistrements = (regle) => regle?.element?.cles?.has('id') === true; -// Lecture de chemin dans le schéma, depuis la charge : regle, la règle de la -// place qu'il désigne, undefined quand une étape sort du schéma ; et -// sousAPart, vrai quand une règle aPart le précède, c'est-à-dire quand la -// place est dans une proposition ou dans le retenu, que l'analyse n'examine -// que comme conteneurs (§ 8.9). null quand une étape { id } ne tombe pas -// dans une liste d'enregistrements à identifiant. C'est le schéma qui en -// décide, et non le contenu de la liste : une liste vide n'a aucun élément -// qui montre la forme des autres, et tout identifiant y est absent, ce qui -// ouvrirait une insertion par { id } dans une réserve, une liste de table -// ou les réservations. Une clé et un rang se contrôlent sur la valeur, qui -// les porte ou non. +// Lecture de chemin dans le schéma, depuis la charge : { regle }, la règle +// de la place qu'il désigne, undefined quand une étape sort du schéma ; null +// quand une étape { id } ne tombe pas dans une liste d'enregistrements à +// identifiant. C'est le schéma qui en décide, et non le contenu de la +// liste : une liste vide n'a aucun élément qui montre la forme des autres, +// et tout identifiant y est absent, ce qui ouvrirait une insertion par +// { id } dans une réserve, une liste de table ou les réservations. Une clé +// et un rang se contrôlent sur la valeur, qui les porte ou non. function lireChemin(chemin) { let regle = CHARGE; - let sousAPart = false; for (const etape of chemin) { if (estObjet(etape) && !estListeDEnregistrements(regle)) return null; - sousAPart ||= regle?.aPart === true; regle = regleSuivante(regle, etape); } - return { regle, sousAPart }; + return { regle }; } -// Vrai quand l'analyse admettrait valeur à la place que lecture désigne -// (§ 8.8) : hors des propositions et du retenu, sa forme entière ; à la -// place de la liste des propositions ou du retenu, son conteneur ; plus bas, -// aucune valeur ne s'examine, et le contrôle des placements juge ce qu'elle -// y fait (§ 8.9). Une place hors du schéma n'en admet aucune. -function valeurAdmise(valeur, { regle, sousAPart }) { - if (sousAPart) return true; - return regle !== undefined && premiereFauteEnPlace(valeur, regle) === null; +// Vrai quand valeur, posée au bout de chemin dans charge, à la place de +// règle regle, suit cette règle, sa forme entière (premiereFaute), +// propositions et retenu compris : une proposition qui suit sa règle la +// suit encore après la pose, et canoniser comme serialiser la lisent. À la +// place du retenu, son conteneur seul (premiereFauteEnPlace) : un retenu que +// sa règle refuse se pose entier. Sous le retenu, une valeur ne se pose que +// quand il suit sa règle, et une valeur qui suit la sienne l'y garde. Une +// place hors du schéma n'en admet aucune. +function valeurAdmise(charge, chemin, regle, valeur) { + if (regle === undefined) return false; + if (regle === RETENU) return premiereFauteEnPlace(valeur, RETENU) === null; + if (chemin[0] === 'retenu' && premiereFaute(charge.retenu, RETENU) !== null) return false; + return premiereFaute(valeur, regle) === null; } -// Rangs des éléments de liste dont l'id vaut id, ou null quand un élément -// n'est pas un objet à id entier exact, ce qu'une valeur posée dans la liste -// des propositions peut y mettre : { id } n'y désigne rien. liste est une -// liste d'enregistrements du schéma, que valeurAdmise garde une liste. +// Rangs des éléments de liste dont l'id vaut id, ou null quand id n'est pas +// un entier exact : { id } n'y désigne rien. liste est une liste +// d'enregistrements du schéma — participants, tables, propositions —, +// chacun un objet à id entier, que l'analyse et valeurAdmise gardent +// conformes à leur règle. function rangsDeLIdentifiant(liste, id) { if (!Number.isSafeInteger(id)) return null; const rangs = []; for (let rang = 0; rang < liste.length; rang += 1) { - const element = liste[rang]; - if (!estObjet(element) || !Number.isSafeInteger(element.id)) return null; - if (element.id === id) rangs.push(rang); + if (liste[rang].id === id) rangs.push(rang); } return rangs; } @@ -246,16 +280,16 @@ function descendre(conteneur, etape) { // Pose une copie de valeur à la place que désigne etape dans conteneur : // une clé qu'il porte déjà, un rang qu'il contient, ou { id } absent de la -// liste, quand valeur est l'enregistrement de cet id ; l'enregistrement -// s'insère alors avant le premier d'id plus grand, ce qui garde une liste -// rangée par identifiant croissant. Rend faux quand etape ne désigne -// aucune de ces places. +// liste, quand valeur, un enregistrement que valeurAdmise a contrôlé, porte +// cet id ; l'enregistrement s'insère alors avant le premier d'id plus +// grand, ce qui garde une liste rangée par identifiant croissant. Rend faux +// quand etape ne désigne aucune de ces places. function poser(conteneur, etape, valeur) { if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) { conteneur[etape] = structuredClone(valeur); return true; } - if (rangsDe(conteneur, etape)?.length !== 0 || !estObjet(valeur) || valeur.id !== etape.id) return false; + if (rangsDe(conteneur, etape)?.length !== 0 || valeur.id !== etape.id) return false; const suivant = conteneur.findIndex((element) => element.id > etape.id); conteneur.splice(suivant === -1 ? conteneur.length : suivant, 0, structuredClone(valeur)); return true; @@ -275,9 +309,10 @@ function retirer(conteneur, etape) { // Applique operation à charge, en place ; rend faux, sans rien changer, // quand elle est mal formée, qu'une étape { id } de son chemin tombe hors // d'une liste d'enregistrements, que ce chemin ne désigne pas une place de -// charge, ou que la valeur posée n'y serait pas admise par l'analyse. Un -// chemin vide n'a pas de dernière étape, et undefined n'en désigne aucune. -// Une opération poser porte une valeur : JSON n'en écrit pas d'undefined. +// charge, ou que la valeur posée n'y suivrait pas sa règle (valeurAdmise). +// Un chemin vide n'a pas de dernière étape, et undefined n'en désigne +// aucune. Une opération poser porte une valeur : JSON n'en écrit pas +// d'undefined. function executer(charge, operation) { if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false; const { op, chemin, valeur } = operation; @@ -290,7 +325,8 @@ function executer(charge, operation) { } const derniere = chemin[chemin.length - 1]; if (op === 'poser') { - return valeur !== undefined && valeurAdmise(valeur, lecture) && poser(conteneur, derniere, valeur); + if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false; + return poser(conteneur, derniere, valeur); } if (op === 'retirer') return retirer(conteneur, derniere); return false; @@ -299,9 +335,11 @@ function executer(charge, operation) { /** * Applique un correctif à la copie canonique de a (§ 8.6), opération après * opération, et rend cette copie : une charge neuve, qui ne partage aucun - * objet avec a ni avec le correctif. a n'est pas modifiée. Appliqué à a, le - * correctif de difference(a, b) rend la copie canonique de b. Une insertion - * par { id } garde l'ordre des identifiants. + * objet avec a ni avec le correctif. a n'est pas modifiée ; c'est une charge + * que l'analyse admet et dont chaque proposition suit sa règle, comme celle + * que rend examiner (placements.js). Appliqué à a, le correctif de + * difference(a, b) rend la copie canonique de b. Une insertion par { id } + * garde l'ordre des identifiants. * * Lève ErreurStockage('CORRECTIF', { rang }) à la première opération qui ne * s'applique pas — rang est sa place dans le correctif, à partir de 0 — : @@ -311,14 +349,16 @@ function executer(charge, operation) { * ou exactement un enregistrement ; poser à une clé que l'objet ne porte * pas ou au-delà de la fin d'une liste ; poser par { id } un identifiant * déjà présent, ou une valeur qui n'est pas l'enregistrement de cet - * identifiant ; poser une valeur que l'analyse refuserait à sa place — - * hors des propositions et du retenu, une valeur qui sort de sa règle, en - * genre, en domaine, null compris, ou en clés ; à la place de la liste des - * propositions ou du retenu, un autre conteneur — ; retirer autre chose - * qu'un enregistrement par { id }. Le contrôle porte sur la forme : une - * opération ne porte pas la valeur qu'elle remplace, et un correctif dont - * chaque chemin existe aussi dans une autre charge s'y applique sans lever. - * Un correctif qui n'est pas une liste lève TypeError. + * identifiant ; poser une valeur qui sort de la règle de sa place, en + * genre, en domaine, null compris, ou en clés, jusque dans les propositions + * et le retenu ; à la place du retenu, autre chose qu'un objet ou null ; + * sous un retenu que sa règle refuse, quoi que ce soit ; retirer autre + * chose qu'un enregistrement par { id }. Une charge rendue garde ainsi + * chaque proposition dans sa règle, et canoniser, difference et serialiser + * la lisent sans lever. Le contrôle porte sur la forme : une opération ne + * porte pas la valeur qu'elle remplace, et un correctif dont chaque chemin + * existe aussi dans une autre charge s'y applique sans lever. Un correctif + * qui n'est pas une liste lève TypeError. * * @param {import('./types.js').Charge} a * @param {Operation[]} correctif diff --git a/src/stockage/correctifs.test.js b/src/stockage/correctifs.test.js index 352a31c..1e589dc 100644 --- a/src/stockage/correctifs.test.js +++ b/src/stockage/correctifs.test.js @@ -658,27 +658,65 @@ describe('difference : localité sur la grande démonstration (§ 8.9)', () => { assert.ok(taille < 200, `${taille} octets`); }); - test('sans attribution, un échange qui décale les rangs dans les listes triées ne touche que ces deux listes, une entrée par rang changé', () => { + test("sans attribution, un échange entre deux tables pleines tient en deux opérations de moins de 250 octets, une par liste : une liste d'identifiants se pose entière quand ses opérations rang par rang pèsent plus qu'elle", () => { const avant = grande(); - const apres = copie(avant); - const { sieges } = apres.propositions.find((p) => p.id === 4).placement[1]; - // La plus petite personne de la table de rang 4 contre la plus grande de - // celle de rang 30 : chacune entre vers un bout de l'autre liste, et les + const listes = avant.propositions.find((p) => p.id === 4).placement[1].sieges; + // Échange [t1, r1, t2, r2] : la personne du rang r1 de la table de rang + // t1 contre celle du rang r2 de la table de rang t2, puis chaque liste + // rangée par identifiant, comme la copie canonique d'une proposition + // sans attribution. Chacune entre dans l'autre liste à son rang, et les // entrées qu'elle franchit se décalent d'un rang. - const [premiere, seconde] = [sieges[4], sieges[30]]; - const [plusPetit, plusGrand] = [premiere[0], seconde[seconde.length - 1]]; - sieges[4] = [...premiere.slice(1), plusGrand].sort(croissant); - sieges[30] = [plusPetit, ...seconde.slice(0, -1)].sort(croissant); - const changes = (avantEchange, apresEchange) => avantEchange.filter((id, rang) => apresEchange[rang] !== id).length; - const correctif = difference(avant, apres); - assert.ok(correctif.length > 2, `${correctif.length} opérations`); - assert.equal(correctif.length, changes(premiere, sieges[4]) + changes(seconde, sieges[30])); - for (const { op, chemin } of correctif) { - assert.equal(op, 'poser'); - assert.deepEqual(chemin.slice(0, 5), listesDuTour2(4)); - assert.ok(chemin.length === 7 && (chemin[5] === 4 || chemin[5] === 30), JSON.stringify(chemin)); + const echanger = ([t1, r1, t2, r2]) => { + const apres = copie(avant); + const { sieges } = apres.propositions.find((p) => p.id === 4).placement[1]; + [sieges[t1][r1], sieges[t2][r2]] = [sieges[t2][r2], sieges[t1][r1]]; + for (const t of [t1, t2]) sieges[t].sort(croissant); + return { apres, sieges }; + }; + // Les deux listes posées entières, dans l'ordre des rangs de table. + const entieres = (sieges, tables) => + [...tables].sort(croissant).map((t) => ({ op: 'poser', chemin: [...listesDuTour2(4), t], valeur: sieges[t] })); + // La plus petite personne de la table de rang 4 contre la plus grande de + // celle de rang 30 : chacune franchit toute l'autre liste, et ses poses + // rang par rang pèseraient plus que les deux listes entières. + const extremes = [4, 0, 30, listes[30].length - 1]; + const { apres, sieges } = echanger(extremes); + assert.deepEqual(difference(avant, apres), entieres(sieges, [4, 30])); + // Deux identifiants consécutifs, que l'échange laisse chacun au rang de + // l'autre : une pose par liste, au rang qui change. Puis, de chaque + // table, une personne contre une personne de la table onze rangs plus + // loin, à des rangs qui varient d'une table à l'autre. + const voisin = listes[4][2] + 1; + const tableDuVoisin = listes.findIndex((liste) => liste.includes(voisin)); + const echanges = [ + extremes, + [4, 2, tableDuVoisin, listes[tableDuVoisin].indexOf(voisin)], + ...listes.map((liste, t) => { + const autre = (t + 11) % listes.length; + return [t, t % liste.length, autre, (3 * t + 1) % listes[autre].length]; + }), + ]; + const formes = new Set(); + for (const echange of echanges) { + const [t1, , t2] = echange; + const { apres: rendu, sieges: rangees } = echanger(echange); + const correctif = difference(avant, rendu); + const cas = JSON.stringify(echange); + assert.equal(correctif.length, 2, cas); + assert.deepEqual(correctif.map(({ chemin }) => chemin[5]), [t1, t2].sort(croissant), cas); + for (const { op, chemin } of correctif) { + assert.equal(op, 'poser', cas); + assert.deepEqual(chemin.slice(0, 5), listesDuTour2(4), cas); + formes.add(chemin.length); + } + const taille = octets(JSON.stringify(correctif)); + assert.ok(taille < 250, `${cas} : ${taille} octets`); + assert.ok(taille <= octets(JSON.stringify(entieres(rangees, [t1, t2]))), `${cas} : ${taille} octets`); + assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(rendu), cas); } - assert.equal(serialiserCharge(appliquer(avant, correctif)), serialiserCharge(apres)); + // Le balayage rencontre les deux issues : une liste posée entière, et une + // seule pose au rang qui change. + assert.deepEqual([...formes].sort(croissant), [6, 7]); }); test('ajouter un participant donne une opération poser à { id }, et prochainsIds avance', () => { @@ -746,6 +784,15 @@ describe('difference : localité sur la grande démonstration (§ 8.9)', () => { }); describe('difference : les règles (§ 8.6)', () => { + // Poses au rang qui mènent de la liste d'identifiants avant à la liste + // apres, de même longueur, au bout de chemin : une par rang qui change. + const posesAuRang = (avant, apres, chemin) => + avant.flatMap((id, rang) => + id === apres[rang] ? [] : [{ op: 'poser', chemin: [...chemin, rang], valeur: apres[rang] }], + ); + // Poids d'opérations : les octets de leur texte JSON. + const poids = (operations) => octets(JSON.stringify(operations)); + test("deux charges égales à l'ordre près donnent [] : ordre des clés, des collections, et des tables d'une proposition sans attribution", () => { const a = petite(); const b = copie(a); @@ -885,7 +932,7 @@ describe('difference : les règles (§ 8.6)', () => { } }); - test('les autres listes : de même longueur, rang par rang ; de longueurs différentes, posées entières', () => { + test("les autres listes : de même longueur, rang par rang, des capacités toutes changées comprises ; de longueurs différentes, posées entières", () => { const a = petite(); a.reservations = [ { participant: 2, table: 1, siege: 1, portee: 'tous', tour: null }, @@ -895,6 +942,10 @@ describe('difference : les règles (§ 8.6)', () => { const b = copie(a); b.reservations[1].siege = 2; b.titres.push({ table: 2, siege: 3, libelle: 'accueil' }); + // Une capacité ne change que son rang : la pose d'une liste entière plus + // légère que ses poses rang par rang ne vaut que pour une liste + // d'identifiants, que l'ordre canonique décale. + b.propositions[0].capacites = [4, 4, 4, 4]; // Au tour 1, la première personne de la table de rang 0 passe à la réserve. const tour1 = b.propositions[0].placement[0]; const [personne, ...restants] = tour1.sieges[0]; @@ -910,6 +961,11 @@ describe('difference : les règles (§ 8.6)', () => { { table: 2, siege: 3, libelle: 'accueil' }, ], }, + ...[0, 1, 2, 3].map((rang) => ({ + op: 'poser', + chemin: ['propositions', { id: 1 }, 'capacites', rang], + valeur: 4, + })), { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0], valeur: restants }, { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [personne] }, ]); @@ -985,6 +1041,26 @@ describe('difference : les règles (§ 8.6)', () => { assert.deepEqual(ecarts, []); }); + test("un retenu que sa règle admet mais qui se contredit — cinq tours déclarés, quatre écrits — se compare champ par champ, comme un retenu cohérent : seul un retenu hors de sa règle se pose entier (§ 8.9, point 3)", () => { + const a = petite(); + a.retenu = { ...retenuDe(a.propositions[0]), tours: 5 }; + a.evenement.etat = 'retenu'; + // Relu, le retenu est gardé, et sa contradiction nommée. + const examen = examiner(analyser(serialiser(a, ENTETE)).charge); + assert.deepEqual(examen.retenu.fautes, [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }]); + assert.equal(porteUnRetenuAbime(a), false); + // Basculer le drapeau garde l'ordre des listes, rangées sans + // attribution ; la première liste du premier tour s'inverse. + const b = copie(a); + b.retenu.siegesAttribues = true; + b.retenu.placement[0].sieges[0].reverse(); + assert.deepEqual(difference(a, b), [ + { op: 'poser', chemin: ['retenu', 'siegesAttribues'], valeur: true }, + { op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: b.retenu.placement[0].sieges[0] }, + ]); + assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b)); + }); + test("deux propositions de même identifiant : la liste se compare par rang dans l'ordre canonique, ou se pose entière", () => { const a = petite(); a.propositions.push({ ...copie(a.propositions[0]), graine: 7 }); @@ -1006,11 +1082,124 @@ describe('difference : les règles (§ 8.6)', () => { ]); const b = copie(a); for (const proposition of b.propositions) proposition.placement[0].sieges[0].reverse(); - const [premier, , dernier] = a.propositions[1].placement[0].sieges[0]; + // Le premier et le dernier des trois sièges changent : leurs deux poses + // pèsent plus que la liste posée entière. assert.deepEqual(difference(a, b), [ - { op: 'poser', chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0, 0], valeur: dernier }, - { op: 'poser', chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0, 2], valeur: premier }, + { + op: 'poser', + chemin: ['propositions', { id: 2 }, 'placement', 0, 'sieges', 0], + valeur: [...a.propositions[1].placement[0].sieges[0]].reverse(), + }, ]); + assert.equal(serialiserCharge(appliquer(a, difference(a, b))), serialiserCharge(b)); + }); + + test("à poids égal, la pose au rang reste : la seule personne de la réserve échangée contre un occupant donne une pose au rang de chaque liste", () => { + // Au tour 1 d'une proposition à sièges attribués, la troisième personne + // de la première table attend à la réserve, puis prend le siège du + // milieu de la deuxième table, dont l'occupant passe à la réserve. + const a = chargeDe('petite', [{ id: 1, siegesAttribues: true }]); + const tour1 = a.propositions[0].placement[0]; + tour1.reserve = [tour1.sieges[0].pop()]; + const b = copie(a); + const { sieges, reserve } = b.propositions[0].placement[0]; + [sieges[1][1], reserve[0]] = [reserve[0], sieges[1][1]]; + const chemin = ['propositions', { id: 1 }, 'placement', 0]; + const correctif = difference(a, b); + assert.deepEqual(correctif, [ + { op: 'poser', chemin: [...chemin, 'sieges', 1, 1], valeur: sieges[1][1] }, + { op: 'poser', chemin: [...chemin, 'reserve', 0], valeur: reserve[0] }, + ]); + // La réserve posée entière pèserait autant que sa pose au rang. + const entiere = { op: 'poser', chemin: [...chemin, 'reserve'], valeur: reserve }; + assert.equal(octets(JSON.stringify([entiere])), octets(JSON.stringify([correctif[1]]))); + assert.equal(serialiserCharge(appliquer(a, correctif)), serialiserCharge(b)); + }); + + test("une liste d'identifiants se pose entière selon le poids de ses poses au rang, et non selon leur nombre : deux rangs changés gardent leurs deux poses dans les 260 participants d'une proposition, et cèdent la place à la liste entière dans une liste de table de huit", () => { + const a = grande(); + const chemin = ['propositions', { id: 4 }]; + // Dans les participants de la proposition 4, 259 cède la place à 261 : + // la liste rangée change à ses deux derniers rangs, et leurs deux poses + // pèsent bien moins que la liste entière. + const b = copie(a); + const declaree = b.propositions.find((p) => p.id === 4); + assert.deepEqual(declaree.participants.slice(-2), [259, 260]); + declaree.participants = [...declaree.participants.slice(0, -2), 260, 261]; + const cheminDesParticipants = [...chemin, 'participants']; + const deuxPoses = [ + { op: 'poser', chemin: [...cheminDesParticipants, 258], valeur: 260 }, + { op: 'poser', chemin: [...cheminDesParticipants, 259], valeur: 261 }, + ]; + assert.deepEqual(difference(a, b), deuxPoses); + const listeEntiere = { op: 'poser', chemin: cheminDesParticipants, valeur: declaree.participants }; + assert.ok(poids(deuxPoses) < poids([listeEntiere]), `${poids(deuxPoses)} octets au rang`); + assert.equal(serialiserCharge(appliquer(a, deuxPoses)), serialiserCharge(b)); + // Au tour 2, la quatrième personne de la table de rang 0 contre la + // cinquième de celle de rang 1 : chaque liste de huit, rangée, change à + // deux rangs, et ses deux poses pèsent plus qu'elle posée entière. + const c = copie(a); + const avant = a.propositions.find((p) => p.id === 4).placement[1].sieges; + const { sieges } = c.propositions.find((p) => p.id === 4).placement[1]; + [sieges[0][3], sieges[1][4]] = [sieges[1][4], sieges[0][3]]; + for (const t of [0, 1]) sieges[t].sort(croissant); + const cheminDesListes = [...chemin, 'placement', 1, 'sieges']; + const entieres = [0, 1].map((t) => ({ op: 'poser', chemin: [...cheminDesListes, t], valeur: sieges[t] })); + for (const [t, entiere] of entieres.entries()) { + const auRang = posesAuRang(avant[t], sieges[t], entiere.chemin); + assert.equal(auRang.length, 2, `table de rang ${t}`); + assert.ok(poids(auRang) > poids([entiere]), `table de rang ${t} : ${poids(auRang)} octets au rang`); + } + assert.deepEqual(difference(a, c), entieres); + assert.equal(serialiserCharge(appliquer(a, entieres)), serialiserCharge(c)); + }); + + test("la pose entière vaut pour chaque sorte de liste d'identifiants, d'une proposition comme du retenu — ses tables, ses participants, la réserve d'un tour — : une liste qui change à chaque rang donne une seule pose, la liste entière", () => { + // La grande démonstration : au tour 2 de la proposition 4, trois + // personnes de la table de rang 0 attendent à la réserve ; le retenu + // vient de cette proposition. + const a = grande(); + const proposition = a.propositions.find((p) => p.id === 4); + const tour2 = proposition.placement[1]; + tour2.reserve = tour2.sieges[0].splice(1, 3); + a.retenu = retenuDe(proposition); + a.evenement.etat = 'retenu'; + // Chaque liste change dans une copie de a, à chaque rang : les tables + // tournent d'un rang ; dans un ensemble rangé, le plus petit identifiant + // cède la place à 261, plus grand que tous. + const tourner = (liste) => [...liste.slice(1), liste[0]]; + const remplacerLePremier = (liste) => [...liste.slice(1), 261]; + const listes = [ + [['tables'], tourner], + [['participants'], remplacerLePremier], + [['placement', 1, 'reserve'], remplacerLePremier], + ]; + const porteurs = [ + [['propositions', { id: 4 }], (charge) => charge.propositions.find((p) => p.id === 4)], + [['retenu'], (charge) => charge.retenu], + ]; + const ecarts = porteurs.flatMap(([cheminDuPorteur, porteurDe]) => + listes.flatMap(([cles, changer]) => { + const b = copie(a); + const parent = cles.slice(0, -1).reduce((valeur, cle) => valeur[cle], porteurDe(b)); + const cle = cles.at(-1); + const avant = parent[cle]; + parent[cle] = changer(avant); + const chemin = [...cheminDuPorteur, ...cles]; + const attendu = [{ op: 'poser', chemin, valeur: parent[cle] }]; + const auRang = posesAuRang(avant, parent[cle], chemin); + const correctif = difference(a, b); + const cas = JSON.stringify(chemin); + // Chaque rang change : rang par rang, le correctif compterait + // plusieurs poses. + if (auRang.length < 2 || auRang.length !== avant.length) return [`${cas} : ${auRang.length} rangs changés`]; + if (JSON.stringify(correctif) !== JSON.stringify(attendu)) { + return [`${cas} : ${correctif.length} opérations, ${poids(correctif)} octets`]; + } + return serialiserCharge(appliquer(a, correctif)) === serialiserCharge(b) ? [] : [`${cas} : cible non rendue`]; + }), + ); + assert.deepEqual(ecarts, []); }); }); @@ -1024,7 +1213,10 @@ describe('appliquer : une copie canonique, sans partage (§ 8.6)', () => { b.tables.push({ id: b.prochainsIds.table, numero: 34, sieges: null, forme: 'carree', position: { x: 0, y: 1500 } }); b.prochainsIds.table += 1; b.tables[0].position.y = 75; - b.propositions.find((p) => p.id === 7).placement[3].sieges[0].reverse(); + // Deux personnes échangent leurs sièges attribués : une pose au rang de + // chacune des deux listes. + const { sieges: echangees } = b.propositions.find((p) => p.id === 7).placement[3]; + [echangees[0][1], echangees[1][2]] = [echangees[1][2], echangees[0][1]]; const tour1 = b.propositions.find((p) => p.id === 9).placement[0]; tour1.reserve = [tour1.sieges[0].pop()]; b.retenu = retenuDe(b.propositions[0]); @@ -1047,6 +1239,34 @@ describe('appliquer : une copie canonique, sans partage (§ 8.6)', () => { assert.equal(partages.length, 0); }); + test("un retenu hors de sa règle, recopié hors du schéma, ne se partage pas davantage : le résultat ne partage rien avec a ni avec le correctif, et le correctif rien avec sa cible", () => { + // La petite démonstration, son retenu hors de sa règle : « tours » + // renommé « toura », une clé inconnue. Ses listes d'entiers se recopient + // hors du schéma. + const a = petite(); + const { tours, ...plan } = retenuDe(a.propositions[0]); + a.retenu = { ...plan, toura: tours, note: 'à revoir' }; + a.evenement.etat = 'retenu'; + geler(a); + assert.ok(porteUnRetenuAbime(a)); + // Un nom changé : le retenu du résultat vient de la copie de a. Une + // capacité du retenu changée : le correctif pose le retenu entier. + const renommee = copie(a); + renommee.participants[0].nom = 'Brindille'; + const retouchee = copie(a); + retouchee.retenu.capacites[0] = 4; + assert.deepEqual(difference(a, retouchee), [{ op: 'poser', chemin: ['retenu'], valeur: retouchee.retenu }]); + for (const cible of [renommee, retouchee]) { + const correctif = geler(difference(a, cible)); + const rendu = appliquer(a, correctif); + assert.equal(serialiserCharge(rendu), serialiserCharge(cible)); + const ailleurs = new Set([...objetsDe(a), ...objetsDe(correctif)]); + assert.deepEqual([...objetsDe(rendu)].filter((objet) => ailleurs.has(objet)), []); + const deLaCible = objetsDe(cible); + assert.deepEqual([...objetsDe(correctif)].filter((objet) => deLaCible.has(objet)), []); + } + }); + test('un correctif vide rend la copie canonique de a, et non a', () => { const a = petite(); a.participants.reverse(); @@ -1086,6 +1306,12 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le // La petite démonstration, et la même avec deux propositions d'id 1. const jumelles = petite(); jumelles.propositions.push({ ...copie(jumelles.propositions[0]), graine: 7 }); + // La même avec un retenu dans sa règle ; puis avec un retenu hors de sa + // règle, dont une clé inconnue porte une liste d'objets à identifiant. + const avecRetenu = petite(); + avecRetenu.retenu = retenuDe(avecRetenu.propositions[0]); + const retenuAbime = petite(); + retenuAbime.retenu = { ...retenuDe(retenuAbime.propositions[0]), note: [{ id: 1 }] }; // Chaque cas suit, au rang 0, une pose qu'appliquer admet : POSE, ou // celle que le cas nomme après son opération. const POSE = { op: 'poser', chemin: ['evenement', 'nom'], valeur: 'Soirée renommée' }; @@ -1199,9 +1425,8 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le ['une étape qui est une liste', base, { op: 'retirer', chemin: ['participants', [3]] }], ['un { id } qui est un texte', base, { op: 'retirer', chemin: ['participants', { id: '3' }] }], ['une étape objet sans id', base, { op: 'retirer', chemin: ['participants', {}] }], - // Une valeur posée est une valeur que l'analyse admet à sa place : hors - // des propositions et du retenu, sa forme entière ; à leur place, leur - // conteneur. Un cas par genre de règle. + // Une valeur posée suit la règle de sa place, sa forme entière ; à la + // place du retenu, son seul conteneur. Un cas par genre de règle. ["poser un nombre à la place d'un objet", base, { op: 'poser', chemin: ['evenement'], valeur: 5 }], ['poser un objet sans les clés de sa règle', base, { op: 'poser', chemin: ['evenement'], valeur: {} }], [ @@ -1234,36 +1459,97 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le base, { op: 'poser', chemin: ['participants', { id: 13 }], valeur: { ...troisieme, id: 13, nom: '' } }, ], + // Un identifiant reste sous 2^31. Rien ne compare ici l'enregistrement + // posé au compteur, qui borne déjà l'identifiant à l'analyse : seule sa + // règle le refuse. + [ + "poser par { id } un participant d'identifiant 2^31", + base, + { op: 'poser', chemin: ['participants', { id: 2 ** 31 }], valeur: { ...troisieme, id: 2 ** 31 } }, + ], + [ + "poser par { id } une table d'identifiant 2^31", + base, + { op: 'poser', chemin: ['tables', { id: 2 ** 31 }], valeur: { ...copie(base.tables[2]), id: 2 ** 31 } }, + ], ['poser un objet à la place des propositions', base, { op: 'poser', chemin: ['propositions'], valeur: {} }], ['poser un nombre à la place du retenu', base, { op: 'poser', chemin: ['retenu'], valeur: 5 }], ['poser une liste à la place du retenu', base, { op: 'poser', chemin: ['retenu'], valeur: [] }], - // L'analyse n'entre ni dans les propositions ni dans le retenu : une - // valeur posée plus bas ne s'examine pas, et peut donner à une liste - // une forme qui n'est pas la sienne. { id } n'y vaut toujours que dans - // une liste d'enregistrements du schéma, et sur des enregistrements. + // Les propositions et le retenu, l'analyse ne les lit que comme + // conteneurs ; une valeur posée en eux suit pourtant la règle de sa + // place : une charge rendue n'a jamais une proposition que canoniser, + // difference ou serialiser ne sauraient lire. [ - "descendre par { id } dans un placement qu'une pose a doté d'identifiants", + "poser des propositions qui sont une liste d'entiers", + base, + { op: 'poser', chemin: ['propositions'], valeur: [12] }, + ], + [ + 'poser par { id } une proposition sans les clés de sa règle', + base, + { op: 'poser', chemin: ['propositions', { id: 9 }], valeur: { id: 9 } }, + ], + [ + 'poser par { id } la valeur null dans les propositions', + base, + { op: 'poser', chemin: ['propositions', { id: 13 }], valeur: null }, + ], + [ + 'poser un placement qui est un objet', + base, + { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement'], valeur: {} }, + ], + [ + 'poser un placement dont un tour porte un identifiant', base, - { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', { id: 1 }, 'reserve'], valeur: [] }, { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement'], valeur: [{ id: 1, sieges: [], reserve: [] }] }, ], [ - "poser par { id } dans une liste vide qu'une pose a ajoutée sous une clé hors du schéma", + 'poser un tour à clé hors du schéma', base, - { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'x', { id: 1 }], valeur: { id: 1 } }, { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0], valeur: { sieges: [], reserve: [], x: [] } }, ], [ - "poser par { id } dans des propositions qu'une pose a faites liste d'entiers", + "poser un occupant qui n'est pas un identifiant", base, - { op: 'poser', chemin: ['propositions', { id: 13 }], valeur: { ...base.propositions[0], id: 13 } }, - { op: 'poser', chemin: ['propositions'], valeur: [12] }, + { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 0], valeur: 'Brindille' }, + ], + [ + 'poser un occupant au-delà de 2^31 − 1', + base, + { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 0], valeur: 2 ** 31 }, + ], + [ + 'poser sous le retenu un placement objet', + avecRetenu, + { op: 'poser', chemin: ['retenu', 'placement'], valeur: {} }, + ], + [ + "poser sous le retenu une table qui n'est pas un identifiant", + avecRetenu, + { op: 'poser', chemin: ['retenu', 'tables', 0], valeur: 'x' }, + ], + // Un retenu hors de sa règle se pose entier, jamais par morceaux : + // difference ne descend pas en lui. + [ + 'poser sous un retenu hors de sa règle, même une valeur que sa place admet', + retenuAbime, + { op: 'poser', chemin: ['retenu', 'capacites', 0], valeur: 3 }, + ], + // Une étape { id } ne vaut que dans une liste d'enregistrements du + // schéma : une clé hors du schéma n'en mène à aucune, même quand elle + // porte des objets à identifiant. + [ + "retirer par { id } dans une liste qu'un retenu hors de sa règle porte sous une clé hors du schéma", + retenuAbime, + { op: 'retirer', chemin: ['retenu', 'note', { id: 1 }] }, ], - ['poser par { id } la valeur null dans les propositions', base, { op: 'poser', chemin: ['propositions', { id: 13 }], valeur: null }], ]; const texteBase = JSON.stringify(base); geler(base); geler(jumelles); + geler(avecRetenu); + geler(retenuAbime); const ecarts = cas.flatMap(([libelle, charge, operation, prealable = POSE]) => { const refus = refusDe(charge, geler([prealable, operation])); return JSON.stringify(refus) === JSON.stringify({ rang: 1 }) ? [] : [`${libelle} : ${JSON.stringify(refus)}`]; @@ -1311,7 +1597,7 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le assert.deepEqual(ecarts, []); }); - test("une valeur posée passe la forme que l'analyse donne à sa place : hors des propositions et du retenu, à chaque place, une valeur d'une autre sorte est refusée et la sienne admise ; plus bas, aucune ne s'examine", () => { + test("une valeur posée suit la règle de sa place : à chaque place, propositions et retenu compris, une valeur d'une autre sorte est refusée et la sienne admise ; à la place du retenu, un retenu hors de sa règle reste admis", () => { // La petite démonstration, avec une date, une filiation, une // réservation, un titre et un retenu : chaque règle du schéma y a une // valeur à éprouver. @@ -1341,12 +1627,11 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le // qui le remet en place se rejoue. const retenuFautif = { ...retenuDe(charge.propositions[0]), tours: 0 }; assert.equal(refusDe(canonique, [{ op: 'poser', chemin: ['retenu'], valeur: retenuFautif }]), 'aucun refus'); - const sousAPart = (chemin) => chemin.length > 1 && (chemin[0] === 'propositions' || chemin[0] === 'retenu'); const ecarts = valeurs.flatMap(({ chemin, valeur }) => { const autre = Array.isArray(valeur) ? {} : []; return [ ['sa valeur', valeur, 'aucun refus'], - ["une valeur d'une autre sorte", autre, sousAPart(chemin) ? 'aucun refus' : { rang: 0 }], + ["une valeur d'une autre sorte", autre, { rang: 0 }], ].flatMap(([cas, posee, attendu]) => { const refus = JSON.stringify(refusDe(canonique, [{ op: 'poser', chemin, valeur: posee }])); return refus === JSON.stringify(attendu) ? [] : [`${JSON.stringify(chemin)}, ${cas} : ${refus}`]; diff --git a/src/stockage/document.js b/src/stockage/document.js index e99df46..4dfb979 100644 --- a/src/stockage/document.js +++ b/src/stockage/document.js @@ -8,8 +8,9 @@ // 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. +// est le seul : premiereFaute le rend au contrôle des placements et aux +// correctifs, et premiereFauteEnPlace aux correctifs, pour le retenu posé à +// sa place. // // analyser lit un texte sans rien écrire et lève, à la première faute, // ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), en contrôlant dans @@ -69,7 +70,14 @@ const CHAINE = chaine(); const CHAINE_OU_NUL = chaine({ nul: true }); const BOOLEEN = regle('booleen'); const NOMBRE = regle('nombre'); -const IDENTIFIANT = entier(1); +// Un identifiant entier — d'un participant, d'une table, d'une proposition, +// ou de ce qu'une réservation, un titre ou un placement désigne — reste sous +// 2^31, dans un entier signé de 32 bits, la largeur des tableaux du moteur +// (Int32Array). Chaque compteur de prochainsIds, le prochain identifiant à +// attribuer, suit la même règle : relevé au-delà d'un identifiant lu, il +// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la +// valeur sort de sa règle. +const IDENTIFIANT = entier(1, 2 ** 31 - 1); const DATE_OU_NUL = regle('date', { nul: true }); // Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les @@ -459,8 +467,8 @@ export function premiereFaute(valeur, regle, chemin = '') { /** * 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. + * même donnée ici. Les correctifs contrôlent ainsi le retenu posé à sa + * place, que la lecture admet hors de sa règle. * * @param {*} valeur * @param {import('./types.js').Regle} regle diff --git a/src/stockage/document.test.js b/src/stockage/document.test.js index cda04a5..718e7b9 100644 --- a/src/stockage/document.test.js +++ b/src/stockage/document.test.js @@ -334,8 +334,8 @@ describe('analyser : ce qui se lit (§ 8.8)', () => { // 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.propositions.push(null, { id: 'neuf' }, { id: 9.5 }, { id: 2 ** 31 }, { id: 2 ** 53 }, [9]); + d.entete.comptes.propositions = 7; d.charge.retenu = { ...retenuValide(), proposition: '9' }; d.entete.comptes.retenu = 1; })], @@ -347,6 +347,10 @@ describe('analyser : ce qui se lit (§ 8.8)', () => { d.charge.retenu = retenuValide(); d.entete.comptes.retenu = 1; })], + ['compteurs au plus grand identifiant, 2^31 − 1, et une personne juste en deçà', abime((d) => { + d.charge.prochainsIds = { participant: 2 ** 31 - 1, table: 2 ** 31 - 1, proposition: 2 ** 31 - 1 }; + d.charge.participants[3].id = 2 ** 31 - 2; + })], ['toutes les listes vides', abime((d) => { for (const cle of ['participants', 'tables', 'reservations', 'titres', 'propositions']) { d.charge[cle] = []; @@ -490,6 +494,24 @@ describe('analyser : forme (§ 8.8)', () => { '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'], + // Un identifiant, et chaque compteur qui en est le prochain, reste sous + // 2^31 ; une référence vers un identifiant au-delà sort de sa règle + // avant de désigner quoi que ce soit. L'identifiant d'un participant + // ou d'une table, l'analyse le compare aussi à son compteur, qui le + // tient déjà sous 2^31 − 1, sous le même chemin : sa règle s'éprouve + // là où rien ne le compare au compteur, dans les poses d'un correctif + // (correctifs.test.js). + ['prochain identifiant à 2^31', (d) => { d.charge.prochainsIds.participant = 2 ** 31; }, + 'charge.prochainsIds.participant'], + ['prochain identifiant de table à 2^31', (d) => { d.charge.prochainsIds.table = 2 ** 31; }, + 'charge.prochainsIds.table'], + ['prochain identifiant de proposition à 2^31', (d) => { d.charge.prochainsIds.proposition = 2 ** 31; }, + 'charge.prochainsIds.proposition'], + ['réservation de la personne 2^31', (d) => { d.charge.reservations[0].participant = 2 ** 31; }, + 'charge.reservations[0].participant'], + ['réservation de la table 2^31', (d) => { d.charge.reservations[1].table = 2 ** 31; }, + 'charge.reservations[1].table'], + ['titre de la table 2^31', (d) => { d.charge.titres[0].table = 2 ** 31; }, 'charge.titres[0].table'], ['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'], diff --git a/src/stockage/placements.js b/src/stockage/placements.js index 7b93529..354f371 100644 --- a/src/stockage/placements.js +++ b/src/stockage/placements.js @@ -26,6 +26,7 @@ // manière. Aucune fonction ne modifie ce qu'elle reçoit ; aucune ne lit // l'horloge ni un aléa. import { SCHEMA, capacite, premiereFaute } from './document.js'; +import { ErreurStockage } from './erreurs.js'; /** * Une faute d'un placement ; types.js en donne la table des codes. FORME : @@ -248,11 +249,17 @@ function exigerCoherent(p, fonction) { * du schéma ; rien n'est partagé avec les arguments, qui ne sont pas * modifiés. * - * Lève TypeError, sans rien rendre, quand decalage n'est pas un entier ≥ 0, - * ou quand la forme produite serait fautive : attribuerSieges qui n'est pas - * un booléen, version vide ou absente, réglage hors de son domaine, plan que - * la configuration contredit — table inconnue d'elle, nombre de tours, - * personne non placée, liste au-delà d'une capacité. + * Lève ErreurStockage('COMPTEUR_SATURE', { compteur: 'proposition' }), sans + * rien rendre, quand l'identifiant atteint 2^31 − 1, le plus grand + * qu'admette la règle d'un identifiant, que suit aussi le compteur, qui + * n'aurait alors aucune valeur au-delà de lui. Un fichier valide y mène — un + * compteur écrit à la main à 2^31 − 1, ou un identifiant qui l'y relève — et + * le compteur ne recule jamais : l'appelant lit ce code avant d'écrire la + * génération. Lève TypeError, sans rien rendre, quand decalage n'est pas un + * entier ≥ 0, ou quand la forme produite serait fautive : attribuerSieges + * qui n'est pas un booléen, version vide ou absente, réglage hors de son + * domaine, plan que la configuration contredit — table inconnue d'elle, + * nombre de tours, personne non placée, liste au-delà d'une capacité. * * @param {import('../moteur/recherche.js').Proposition} proposition * @param {import('../moteur/types.js').Configuration} configuration @@ -265,11 +272,16 @@ export function versFichier(proposition, configuration, { produitVersion, attrib if (!Number.isSafeInteger(decalage) || decalage < 0) { throw new TypeError(`versFichier : décalage entier ≥ 0 attendu, reçu ${JSON.stringify(decalage)}`); } + // Le compteur que l'appelant porte au-delà de l'identifiant écrit suit la + // règle d'un identifiant : un identifiant qui atteint le plus grand + // qu'elle admet ne laisserait aucun compteur au-delà de lui. + const identifiant = decalage + proposition.id; + if (identifiant >= IDENTIFIANT.max) throw new ErreurStockage('COMPTEUR_SATURE', { compteur: 'proposition' }); const { plan } = proposition; const capaciteDe = new Map(configuration.tables.map(({ id, capacite: places }) => [id, places])); const ordonner = (liste) => (attribuerSieges ? [...liste] : [...liste].sort(croissant)); const fichier = { - id: decalage + proposition.id, + id: identifiant, graine: proposition.graine, arret: proposition.arret, historique: proposition.historique, @@ -435,16 +447,17 @@ export function formeNommee(p, charge) { return lignes; } -// valeur quand elle est un identifiant de proposition, null sinon. +// valeur quand elle est un identifiant de proposition, null sinon : un +// entier au-delà de 2^31 − 1 n'en est pas un. const identifiantLisible = (valeur) => (premiereFaute(valeur, IDENTIFIANT) === null ? valeur : null); // Compteur des propositions de la charge que rend examiner : celui de la // charge, relevé au-delà de plusGrand, le plus grand identifiant lu, 0 quand -// aucun ne se lit ; il ne recule jamais. Le compteur est un identifiant, un -// entier exact : relevé, il s'arrête à 2^53 − 1, et un identifiant de cette -// valeur reste hors de lui. +// aucun ne se lit ; il ne recule jamais. Le compteur suit la règle d'un +// identifiant : relevé, il s'arrête au plus grand qu'elle admet, 2^31 − 1, +// et un identifiant de cette valeur reste hors de lui. const compteurReleve = (charge, plusGrand) => - Math.max(charge.prochainsIds.proposition, Math.min(plusGrand + 1, Number.MAX_SAFE_INTEGER)); + Math.max(charge.prochainsIds.proposition, Math.min(plusGrand + 1, IDENTIFIANT.max)); // Faute d'un identifiant de proposition que la charge a attribué au-delà de // son compteur, ou [] : prochainsIds.proposition dépasse tout identifiant @@ -494,13 +507,17 @@ function examinerRetenu(charge) { * y vaut le plus grand du compteur reçu et du plus grand identifiant lu plus * un — celui de chaque élément de la liste, gardé ou écarté, quelle que soit * sa faute, et la proposition d'origine du retenu, fautif ou non ; une - * valeur qui n'est pas un identifiant n'y entre pas. Le compteur ne recule - * donc jamais, et une génération numérotée depuis lui, comme le dit - * versFichier, ne réattribue ni l'identifiant d'une proposition écartée ni - * celui de l'origine du retenu : le retenu ne désigne jamais une autre - * proposition que la sienne. Le compteur reste un entier exact, qui s'arrête - * à 2^53 − 1 ; un identifiant de cette valeur reste hors de lui, et - * IDENTIFIANT_HORS_COMPTEUR le nomme tant que le fichier le porte. + * valeur qui n'est pas un identifiant, un entier au-delà de 2^31 − 1 + * compris, n'y entre pas : la proposition qui la porte est fautive, FORME, + * le retenu aussi. Le compteur ne recule donc jamais, et une génération + * numérotée depuis lui, comme le dit versFichier, ne réattribue ni + * l'identifiant d'une proposition écartée ni celui de l'origine du retenu : + * le retenu ne désigne jamais une autre proposition que la sienne. Le + * compteur suit la règle d'un identifiant, qui s'arrête à 2^31 − 1 ; un + * identifiant de cette valeur reste hors de lui, et + * IDENTIFIANT_HORS_COMPTEUR le nomme tant que le fichier le porte. Un + * compteur à 2^31 − 1 ne numérote plus aucune génération : versFichier lève + * COMPTEUR_SATURE. * * Rend une charge neuve, dont propositions ne porte que les gardées, dans * l'ordre de la liste, et prochainsIds un objet neuf, son compteur des diff --git a/src/stockage/placements.test.js b/src/stockage/placements.test.js index e469408..2cef2df 100644 --- a/src/stockage/placements.test.js +++ b/src/stockage/placements.test.js @@ -20,6 +20,7 @@ import { HISTORIQUE_PAR_DEFAUT, rechercher, regenerer } from '../moteur/recherch import { VERSION } from '../version.genere.js'; import { serialiser } from './canonique.js'; import { analyser, configurationDepuisCharge, creerCharge } from './document.js'; +import { ErreurStockage } from './erreurs.js'; import { derive, examiner, fautes, formeNommee, planDepuisFichier, versFichier } from './placements.js'; const ENTETE = Object.freeze({ revision: 1, produitVersion: VERSION.affichee }); @@ -192,6 +193,22 @@ function leveTypeError(appel, motif, message) { assert.throws(appel, (erreur) => erreur instanceof TypeError && motif.test(erreur.message), message); } +// Code et détails de l'ErreurStockage que lève appel, ou un texte qui dit +// l'écart : une autre erreur, aucun refus. +function refusDe(appel) { + try { + appel(); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage)) return `${erreur?.name} au lieu d'une ErreurStockage : ${erreur?.message}`; + return { code: erreur.code, details: erreur.details }; + } + return 'aucun refus'; +} + +// Le refus d'un identifiant qui ne laisserait au compteur des propositions +// aucune valeur au-delà. +const COMPTEUR_SATURE = Object.freeze({ code: 'COMPTEUR_SATURE', details: Object.freeze({ compteur: 'proposition' }) }); + // Une ligne attendue de la forme nommée. const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({ tour, @@ -356,6 +373,27 @@ describe('versFichier et planDepuisFichier : la proposition du moteur et sa form leveTypeError(() => versFichier(PARFAITE, sansTable, options), /"FORME"/, 'table 4 absente'); }); + test("versFichier refuse sous un code l'identifiant qui ne laisserait au compteur aucune valeur au-delà, 2^31 − 1 et plus : ErreurStockage COMPTEUR_SATURE, qui nomme le compteur des propositions ; 2^31 − 2, le dernier qui lui en laisse une, s'écrit", () => { + // Une troisième proposition, que son décalage porte comme la première à + // l'identifiant 2^31 − 1. + const troisieme = { ...PARFAITE, id: 3 }; + const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }; + assert.equal(versFichier(PARFAITE, petite(), { ...options, decalage: 2 ** 31 - 3 }).id, 2 ** 31 - 2); + const refus = [ + [PARFAITE, 2 ** 31 - 2], + [troisieme, 2 ** 31 - 4], + [PARFAITE, 2 ** 31 - 1], + ].map(([proposition, decalage]) => [ + decalage + proposition.id, + refusDe(() => versFichier(proposition, petite(), { ...options, decalage })), + ]); + assert.deepStrictEqual(refus, [ + [2 ** 31 - 1, COMPTEUR_SATURE], + [2 ** 31 - 1, COMPTEUR_SATURE], + [2 ** 31, COMPTEUR_SATURE], + ]); + }); + test("le fichier écrit se relit : examiner garde chaque proposition telle qu'écrite, sans faute ni dérive", () => { const charge = chargeAvecGeneration(); const examen = examiner(relire(charge)); @@ -573,6 +611,12 @@ describe('fautes : la corruption délibérée (§ 8.9, points 1 à 3)', () => { ['liste de table en nombre', (p) => { p.placement[2].sieges[1] = 7; return p; }, 'placement[2].sieges[1]'], ['occupant fractionnaire', (p) => { p.placement[3].sieges[0][2] = 2.5; return p; }, 'placement[3].sieges[0][2]'], ['occupant au-delà des entiers exacts', (p) => { p.placement[3].sieges[1][0] = 2 ** 53; return p; }, 'placement[3].sieges[1][0]'], + // Un identifiant reste sous 2^31. + ['identifiant 2^31', (p) => { p.id = 2 ** 31; return p; }, 'id'], + ['identifiant de table 2^31', (p) => { p.tables[3] = 2 ** 31; return p; }, 'tables[3]'], + ['participant déclaré 2^31', (p) => { p.participants[11] = 2 ** 31; return p; }, 'participants[11]'], + ['occupant 2^31', (p) => { p.placement[3].sieges[1][0] = 2 ** 31; return p; }, 'placement[3].sieges[1][0]'], + ['en réserve, 2^31', (p) => { p.placement[2].reserve = [2 ** 31]; return p; }, 'placement[2].reserve[0]'], ['réserve absente', (p) => { delete p.placement[0].reserve; return p; }, 'placement[0].reserve'], ['graine absente', (p) => { delete p.graine; return p; }, 'graine'], ['deux clés inconnues : la plus petite', (p) => { p.zeta = 1; p.alpha = 2; return p; }, 'alpha'], @@ -919,9 +963,9 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => { assert.deepStrictEqual(examen.retenu, { fautes: [longueur], raisons: [] }); }); - test("la charge rendue relève prochainsIds.proposition au-delà de chaque identifiant lu — d'une proposition écartée, quelle que soit sa faute, et de l'origine du retenu, cohérent ou non — ; le compteur ne recule jamais, ce qui n'est pas un identifiant n'y entre pas, et il reste un entier exact que l'analyse relit", () => { + test("la charge rendue relève prochainsIds.proposition au-delà de chaque identifiant lu — d'une proposition écartée, quelle que soit sa faute, et de l'origine du retenu, cohérent ou non — ; le compteur ne recule jamais, ce qui n'est pas un identifiant, au-delà de 2^31 − 1 compris, n'y entre pas, et relevé, il s'arrête au plus grand identifiant, que l'analyse relit", () => { const HORS = 'IDENTIFIANT_HORS_COMPTEUR'; - const MAX = Number.MAX_SAFE_INTEGER; + const PLUS_GRAND = 2 ** 31 - 1; // Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la // troisième. Chaque cas abîme la charge du fichier écrit, que l'analyse // relit et qui se fige avant l'examen. Il nomme les écartées, [id, @@ -942,6 +986,9 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => { ['la troisième répète la deuxième', (c) => { c.propositions[2].id = 2; }, [[2, 'IDENTIFIANT_REPETE']], [], 4], ['la troisième à 9.5', (c) => { c.propositions[2].id = 9.5; }, [[null, 'FORME']], [], 4], ['la troisième à 2^53', (c) => { c.propositions[2].id = 2 ** 53; }, [[null, 'FORME']], [], 4], + ['la troisième à 2^31', (c) => { c.propositions[2].id = 2 ** 31; }, [[null, 'FORME']], [], 4], + ['la troisième au plus grand identifiant', (c) => { c.propositions[2].id = PLUS_GRAND; }, + [[PLUS_GRAND, HORS]], [], PLUS_GRAND], ["l'origine du retenu à 9", (c) => { c.retenu.proposition = 9; }, [], [HORS], 10], ["l'origine du retenu à 9, qui se contredit", (c) => { Object.assign(c.retenu, { proposition: 9, tours: 5 }); }, [], ['LONGUEUR'], 10], @@ -950,7 +997,11 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => { delete c.retenu.tours; }, [], ['FORME'], 10], ["l'origine du retenu à 9.5", (c) => { c.retenu.proposition = 9.5; }, [], ['FORME'], 4], - ["l'origine du retenu au plus grand entier exact", (c) => { c.retenu.proposition = MAX; }, [], [HORS], MAX], + ["l'origine du retenu au plus grand entier exact", (c) => { c.retenu.proposition = Number.MAX_SAFE_INTEGER; }, + [], ['FORME'], 4], + ["l'origine du retenu à 2^31", (c) => { c.retenu.proposition = 2 ** 31; }, [], ['FORME'], 4], + ["l'origine du retenu au plus grand identifiant", (c) => { c.retenu.proposition = PLUS_GRAND; }, + [], [HORS], PLUS_GRAND], ]; // Le compteur que l'analyse relit dans le fichier de la charge rendue, // ou le refus qu'elle lève. @@ -984,6 +1035,95 @@ describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => { ); }); + test("un identifiant au-delà de 2^31 − 1, écrit à la main à l'origine du retenu ou à une proposition, n'en est pas un : le retenu est fautif, la proposition écartée, le compteur reste à 4 ; la génération suivante, numérotée comme le dit versFichier, prend 4, 5 et 6, et le fichier se rouvre", () => { + // Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la + // première. Une valeur change dans le texte écrit, que l'analyse lit + // sans entrer dans les propositions ni dans le retenu. + const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 }); + const charge = chargeAvecGeneration(); + charge.retenu = retenuDe(charge.propositions[0]); + charge.evenement.etat = 'retenu'; + const texte = serialiser(charge, ENTETE); + const FORME_ORIGINE = { code: 'FORME', chemin: 'proposition' }; + const cas = [ + ["l'origine du retenu à 2^53 − 2", '"retenu": {"proposition":1,', 2 ** 53 - 2, [1, 2, 3], [FORME_ORIGINE]], + ["l'origine du retenu à 2^31", '"retenu": {"proposition":1,', 2 ** 31, [1, 2, 3], [FORME_ORIGINE]], + ['la troisième proposition à 2^53 − 2', '{"id":3,"graine"', 2 ** 53 - 2, [1, 2], []], + ['la troisième proposition à 2^31', '{"id":3,"graine"', 2 ** 31, [1, 2], []], + ]; + const observes = []; + const attendus = []; + for (const [libelle, avant, valeur, gardees, fautesDuRetenu] of cas) { + assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`); + const ouverte = examiner(analyser(texte.replace(avant, avant.replace(/\d+,/, `${valeur},`))).charge).charge; + // L'appelant, comme le dit versFichier : la génération décalée de + // prochainsIds.proposition − 1, puis le compteur porté au-delà. + const decalage = ouverte.prochainsIds.proposition - 1; + const configuration = configurationDepuisCharge(ouverte); + const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }; + const generee = { + ...ouverte, + prochainsIds: { ...ouverte.prochainsIds, proposition: decalage + suivante.length + 1 }, + propositions: [ + ...ouverte.propositions, + ...suivante.map((proposition) => versFichier(proposition, configuration, options)), + ], + }; + const rouverte = examiner(relire(generee)); + observes.push([ + libelle, + decalage, + rouverte.charge.propositions.map(({ id }) => id), + rouverte.fautives, + rouverte.retenu.fautes, + rouverte.charge.prochainsIds.proposition, + ]); + attendus.push([libelle, 3, [...gardees, 4, 5, 6], [], fautesDuRetenu, 7]); + } + assert.deepStrictEqual(observes, attendus); + }); + + test("un identifiant de 2^31 − 1, le plus grand, écrit à la main à l'origine du retenu ou à une proposition, ou le compteur écrit à cette valeur, laisse à 2^31 − 1 le compteur de la charge rendue : la génération suivante, d'une proposition ou de trois, numérotée comme le dit versFichier, lève COMPTEUR_SATURE à sa première proposition, un code que l'appelant lit avant toute écriture", () => { + // Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la + // première. Une valeur change dans le texte écrit, que l'analyse admet : + // elle ne lit des propositions et du retenu que le conteneur, et le + // compteur reste dans sa règle. + const PLUS_GRAND = 2 ** 31 - 1; + const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 }); + const charge = chargeAvecGeneration(); + charge.retenu = retenuDe(charge.propositions[0]); + charge.evenement.etat = 'retenu'; + const texte = serialiser(charge, ENTETE); + const cas = [ + ["l'origine du retenu", '"retenu": {"proposition":1,', `"retenu": {"proposition":${PLUS_GRAND},`], + ['la troisième proposition', '{"id":3,"graine"', `{"id":${PLUS_GRAND},"graine"`], + ['le compteur', '"proposition":4}', `"proposition":${PLUS_GRAND}}`], + ]; + const observes = []; + const attendus = []; + for (const [libelle, avant, apres] of cas) { + assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`); + const ouverte = examiner(analyser(texte.replace(avant, apres)).charge).charge; + // L'appelant, comme le dit versFichier : la génération décalée de + // prochainsIds.proposition − 1, chacune de ses propositions mise en + // forme avant qu'aucune ne s'écrive. + const decalage = ouverte.prochainsIds.proposition - 1; + const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }; + const configuration = configurationDepuisCharge(ouverte); + for (const nombre of [1, 3]) { + const generation = suivante.slice(0, nombre); + observes.push([ + libelle, + nombre, + ouverte.prochainsIds.proposition, + refusDe(() => generation.map((proposition) => versFichier(proposition, configuration, options))), + ]); + attendus.push([libelle, nombre, PLUS_GRAND, COMPTEUR_SATURE]); + } + } + assert.deepStrictEqual(observes, attendus); + }); + test("un compteur abaissé à la main, puis une génération numérotée depuis la charge rendue, comme le dit versFichier : aucun identifiant écarté ne revient, et l'origine du retenu n'est jamais réattribuée", () => { // Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de // l'une d'elles. Le compteur, abaissé à 1, 2 ou 3 dans le fichier écrit, diff --git a/src/stockage/types.js b/src/stockage/types.js index c224d8e..21e747a 100644 --- a/src/stockage/types.js +++ b/src/stockage/types.js @@ -14,7 +14,10 @@ // 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. +// vide. Un entier est un entier exact, au plus 2^53 − 1 ; un identifiant — +// d'un participant, d'une table, d'une proposition, et ce qu'une +// réservation, un titre ou un placement désigne — et chaque compteur de +// prochainsIds, au plus 2^31 − 1. /** * Le fichier d'état, format 1 (§ 8.8, § 8.9). Deux régions : l'en-tête et la @@ -94,9 +97,10 @@ * @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) + * @typedef {Object} ProchainsIds entiers de 1 à 2^31 − 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 @@ -111,10 +115,14 @@ * chaque identifiant lu, écartées et * origine du retenu comprises : abaissé à * la main, il ne fait réattribuer aucun - * d'eux + * d'eux. Relevé, il s'arrête à 2^31 − 1 ; + * un entier au-delà n'est pas un + * identifiant, et n'y entre pas. À + * 2^31 − 1, aucune génération ne se + * numérote plus (COMPTEUR_SATURE) * * @typedef {Object} Participant - * @property {number} id entier ≥ 1, unique + * @property {number} id identifiant, unique * @property {string} nom non vide * @property {string|null} prenom * @property {string|null} appartenance @@ -124,7 +132,7 @@ * @property {boolean} exclu (§ 4.4) * * @typedef {Object} Table - * @property {number} id entier ≥ 1, unique + * @property {number} id identifiant, unique * @property {number} numero affiché, entier ≥ 1 * @property {number|null} sieges null : suit le défaut ; entier ≥ 2 : * surcharge (§ 6.1) @@ -161,7 +169,7 @@ * @property {number[]} reserve triée par identifiant croissant * * @typedef {Object} PropositionFichier - * @property {number} id entier ≥ 1 : place dans la suite des + * @property {number} id identifiant : 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 @@ -184,7 +192,9 @@ * 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 + * rangées, ses listes dans l'ordre écrit ; un + * correctif le pose entier, et ne pose rien sous + * lui (correctifs.js) * @property {number} proposition id de la proposition d'origine, sous * prochainsIds.proposition * @property {boolean} siegesAttribues @@ -344,6 +354,16 @@ * collision dépasserait les * deux chiffres que la borne * réserve (§ 8.7) + * COMPTEUR_SATURE {compteur} l'identifiant à attribuer + * atteindrait 2^31 − 1 : le + * compteur de prochainsIds + * que compteur nomme — + * 'proposition' pour + * versFichier — n'aurait + * aucune valeur au-delà ; un + * fichier valide y mène, et + * l'appelant le lit avant + * toute écriture * * @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 @@ -351,8 +371,9 @@ * 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 + * domaine — un identifiant ou un compteur au-delà de + * 2^31 − 1 compris —, identifiant de participant ou de + * table en double ou atteignant prochainsIds, tour d'une * réservation hors de sa portée ; des propositions et du * retenu, seul le conteneur * COMPTES un compte de l'en-tête contredit sa liste @@ -421,5 +442,5 @@ * 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 + * réattribue pas, jusqu'à 2^31 − 1 */