From d0f4081961255b37043e174a2fd0b57465f14dbe Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 13:12:20 -0400 Subject: [PATCH] [ADD] storage: positional placements, faulty versus stale propositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placements are stored positionally: per proposition its seed, stop count, history length and building version, then per round the ordered tables and the reserve. A proposition that contradicts itself — wrong length, repeated, unknown or missing id, an id already taken — is named and left out, and the event still opens. One that no longer matches the event — person excluded or added, table removed, capacity or rounds changed — is kept and reported as drift. A named form serves reading. Checked: 42 tests on the small demonstration; reviewed, mutants killed. --- FR --- [ADD] stockage : placements positionnels, fautes et dérives Les placements se rangent par position : pour chaque proposition sa graine, son compte d'arrêt, la longueur d'historique et la version qui l'a produite, puis par tour les tables ordonnées et la réserve. Une proposition qui se contredit — longueur fausse, identifiant répété, inconnu, manquant ou déjà pris — est nommée et écartée, et l'événement s'ouvre. Celle qui ne décrit plus l'événement — personne exclue ou ajoutée, table retirée, capacité ou tours changés — est gardée et signalée comme dérive. Une forme nommée sert à la lecture. Vérifié : 42 épreuves sur la petite démonstration ; revue, mutants tués. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- src/stockage/placements.js | 545 +++++++++++++ src/stockage/placements.test.js | 1282 +++++++++++++++++++++++++++++++ 2 files changed, 1827 insertions(+) create mode 100644 src/stockage/placements.js create mode 100644 src/stockage/placements.test.js diff --git a/src/stockage/placements.js b/src/stockage/placements.js new file mode 100644 index 0000000..7b93529 --- /dev/null +++ b/src/stockage/placements.js @@ -0,0 +1,545 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les placements du fichier d'état, sous leur forme positionnelle (§ 8.9) : +// la proposition du moteur mise en forme de fichier, et le plan du moteur +// qu'on en tire ; le contrôle de cohérence d'une proposition ou du retenu ; +// leur dérive contre la charge courante ; leur forme nommée ; le partage +// d'une charge lue entre propositions gardées et écartées. Les formes sont +// décrites dans types.js et dans ce module. +// +// Fautif et périmé sont deux choses (§ 8.9, point 3). Un placement fautif se +// contredit lui-même : fautes nomme chaque contradiction, et examiner écarte +// la proposition fautive de la charge sans refuser le fichier ; chacune tombe +// seule. examiner compare aussi les identifiants des propositions entre eux +// et à prochainsIds.proposition, que l'analyse du fichier ne lit pas, et +// rend ce compteur relevé au-delà de chaque identifiant lu : aucun ne se +// réattribue. Un placement cohérent qui ne décrit plus la configuration +// courante est périmé : derive dit pourquoi, et examiner le garde (§ 9). Le +// retenu fautif est signalé et gardé : sa reprise par le .precedent +// appartient au dépôt (§ 8.8). +// +// La forme d'une proposition et du retenu suit les règles de SCHEMA, le +// 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. +import { SCHEMA, capacite, premiereFaute } from './document.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 + * 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 + * nombre de tours, table null seule le nombre de listes du tour. DOUBLON, + * INCONNU, MANQUANT : un identifiant écrit plus d'une fois, écrit sans être + * déclaré, déclaré sans être écrit, dans les listes de table et la réserve + * d'un tour. Les tours se numérotent à partir de 1, tables et participants + * par identifiant. Ces quatre codes sont des contradictions internes, que + * fautes nomme. IDENTIFIANT_REPETE : une proposition cohérente porte + * l'identifiant d'une proposition gardée avant elle. + * IDENTIFIANT_HORS_COMPTEUR : une proposition cohérente, ou le retenu par sa + * proposition d'origine, porte un identifiant qui atteint + * prochainsIds.proposition. examiner seul nomme ces deux-là, en comparant + * les propositions entre elles et à la charge. + * + * @typedef {{code: 'FORME', chemin: string} + * | {code: 'LONGUEUR', tour: number|null, table: number|null, declare: number, ecrit: number} + * | {code: 'DOUBLON'|'INCONNU'|'MANQUANT', tour: number, participant: number} + * | {code: 'IDENTIFIANT_REPETE'|'IDENTIFIANT_HORS_COMPTEUR'}} Faute + * + * Une raison de dérive d'un placement cohérent contre la charge courante. + * + * @typedef {{code: 'PARTICIPANT_EXCLU'|'PARTICIPANT_SUPPRIME'|'PARTICIPANT_NON_PLACE', participant: number} + * | {code: 'TABLE_SUPPRIMEE'|'TABLE_AJOUTEE', table: number} + * | {code: 'CAPACITE_CHANGEE', table: number, avant: number, maintenant: number} + * | {code: 'TOURS_CHANGES', avant: number, maintenant: number}} Raison + * + * Une ligne de la forme nommée : une personne placée à un tour. + * + * @typedef {Object} Ligne + * @property {number} tour + * @property {number|null} table identifiant ; null pour la réserve + * @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} participant + * @property {string|null} nom null pour une personne que la charge + * n'a plus + * @property {string|null} prenom + * + * Ce que rend examiner. + * + * @typedef {Object} Examen + * @property {import('./types.js').Charge} charge sans les propositions + * fautives, son compteur des propositions relevé au-delà de chaque + * identifiant lu + * @property {Array<{rang: number, id: number|null, fautes: Faute[]}>} fautives + * par rang dans la liste lue ; id null quand il ne se lit pas + * @property {Array<{id: number, raisons: Raison[]}>} derives par identifiant + * @property {{fautes: Faute[], raisons: Raison[]}} retenu + */ + +// Règle du champ cle d'un objet du schéma. +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. +const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element; +const RETENU = regleDuChamp(CHARGE, 'retenu'); +const IDENTIFIANT = regleDuChamp(PROPOSITION, 'id'); + +const croissant = (a, b) => a - b; +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +// Chemin du second exemplaire du premier identifiant que liste porte deux +// fois, ou null. +function doublon(liste, nom) { + const vus = new Set(); + for (let rang = 0; rang < liste.length; rang += 1) { + if (vus.has(liste[rang])) return `${nom}[${rang}]`; + vus.add(liste[rang]); + } + return null; +} + +// Chemin de la première contradiction de la déclaration d'un placement de +// forme conforme, ou null, dans l'ordre du schéma : une table déclarée deux +// fois ; des capacités qui ne correspondent pas aux tables rang à rang ; un +// participant déclaré deux fois. D'un doublon, le second est nommé, comme +// l'analyse le fait des identifiants de la charge. +function fauteDeDeclaration({ tables, capacites, participants }) { + return ( + doublon(tables, 'tables') + ?? (capacites.length === tables.length ? null : 'capacites') + ?? doublon(participants, 'participants') + ); +} + +// 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. +// +// 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 +// plus courte que sa capacité n'est pas fautive. Un décalage qui remplit une +// table au-delà de sa capacité l'est. +// +// Les occurrences d'un tour se comptent dans un Map ; aucune décision ne +// suit son ordre : doublons et manquants se lisent dans l'ordre des +// identifiants déclarés, triés, et les inconnus se trient avant d'être +// nommés. +function fautesDeCoherence({ tables, capacites, tours, participants, placement }) { + const liste = []; + if (placement.length !== tours) { + liste.push({ code: 'LONGUEUR', tour: null, table: null, declare: tours, ecrit: placement.length }); + } + const declares = [...participants].sort(croissant); + const estDeclare = new Set(declares); + placement.forEach(({ sieges, reserve }, rang) => { + const tour = rang + 1; + if (sieges.length !== tables.length) { + liste.push({ code: 'LONGUEUR', tour, table: null, declare: tables.length, ecrit: sieges.length }); + } + const appariees = Math.min(sieges.length, tables.length); + for (let i = 0; i < appariees; i += 1) { + if (sieges[i].length > capacites[i]) { + liste.push({ code: 'LONGUEUR', tour, table: tables[i], declare: capacites[i], ecrit: sieges[i].length }); + } + } + const occurrences = new Map(); + const inconnus = []; + for (const ids of [...sieges, reserve]) { + for (const id of ids) { + const deja = occurrences.get(id) ?? 0; + occurrences.set(id, deja + 1); + if (deja === 0 && !estDeclare.has(id)) inconnus.push(id); + } + } + for (const id of declares) { + if (occurrences.get(id) > 1) liste.push({ code: 'DOUBLON', tour, participant: id }); + } + for (const id of inconnus.sort(croissant)) liste.push({ code: 'INCONNU', tour, participant: id }); + for (const id of declares) { + if (!occurrences.has(id)) liste.push({ code: 'MANQUANT', tour, participant: id }); + } + }); + 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. +function fautesSelon(valeur, regle) { + const chemin = premiereFaute(valeur, regle) ?? fauteDeDeclaration(valeur); + return chemin === null ? fautesDeCoherence(valeur) : [{ code: 'FORME', chemin }]; +} + +/** + * Fautes internes d'un placement, proposition ou retenu (§ 8.9, points 1 et + * 2) : il se compare à lui-même, jamais à la charge. La règle est celle du + * 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, + * { 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 + * d'un tour ; une table pour une liste plus longue que sa capacité — puis, + * dans chaque tour, { code: 'DOUBLON'|'INCONNU'|'MANQUANT', tour, + * participant } par identifiant croissant. Rend [] pour un placement + * cohérent ; ne lève jamais. + * + * Une permutation dans une table, ou l'échange du contenu de deux tables de + * même capacité, gardent longueurs et multiensemble : rien dans le fichier + * ne les distingue d'un placement légitime, et fautes rend []. + * + * @param {*} p + * @returns {Faute[]} + */ +export function fautes(p) { + const retenu = typeof p === 'object' && p !== null && Object.hasOwn(p, 'proposition'); + return fautesSelon(p, retenu ? RETENU : PROPOSITION); +} + +// Lève TypeError, au nom de fonction, quand p n'est pas un placement +// cohérent : le lire rendrait sans le dire une lecture fausse. +function exigerCoherent(p, fonction) { + const [premiere] = fautes(p); + if (premiere !== undefined) throw new TypeError(`${fonction} : placement fautif ${JSON.stringify(premiere)}`); +} + +/** + * Forme de fichier d'une proposition du moteur (§ 8.9). Son identifiant est + * decalage + proposition.id : la place de la proposition dans la suite des + * graines dérivées de sa génération (§ 5.7), que le moteur numérote à partir + * de 1, décalée au-delà de tout identifiant de proposition jamais attribué. + * L'appelant passe pour decalage prochainsIds.proposition − 1, ce compteur + * lu dans la charge qu'examiner rend, puis porte le compteur au-delà du + * dernier identifiant écrit. examiner le relève au-delà de chaque + * identifiant que le fichier porte, propositions écartées et origine du + * retenu comprises : ce compteur dépasse les identifiants de la liste, celui + * de la proposition d'origine du retenu et ceux qu'un effacement a retirés, + * et ne recule jamais, même abaissé à la main dans le fichier. Les + * générations s'accumulent ainsi (§ 5.7) sans répéter ni réattribuer un + * identifiant, et examiner, qui écarte à l'ouverture la seconde proposition + * d'un identifiant répété, les garde toutes. + * + * Elle porte ensuite graine, arret et historique de la proposition, + * produitVersion, et siegesAttribues, qui vaut attribuerSieges au moment où + * elle est produite ; puis ce que la configuration déclare : les tables du + * plan, dans l'ordre de ses listes, chacune avec sa capacité dans la + * 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 + * 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é. + * + * @param {import('../moteur/recherche.js').Proposition} proposition + * @param {import('../moteur/types.js').Configuration} configuration + * @param {{produitVersion: string, attribuerSieges: boolean, decalage: number}} options + * @returns {import('./types.js').PropositionFichier} + */ +export function versFichier(proposition, configuration, { produitVersion, attribuerSieges, decalage }) { + // Le décalage se contrôle avant la forme : négatif, il peut rendre un + // identifiant que la forme admet et qu'une autre proposition porte déjà. + if (!Number.isSafeInteger(decalage) || decalage < 0) { + throw new TypeError(`versFichier : décalage entier ≥ 0 attendu, reçu ${JSON.stringify(decalage)}`); + } + 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, + graine: proposition.graine, + arret: proposition.arret, + historique: proposition.historique, + produitVersion, + siegesAttribues: attribuerSieges, + tables: [...plan.tables], + capacites: plan.tables.map((id) => capaciteDe.get(id)), + tours: configuration.tours, + participants: configuration.participants + .filter(({ exclu }) => exclu !== true) + .map(({ id }) => id) + .sort(croissant), + placement: plan.tours.map((listes, rang) => ({ + sieges: listes.map((liste) => ordonner(liste)), + reserve: [...plan.reserves[rang]].sort(croissant), + })), + }; + exigerCoherent(fichier, 'versFichier'); + return fichier; +} + +/** + * 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 + * n'est pas cohérent (fautes). + * + * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p + * @returns {import('../moteur/types.js').Plan} + */ +export function planDepuisFichier(p) { + exigerCoherent(p, 'planDepuisFichier'); + return { + tables: [...p.tables], + tours: p.placement.map(({ sieges }) => sieges.map((liste) => [...liste].sort(croissant))), + reserves: p.placement.map(({ reserve }) => [...reserve].sort(croissant)), + }; +} + +// Code, comparé unité UTF-16 par unité, puis identifiant du participant ou +// de la table que la raison désigne ; TOURS_CHANGES n'en désigne aucun et +// n'apparaît qu'une fois. +const ordreDesRaisons = (a, b) => + comparerTextes(a.code, b.code) || (a.participant ?? a.table ?? 0) - (b.participant ?? b.table ?? 0); + +// Raisons de dérive d'un placement cohérent, sans contrôle : derive les +// rend après l'avoir contrôlé, examiner après l'avoir fait lui-même. +function raisonsDeDerive({ tables, capacites, tours, participants }, charge) { + const raisons = []; + const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne])); + const places = new Set(participants); + for (const id of participants) { + const personne = personneDe.get(id); + if (personne === undefined) raisons.push({ code: 'PARTICIPANT_SUPPRIME', participant: id }); + else if (personne.exclu) raisons.push({ code: 'PARTICIPANT_EXCLU', participant: id }); + } + for (const { id, exclu } of charge.participants) { + if (!exclu && !places.has(id)) raisons.push({ code: 'PARTICIPANT_NON_PLACE', participant: id }); + } + const tableDe = new Map(charge.tables.map((table) => [table.id, table])); + tables.forEach((id, rang) => { + const table = tableDe.get(id); + if (table === undefined) { + raisons.push({ code: 'TABLE_SUPPRIMEE', table: id }); + return; + } + const maintenant = capacite(charge, table); + if (maintenant !== capacites[rang]) { + raisons.push({ code: 'CAPACITE_CHANGEE', table: id, avant: capacites[rang], maintenant }); + } + }); + const declarees = new Set(tables); + for (const { id } of charge.tables) { + if (!declarees.has(id)) raisons.push({ code: 'TABLE_AJOUTEE', table: id }); + } + if (tours !== charge.evenement.tours) { + raisons.push({ code: 'TOURS_CHANGES', avant: tours, maintenant: charge.evenement.tours }); + } + return raisons.sort(ordreDesRaisons); +} + +/** + * Raisons de dérive d'un placement cohérent, proposition ou retenu, contre + * la charge courante (§ 8.9, point 3 ; § 9) : ce qu'il déclare comparé à la + * configuration d'aujourd'hui. Une personne qu'il place et que la charge + * exclut (§ 4.4), ou n'a plus : PARTICIPANT_EXCLU, PARTICIPANT_SUPPRIME + * {participant}. Une personne non exclue qu'il ne place pas, ajoutée ou + * réintégrée depuis : PARTICIPANT_NON_PLACE {participant}. Une table qu'il + * déclare et que la charge n'a plus, ou une table de la charge qu'il ne + * déclare pas : TABLE_SUPPRIMEE, TABLE_AJOUTEE {table}. Une capacité + * déclarée qui n'est plus la capacité courante de sa table : + * CAPACITE_CHANGEE {table, avant, maintenant}. Un nombre de tours qui n'est + * plus celui de l'événement : TOURS_CHANGES {avant, maintenant}. + * + * Triées par code, unité UTF-16 par unité, puis par identifiant ; [] quand + * le placement décrit la configuration courante : son plan passe alors + * indexerPlan du moteur sur elle. Noms, appartenances, numéros, positions, + * formes de table et réglages n'entrent pas dans la dérive. Lève TypeError + * quand p n'est pas cohérent (fautes). + * + * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p + * @param {import('./types.js').Charge} charge + * @returns {Raison[]} + */ +export function derive(p, charge) { + exigerCoherent(p, 'derive'); + return raisonsDeDerive(p, charge); +} + +// Ordre d'affichage des tables : numéro croissant, une table absente de la +// charge — numéro null — après les autres ; à numéro égal, identifiant +// croissant. +const ordreDesTables = (a, b) => + (a.numero === null) - (b.numero === null) || a.numero - b.numero || a.id - b.id; + +/** + * Forme nommée d'un placement cohérent, proposition ou retenu (§ 8.9, + * point 4) : une ligne par personne placée à un tour, { tour, table, + * numero, siege, participant, nom, prenom }, pour la lecture et le + * signalement d'un défaut. Les lignes se rangent par tour, puis par numéro + * de table — une table que la charge n'a plus vient après les autres, sans + * numéro, et 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 personne qu'elle n'a plus les a null. Lève TypeError quand p + * n'est pas cohérent (fautes). + * + * @param {import('./types.js').PropositionFichier|import('./types.js').Retenu} p + * @param {import('./types.js').Charge} charge + * @returns {Ligne[]} + */ +export function formeNommee(p, charge) { + exigerCoherent(p, 'formeNommee'); + const personneDe = new Map(charge.participants.map((personne) => [personne.id, personne])); + const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero])); + const tables = p.tables + .map((id, rang) => ({ id, rang, numero: numeroDe.get(id) ?? null })) + .sort(ordreDesTables); + const lignes = []; + const ajouter = (tour, table, numero, siege, participant) => { + const personne = personneDe.get(participant); + lignes.push({ + tour, + table, + numero, + siege, + participant, + nom: personne?.nom ?? null, + prenom: personne?.prenom ?? null, + }); + }; + p.placement.forEach(({ sieges, reserve }, rangDuTour) => { + const tour = rangDuTour + 1; + for (const { id, rang, numero } of tables) { + sieges[rang].forEach((participant, position) => { + ajouter(tour, id, numero, p.siegesAttribues ? position + 1 : null, participant); + }); + } + for (const participant of reserve) ajouter(tour, null, null, null, participant); + }); + return lignes; +} + +// valeur quand elle est un identifiant de proposition, null sinon. +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. +const compteurReleve = (charge, plusGrand) => + Math.max(charge.prochainsIds.proposition, Math.min(plusGrand + 1, Number.MAX_SAFE_INTEGER)); + +// Faute d'un identifiant de proposition que la charge a attribué au-delà de +// son compteur, ou [] : prochainsIds.proposition dépasse tout identifiant +// attribué (§ 5.7). +const fautesDuCompteur = (id, charge) => + id >= charge.prochainsIds.proposition ? [{ code: 'IDENTIFIANT_HORS_COMPTEUR' }] : []; + +// Fautes et raisons du retenu, examiné sous sa propre règle. Un retenu qui +// se contredit n'a que ses fautes ; un retenu cohérent a ses raisons de +// dérive, et la faute d'une proposition d'origine qui atteint le compteur ; +// un retenu nul n'a ni l'un ni l'autre. +function examinerRetenu(charge) { + const { retenu } = charge; + if (retenu === null) return { fautes: [], raisons: [] }; + const liste = fautesSelon(retenu, RETENU); + if (liste.length > 0) return { fautes: liste, raisons: [] }; + return { fautes: fautesDuCompteur(retenu.proposition, charge), raisons: raisonsDeDerive(retenu, charge) }; +} + +/** + * Partage les placements d'une charge lue (§ 8.9, point 3). L'analyse du + * fichier admet toute proposition et tout retenu ; examiner les contrôle. + * + * Chaque élément de la liste est examiné dans son ordre, sous la règle + * d'une proposition, même quand il porte la clé d'un retenu : fautif, il + * est écarté et nommé dans fautives, { rang, id, fautes }, rang étant sa + * place dans la liste et id son identifiant, null quand il n'en est pas un ; + * cohérente, elle est gardée, et ses raisons de dérive, s'il y en a, vont + * dans derives, { id, raisons }, rangées par identifiant. Une proposition + * cohérente qui porte l'identifiant d'une proposition déjà gardée est + * fautive, { code: 'IDENTIFIANT_REPETE' } ; celle dont l'identifiant atteint + * prochainsIds.proposition aussi, { code: 'IDENTIFIANT_HORS_COMPTEUR' } : + * l'identifiant d'une proposition est unique dans la charge rendue, et sous + * le compteur, et le fichier s'ouvre sans elle. Un compteur abaissé écarte + * ainsi les propositions qu'il ne couvre plus, qui se recalculent, sans + * fermer la saisie. Une proposition écartée n'en écarte aucune autre : une + * cohérente qui porte l'identifiant d'une fautive n'est pas une répétition, + * et une fautive se nomme par ses propres fautes. + * + * Le retenu n'est jamais retiré : retenu porte ses fautes, sous la règle du + * retenu. Cohérent, il porte aussi ses raisons de dérive, et pour faute + * { code: 'IDENTIFIANT_HORS_COMPTEUR' } quand sa proposition d'origine + * atteint prochainsIds.proposition. Un retenu nul n'a ni l'un ni l'autre. + * + * Ces fautes se jugent contre le compteur reçu. La charge rendue porte le + * compteur relevé au-delà de chaque identifiant lu : prochainsIds.proposition + * 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. + * + * 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 + * propositions relevé ; tout le reste, propositions gardées et retenu + * compris, est celui de la charge reçue, qui n'est pas modifiée. Cette + * charge s'écrit et se compare telle quelle : chaque proposition y suit sa + * règle, et un retenu qui ne suit pas la sienne, canonique.js le recopie + * hors du schéma (retenuHorsDeSaRegle). + * + * @param {import('./types.js').Charge} charge + * @returns {Examen} + */ +export function examiner(charge) { + const gardees = []; + const fautives = []; + const derives = []; + const identifiants = new Set(); + let plusGrand = identifiantLisible(charge.retenu?.proposition) ?? 0; + charge.propositions.forEach((proposition, rang) => { + const id = identifiantLisible(proposition?.id); + plusGrand = Math.max(plusGrand, id ?? 0); + let liste = fautesSelon(proposition, PROPOSITION); + if (liste.length === 0 && identifiants.has(proposition.id)) liste = [{ code: 'IDENTIFIANT_REPETE' }]; + if (liste.length === 0) liste = fautesDuCompteur(proposition.id, charge); + if (liste.length > 0) { + fautives.push({ rang, id, fautes: liste }); + return; + } + identifiants.add(proposition.id); + gardees.push(proposition); + const raisons = raisonsDeDerive(proposition, charge); + if (raisons.length > 0) derives.push({ id: proposition.id, raisons }); + }); + derives.sort((a, b) => a.id - b.id); + const prochainsIds = { ...charge.prochainsIds, proposition: compteurReleve(charge, plusGrand) }; + return { + charge: { ...charge, prochainsIds, propositions: gardees }, + fautives, + derives, + retenu: examinerRetenu(charge), + }; +} diff --git a/src/stockage/placements.test.js b/src/stockage/placements.test.js new file mode 100644 index 0000000..e469408 --- /dev/null +++ b/src/stockage/placements.test.js @@ -0,0 +1,1282 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des placements du fichier d'état (§ 8.9, § 9, § 4.4, § 5.7) : la +// conversion entre la proposition du moteur et la forme positionnelle, la +// numérotation des générations qui s'accumulent, le contrôle de cohérence +// sur un fichier abîmé, aux capacités égales ou non, ce que ce contrôle +// n'attrape pas, la dérive d'une proposition sur une configuration changée, +// le partage d'une charge lue entre propositions gardées et écartées, sous +// un compteur relevé au-delà de chaque identifiant lu, la forme nommée, et +// des résultats qui ne dépendent que des arguments. Les charges d'épreuve +// reprennent la petite démonstration (§ 15.3) et son plan parfait ; leurs +// noms sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; +import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { ErreurConfiguration } from '../moteur/erreurs.js'; +import { HISTORIQUE_PAR_DEFAUT, rechercher, regenerer } from '../moteur/recherche.js'; +import { VERSION } from '../version.genere.js'; +import { serialiser } from './canonique.js'; +import { analyser, configurationDepuisCharge, creerCharge } from './document.js'; +import { derive, examiner, fautes, formeNommee, planDepuisFichier, versFichier } from './placements.js'; + +const ENTETE = Object.freeze({ revision: 1, produitVersion: VERSION.affichee }); + +// Fige une valeur à toute profondeur et la rend : une fonction qui +// écrirait dans ce qu'elle reçoit lève alors TypeError. +function geler(valeur) { + if (typeof valeur === 'object' && valeur !== null) { + for (const enfant of Object.values(valeur)) geler(enfant); + Object.freeze(valeur); + } + return valeur; +} + +// La configuration de la petite démonstration (§ 15.3), neuve à chaque +// appel : 12 personnes, 4 tables de 3 sièges, 4 tours, aucune réservation. +const petite = () => CATALOGUE.find(({ cle }) => cle === 'petite').construire(); + +// Trois propositions du moteur sur la petite démonstration, figées. +const GENERATION = geler(rechercher(petite(), { graine: 5, arret: 2_000, nombre: 3 })); + +// Le plan parfait de la petite démonstration, en proposition du moteur. +const PARFAITE = Object.freeze({ id: 1, graine: 48_271, arret: 20_000, historique: 1_000, plan: PLAN_PARFAIT_PETITE }); + +// La forme positionnelle attendue de PARFAITE, sièges non attribués, écrite +// à la main : une copie neuve à chaque appel. +function fichierParfait() { + return { + id: 1, + graine: 48_271, + arret: 20_000, + historique: 1_000, + produitVersion: VERSION.affichee, + siegesAttribues: false, + tables: [1, 2, 3, 4], + capacites: [3, 3, 3, 3], + tours: 4, + participants: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + placement: [ + { sieges: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], reserve: [] }, + { sieges: [[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]], reserve: [] }, + { sieges: [[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]], reserve: [] }, + { sieges: [[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]], reserve: [] }, + ], + }; +} + +// fichierParfait, cohérent lui aussi, ses tables déclarées dans l'ordre 3, +// 1, 2, 4, chaque tour portant ses listes dans cet ordre, et ses +// participants déclarés à rebours : un ordre de sortie qui suivrait celui +// de la déclaration s'y voit. +function fichierPermute() { + const p = fichierParfait(); + const ordre = [2, 0, 1, 3]; + p.tables = ordre.map((rang) => p.tables[rang]); + p.capacites = ordre.map((rang) => p.capacites[rang]); + for (const tour of p.placement) tour.sieges = ordre.map((rang) => tour.sieges[rang]); + p.participants.reverse(); + return p; +} + +// fichierParfait, cohérent lui aussi, dont le tour 1 laisse les personnes 6 +// et 3 à la réserve, écrite dans cet ordre, hors de l'ordre croissant. +function fichierAReserve() { + const p = fichierParfait(); + p.placement[0] = { sieges: [[1, 2], [4, 5], [7, 8, 9], [10, 11, 12]], reserve: [6, 3] }; + return p; +} + +// fichierParfait, cohérent lui aussi, la table 4 portée à quatre sièges et +// déclarée la première : tables 4, 1, 2, 3, capacités 4, 3, 3, 3. Au tour 1, +// la table 3 cède la personne 9 à la table 4, pleine à sa propre capacité, +// au-delà de celle de ses voisines. Une capacité lue au rang de +// l'identifiant moins un, et non au rang de la déclaration, donnerait 3 à la +// table 4 et 4 à la table 1. +function fichierInegal() { + const p = fichierParfait(); + const ordre = [3, 0, 1, 2]; + p.tables = ordre.map((rang) => p.tables[rang]); + p.capacites = [4, 3, 3, 3]; + for (const tour of p.placement) tour.sieges = ordre.map((rang) => tour.sieges[rang]); + p.placement[0].sieges = [[9, 10, 11, 12], [1, 2, 3], [4, 5, 6], [7, 8]]; + return p; +} + +// Noms et prénoms inventés des personnes 1 à 12 de la charge d'épreuve, puis +// ceux de la personne 13, que des épreuves ajoutent. +const PERSONNES = Object.freeze([ + ['Aubépine', 'Agathe'], + ['Bruyère', 'Basile'], + ['Chardon', 'Céleste'], + ['Doucette', 'Désiré'], + ['Églantine', 'Edmée'], + ['Fougère', 'Firmin'], + ['Giroflée', 'Gisèle'], + ['Houblon', 'Honoré'], + ['Isatis', 'Ida'], + ['Jonquille', 'Joachim'], + ['Lavande', 'Lazare'], + ['Mélisse', 'Mathurin'], +]); +const TREIZIEME = Object.freeze(['Nigelle', 'Noémie']); +// Appartenances inventées, données aux groupes de la démonstration dans +// l'ordre où ils apparaissent, par identifiant croissant. +const CERCLES = Object.freeze(['Cercle Aulne', 'Cercle Bouleau', 'Cercle Charme']); + +const personne = (id, nom, prenom, appartenance = null) => ({ + id, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, +}); +const table = (id, numero, sieges = null) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } }); + +// La charge de la petite démonstration, sans proposition : ses personnes +// sous des noms inventés et leurs groupes, ses tables aux sièges du défaut, +// 3, ses 4 tours. +function chargePetite() { + const configuration = petite(); + const charge = creerCharge({ id: 'evt-pinsons', nom: 'Soirée des Pinsons', siegesParDefaut: 3, tours: 4 }); + const cercleDe = new Map(); + charge.participants = configuration.participants.map(({ id, appartenance }) => { + if (!cercleDe.has(appartenance)) cercleDe.set(appartenance, CERCLES[cercleDe.size]); + const [nom, prenom] = PERSONNES[id - 1]; + return personne(id, nom, prenom, cercleDe.get(appartenance)); + }); + charge.tables = configuration.tables.map(({ id, numero }) => table(id, numero)); + charge.prochainsIds = { participant: 13, table: 5, proposition: 1 }; + return charge; +} + +// chargePetite avec les propositions de GENERATION, converties sans +// attribution de sièges sur sa propre configuration et numérotées à partir +// de prochainsIds.proposition, qui avance au-delà de la dernière. +function chargeAvecGeneration() { + const charge = chargePetite(); + const configuration = configurationDepuisCharge(charge); + const decalage = charge.prochainsIds.proposition - 1; + charge.propositions = GENERATION.map((proposition) => + versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }), + ); + charge.prochainsIds.proposition = decalage + GENERATION.length + 1; + charge.evenement.etat = 'propose'; + return charge; +} + +// Le retenu tiré d'une proposition de fichier : son identifiant à la clé +// proposition, une copie de son plan. +const retenuDe = ({ id, siegesAttribues, tables, capacites, tours, participants, placement }) => + structuredClone({ proposition: id, siegesAttribues, tables, capacites, tours, participants, placement }); + +// La charge écrite dans son fichier, puis relue : ce que trouve l'ouverture. +const relire = (charge) => analyser(serialiser(charge, ENTETE)).charge; + +// Le fichier de la charge, abîmé : abimer reçoit le JSON du texte écrit et +// le modifie, puis le texte qui en résulte est relu par l'analyse, qui ne +// contrôle des propositions et du retenu que leur conteneur. +function abimerFichier(charge, abimer) { + const ecrit = JSON.parse(serialiser(charge, ENTETE)); + abimer(ecrit); + return analyser(JSON.stringify(ecrit)).charge; +} + +// Exige que appel lève TypeError, et que son message réponde à motif. +function leveTypeError(appel, motif, message) { + assert.throws(appel, (erreur) => erreur instanceof TypeError && motif.test(erreur.message), message); +} + +// Une ligne attendue de la forme nommée. +const ligne = (tour, idTable, numero, siege, participant, nom, prenom) => ({ + tour, + table: idTable, + numero, + siege, + participant, + nom, + prenom, +}); + +describe('versFichier et planDepuisFichier : la proposition du moteur et sa forme positionnelle (§ 8.9)', () => { + test('le plan parfait de la petite démonstration donne la forme positionnelle attendue, et planDepuisFichier rend le plan', () => { + const fichier = versFichier(PARFAITE, petite(), { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }); + assert.deepStrictEqual(fichier, fichierParfait()); + assert.deepStrictEqual(planDepuisFichier(fichier), PLAN_PARFAIT_PETITE); + }); + + test("chaque proposition de deux générations, sous l'historique par défaut du moteur puis sous un autre : graine, arrêt, historique et produitVersion portés, les trois premiers régénèrent le plan, listes de table croissantes, le plan rendu tel quel", () => { + // GENERATION suit l'historique que le moteur prend par défaut ; la + // seconde génération en demande un autre : sur elle seule, un fichier + // qui porte le défaut au lieu de l'historique reçu se distingue, et sa + // régénération rend un autre plan (§ 8.9). + const seconde = rechercher(petite(), { graine: 5, arret: 500, nombre: 1, historique: 37 }); + assert.notEqual(HISTORIQUE_PAR_DEFAUT, 37); + assert.deepStrictEqual(seconde.map(({ historique }) => historique), [37]); + assert.equal(GENERATION.length, 3); + for (const proposition of [...GENERATION, ...seconde]) { + for (const attribuerSieges of [false, true]) { + const fichier = versFichier(proposition, petite(), { produitVersion: VERSION.affichee, attribuerSieges, decalage: 0 }); + const { id, graine, arret, historique } = proposition; + assert.deepStrictEqual( + [fichier.id, fichier.graine, fichier.arret, fichier.historique, fichier.produitVersion], + [id, graine, arret, historique, VERSION.affichee], + ); + assert.equal(fichier.siegesAttribues, attribuerSieges); + assert.deepStrictEqual(fichier.tables, [1, 2, 3, 4]); + assert.deepStrictEqual(fichier.capacites, [3, 3, 3, 3]); + assert.equal(fichier.tours, 4); + assert.deepStrictEqual(fichier.participants, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + assert.equal(fichier.placement.length, 4); + for (const { sieges, reserve } of fichier.placement) { + for (const liste of sieges) assert.deepStrictEqual(liste, [...liste].sort((a, b) => a - b)); + assert.deepStrictEqual(reserve, []); + } + assert.deepStrictEqual(fautes(fichier), []); + assert.deepStrictEqual(planDepuisFichier(fichier), proposition.plan); + assert.deepStrictEqual(regenerer(petite(), fichier), proposition.plan); + } + } + }); + + test("sièges non attribués : chaque liste de table se trie par identifiant croissant ; attribués : l'ordre des sièges est gardé ; la réserve se trie dans les deux cas", () => { + const configuration = petite(); + configuration.participants.push({ id: 13, nom: TREIZIEME[0], prenom: TREIZIEME[1], appartenance: null }); + const inverses = PLAN_PARFAIT_PETITE.tours.map((listes) => listes.map((liste) => [...liste].reverse())); + inverses[0][3] = [11, 10]; + const proposition = { + ...PARFAITE, + plan: { tables: [1, 2, 3, 4], tours: inverses, reserves: [[13, 12], [13], [13], [13]] }, + }; + const reserves = [[12, 13], [13], [13], [13]]; + + const nonAttribues = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }); + assert.equal(nonAttribues.siegesAttribues, false); + assert.deepStrictEqual(nonAttribues.participants, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); + const tries = PLAN_PARFAIT_PETITE.tours.map((listes) => listes.map((liste) => [...liste])); + tries[0][3] = [10, 11]; + assert.deepStrictEqual( + nonAttribues.placement, + tries.map((sieges, r) => ({ sieges, reserve: reserves[r] })), + ); + + const attribues = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: true, decalage: 0 }); + assert.equal(attribues.siegesAttribues, true); + assert.deepStrictEqual( + attribues.placement, + inverses.map((sieges, r) => ({ sieges, reserve: reserves[r] })), + ); + assert.deepStrictEqual(planDepuisFichier(attribues), planDepuisFichier(nonAttribues)); + }); + + test("les tables dans l'ordre du plan, chacune avec sa capacité dans la configuration, quel que soit l'ordre où la configuration les range", () => { + const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }; + const configuration = petite(); + configuration.tables[3].capacite = 4; + configuration.tables.reverse(); + const [proposition] = rechercher(configuration, { graine: 5, arret: 2_000, nombre: 1 }); + assert.deepStrictEqual(proposition.plan.tables, [4, 3, 2, 1]); + const engendre = versFichier(proposition, configuration, options); + assert.deepStrictEqual([engendre.tables, engendre.capacites], [[4, 3, 2, 1], [4, 3, 3, 3]]); + assert.deepStrictEqual(planDepuisFichier(engendre), proposition.plan); + const parfait = versFichier(PARFAITE, configuration, options); + assert.deepStrictEqual([parfait.tables, parfait.capacites], [[1, 2, 3, 4], [3, 3, 3, 4]]); + assert.deepStrictEqual(planDepuisFichier(parfait), PLAN_PARFAIT_PETITE); + // Les participants se déclarent par identifiant croissant, même quand + // la configuration les range à rebours. + const renversee = petite(); + renversee.participants.reverse(); + const [tiree] = rechercher(renversee, { graine: 5, arret: 2_000, nombre: 1 }); + assert.deepStrictEqual(versFichier(tiree, renversee, options).participants, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + }); + + test("planDepuisFichier rend chaque réserve par identifiant croissant, comme le moteur les rend, quel que soit l'ordre écrit", () => { + const p = fichierAReserve(); + assert.deepStrictEqual(fautes(p), []); + assert.deepStrictEqual(planDepuisFichier(p).reserves[0], [3, 6]); + }); + + test("versFichier ne modifie ni la proposition ni la configuration, et ne partage aucune liste avec elles ; planDepuisFichier non plus avec le fichier", () => { + const configuration = geler(petite()); + for (const proposition of GENERATION) { + const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: true, decalage: 0 }); + assert.notEqual(fichier.tables, proposition.plan.tables); + fichier.placement.forEach(({ sieges, reserve }, r) => { + assert.notEqual(reserve, proposition.plan.reserves[r]); + sieges.forEach((liste, i) => assert.notEqual(liste, proposition.plan.tours[r][i])); + }); + const gele = geler(fichier); + const plan = planDepuisFichier(gele); + assert.notEqual(plan.tables, gele.tables); + plan.tours.forEach((listes, r) => { + assert.notEqual(plan.reserves[r], gele.placement[r].reserve); + listes.forEach((liste, i) => assert.notEqual(liste, gele.placement[r].sieges[i])); + }); + } + }); + + test('versFichier refuse un drapeau, une version ou un décalage qui manquent ou sortent de leur domaine, et un plan que la configuration contredit : TypeError', () => { + // Une troisième proposition : le décalage −2 lui donnerait l'identifiant + // 1, que le contrôle de forme admet. + const troisieme = { ...PARFAITE, id: 3 }; + for (const options of [ + { produitVersion: VERSION.affichee, decalage: 0 }, + { produitVersion: VERSION.affichee, attribuerSieges: 'non', decalage: 0 }, + { attribuerSieges: false, decalage: 0 }, + { produitVersion: '', attribuerSieges: false, decalage: 0 }, + ]) { + assert.throws(() => versFichier(troisieme, petite(), options), TypeError, JSON.stringify(options)); + } + // Un décalage hors de son domaine se refuse en son nom, avant toute + // forme. + for (const decalage of [undefined, -2, 0.5, '0']) { + leveTypeError( + () => versFichier(troisieme, petite(), { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }), + /décalage/, + `décalage ${JSON.stringify(decalage) ?? 'absent'}`, + ); + } + const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }; + const avecTreize = petite(); + avecTreize.participants.push({ id: 13, nom: TREIZIEME[0], appartenance: null }); + leveTypeError(() => versFichier(PARFAITE, avecTreize, options), /"MANQUANT"/, 'personne 13 non placée'); + const tableReduite = petite(); + tableReduite.tables[3].capacite = 2; + leveTypeError(() => versFichier(PARFAITE, tableReduite, options), /"LONGUEUR"/, 'table 4 réduite'); + const cinqTours = petite(); + cinqTours.tours = 5; + leveTypeError(() => versFichier(PARFAITE, cinqTours, options), /"LONGUEUR"/, 'cinq tours'); + const sansTable = petite(); + sansTable.tables.pop(); + leveTypeError(() => versFichier(PARFAITE, sansTable, options), /"FORME"/, 'table 4 absente'); + }); + + 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)); + assert.deepStrictEqual(examen.fautives, []); + assert.deepStrictEqual(examen.derives, []); + assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] }); + assert.deepStrictEqual(examen.charge.propositions, charge.propositions); + examen.charge.propositions.forEach((proposition, rang) => { + assert.deepStrictEqual(planDepuisFichier(proposition), GENERATION[rang].plan); + }); + }); + + test("des générations qui s'accumulent (§ 5.7) : chacune décalée de prochainsIds.proposition − 1, qui avance ensuite ; après un effacement qui épargne le retenu, aucun identifiant ne revient, et examiner garde chaque proposition relue", () => { + const charge = chargePetite(); + const configuration = configurationDepuisCharge(charge); + // L'appelant décale une génération de prochainsIds.proposition − 1, + // puis porte le compteur au-delà du dernier identifiant écrit. + const generer = (graine) => { + const decalage = charge.prochainsIds.proposition - 1; + const options = { produitVersion: VERSION.affichee, attribuerSieges: false, decalage }; + const propositions = rechercher(configuration, { graine, arret: 2_000, nombre: 3 }); + for (const proposition of propositions) charge.propositions.push(versFichier(proposition, configuration, options)); + charge.prochainsIds.proposition = decalage + propositions.length + 1; + }; + generer(5); + generer(9); + charge.evenement.etat = 'propose'; + assert.deepStrictEqual(charge.propositions.map(({ id }) => id), [1, 2, 3, 4, 5, 6]); + const examen = examiner(relire(charge)); + assert.deepStrictEqual(examen.fautives, []); + assert.deepStrictEqual(examen.charge.propositions, charge.propositions); + // Effacer la liste en épargnant le retenu (§ 5.7) garde la proposition + // 2 seule ; la génération suivante prend 7, 8 et 9, aucun des + // identifiants effacés. + charge.retenu = retenuDe(charge.propositions[1]); + charge.propositions = [charge.propositions[1]]; + charge.evenement.etat = 'retenu'; + generer(13); + assert.deepStrictEqual(charge.propositions.map(({ id }) => id), [2, 7, 8, 9]); + const apres = examiner(relire(charge)); + assert.deepStrictEqual(apres.fautives, []); + assert.deepStrictEqual(apres.charge.propositions, charge.propositions); + assert.deepStrictEqual(apres.retenu, { fautes: [], raisons: [] }); + }); +}); + +describe('fautes : la corruption délibérée (§ 8.9, points 1 à 3)', () => { + // Les quatre façons d'abîmer une proposition de la petite démonstration, + // chacune dans son tour : chaque fonction abîme la proposition reçue et + // rend la faute que le contrôle doit nommer. + const QUATRE_FACONS = [ + ['une longueur fausse', (p) => { + const { sieges } = p.placement[0]; + sieges[1].push(sieges[0].shift()); + return { code: 'LONGUEUR', tour: 1, table: 2, declare: 3, ecrit: 4 }; + }], + ['un identifiant dupliqué', (p) => { + const { sieges, reserve } = p.placement[1]; + reserve.push(sieges[0][0]); + return { code: 'DOUBLON', tour: 2, participant: sieges[0][0] }; + }], + ['un identifiant inconnu', (p) => { + p.placement[2].reserve.push(13); + return { code: 'INCONNU', tour: 3, participant: 13 }; + }], + ['un identifiant manquant sans entrée de réserve', (p) => { + const perdu = p.placement[3].sieges[2].pop(); + return { code: 'MANQUANT', tour: 4, participant: perdu }; + }], + ]; + + test('trois propositions, la deuxième abîmée de quatre façons tour à tour : fautes nomme chacune avec ses coordonnées, examiner écarte la seule fautive et garde les deux autres', () => { + const charge = chargeAvecGeneration(); + assert.deepStrictEqual(charge.propositions[1].tables, [1, 2, 3, 4]); + for (const [libelle, abimer] of QUATRE_FACONS) { + let attendue; + const lue = abimerFichier(charge, (ecrit) => { + attendue = abimer(ecrit.charge.propositions[1]); + }); + const abimee = lue.propositions[1]; + assert.equal(abimee.id, 2, libelle); + assert.notDeepStrictEqual(abimee, charge.propositions[1], libelle); + assert.deepStrictEqual(fautes(abimee), [attendue], libelle); + const examen = examiner(lue); + assert.deepStrictEqual(examen.fautives, [{ rang: 1, id: 2, fautes: [attendue] }], libelle); + assert.deepStrictEqual(examen.charge.propositions, [charge.propositions[0], charge.propositions[2]], libelle); + assert.deepStrictEqual(examen.derives, [], libelle); + } + }); + + test("les quatre défauts dans la même proposition sont nommés dans l'ordre des tours", () => { + const abimee = structuredClone(chargeAvecGeneration().propositions[1]); + const attendues = QUATRE_FACONS.map(([, abimer]) => abimer(abimee)); + assert.deepStrictEqual(fautes(abimee), [attendues[0], attendues[1], attendues[2], attendues[3]]); + assert.deepStrictEqual(attendues.map(({ tour }) => tour), [1, 2, 3, 4]); + }); + + test("dans un tour : le nombre de listes, chaque table dans l'ordre déclaré, puis doublons, inconnus et manquants par identifiant croissant ; le nombre de tours avant tout", () => { + const p = fichierParfait(); + p.tours = 5; + p.placement[1] = { sieges: [[6, 9, 12, 3], [8], [2, 10, 14, 9], [1, 4, 7], [20]], reserve: [15, 1, 13, 15] }; + assert.deepStrictEqual(fautes(p), [ + { code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }, + { code: 'LONGUEUR', tour: 2, table: null, declare: 4, ecrit: 5 }, + { code: 'LONGUEUR', tour: 2, table: 1, declare: 3, ecrit: 4 }, + { code: 'LONGUEUR', tour: 2, table: 3, declare: 3, ecrit: 4 }, + { code: 'DOUBLON', tour: 2, participant: 1 }, + { code: 'DOUBLON', tour: 2, participant: 9 }, + { code: 'INCONNU', tour: 2, participant: 13 }, + { code: 'INCONNU', tour: 2, participant: 14 }, + { code: 'INCONNU', tour: 2, participant: 15 }, + { code: 'INCONNU', tour: 2, participant: 20 }, + { code: 'MANQUANT', tour: 2, participant: 5 }, + { code: 'MANQUANT', tour: 2, participant: 11 }, + ]); + // Plus de tours écrits que déclarés : la même faute, dans l'autre sens. + const enTrop = { ...fichierParfait(), tours: 3 }; + assert.deepStrictEqual(fautes(enTrop), [{ code: 'LONGUEUR', tour: null, table: null, declare: 3, ecrit: 4 }]); + }); + + test("un identifiant se nomme une fois par tour : inconnu écrit deux fois, déclaré écrit trois fois ; une liste de moins que les tables nomme aussi ses absents", () => { + const p = fichierParfait(); + p.placement[0].reserve = [13, 2, 13, 2]; + p.placement[2].sieges.pop(); + assert.deepStrictEqual(fautes(p), [ + { code: 'DOUBLON', tour: 1, participant: 2 }, + { code: 'INCONNU', tour: 1, participant: 13 }, + { code: 'LONGUEUR', tour: 3, table: null, declare: 4, ecrit: 3 }, + { code: 'MANQUANT', tour: 3, participant: 3 }, + { code: 'MANQUANT', tour: 3, participant: 5 }, + { code: 'MANQUANT', tour: 3, participant: 9 }, + ]); + }); + + test("les tables suivent l'ordre déclaré, les identifiants l'ordre croissant, quel que soit l'ordre de la déclaration des participants", () => { + const permute = fichierPermute(); + assert.deepStrictEqual(permute.tables, [3, 1, 2, 4]); + assert.deepStrictEqual(fautes(permute), []); + const longues = fichierPermute(); + longues.placement[0].sieges = [[7, 8, 9, 4], [1, 2, 3, 5], [6], [10, 11, 12]]; + assert.deepStrictEqual(fautes(longues), [ + { code: 'LONGUEUR', tour: 1, table: 3, declare: 3, ecrit: 4 }, + { code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 }, + ]); + const melees = fichierPermute(); + melees.placement[0].sieges[0] = [7, 8]; + melees.placement[0].sieges[1] = [1, 3]; + melees.placement[0].reserve = [5, 3]; + assert.deepStrictEqual(fautes(melees), [ + { code: 'DOUBLON', tour: 1, participant: 3 }, + { code: 'DOUBLON', tour: 1, participant: 5 }, + { code: 'MANQUANT', tour: 1, participant: 2 }, + { code: 'MANQUANT', tour: 1, participant: 9 }, + ]); + }); + + test("capacités inégales, tables déclarées hors de l'ordre des identifiants : chaque liste se mesure à la capacité de son rang dans la déclaration, et la proposition se relit sans être écartée", () => { + const inegal = fichierInegal(); + assert.deepStrictEqual(fautes(inegal), []); + // La table 4 au-delà de ses quatre sièges, la table 1 au-delà de ses + // trois mais en deçà de quatre ; la table 3 vide n'est pas fautive. + const debordees = fichierInegal(); + debordees.placement[0].sieges = [[8, 9, 10, 11, 12], [1, 2, 3, 7], [4, 5, 6], []]; + assert.deepStrictEqual(fautes(debordees), [ + { code: 'LONGUEUR', tour: 1, table: 4, declare: 4, ecrit: 5 }, + { code: 'LONGUEUR', tour: 1, table: 1, declare: 3, ecrit: 4 }, + ]); + const charge = chargePetite(); + charge.tables[3].sieges = 4; + charge.propositions = [inegal]; + charge.prochainsIds.proposition = 2; + charge.evenement.etat = 'propose'; + const examen = examiner(relire(charge)); + assert.deepStrictEqual(examen.fautives, []); + assert.deepStrictEqual(examen.derives, []); + assert.deepStrictEqual(examen.charge.propositions, [inegal]); + }); + + test("une table moins remplie que sa capacité n'est pas fautive : la longueur déclarée borne la liste, une salle peut avoir des places vides", () => { + const configuration = petite(); + configuration.participants[4].exclu = true; + const [proposition] = rechercher(configuration, { graine: 9, arret: 1_000, nombre: 1 }); + const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }); + assert.deepStrictEqual(fichier.participants, [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12]); + for (const { sieges } of fichier.placement) { + assert.deepStrictEqual(sieges.map((liste) => liste.length).sort((a, b) => a - b), [2, 3, 3, 3]); + } + assert.deepStrictEqual(fautes(fichier), []); + const versLaReserve = fichierParfait(); + versLaReserve.placement[0] = { sieges: [[1, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], reserve: [2] }; + assert.deepStrictEqual(fautes(versLaReserve), []); + }); + + test("la forme se contrôle d'abord : la première valeur hors de sa règle est nommée FORME, par son chemin dans la proposition", () => { + const cas = [ + ['un nombre', () => 42, ''], + ['null', () => null, ''], + ['une liste', () => [], ''], + ['identifiant 0', (p) => { p.id = 0; return p; }, 'id'], + ['identifiant en chaîne', (p) => { p.id = '1'; return p; }, 'id'], + ['graine négative', (p) => { p.graine = -1; return p; }, 'graine'], + ['graine de 2^32', (p) => { p.graine = 2 ** 32; return p; }, 'graine'], + ['arrêt nul', (p) => { p.arret = 0; return p; }, 'arret'], + ['historique fractionnaire', (p) => { p.historique = 1.5; return p; }, 'historique'], + ['version vide', (p) => { p.produitVersion = ''; return p; }, 'produitVersion'], + ['drapeau en chaîne', (p) => { p.siegesAttribues = 'non'; return p; }, 'siegesAttribues'], + ['tables en objet', (p) => { p.tables = {}; return p; }, 'tables'], + ['identifiant de table 0', (p) => { p.tables[3] = 0; return p; }, 'tables[3]'], + ["capacité d'un siège", (p) => { p.capacites[2] = 1; return p; }, 'capacites[2]'], + ['zéro tour', (p) => { p.tours = 0; return p; }, 'tours'], + ['participant en chaîne', (p) => { p.participants[1] = '2'; return p; }, 'participants[1]'], + ['placement en objet', (p) => { p.placement = {}; return p; }, 'placement'], + ['tour en liste', (p) => { p.placement[0] = []; return p; }, 'placement[0]'], + ['sièges en chaîne', (p) => { p.placement[1].sieges = 'x'; return p; }, 'placement[1].sieges'], + ['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]'], + ['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'], + ["une clé inconnue qui ne s'écrit pas comme un identifiant", (p) => { p['clé inconnue'] = 1; return p; }, '["clé inconnue"]'], + ['une clé inconnue dans un tour', (p) => { p.placement[1].note = 'x'; return p; }, 'placement[1].note'], + ['un champ fautif avant une clé inconnue', (p) => { p.aaa = 1; p.tours = 0; return p; }, 'tours'], + ['une table déclarée deux fois', (p) => { p.tables[2] = 2; return p; }, 'tables[2]'], + ['des capacités moins nombreuses que les tables', (p) => { p.capacites.pop(); return p; }, 'capacites'], + ['des capacités plus nombreuses que les tables', (p) => { p.capacites.push(3); return p; }, 'capacites'], + ['un participant déclaré deux fois', (p) => { p.participants[3] = 3; return p; }, 'participants[3]'], + ['une forme fautive dans un placement incohérent : la forme seule', (p) => { + p.placement[0].sieges[0].pop(); + p.historique = 0; + return p; + }, 'historique'], + ]; + const ecarts = cas.flatMap(([libelle, abimer, chemin]) => { + const obtenues = fautes(abimer(fichierParfait())); + const attendues = [{ code: 'FORME', chemin }]; + return JSON.stringify(obtenues) === JSON.stringify(attendues) + ? [] + : [`${libelle} : ${JSON.stringify(obtenues)} au lieu de ${JSON.stringify(attendues)}`]; + }); + assert.deepStrictEqual(ecarts, []); + }); + + test("le retenu se contrôle sous sa propre forme : la clé proposition au lieu de l'identifiant, ni graine, ni arrêt, ni historique, ni version", () => { + const retenu = retenuDe(fichierParfait()); + assert.deepStrictEqual(fautes(retenu), []); + assert.deepStrictEqual(fautes({ ...retenuDe(fichierParfait()), graine: 1 }), [{ code: 'FORME', chemin: 'graine' }]); + const sansDrapeau = retenuDe(fichierParfait()); + delete sansDrapeau.siegesAttribues; + assert.deepStrictEqual(fautes(sansDrapeau), [{ code: 'FORME', chemin: 'siegesAttribues' }]); + assert.deepStrictEqual(fautes({ ...retenuDe(fichierParfait()), proposition: 0 }), [{ code: 'FORME', chemin: 'proposition' }]); + retenu.placement[1].sieges[3].pop(); + assert.deepStrictEqual(fautes(retenu), [{ code: 'MANQUANT', tour: 2, participant: 7 }]); + }); +}); + +describe("ce que le contrôle n'attrape pas, et ne prétend pas attraper (§ 8.9, point 2)", () => { + test("une permutation dans une table, et l'échange du contenu de deux tables de même capacité, gardent le multiensemble et les longueurs : fautes rend [], examiner garde la proposition", () => { + for (const siegesAttribues of [true, false]) { + const reference = { ...fichierParfait(), siegesAttribues }; + const permutee = structuredClone(reference); + permutee.placement[0].sieges[0] = [3, 1, 2]; + const echangee = structuredClone(reference); + const { sieges } = echangee.placement[1]; + [sieges[0], sieges[1]] = [sieges[1], sieges[0]]; + for (const abimee of [permutee, echangee]) { + assert.notDeepStrictEqual(abimee, reference); + assert.deepStrictEqual(fautes(abimee), []); + const charge = chargePetite(); + charge.propositions = [abimee]; + charge.prochainsIds.proposition = 2; + const examen = examiner(charge); + assert.deepStrictEqual(examen.fautives, []); + assert.deepStrictEqual(examen.derives, []); + assert.deepStrictEqual(examen.charge.propositions, [abimee]); + } + } + }); +}); + +describe('derive : la péremption délibérée (§ 8.9, point 3 ; § 9 ; § 4.4)', () => { + test('exclure 5, supprimer la table 2, réduire la table 3, passer à 3 tours, ajouter 13 : chaque raison nommée, triées par code puis identifiant ; examiner conserve chaque proposition et le retenu', () => { + const charge = chargeAvecGeneration(); + charge.retenu = retenuDe(charge.propositions[1]); + charge.evenement.etat = 'retenu'; + charge.participants[4].exclu = true; + charge.tables = charge.tables.filter(({ id }) => id !== 2); + charge.tables.find(({ id }) => id === 3).sieges = 2; + charge.evenement.tours = 3; + charge.participants.push(personne(13, ...TREIZIEME)); + charge.prochainsIds.participant = 14; + const raisons = [ + { code: 'CAPACITE_CHANGEE', table: 3, avant: 3, maintenant: 2 }, + { code: 'PARTICIPANT_EXCLU', participant: 5 }, + { code: 'PARTICIPANT_NON_PLACE', participant: 13 }, + { code: 'TABLE_SUPPRIMEE', table: 2 }, + { code: 'TOURS_CHANGES', avant: 4, maintenant: 3 }, + ]; + const lue = relire(charge); + for (const proposition of lue.propositions) assert.deepStrictEqual(derive(proposition, lue), raisons); + const examen = examiner(lue); + assert.deepStrictEqual(examen.fautives, []); + assert.deepStrictEqual(examen.derives, [1, 2, 3].map((id) => ({ id, raisons }))); + assert.deepStrictEqual(examen.charge.propositions, charge.propositions); + assert.deepStrictEqual(examen.retenu, { fautes: [], raisons }); + assert.equal(examen.charge.retenu, lue.retenu); + }); + + test("chaque changement seul donne sa seule raison ; ce qui ne touche ni les personnes placées, ni le mobilier, ni les tours n'est pas une dérive", () => { + const cas = [ + ['exclure la personne 5', (c) => { c.participants[4].exclu = true; }, + [{ code: 'PARTICIPANT_EXCLU', participant: 5 }]], + ['supprimer la personne 7', (c) => { c.participants.splice(6, 1); }, + [{ code: 'PARTICIPANT_SUPPRIME', participant: 7 }]], + ['ajouter la personne 13', (c) => { c.participants.push(personne(13, ...TREIZIEME)); }, + [{ code: 'PARTICIPANT_NON_PLACE', participant: 13 }]], + ['ajouter la personne 13, exclue', (c) => { c.participants.push({ ...personne(13, ...TREIZIEME), exclu: true }); }, + []], + ['supprimer la table 2', (c) => { c.tables.splice(1, 1); }, + [{ code: 'TABLE_SUPPRIMEE', table: 2 }]], + ['ajouter la table 5', (c) => { c.tables.push(table(5, 5)); }, + [{ code: 'TABLE_AJOUTEE', table: 5 }]], + ['réduire la table 3 à deux sièges', (c) => { c.tables[2].sieges = 2; }, + [{ code: 'CAPACITE_CHANGEE', table: 3, avant: 3, maintenant: 2 }]], + ['agrandir la table 1 à quatre sièges', (c) => { c.tables[0].sieges = 4; }, + [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 3, maintenant: 4 }]], + ['passer le défaut à quatre sièges', (c) => { c.evenement.siegesParDefaut = 4; }, + [1, 2, 3, 4].map((id) => ({ code: 'CAPACITE_CHANGEE', table: id, avant: 3, maintenant: 4 }))], + ['surcharger la table 4 à la valeur du défaut', (c) => { c.tables[3].sieges = 3; }, + []], + ['passer à cinq tours', (c) => { c.evenement.tours = 5; }, + [{ code: 'TOURS_CHANGES', avant: 4, maintenant: 5 }]], + ['passer à trois tours', (c) => { c.evenement.tours = 3; }, + [{ code: 'TOURS_CHANGES', avant: 4, maintenant: 3 }]], + ["renommer, changer d'appartenance, renuméroter, déplacer et changer de forme, attribuer les sièges", (c) => { + c.participants[0].nom = 'Aubier'; + c.participants[1].appartenance = null; + c.tables[1].numero = 7; + c.tables[2].position = { x: -40, y: 125.5 }; + c.tables[3].forme = 'carree'; + c.reglages.attribuerSieges = true; + }, []], + ]; + for (const [libelle, changer, raisons] of cas) { + const charge = chargeAvecGeneration(); + changer(charge); + for (const proposition of charge.propositions) { + assert.deepStrictEqual(derive(proposition, charge), raisons, libelle); + } + const examen = examiner(charge); + assert.deepStrictEqual(examen.fautives, [], libelle); + assert.equal(examen.charge.propositions.length, 3, libelle); + const attendues = raisons.length === 0 ? [] : [1, 2, 3].map((id) => ({ id, raisons })); + assert.deepStrictEqual(examen.derives, attendues, libelle); + } + }); + + test("les raisons d'un même code se rangent par identifiant, quel que soit l'ordre de la déclaration ou de la charge", () => { + const p = fichierPermute(); + const charge = chargePetite(); + charge.evenement.siegesParDefaut = 4; + charge.tables = charge.tables.filter(({ id }) => id !== 3 && id !== 1); + charge.tables.push(table(6, 6), table(5, 5)); + charge.participants[8].exclu = true; + charge.participants[1].exclu = true; + charge.participants = charge.participants.filter(({ id }) => id !== 11 && id !== 4); + charge.participants.push(personne(14, 'Ortie', 'Ovide'), personne(13, ...TREIZIEME)); + assert.deepStrictEqual(derive(p, charge), [ + { code: 'CAPACITE_CHANGEE', table: 2, avant: 3, maintenant: 4 }, + { code: 'CAPACITE_CHANGEE', table: 4, avant: 3, maintenant: 4 }, + { code: 'PARTICIPANT_EXCLU', participant: 2 }, + { code: 'PARTICIPANT_EXCLU', participant: 9 }, + { code: 'PARTICIPANT_NON_PLACE', participant: 13 }, + { code: 'PARTICIPANT_NON_PLACE', participant: 14 }, + { code: 'PARTICIPANT_SUPPRIME', participant: 4 }, + { code: 'PARTICIPANT_SUPPRIME', participant: 11 }, + { code: 'TABLE_AJOUTEE', table: 5 }, + { code: 'TABLE_AJOUTEE', table: 6 }, + { code: 'TABLE_SUPPRIMEE', table: 1 }, + { code: 'TABLE_SUPPRIMEE', table: 3 }, + ]); + }); + + test("capacités inégales, tables déclarées hors de l'ordre des identifiants : chaque capacité déclarée se compare à celle de sa table, et seule celle qui change est nommée", () => { + const p = fichierInegal(); + const charge = chargePetite(); + charge.tables[3].sieges = 4; + assert.deepStrictEqual(derive(p, charge), []); + charge.tables[3].sieges = 5; + assert.deepStrictEqual(derive(p, charge), [{ code: 'CAPACITE_CHANGEE', table: 4, avant: 4, maintenant: 5 }]); + charge.tables[3].sieges = 4; + charge.evenement.siegesParDefaut = 4; + assert.deepStrictEqual( + derive(p, charge), + [1, 2, 3].map((id) => ({ code: 'CAPACITE_CHANGEE', table: id, avant: 3, maintenant: 4 })), + ); + }); + + test("une personne exclue à la génération puis réintégrée n'est pas placée : PARTICIPANT_NON_PLACE", () => { + const configuration = petite(); + configuration.participants[4].exclu = true; + const [proposition] = rechercher(configuration, { graine: 9, arret: 1_000, nombre: 1 }); + const fichier = versFichier(proposition, configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }); + const charge = chargePetite(); + charge.participants[4].exclu = true; + assert.deepStrictEqual(derive(fichier, charge), []); + charge.participants[4].exclu = false; + assert.deepStrictEqual(derive(fichier, charge), [{ code: 'PARTICIPANT_NON_PLACE', participant: 5 }]); + }); + + test('sans dérive, le plan passe indexerPlan sur la configuration courante ; une table ajoutée est une dérive, que le moteur refuserait', () => { + const charge = chargeAvecGeneration(); + const instance = normaliser(configurationDepuisCharge(charge)); + for (const proposition of charge.propositions) { + assert.deepStrictEqual(derive(proposition, charge), []); + indexerPlan(instance, planDepuisFichier(proposition)); + } + charge.tables.push(table(5, 5)); + const agrandie = normaliser(configurationDepuisCharge(charge)); + for (const proposition of charge.propositions) { + assert.deepStrictEqual(derive(proposition, charge), [{ code: 'TABLE_AJOUTEE', table: 5 }]); + assert.throws( + () => indexerPlan(agrandie, planDepuisFichier(proposition)), + (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'PLAN_TABLE_ABSENTE' && erreur.details.table === 5, + ); + } + }); +}); + +describe("examiner : le partage d'une charge lue (§ 8.9, point 3)", () => { + test("un retenu fautif est signalé et reste dans la charge, sans raison de dérive : la reprise par le .precedent n'est pas de son ressort", () => { + const charge = chargeAvecGeneration(); + const retenu = retenuDe(charge.propositions[0]); + const perdu = retenu.placement[1].sieges[3].pop(); + charge.retenu = retenu; + charge.evenement.etat = 'retenu'; + charge.participants[4].exclu = true; + const lue = relire(charge); + const examen = examiner(lue); + assert.deepStrictEqual(examen.retenu, { fautes: [{ code: 'MANQUANT', tour: 2, participant: perdu }], raisons: [] }); + assert.equal(examen.charge.retenu, lue.retenu); + assert.deepStrictEqual(examen.fautives, []); + assert.deepStrictEqual(examen.charge.propositions, charge.propositions); + const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 5 }]; + assert.deepStrictEqual(examen.derives, [1, 2, 3].map((id) => ({ id, raisons }))); + }); + + test("un retenu de forme quelconque, que l'analyse admet, est fautif sous la forme du retenu", () => { + const sansCle = abimerFichier(chargeAvecGeneration(), (ecrit) => { + ecrit.charge.retenu = { inconnue: [] }; + ecrit.entete.comptes.retenu = 1; + }); + assert.deepStrictEqual(examiner(sansCle).retenu, { fautes: [{ code: 'FORME', chemin: 'proposition' }], raisons: [] }); + const charge = chargeAvecGeneration(); + const sansProposition = retenuDe(charge.propositions[0]); + delete sansProposition.proposition; + charge.retenu = sansProposition; + assert.deepStrictEqual(examiner(charge).retenu, { fautes: [{ code: 'FORME', chemin: 'proposition' }], raisons: [] }); + assert.equal(examiner(charge).charge.retenu, sansProposition); + }); + + test('un retenu cohérent et périmé porte ses raisons, sans faute ; sans retenu, ni faute ni raison', () => { + const charge = chargeAvecGeneration(); + assert.deepStrictEqual(examiner(charge).retenu, { fautes: [], raisons: [] }); + charge.retenu = retenuDe(charge.propositions[2]); + assert.deepStrictEqual(examiner(charge).retenu, { fautes: [], raisons: [] }); + charge.participants[4].exclu = true; + assert.deepStrictEqual(examiner(charge).retenu, { + fautes: [], + raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 5 }], + }); + }); + + test("deux propositions cohérentes de même identifiant : la seconde dans l'ordre de la liste est fautive, IDENTIFIANT_REPETE, et le fichier s'ouvre sans elle ; une fautive n'écarte pas la cohérente qui porte son identifiant", () => { + const charge = chargeAvecGeneration(); + const [p1, p2, p3] = charge.propositions; + const homonyme = { ...structuredClone(p3), id: 2 }; + charge.propositions = [p1, p2, homonyme, p3]; + const examen = examiner(charge); + assert.deepStrictEqual(examen.fautives, [{ rang: 2, id: 2, fautes: [{ code: 'IDENTIFIANT_REPETE' }] }]); + assert.deepStrictEqual(examen.charge.propositions, [p1, p2, p3]); + assert.equal(examen.charge.propositions[1], p2); + // Au travers du fichier, que l'analyse admet avec ses deux propositions 2. + const lue = examiner(relire(charge)); + assert.deepStrictEqual(lue.fautives.map(({ id, fautes }) => [id, fautes]), [[2, [{ code: 'IDENTIFIANT_REPETE' }]]]); + assert.deepStrictEqual(lue.charge.propositions.map(({ id }) => id), [1, 2, 3]); + + const abimee = { ...structuredClone(p1), tours: 5 }; + charge.propositions = [abimee, p1, p2, p3]; + const seconde = examiner(charge); + assert.deepStrictEqual(seconde.fautives, [ + { rang: 0, id: 1, fautes: [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }] }, + ]); + assert.deepStrictEqual(seconde.charge.propositions, [p1, p2, p3]); + // Après la cohérente, une fautive de même identifiant se nomme par sa + // propre faute, et non comme une répétition. + charge.propositions = [p1, abimee]; + assert.deepStrictEqual(examiner(charge).fautives, [ + { rang: 1, id: 1, fautes: [{ code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }] }, + ]); + }); + + test("une proposition cohérente dont l'identifiant atteint prochainsIds.proposition est fautive, IDENTIFIANT_HORS_COMPTEUR, et le fichier s'ouvre sans elle ; un retenu dont la proposition d'origine l'atteint porte cette faute, reste, et garde ses raisons de dérive", () => { + const HORS = { code: 'IDENTIFIANT_HORS_COMPTEUR' }; + // Les propositions 1, 2 et 3 sous le compteur 4 ; le retenu vient de la + // première. Un octet change dans le fichier écrit : chaque fois, + // l'analyse le lit, et examiner n'écarte que la proposition touchée. + const charge = chargeAvecGeneration(); + const [p1, p2, p3] = charge.propositions; + charge.retenu = retenuDe(p1); + charge.evenement.etat = 'retenu'; + const texte = serialiser(charge, ENTETE); + const unOctet = ([avant, apres]) => { + assert.equal(texte.split(avant).length, 2, `« ${avant} » doit figurer une fois`); + return examiner(analyser(texte.replace(avant, apres)).charge); + }; + // L'identifiant de la troisième proposition, puis le compteur, réécrits. + const troisieme = (id) => ['{"id":3,"graine"', `{"id":${id},"graine"`]; + const compteur = (prochain) => ['"proposition":4}', `"proposition":${prochain}}`]; + const cas = [ + ['la troisième au compteur', troisieme(4), [{ rang: 2, id: 4, fautes: [HORS] }], [p1, p2]], + ['la troisième au-delà', troisieme(9), [{ rang: 2, id: 9, fautes: [HORS] }], [p1, p2]], + ['la troisième répète la deuxième', troisieme(2), + [{ rang: 2, id: 2, fautes: [{ code: 'IDENTIFIANT_REPETE' }] }], [p1, p2]], + ['la troisième à 0', troisieme(0), [{ rang: 2, id: null, fautes: [{ code: 'FORME', chemin: 'id' }] }], [p1, p2]], + ['le compteur abaissé à 2', compteur(2), + [{ rang: 1, id: 2, fautes: [HORS] }, { rang: 2, id: 3, fautes: [HORS] }], [p1]], + ]; + for (const [libelle, octet, fautives, gardees] of cas) { + const examen = unOctet(octet); + assert.deepStrictEqual(examen.fautives, fautives, libelle); + assert.deepStrictEqual(examen.charge.propositions, gardees, libelle); + assert.deepStrictEqual(examen.retenu, { fautes: [], raisons: [] }, libelle); + } + // Abaissé à 1, le compteur écarte chaque proposition, et le retenu, dont + // l'origine l'atteint, porte la faute sans être retiré. Son placement se + // tient : sa dérive se nomme encore. + const auPlusBas = unOctet(compteur(1)); + assert.deepStrictEqual( + auPlusBas.fautives.map(({ id, fautes }) => [id, fautes]), + [[1, [HORS]], [2, [HORS]], [3, [HORS]]], + ); + assert.deepStrictEqual(auPlusBas.charge.propositions, []); + assert.deepStrictEqual(auPlusBas.retenu, { fautes: [HORS], raisons: [] }); + assert.deepStrictEqual(auPlusBas.charge.retenu, charge.retenu); + const exclue = structuredClone(charge); + exclue.participants[4].exclu = true; + exclue.prochainsIds.proposition = 1; + assert.deepStrictEqual(examiner(exclue).retenu, { + fautes: [HORS], + raisons: [{ code: 'PARTICIPANT_EXCLU', participant: 5 }], + }); + // Une proposition qui se contredit se nomme par ses propres fautes, + // même quand son identifiant atteint aussi le compteur ; un retenu qui se + // contredit aussi, sans raison de dérive. + const abimee = { ...structuredClone(p3), id: 4, tours: 5 }; + const contradictoire = { ...charge, propositions: [p1, abimee], retenu: { ...retenuDe(abimee), proposition: 4 } }; + const longueur = { code: 'LONGUEUR', tour: null, table: null, declare: 5, ecrit: 4 }; + const examen = examiner(contradictoire); + assert.deepStrictEqual(examen.fautives, [{ rang: 1, id: 4, fautes: [longueur] }]); + 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", () => { + const HORS = 'IDENTIFIANT_HORS_COMPTEUR'; + const MAX = Number.MAX_SAFE_INTEGER; + // 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, + // code], et les fautes du retenu, ce que le cas éprouve, puis le + // compteur attendu. + const base = chargeAvecGeneration(); + base.retenu = retenuDe(base.propositions[2]); + base.evenement.etat = 'retenu'; + const cas = [ + ["le fichier tel qu'écrit", () => {}, [], [], 4], + ['le compteur porté à 9', (c) => { c.prochainsIds.proposition = 9; }, [], [], 9], + ['le compteur abaissé à 2', (c) => { c.prochainsIds.proposition = 2; }, [[2, HORS], [3, HORS]], [HORS], 4], + ['le compteur abaissé à 1', (c) => { c.prochainsIds.proposition = 1; }, + [[1, HORS], [2, HORS], [3, HORS]], [HORS], 4], + ['la troisième à 9', (c) => { c.propositions[2].id = 9; }, [[9, HORS]], [], 10], + ['la troisième à 9, qui se contredit', (c) => { Object.assign(c.propositions[2], { id: 9, tours: 5 }); }, + [[9, 'LONGUEUR']], [], 10], + ['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], + ["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], + ["l'origine du retenu à 9, hors de sa règle", (c) => { + Object.assign(c.retenu, { proposition: 9, toura: c.retenu.tours }); + 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], + ]; + // Le compteur que l'analyse relit dans le fichier de la charge rendue, + // ou le refus qu'elle lève. + const compteurRelu = (charge) => { + try { + return relire(charge).prochainsIds.proposition; + } catch (erreur) { + return `${erreur.name} ${erreur.message}`; + } + }; + const observes = cas.map(([libelle, abimer]) => { + const lue = geler(abimerFichier(base, (ecrit) => abimer(ecrit.charge))); + const examen = examiner(lue); + return [ + libelle, + examen.fautives.map(({ id, fautes: [premiere] }) => [id, premiere.code]), + examen.retenu.fautes.map(({ code }) => code), + examen.charge.prochainsIds, + compteurRelu(examen.charge), + ]; + }); + assert.deepStrictEqual( + observes, + cas.map(([libelle, , ecartees, fautesDuRetenu, compteur]) => [ + libelle, + ecartees, + fautesDuRetenu, + { participant: 13, table: 5, proposition: compteur }, + compteur, + ]), + ); + }); + + 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, + // écarte celles qu'il ne couvre plus. La génération suivante, décalée de + // prochainsIds.proposition − 1 de la charge rendue, prend 4, 5 et 6 ; à + // la réouverture, l'identifiant de l'origine du retenu ne désigne que + // l'origine elle-même, quand elle est gardée, et rien sinon. + const suivante = rechercher(petite(), { graine: 13, arret: 2_000, nombre: 3 }); + const observes = []; + const attendus = []; + for (const abaisse of [1, 2, 3]) { + for (const rang of [0, 1, 2]) { + const charge = chargeAvecGeneration(); + const origine = charge.propositions[rang]; + charge.retenu = retenuDe(origine); + charge.evenement.etat = 'retenu'; + const texte = serialiser(charge, ENTETE); + assert.equal(texte.split('"proposition":4}').length, 2, 'le compteur doit figurer une fois'); + const abime = texte.replace('"proposition":4}', `"proposition":${abaisse}}`); + const ouverte = examiner(analyser(abime).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 configuration = configurationDepuisCharge(ouverte); + const decalage = ouverte.prochainsIds.proposition - 1; + 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)); + const libelle = `compteur abaissé à ${abaisse}, retenu de la proposition ${origine.id}`; + observes.push([ + libelle, + rouverte.charge.propositions.map(({ id }) => id), + rouverte.fautives, + rouverte.retenu, + rouverte.charge.propositions.filter(({ id }) => id === origine.id).map(({ graine }) => graine), + ]); + attendus.push([ + libelle, + [...[1, 2, 3].filter((id) => id < abaisse), 4, 5, 6], + [], + { fautes: [], raisons: [] }, + origine.id < abaisse ? [origine.graine] : [], + ]); + } + } + assert.deepStrictEqual(observes, attendus); + }); + + test("une proposition qui ne se lit pas est écartée seule : rang dans la liste, id null quand son identifiant n'en est pas un ; null, et un retenu égaré parmi les propositions, aussi", () => { + const charge = chargeAvecGeneration(); + const lue = abimerFichier(charge, (ecrit) => { + const [, deuxieme] = ecrit.charge.propositions; + ecrit.charge.propositions.unshift(42, { id: 1, inconnue: true }, { ...deuxieme, id: 'deux' }, null, retenuDe(deuxieme)); + ecrit.entete.comptes.propositions = 8; + }); + const examen = examiner(lue); + // Le retenu égaré passe la forme du retenu : seule la règle d'une + // proposition, que l'examen applique à chaque élément de la liste, + // l'écarte. + assert.deepStrictEqual(fautes(lue.propositions[4]), []); + assert.deepStrictEqual(examen.fautives, [ + { rang: 0, id: null, fautes: [{ code: 'FORME', chemin: '' }] }, + { rang: 1, id: 1, fautes: [{ code: 'FORME', chemin: 'graine' }] }, + { rang: 2, id: null, fautes: [{ code: 'FORME', chemin: 'id' }] }, + { rang: 3, id: null, fautes: [{ code: 'FORME', chemin: '' }] }, + { rang: 4, id: null, fautes: [{ code: 'FORME', chemin: 'id' }] }, + ]); + assert.deepStrictEqual(examen.charge.propositions, charge.propositions); + assert.deepStrictEqual(examen.derives, []); + }); + + test("la charge rendue garde l'ordre de la liste ; les dérives se rangent par identifiant, les fautives par rang", () => { + const charge = chargeAvecGeneration(); + const [p1, p2, p3] = charge.propositions; + const abimee = { ...structuredClone(p2), id: 4, tours: 5 }; + charge.propositions = [p3, abimee, p1, p2]; + charge.participants[4].exclu = true; + const examen = examiner(charge); + const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 5 }]; + assert.deepStrictEqual(examen.charge.propositions, [p3, p1, p2]); + assert.deepStrictEqual(examen.derives, [1, 2, 3].map((id) => ({ id, raisons }))); + assert.deepStrictEqual(examen.fautives.map(({ rang, id }) => [rang, id]), [[1, 4]]); + }); + + test('examiner ne modifie pas la charge reçue et rend une charge neuve, dont le reste est celui de la charge reçue', () => { + const charge = chargeAvecGeneration(); + charge.propositions[0].placement[0].reserve.push(13); + charge.retenu = retenuDe(charge.propositions[1]); + charge.participants[4].exclu = true; + const avant = structuredClone(charge); + geler(charge); + const examen = examiner(charge); + assert.deepStrictEqual(charge, avant); + assert.notEqual(examen.charge, charge); + assert.deepStrictEqual(examen.charge, { ...avant, propositions: avant.propositions.slice(1) }); + assert.equal(examen.charge.participants, charge.participants); + assert.equal(examen.charge.retenu, charge.retenu); + }); +}); + +describe('formeNommee : la lecture et le signalement (§ 8.9, point 4)', () => { + test('le tour 1 du plan parfait : douze lignes, table 1 → 1, 2, 3, avec les noms et prénoms de la charge, siège null sans attribution', () => { + const lignes = formeNommee(fichierParfait(), chargePetite()); + assert.equal(lignes.length, 48); + assert.deepStrictEqual(lignes.filter(({ tour }) => tour === 1), [ + ligne(1, 1, 1, null, 1, 'Aubépine', 'Agathe'), + ligne(1, 1, 1, null, 2, 'Bruyère', 'Basile'), + ligne(1, 1, 1, null, 3, 'Chardon', 'Céleste'), + ligne(1, 2, 2, null, 4, 'Doucette', 'Désiré'), + ligne(1, 2, 2, null, 5, 'Églantine', 'Edmée'), + ligne(1, 2, 2, null, 6, 'Fougère', 'Firmin'), + ligne(1, 3, 3, null, 7, 'Giroflée', 'Gisèle'), + ligne(1, 3, 3, null, 8, 'Houblon', 'Honoré'), + ligne(1, 3, 3, null, 9, 'Isatis', 'Ida'), + ligne(1, 4, 4, null, 10, 'Jonquille', 'Joachim'), + ligne(1, 4, 4, null, 11, 'Lavande', 'Lazare'), + ligne(1, 4, 4, null, 12, 'Mélisse', 'Mathurin'), + ]); + assert.deepStrictEqual( + lignes.map(({ tour, numero, participant }) => [tour, numero, participant]), + PLAN_PARFAIT_PETITE.tours.flatMap((listes, r) => listes.flatMap((ids, i) => ids.map((id) => [r + 1, i + 1, id]))), + ); + }); + + test("sièges attribués : le siège est le rang dans la liste, à partir de 1, dans l'ordre écrit", () => { + const p = { ...fichierParfait(), siegesAttribues: true }; + p.placement[0].sieges[0] = [3, 1, 2]; + const lignes = formeNommee(p, chargePetite()).filter(({ tour, table: id }) => tour === 1 && id === 1); + assert.deepStrictEqual(lignes, [ + ligne(1, 1, 1, 1, 3, 'Chardon', 'Céleste'), + ligne(1, 1, 1, 2, 1, 'Aubépine', 'Agathe'), + ligne(1, 1, 1, 3, 2, 'Bruyère', 'Basile'), + ]); + }); + + test("lignes rangées par tour, numéro de table, rang : numéros égaux départagés par l'identifiant, une table supprimée après les autres sans numéro, la réserve en dernier, une personne supprimée sans nom", () => { + const p = fichierPermute(); + p.participants.push(13); + for (const tour of p.placement) tour.reserve = [13]; + const charge = chargePetite(); + charge.participants.push(personne(13, ...TREIZIEME)); + charge.participants[6].prenom = null; + charge.participants.splice(11, 1); + charge.prochainsIds.participant = 14; + charge.tables = [table(1, 3), table(2, 1), table(3, 1)]; + const lignes = formeNommee(p, charge); + assert.equal(lignes.length, 52); + assert.deepStrictEqual(lignes.filter(({ tour }) => tour === 1), [ + ligne(1, 2, 1, null, 4, 'Doucette', 'Désiré'), + ligne(1, 2, 1, null, 5, 'Églantine', 'Edmée'), + ligne(1, 2, 1, null, 6, 'Fougère', 'Firmin'), + ligne(1, 3, 1, null, 7, 'Giroflée', null), + ligne(1, 3, 1, null, 8, 'Houblon', 'Honoré'), + ligne(1, 3, 1, null, 9, 'Isatis', 'Ida'), + ligne(1, 1, 3, null, 1, 'Aubépine', 'Agathe'), + ligne(1, 1, 3, null, 2, 'Bruyère', 'Basile'), + ligne(1, 1, 3, null, 3, 'Chardon', 'Céleste'), + ligne(1, 4, null, null, 10, 'Jonquille', 'Joachim'), + ligne(1, 4, null, null, 11, 'Lavande', 'Lazare'), + ligne(1, 4, null, null, 12, null, null), + ligne(1, null, null, null, 13, 'Nigelle', 'Noémie'), + ]); + assert.deepStrictEqual( + lignes.map(({ tour }) => tour), + [1, 2, 3, 4].flatMap((tour) => Array(13).fill(tour)), + ); + }); + + test("la réserve suit les tables de son tour, ses personnes dans l'ordre écrit", () => { + const lignes = formeNommee(fichierAReserve(), chargePetite()).filter(({ tour, table: id }) => tour === 1 && id === null); + assert.deepStrictEqual(lignes, [ + ligne(1, null, null, null, 6, 'Fougère', 'Firmin'), + ligne(1, null, null, null, 3, 'Chardon', 'Céleste'), + ]); + }); + + test("planDepuisFichier, derive et formeNommee refusent une proposition fautive par TypeError, au lieu d'en rendre une lecture fausse ; fautes ne lève jamais", () => { + const charge = chargePetite(); + const abimee = fichierParfait(); + abimee.placement[0].sieges[0].pop(); + for (const [nom, appel] of [ + ['planDepuisFichier', () => planDepuisFichier(abimee)], + ['derive', () => derive(abimee, charge)], + ['formeNommee', () => formeNommee(abimee, charge)], + ]) { + leveTypeError(appel, /"MANQUANT"/, nom); + } + for (const illisible of [42, undefined, { ...fichierParfait(), placement: 'x' }]) { + leveTypeError(() => planDepuisFichier(illisible), /"FORME"/, String(illisible)); + leveTypeError(() => derive(illisible, charge), /"FORME"/, String(illisible)); + leveTypeError(() => formeNommee(illisible, charge), /"FORME"/, String(illisible)); + } + assert.deepStrictEqual(fautes(undefined), [{ code: 'FORME', chemin: '' }]); + }); +}); + +// Exécute fonction pendant que l'horloge et les sources d'aléa lèvent à leur +// lecture, puis les rétablit telles qu'elles étaient, propres ou héritées ; +// rend ce que rend fonction. +function sansHorlogeNiAlea(fonction) { + const interdite = (nom) => () => { + throw new Error(`${nom} lu`); + }; + const remplacees = [ + [Math, 'random'], + [Date, 'now'], + [performance, 'now'], + [crypto, 'getRandomValues'], + [crypto, 'randomUUID'], + ].map(([objet, nom]) => ({ objet, nom, propre: Object.hasOwn(objet, nom), valeur: objet[nom] })); + const DateReelle = globalThis.Date; + for (const { objet, nom } of remplacees) objet[nom] = interdite(nom); + function DateInterdite() { + throw new Error('Date lu'); + } + DateInterdite.now = interdite('Date.now'); + globalThis.Date = DateInterdite; + try { + return fonction(); + } finally { + globalThis.Date = DateReelle; + for (const { objet, nom, propre, valeur } of remplacees) { + if (propre) objet[nom] = valeur; + else delete objet[nom]; + } + } +} + +// La valeur refaite à toute profondeur, les clés de chaque objet à rebours. +function aRebours(valeur) { + if (Array.isArray(valeur)) return valeur.map(aRebours); + if (typeof valeur !== 'object' || valeur === null) return valeur; + const copie = {}; + for (const cle of Object.keys(valeur).reverse()) copie[cle] = aRebours(valeur[cle]); + return copie; +} + +describe('déterminisme (§ 8.8, § 14.7)', () => { + // Les résultats de chaque fonction du module sur une charge où se trouvent + // une proposition fautive, une dérive et un retenu, en un seul texte. + function resultats(charge) { + const configuration = petite(); + const [, coherente] = charge.propositions; + const examen = examiner(charge); + return JSON.stringify([ + versFichier(GENERATION[0], configuration, { produitVersion: VERSION.affichee, attribuerSieges: false, decalage: 0 }), + charge.propositions.map((proposition) => fautes(proposition)), + derive(coherente, charge), + formeNommee(coherente, charge), + planDepuisFichier(coherente), + [examen.fautives, examen.derives, examen.retenu, examen.charge.propositions.map(({ id }) => id)], + ]); + } + function chargeMelee() { + const charge = chargeAvecGeneration(); + charge.propositions[0].placement[0].reserve.push(13, 2); + charge.propositions[2].placement[3].sieges[1].pop(); + charge.retenu = retenuDe(charge.propositions[1]); + charge.participants[4].exclu = true; + return charge; + } + + test('ni horloge ni aléa : la sortie de chaque fonction ne dépend que de ses arguments', () => { + // Le filet est éprouvé d'abord : chaque source lève pendant qu'il est tendu. + for (const lecture of [ + () => Math.random(), + () => Date.now(), + () => new Date(), + () => performance.now(), + () => crypto.getRandomValues(new Uint8Array(1)), + () => crypto.randomUUID(), + ]) { + assert.throws(() => sansHorlogeNiAlea(lecture), / lu$/); + } + assert.equal(typeof Date.now(), 'number'); + assert.equal(sansHorlogeNiAlea(() => resultats(chargeMelee())), resultats(chargeMelee())); + }); + + test("l'ordre des clés reçues n'entre dans aucun résultat, ni dans la clé inconnue nommée", () => { + const charge = chargeMelee(); + const inversee = aRebours(charge); + assert.notEqual(JSON.stringify(inversee), JSON.stringify(charge)); + assert.equal(resultats(inversee), resultats(charge)); + const p = fichierParfait(); + const zetaPuisAlpha = { ...p, zeta: 1, alpha: 2 }; + const alphaPuisZeta = { ...p, alpha: 2, zeta: 1 }; + assert.deepStrictEqual(fautes(zetaPuisAlpha), [{ code: 'FORME', chemin: 'alpha' }]); + assert.deepStrictEqual(fautes(alphaPuisZeta), [{ code: 'FORME', chemin: 'alpha' }]); + }); +});