From 03c04bdf16828a26c2d7df4fcfd13d1217c2229b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 02:31:26 -0400 Subject: [PATCH] [ADD] storage: empty chair in a table list when seats are assigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator plans, the room is not tracked live: removing a person from the retained plan must free her chair and leave every other seat where it is. With seats assigned, a table list may now hold null before its last occupant, seat 1 included, and never end on it; without assignment no list holds null. Validation, canonical form, patches and the named form handle it, and the delivered demos keep their bytes. Checked: 1549 node and 58 node-long tests; depot.js, journal.js at 100 %. --- FR --- [ADD] stockage : chaise vide dans une liste de table aux sièges attribués L'opérateur planifie, la salle ne se suit pas en direct : retirer une personne du plan retenu doit libérer sa chaise et laisser tout autre siège à sa place. Sièges attribués, une liste de table peut porter null avant son dernier occupant, siège 1 compris, jamais à la fin ; sans attribution, aucune liste ne porte null. Contrôle, forme canonique, correctifs et forme nommée le traitent, et les démonstrations livrées gardent leurs octets. Vérifié : 1549 node et 58 node-long ; depot.js, journal.js à 100 %. Assisted-by: Claude Opus 5.5 --- spec.md | 7 +- src/stockage/correctifs.js | 41 ++++++---- src/stockage/correctifs.test.js | 87 ++++++++++++++++++++ src/stockage/document.js | 15 +++- src/stockage/document.test.js | 45 +++++++++++ src/stockage/placements.js | 79 ++++++++++++------ src/stockage/placements.test.js | 137 ++++++++++++++++++++++++++++++++ 7 files changed, 366 insertions(+), 45 deletions(-) diff --git a/spec.md b/spec.md index 26445c7..41973fe 100644 --- a/spec.md +++ b/spec.md @@ -1565,7 +1565,12 @@ régénérable. Puis, **par tour** : la liste des tables, chacune étant la liste des **identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la **réserve**, la liste des identifiants assis nulle part à ce tour. Le numéro de -siège n'est pas stocké : il est la position dans la liste. +siège n'est pas stocké : il est la position dans la liste. Quand les sièges sont +attribués, une chaise vide avant le dernier occupant, siège 1 compris, s'écrit +`null` à sa position, et une liste ne finit jamais par `null` : retirer une +personne libère sa chaise, et aucun autre siège ne change — le placement se +prépare, il ne suit pas la salle. +Sans attribution, aucune liste ne porte `null`. **Quand les sièges ne sont pas attribués** (§ 5.3), l'ordre à l'intérieur d'une table ne porte aucune information : le logiciel l'écrit **trié par identifiant diff --git a/src/stockage/correctifs.js b/src/stockage/correctifs.js index b274274..862a06d 100644 --- a/src/stockage/correctifs.js +++ b/src/stockage/correctifs.js @@ -24,7 +24,9 @@ // d'une proposition, la place d'une autre à une autre table touche les deux // listes de ces tables et rien d'autre : une entrée de chacune quand chaque // 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 +// ils sont attribués ; une personne qui quitte sa chaise sans être +// remplacée y laisse null, le marqueur d'une chaise vide, une pose au rang +// de la chaise. Sans attribution, chaque liste se trie par // identifiant, et l'entrée qui change de rang décale celles qu'elle // 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 @@ -64,12 +66,15 @@ import { ErreurStockage } from './erreurs.js'; * @typedef {OperationPoser|OperationRetirer} Operation */ -// Règles de la charge, du retenu et d'un identifiant dans le schéma du -// fichier d'état. +// Règles de la charge, du retenu, d'un identifiant et de l'occupant d'une +// liste de table — un identifiant, ou null pour une chaise vide — 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'); +const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element; +const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id'); +const OCCUPANT = regleDuChamp(regleDuChamp(PROPOSITION, 'placement').element, 'sieges').element.element; // Vrai pour un objet qui n'est ni null ni une liste. const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); @@ -103,9 +108,9 @@ const poids = (operations) => JSON.stringify(operations).length; // 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, 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. +// longueur se comparent rang par rang — deux listes d'identifiants ou +// d'occupants 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 }); @@ -117,23 +122,25 @@ function comparer(a, b, regle, chemin, operations) { comparerParIdentifiant(a, b, regle.element, chemin, operations); } else if (a.length !== b.length) { operations.push({ op: 'poser', chemin, valeur: b }); - } else if (regle.element === IDENTIFIANT) { - comparerIdentifiants(a, b, chemin, operations); + } else if (regle.element === IDENTIFIANT || regle.element === OCCUPANT) { + comparerIdentifiants(a, b, regle.element, 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) { +// même longueur, d'élément de règle regleElement, 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. Sous des sièges attribués, une chaise libérée ou reprise +// ne change que son rang : une pose, de valeur null ou de l'occupant. +function comparerIdentifiants(a, b, regleElement, chemin, operations) { const parRang = []; - a.forEach((id, rang) => comparer(id, b[rang], IDENTIFIANT, [...chemin, rang], parRang)); + a.forEach((id, rang) => comparer(id, b[rang], regleElement, [...chemin, rang], parRang)); const entiere = { op: 'poser', chemin, valeur: b }; operations.push(...(poids(parRang) > poids([entiere]) ? [entiere] : parRang)); } diff --git a/src/stockage/correctifs.test.js b/src/stockage/correctifs.test.js index 1e589dc..2a4a3e0 100644 --- a/src/stockage/correctifs.test.js +++ b/src/stockage/correctifs.test.js @@ -1662,3 +1662,90 @@ describe("appliquer : un correctif qui ne s'applique pas lève CORRECTIF avec le } }); }); + +describe('le marqueur de chaise vide : correctifs et forme canonique (§ 8.9)', () => { + // La petite démonstration, sa proposition 1 à sièges attribués, retenue + // telle quelle : au tour 1, la table 1 assied 9, 5 et 1, dans cet ordre + // de sièges. Une charge neuve à chaque appel. + function retenuAttribue() { + const charge = chargeDe('petite', [{ id: 1, siegesAttribues: true }]); + charge.propositions[0].placement[0].sieges[0] = [9, 5, 1]; + charge.retenu = retenuDe(charge.propositions[0]); + charge.evenement.etat = 'retenu'; + return charge; + } + + // retenuAttribue, la personne 5 retirée du tour 1 du retenu : sa chaise, + // le siège 2 de la table 1, est libre, et elle attend en réserve. + function chaiseLiberee() { + const charge = retenuAttribue(); + charge.retenu.placement[0].sieges[0] = [9, null, 1]; + charge.retenu.placement[0].reserve = [5]; + return charge; + } + + test("libérer une chaise du retenu : une seule pose dans la liste, valeur null, au rang de la chaise ; appliquer rend la charge d'après, que canoniser garde dans l'ordre des sièges", () => { + const a = geler(retenuAttribue()); + const b = geler(chaiseLiberee()); + assert.deepStrictEqual(fautes(b.retenu), []); + const correctif = difference(a, b); + assert.deepStrictEqual(correctif, [ + { op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: null }, + { op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] }, + ]); + const rendue = appliquer(a, correctif); + assert.deepStrictEqual(rendue, canoniser(b)); + assert.deepStrictEqual(rendue.retenu.placement[0].sieges[0], [9, null, 1]); + assert.equal(serialiserCharge(rendue), serialiserCharge(b)); + assert.deepStrictEqual(analyser(serialiser(rendue, ENTETE)).charge.retenu, rendue.retenu); + // Le retour : la chaise reprend son occupant, par une pose au même rang. + const retour = difference(b, a); + assert.deepStrictEqual(retour, [ + { op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0, 1], valeur: 5 }, + { op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [] }, + ]); + assert.deepStrictEqual(appliquer(b, retour), canoniser(a)); + }); + + test("dans une proposition aussi, une chaise libérée est une pose au rang de la chaise ; une liste qui change à chaque rang se pose entière, chaises vides comprises", () => { + const a = geler(retenuAttribue()); + const b = retenuAttribue(); + b.propositions[0].placement[0].sieges[0] = [9, null, 1]; + b.propositions[0].placement[0].reserve = [5]; + assert.deepStrictEqual(difference(a, geler(b)), [ + { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'sieges', 0, 1], valeur: null }, + { op: 'poser', chemin: ['propositions', { id: 1 }, 'placement', 0, 'reserve'], valeur: [5] }, + ]); + const c = chaiseLiberee(); + c.retenu.placement[0].sieges[0] = [1, null, 9]; + const correctif = difference(a, geler(c)); + assert.deepStrictEqual(correctif, [ + { op: 'poser', chemin: ['retenu', 'placement', 0, 'sieges', 0], valeur: [1, null, 9] }, + { op: 'poser', chemin: ['retenu', 'placement', 0, 'reserve'], valeur: [5] }, + ]); + assert.deepStrictEqual(appliquer(a, correctif), canoniser(c)); + }); + + test("appliquer admet null dans une liste de table, comme la règle, et le refuse ailleurs dans un placement : réserve, tables, participants, capacités", () => { + const base = geler(chaiseLiberee()); + const admises = [ + ['retenu', 'placement', 1, 'sieges', 2, 1], + ['propositions', { id: 1 }, 'placement', 2, 'sieges', 0, 1], + ]; + for (const chemin of admises) { + assert.equal(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), 'aucun refus', JSON.stringify(chemin)); + } + const refusees = [ + ['retenu', 'placement', 0, 'reserve', 0], + ['retenu', 'tables', 0], + ['retenu', 'participants', 0], + ['retenu', 'capacites', 0], + ['propositions', { id: 1 }, 'placement', 1, 'sieges', 0], + ['propositions', { id: 1 }, 'participants', 3], + ]; + assert.ok(refusees.length > 0); + for (const chemin of refusees) { + assert.deepEqual(refusDe(base, [{ op: 'poser', chemin, valeur: null }]), { rang: 0 }, JSON.stringify(chemin)); + } + }); +}); diff --git a/src/stockage/document.js b/src/stockage/document.js index b39e413..0c24581 100644 --- a/src/stockage/document.js +++ b/src/stockage/document.js @@ -82,6 +82,12 @@ const NOMBRE = regle('nombre'); // 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); +// L'occupant d'un siège dans une liste de table d'un placement : +// l'identifiant d'une personne, ou null, le marqueur d'une chaise vide +// (§ 8.9). La règle admet null à toute place d'une telle liste ; le +// contrôle des placements (placements.js) ne l'admet que sous des sièges +// attribués, et jamais en fin de liste. +const OCCUPANT = entier(1, 2 ** 31 - 1, { nul: true }); const DATE_OU_NUL = regle('date', { nul: true }); // Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les @@ -196,15 +202,18 @@ const TITRE = objet([ ]); // Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément, -// chacun sur sa ligne. sieges porte une liste d'identifiants par table +// chacun sur sa ligne. sieges porte une liste d'occupants par table // déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a // siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par -// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve +// identifiant croissant ; vrai, il est celui des sièges et se garde : le +// siège est le rang dans la liste, et une chaise vide avant le dernier +// occupant, siège 1 compris, y porte null. Une liste qui ne finit jamais +// par null a ainsi une seule écriture, que le tri ne touche pas. reserve // porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se // trie par identifiant croissant, quel que soit le drapeau. const PLACEMENT = liste( objet([ - ['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))], + ['sieges', liste(liste(OCCUPANT, { triSansAttribution: croissant }))], ['reserve', liste(IDENTIFIANT, { tri: croissant })], ]), { mise: 'lignes' }, diff --git a/src/stockage/document.test.js b/src/stockage/document.test.js index 2c0ff79..2e98673 100644 --- a/src/stockage/document.test.js +++ b/src/stockage/document.test.js @@ -1026,3 +1026,48 @@ describe('premiereFaute : le parcours de forme, sans lever (§ 8.8, § 8.9)', () assert.equal(premiereFauteEnPlace({ ...evenement, nom: '' }, EVENEMENT, 'charge.evenement'), 'charge.evenement.nom'); }); }); + +describe('le marqueur de chaise vide : la règle d\'une liste de table (§ 8.9)', () => { + // La proposition du document valide, sièges attribués, dont le tour 2 + // laisse vide le siège 2 de la table 3 : sa liste vaut [1, null, 5]. + const propositionAChaiseVide = () => { + const proposition = documentValide().charge.propositions[0]; + proposition.siegesAttribues = true; + proposition.placement[1].sieges[1] = [1, null, 5]; + return proposition; + }; + + test("premiereFaute admet null dans une liste de table, d'une proposition comme du retenu, et nulle part ailleurs dans un placement", () => { + const regleDe = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1]; + const CHARGE = regleDe(SCHEMA, 'charge'); + const PROPOSITION = regleDe(CHARGE, 'propositions').element; + const RETENU = regleDe(CHARGE, 'retenu'); + const proposition = propositionAChaiseVide(); + const { id, graine, arret, historique, produitVersion, ...plan } = proposition; + const retenu = { proposition: id, ...plan }; + assert.equal(premiereFaute(proposition, PROPOSITION), null); + assert.equal(premiereFaute(retenu, RETENU), null); + const cas = [ + ['dans une réserve', (p) => { p.placement[0].reserve = [null]; }, 'placement[0].reserve[0]'], + ['parmi les participants', (p) => { p.participants[1] = null; }, 'participants[1]'], + ['parmi les tables', (p) => { p.tables[0] = null; }, 'tables[0]'], + ['parmi les capacités', (p) => { p.capacites[1] = null; }, 'capacites[1]'], + ['à la place d\'une liste de table', (p) => { p.placement[0].sieges[0] = null; }, 'placement[0].sieges[0]'], + ]; + assert.ok(cas.length > 0); + for (const [libelle, abimer, chemin] of cas) { + const abimee = propositionAChaiseVide(); + abimer(abimee); + assert.equal(premiereFaute(abimee, PROPOSITION), chemin, libelle); + } + }); + + test("l'analyse lit un fichier dont une proposition porte une chaise vide, et sa relecture rend la liste telle qu'écrite", () => { + const documentLu = documentValide(); + documentLu.charge.propositions = [propositionAChaiseVide()]; + const { charge } = analyser(JSON.stringify(documentLu)); + assert.deepStrictEqual(charge.propositions[0].placement[1].sieges[1], [1, null, 5]); + const relue = analyser(serialiser(charge, { revision: 13, produitVersion: VERSION.affichee })).charge; + assert.deepStrictEqual(relue.propositions[0], propositionAChaiseVide()); + }); +}); diff --git a/src/stockage/placements.js b/src/stockage/placements.js index c97efa8..a8f7b01 100644 --- a/src/stockage/placements.js +++ b/src/stockage/placements.js @@ -23,14 +23,18 @@ // schéma du fichier d'état, dont l'analyse n'applique que celle du // conteneur : fautes applique les autres, avant toute cohérence, par le // parcours même de l'analyse, premiereFaute, qui nomme les chemins à sa -// manière. Aucune fonction ne modifie ce qu'elle reçoit ; aucune ne lit -// l'horloge ni un aléa. +// manière. Une liste de table porte null à la place d'une chaise vide +// (§ 8.9) : la règle l'admet à toute place, et fautes ne le garde que sous +// des sièges attribués, avant le dernier occupant, siège 1 compris. 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 : - * une valeur sort de sa règle, ou la déclaration se contredit ; chemin + * une valeur sort de sa règle, la déclaration se contredit, ou une chaise + * vide est mal placée — sans attribution des sièges, ou en fin de liste ; chemin * désigne l'élément fautif dans le placement, à la manière de l'analyse du * fichier, '' pour le placement lui-même. LONGUEUR : une longueur écrite * contredit celle que le placement déclare ; tour et table null désignent le @@ -66,8 +70,9 @@ import { ErreurStockage } from './erreurs.js'; * @property {number|null} numero numéro affiché de la table dans la * charge ; null pour la réserve et pour * une table que la charge n'a plus - * @property {number|null} siege rang dans la liste, à partir de 1, quand - * les sièges sont attribués ; null sinon + * @property {number|null} siege rang dans la liste, à partir de 1, + * chaises vides comprises, quand les + * sièges sont attribués ; null sinon * @property {number} participant * @property {string|null} nom null pour une personne que la charge * n'a plus @@ -93,8 +98,9 @@ import { ErreurStockage } from './erreurs.js'; const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1]; const CHARGE = regleDuChamp(SCHEMA, 'charge'); // Règles d'une proposition et du retenu. Aucune des valeurs qu'elles -// contiennent n'admet null ; examiner traite le retenu nul, son absence, -// avant de lire le retenu. +// contiennent n'admet null, hors de l'occupant d'une liste de table, la +// chaise vide ; examiner traite le retenu nul, son absence, avant de lire +// le retenu. const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element; const RETENU = regleDuChamp(CHARGE, 'retenu'); const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id'); @@ -127,14 +133,32 @@ function fauteDeDeclaration({ tables, capacites, participants }) { ); } +// Chemin de la première chaise vide mal placée d'un placement de forme et +// de déclaration conformes, ou null, dans l'ordre des tours, des listes et +// des rangs : un null sous siegesAttribues faux, où l'ordre d'une liste ne +// dit aucun siège, ou au dernier rang d'une liste, qu'une écriture sans +// lui dit pareil. Le chemin suit la convention de premiereFaute. +function fauteDeChaiseVide({ siegesAttribues, placement }) { + for (let tour = 0; tour < placement.length; tour += 1) { + const { sieges } = placement[tour]; + for (let table = 0; table < sieges.length; table += 1) { + const liste = sieges[table]; + const rang = siegesAttribues ? liste.length - 1 : liste.indexOf(null); + if (rang >= 0 && liste[rang] === null) return `placement[${tour}].sieges[${table}][${rang}]`; + } + } + return null; +} + // Fautes de cohérence d'un placement de forme et de déclaration conformes // (§ 8.9, points 1 et 2). D'abord le nombre de tours écrits contre celui // déclaré. Puis, tour après tour : le nombre de listes de table contre celui // des tables déclarées ; la longueur de chaque liste appariée à une table -// déclarée, dans l'ordre déclaré, contre sa capacité ; le multiensemble du -// tour, listes de table et réserve comprises, où chaque identifiant déclaré -// figure exactement une fois — doublons, inconnus, puis manquants, chacun -// nommé une fois par tour, par identifiant croissant. +// déclarée, dans l'ordre déclaré, contre sa capacité, chaises vides +// comprises ; le multiensemble du tour, listes de table et réserve +// comprises, où chaque identifiant déclaré figure exactement une fois — +// doublons, inconnus, puis manquants, chacun nommé une fois par tour, par +// identifiant croissant. Une chaise vide n'y entre pas. // // La capacité borne une liste sans la fixer : quand la salle offre plus de // sièges que de personnes, des tables gardent des places vides, et une liste @@ -167,6 +191,7 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement } const inconnus = []; for (const ids of [...sieges, reserve]) { for (const id of ids) { + if (id === null) continue; const deja = occurrences.get(id) ?? 0; occurrences.set(id, deja + 1); if (deja === 0 && !estDeclare.has(id)) inconnus.push(id); @@ -183,11 +208,11 @@ function fautesDeCoherence({ tables, capacites, tours, participants, placement } return liste; } -// Fautes de valeur sous regle : la première faute de forme ou de -// déclaration seule, qui rend la cohérence illisible ; sinon les fautes de -// cohérence. +// Fautes de valeur sous regle : la première faute de forme, de +// déclaration ou de chaise vide seule, qui rend la cohérence illisible ; +// sinon les fautes de cohérence. function fautesSelon(valeur, regle) { - const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur); + const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur) ?? fauteDeChaiseVide(valeur); return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }]; } @@ -197,7 +222,9 @@ function fautesSelon(valeur, regle) { * retenu quand p porte la clé proposition, celle d'une proposition sinon. * * La forme se contrôle d'abord, dans l'ordre du schéma, puis la - * déclaration : la première faute de l'une ou de l'autre est seule rendue, + * déclaration, puis les chaises vides : null, admis par la règle d'une + * liste de table, n'y est cohérent que sous des sièges attribués et avant + * le dernier rang. La première de ces fautes est seule rendue, * { code: 'FORME', chemin }. Sinon, dans l'ordre des tours puis des tables : * { code: 'LONGUEUR', tour, table, declare, ecrit } — tour null pour le * nombre de tours, rendu en premier ; table null pour le nombre de listes @@ -248,8 +275,9 @@ function exigerCoherent(p, fonction) { * configuration, lue par identifiant ; le nombre de tours ; les participants * non exclus, par identifiant croissant. Le placement reprend le plan tour * par tour : sans attribution de sièges, chaque liste de table se trie par - * identifiant croissant ; avec, elle garde son ordre, celui des sièges. La - * réserve, un ensemble, se trie dans les deux cas. Les clés suivent l'ordre + * identifiant croissant ; avec, elle garde son ordre, celui des sièges, et + * ses chaises vides, null. La réserve, un ensemble, se trie dans les deux + * cas. Les clés suivent l'ordre * du schéma ; rien n'est partagé avec les arguments, qui ne sont pas * modifiés. * @@ -263,7 +291,8 @@ function exigerCoherent(p, fonction) { * 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é. + * nombre de tours, personne non placée, liste au-delà d'une capacité, + * chaise vide sans attribution ou en fin de liste. * * @param {import('../moteur/recherche.js').Proposition} proposition * @param {import('../moteur/types.js').Configuration} configuration @@ -309,9 +338,9 @@ export function versFichier(proposition, configuration, { produitVersion, attrib /** * Plan du moteur d'un placement de fichier, proposition ou retenu : les - * tables dans l'ordre déclaré, chaque liste de table et chaque réserve par - * identifiant croissant, comme le moteur les rend ; l'ordre des sièges n'y - * entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p + * tables dans l'ordre déclaré, chaque liste de table sans ses chaises + * vides, et chaque réserve, par identifiant croissant, comme le moteur les + * rend ; l'ordre des sièges n'y entre pas. Aucune liste n'est partagée avec p. Lève TypeError quand p * n'est pas cohérent (fautes). * * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p @@ -321,7 +350,7 @@ export function planDepuisFichier(p) { exigerCoherent(p, 'planDepuisFichier'); return { tables: [...p.tables], - tours: p.placement.map(({ sieges }) => sieges.map((liste) => [...liste].sort(croissant))), + tours: p.placement.map(({ sieges }) => sieges.map((liste) => liste.filter((id) => id !== null).sort(croissant))), reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)), }; } @@ -411,7 +440,8 @@ const ordreDesTables = (a, b) => * l'identifiant départage deux numéros égaux —, puis par rang dans la * liste ; la réserve suit les tables de son tour, table et numéro null. * siege est le rang à partir de 1 quand le placement attribue les sièges, - * null sinon et dans la réserve. Nom et prénom viennent de la charge ; une + * null sinon et dans la réserve ; une chaise vide, null dans la liste, + * garde son rang et n'a pas de ligne. Nom et prénom viennent de la charge ; une * personne qu'elle n'a plus, ou qu'elle n'a jamais eue, les a null. * * Un placement incohérent (fautes : LONGUEUR, DOUBLON, INCONNU, MANQUANT) @@ -454,6 +484,7 @@ export function formeNommee(p, charge) { // au-delà des tables déclarées. const ajouterListe = (tour, ids, table, numero) => { ids.forEach((participant, position) => { + if (participant === null) return; ajouter(tour, table, numero, p.siegesAttribues ? position + 1 : null, participant, false); }); }; diff --git a/src/stockage/placements.test.js b/src/stockage/placements.test.js index 7ed3b76..7e76b59 100644 --- a/src/stockage/placements.test.js +++ b/src/stockage/placements.test.js @@ -1495,3 +1495,140 @@ describe('déterminisme (§ 8.8, § 14.7)', () => { assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]); }); }); + +// fichierParfait, sièges attribués, dont le tour 1 laisse vide le siège 2 +// de la table 1 : sa liste vaut [12, null, 7], et la personne 2 attend en +// réserve. Une copie neuve à chaque appel. +function fichierAChaiseVide() { + const p = fichierParfait(); + p.siegesAttribues = true; + p.placement[0] = { sieges: [[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve: [2] }; + return p; +} + +describe('le marqueur de chaise vide : null avant le dernier occupant, sièges attribués (§ 8.9)', () => { + test("une liste [12, null, 7] est cohérente, proposition comme retenu : le null compte dans la longueur, n'est ni DOUBLON ni INCONNU, et planDepuisFichier le retire", () => { + const p = fichierAChaiseVide(); + assert.deepStrictEqual(fautes(p), []); + assert.deepStrictEqual(fautes(retenuDe(p)), []); + assert.deepStrictEqual(planDepuisFichier(p).tours[0], [[7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]]); + assert.deepStrictEqual(planDepuisFichier(retenuDe(p)).tours[0][0], [7, 12]); + // Deux chaises vides au même tour, à deux tables : aucune n'est un + // doublon de l'autre. + const deux = fichierAChaiseVide(); + deux.placement[0] = { sieges: [[12, null, 7], [4, null, 6], [1, 8, 9], [10, 11, 3]], reserve: [2, 5] }; + assert.deepStrictEqual(fautes(deux), []); + assert.deepStrictEqual(planDepuisFichier(deux).tours[0].slice(0, 2), [[7, 12], [4, 6]]); + // Le null occupe un siège : quatre éléments sur une capacité de 3. + const longue = fichierAChaiseVide(); + longue.placement[0] = { sieges: [[12, null, 7, 9], [4, 5, 6], [1, 8], [10, 11, 3]], reserve: [2] }; + assert.deepStrictEqual(fautes(longue), [{ code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 }]); + }); + + test("une chaise vide au siège 1, [null, 12, 7] ou [null, null, 7], est cohérente, proposition comme retenu : planDepuisFichier la retire, et formeNommee garde le siège de chaque occupant", () => { + const cas = [ + [[null, 12, 7], [2], [7, 12], [ligne(1, 1, 1, 2, 12, 'Mélisse', 'Mathurin'), ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle')]], + [[null, null, 7], [2, 12], [7], [ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle')]], + ]; + assert.ok(cas.length > 0); + for (const [liste, reserve, occupants, assis] of cas) { + const p = fichierAChaiseVide(); + p.placement[0] = { sieges: [liste, [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve }; + assert.deepStrictEqual(fautes(p), [], JSON.stringify(liste)); + assert.deepStrictEqual(fautes(retenuDe(p)), [], JSON.stringify(liste)); + assert.deepStrictEqual(planDepuisFichier(p).tours[0][0], occupants); + assert.deepStrictEqual(planDepuisFichier(retenuDe(p)).tours[0][0], occupants); + const lignes = formeNommee(p, chargePetite()); + assert.deepStrictEqual(lignes.filter(({ tour, table: id }) => tour === 1 && id === 1), assis); + assert.deepStrictEqual( + lignes.filter(({ tour, reserve: enReserve }) => tour === 1 && enReserve).map(({ participant }) => participant), + reserve, + ); + } + }); + + test("sans attribution, ou en fin de liste, null est une faute FORME qui nomme la chaise ; seul sous sa règle, il ne s'admet nulle part ailleurs dans un placement", () => { + const cas = [ + ['sièges non attribués', (p) => { p.siegesAttribues = false; }, 'placement[0].sieges[0][1]'], + ['une liste qui finit par null', (p) => { + p.placement[0] = { sieges: [[12, null], [4, 5, 6], [1, 8, 9], [10, 11, 3]], reserve: [2, 7] }; + }, 'placement[0].sieges[0][1]'], + ['une liste réduite à null', (p) => { + p.placement[1].sieges[3] = [null]; + p.placement[1].reserve = [1, 4, 7]; + }, 'placement[1].sieges[3][0]'], + ['une chaise vide admise au tour 1, puis une fin nulle au tour 3', (p) => { + p.placement[2].sieges[0] = [4, 8, null]; + p.placement[2].reserve = [10]; + }, 'placement[2].sieges[0][2]'], + ['une fin nulle au dernier tour, à la dernière table', (p) => { + p.placement[3].sieges[3] = [2, 6, null]; + p.placement[3].reserve = [8]; + }, 'placement[3].sieges[3][2]'], + ['sièges non attribués, une chaise vide au siège 1', (p) => { + p.siegesAttribues = false; + p.placement[0] = { sieges: [[null, 7, 12], [4, 5, 6], [1, 8, 9], [3, 10, 11]], reserve: [2] }; + }, 'placement[0].sieges[0][0]'], + ['null dans la réserve', (p) => { p.placement[0].reserve = [2, null]; }, 'placement[0].reserve[1]'], + ['null parmi les participants', (p) => { p.participants[11] = null; }, 'participants[11]'], + ['null parmi les tables', (p) => { p.tables[3] = null; }, 'tables[3]'], + ]; + assert.ok(cas.length > 0); + const ecarts = cas.flatMap(([libelle, abimer, chemin]) => + [fichierAChaiseVide(), retenuDe(fichierAChaiseVide())].flatMap((p) => { + abimer(p); + const rendu = fautes(p); + const attendu = [{ code: 'FORME', chemin }]; + return JSON.stringify(rendu) === JSON.stringify(attendu) ? [] : [`${libelle} : ${JSON.stringify(rendu)}`]; + }), + ); + assert.deepEqual(ecarts, []); + leveTypeError(() => planDepuisFichier({ ...fichierAChaiseVide(), siegesAttribues: false }), /FORME/); + }); + + test('formeNommee assied 12 au siège 1 et 7 au siège 3, sans ligne pour la chaise vide', () => { + const lignes = formeNommee(fichierAChaiseVide(), chargePetite()); + assert.equal(lignes.length, 48); + assert.ok(lignes.every(({ participant }) => participant !== null)); + assert.deepStrictEqual(lignes.filter(({ tour, table: id }) => tour === 1 && id === 1), [ + ligne(1, 1, 1, 1, 12, 'Mélisse', 'Mathurin'), + ligne(1, 1, 1, 3, 7, 'Giroflée', 'Gisèle'), + ]); + assert.deepStrictEqual( + lignes.filter(({ tour, reserve }) => tour === 1 && reserve), + [ligne(1, null, null, null, 2, 'Bruyère', 'Basile')], + ); + }); + + test('versFichier garde les chaises vides du plan reçu quand les sièges sont attribués, et lève sans attribution', () => { + const plan = { + tables: [1, 2, 3, 4], + tours: [[[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]], ...PLAN_PARFAIT_PETITE.tours.slice(1)], + reserves: [[2], [], [], []], + }; + const proposition = { ...PARFAITE, plan }; + const options = { produitVersion: VERSION.affichee, decalage: 0 }; + const fichier = versFichier(proposition, petite(), { ...options, attribuerSieges: true }); + assert.deepStrictEqual(fichier.placement[0].sieges, [[12, null, 7], [4, 5, 6], [1, 8, 9], [10, 11, 3]]); + assert.deepStrictEqual(fichier, fichierAChaiseVide()); + leveTypeError(() => versFichier(proposition, petite(), { ...options, attribuerSieges: false }), /FORME/); + }); + + test("le fichier relu porte la liste telle quelle : examiner garde la proposition et le retenu sans faute, et écarte la proposition sans attribution qui porte null", () => { + const charge = chargePetite(); + const proposition = fichierAChaiseVide(); + charge.propositions = [proposition, { ...fichierAChaiseVide(), id: 2, siegesAttribues: false }]; + charge.retenu = retenuDe(proposition); + charge.prochainsIds.proposition = 3; + charge.evenement.etat = 'retenu'; + const relue = relire(charge); + assert.deepStrictEqual(relue.retenu.placement[0].sieges[0], [12, null, 7]); + assert.deepStrictEqual(relue.propositions[0].placement[0].sieges[0], [12, null, 7]); + const examen = examiner(relue); + assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] }); + assert.deepStrictEqual(examen.charge.propositions, [fichierAChaiseVide()]); + assert.deepStrictEqual(examen.fautives, [ + { rang: 1, id: 2, fautes: [{ code: 'FORME', chemin: 'placement[0].sieges[0][0]' }] }, + ]); + }); +});