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 */