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' }]); + }); +});