From 926a3ff21fd2926aa5c0872667711978f9cbf744 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 5 Oct 2026 17:27:34 -0400 Subject: [PATCH] [ADD] engine: invariant and indicator verifiers, as application code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both verifiers ship with the application and run on every generation and every loaded file: the first judges a plan against the instance, the second the line N − 1 ≥ a priori ≥ realised ≥ met for each person. Neither throws on a content defect — each defect is a named violation — and neither modifies what it receives. Checked: one mutation per violation code yields that violation and no other; frozen inputs stay intact; three mutants that wrote to them fail. --- FR --- [ADD] moteur : vérificateurs d'invariants et d'indicateurs, applicatifs Les deux vérificateurs sont livrés avec l'application et jouent à chaque génération et à chaque fichier chargé : le premier juge un plan contre l'instance, le second la ligne N − 1 ≥ a priori ≥ réalisé ≥ rencontres pour chacun. Aucun ne lève sur un défaut du contenu — chaque défaut est une violation nommée — et aucun ne modifie ce qu'il reçoit. Vérifié : une mutation par code produit cette violation et aucune autre ; les entrées gelées restent intactes ; trois mutants qui y écrivaient échouent. Assisted-by: Claude Opus 5.5 --- src/moteur/verification.js | 262 ++++++++++ src/moteur/verification.test.js | 829 ++++++++++++++++++++++++++++++++ 2 files changed, 1091 insertions(+) create mode 100644 src/moteur/verification.js create mode 100644 src/moteur/verification.test.js diff --git a/src/moteur/verification.js b/src/moteur/verification.js new file mode 100644 index 0000000..5610a3e --- /dev/null +++ b/src/moteur/verification.js @@ -0,0 +1,262 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les deux vérificateurs du moteur (§ 14.12), du code de l'application et non +// du code d'épreuve (§ 14.15) : verifierInvariants juge un plan contre +// l'instance, verifierIndicateurs les mesures et les plafonds de chacun +// (§ 12.3). Aucun des deux ne lève sur un défaut du contenu : chaque défaut +// est une violation de la liste rendue, et la liste vide dit que tout tient. +// Aucun ne modifie ce qu'il reçoit. Les formes reçues sont décrites dans +// types.js. + +/** + * @typedef {{code: string, participant?: number, table?: number, tour?: number, + * detail?: string}} Violation ids, tours numérotés à partir de 1 + * + * @typedef {{code: string, index: number}} ViolationIndicateur + * index : rang de la personne dans l'ordre canonique, celui de + * instance.ids + */ + +// Case de instance.fixe sans table imposée. +const LIBRE = -1; +// Index de table d'une liste que plan.tables ne rattache à aucune table de +// l'instance. Aucune case de instance.fixe ne le porte : une réservation ne +// s'honore jamais dans une telle liste. +const SANS_TABLE = -2; + +// Index de table de chaque position de plan.tables : celui de l'id déclaré +// quand c'est sa première déclaration et une table de l'instance, SANS_TABLE +// sinon. Ajoute à violations, dans l'ordre de plan.tables, un TABLE_INCONNUE +// 'inconnue' à la première déclaration d'un id étranger à l'instance, et un +// 'doublon' à la deuxième déclaration d'un id de l'instance : un id de table +// déclaré plusieurs fois se signale une seule fois, en 'doublon' s'il +// appartient à l'instance, en 'inconnue' sinon. Ajoute ensuite un 'absente' +// par table de l'instance non déclarée, dans l'ordre de l'instance. Les +// ensembles ne servent qu'à retrouver un id déjà vu. +function rattacherListes(tables, { idsTables, indexTableDe }, violations) { + const tableDeListe = []; + const declarees = new Set(); + const doublons = new Set(); + for (const id of tables) { + const t = indexTableDe.get(id); + if (!declarees.has(id)) { + declarees.add(id); + if (t === undefined) violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'inconnue' }); + tableDeListe.push(t ?? SANS_TABLE); + continue; + } + if (t !== undefined && !doublons.has(id)) { + doublons.add(id); + violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'doublon' }); + } + tableDeListe.push(SANS_TABLE); + } + for (const id of idsTables) { + if (!declarees.has(id)) violations.push({ code: 'TABLE_INCONNUE', table: id, detail: 'absente' }); + } + return tableDeListe; +} + +/** + * Violations d'un plan par identifiants (§ 8.9), jugé contre l'instance + * (§ 14.12). Code de l'application : appelé après une génération, après un + * retour arrière, au chargement d'un fichier. Rend la liste des violations, + * vide si le plan tient. + * + * Codes : + * - TOURS : plan.tours ou plan.reserves n'a pas R entrées ; + * - TABLE_INCONNUE : les tables du plan ne reprennent pas celles de + * l'instance. detail précise le cas : 'inconnue', un id de plan.tables + * étranger à l'instance ; 'doublon', un id que plan.tables déclare plus + * d'une fois ; 'absente', une table de l'instance que plan.tables ne + * déclare pas ; ces trois-là portent table. 'listes', un tour qui n'a pas + * une liste par table déclarée, porte tour. L'ordre de plan.tables est + * libre ; + * - INCONNU : un id ni participant ni exclu de l'instance, à une table ou en + * réserve ; + * - EXCLU_PLACE : un participant exclu, à une table ou en réserve ; + * - DOUBLE_PLACE : un participant présent plus d'une fois dans un tour, + * réserve comprise ; + * - NON_ASSIS : un participant absent d'un tour, que la réserve soit permise + * ou non, ou présent seulement en réserve quand reserveAutorisee est faux. + * La réserve est une liste du plan (§ 8.9) : un participant qui ne figure + * ni à une table ni en réserve manque au plan, il n'attend pas ; + * - CAPACITE : une liste plus longue que la capacité de sa table. Le siège + * est la position dans la liste : chaque entrée en occupe un, et le siège + * au-delà de la capacité est hors de sa table ; + * - RESERVATION : un participant que instance.fixe impose à une table à un + * tour, placé ailleurs à ce tour, à une autre table ou en réserve ; table + * nomme la table imposée. La règle ne dépend pas de reserveAutorisee : le + * réservé présent seulement en réserve reçoit RESERVATION que la réserve + * soit permise ou non, précédée de NON_ASSIS quand elle est interdite. Ce + * sont deux règles enfreintes : l'asseoir à une autre table lèverait la + * première et non la seconde. Un participant absent du tour, que le plan + * ne place nulle part, ne relève que de NON_ASSIS. + * + * Une liste se rattache à la table que plan.tables déclare à sa position, + * quand cette déclaration est la première d'un id de l'instance. Une liste + * sans table de l'instance (id étranger, doublon, position au-delà de + * plan.tables) n'entre dans aucune capacité ; ses occupants sont placés à une + * table, donc pas NON_ASSIS, et aucune réservation ne s'y honore. Une table + * déclarée sans liste à un tour est vide à ce tour. Seuls les tours que le + * plan porte en entier, listes et réserve, et dont le numéro ne dépasse pas R + * sont lus : un tour manquant ou incomplet n'est signalé que par TOURS. + * + * Un id étranger à l'instance se signale une fois par tour où il figure, et + * DOUBLE_PLACE ne concerne que les participants. Un id de table déclaré + * plusieurs fois se signale une seule fois : en 'doublon' s'il appartient à + * l'instance, en 'inconnue' sinon. + * + * Les violations se rangent dans l'ordre de lecture : TOURS ; les ids de + * plan.tables dans leur ordre, puis les tables absentes dans l'ordre de + * l'instance ; puis chaque tour lu, par ordre croissant : son nombre de + * listes ; les ids des listes dans l'ordre déclaré puis ceux de la réserve, + * un id étranger à sa première apparition dans le tour et DOUBLE_PLACE à la + * seconde ; les capacités, liste par liste ; enfin les participants par id + * croissant, chacun avec son NON_ASSIS puis sa RESERVATION. + * + * @param {import('./types.js').Instance} instance + * @param {import('./types.js').Plan} plan + * @param {{reserveAutorisee?: boolean}} [options] reserveAutorisee, faux par + * défaut : un participant présent seulement en réserve est NON_ASSIS + * (§ 12.6) + * @returns {Violation[]} + */ +export function verifierInvariants(instance, plan, { reserveAutorisee = false } = {}) { + const { N, R, ids, indexDe, exclus, idsTables, capacite, fixe } = instance; + const violations = []; + if (plan.tours.length !== R || plan.reserves.length !== R) violations.push({ code: 'TOURS' }); + const tableDeListe = rattacherListes(plan.tables, instance, violations); + + // Par participant, au tour lu : ses présences, réserve comprise ; sa + // présence à une table ; sa présence à la table que fixe lui impose. + const presences = new Int32Array(N); + const aUneTable = new Uint8Array(N); + const aSaTable = new Uint8Array(N); + const lus = Math.min(R, plan.tours.length, plan.reserves.length); + for (let r = 0; r < lus; r += 1) { + const tour = r + 1; + const listes = plan.tours[r]; + if (listes.length !== tableDeListe.length) { + violations.push({ code: 'TABLE_INCONNUE', tour, detail: 'listes' }); + } + presences.fill(0); + aUneTable.fill(0); + aSaTable.fill(0); + + // Compte une présence de id à ce tour et rend son index ; −1 pour un id + // étranger à l'instance, signalé à sa première apparition dans le tour. + const etrangers = new Set(); + const compter = (id) => { + const p = indexDe.get(id); + if (p === undefined) { + if (!etrangers.has(id)) { + etrangers.add(id); + violations.push({ code: exclus.has(id) ? 'EXCLU_PLACE' : 'INCONNU', participant: id, tour }); + } + return -1; + } + presences[p] += 1; + if (presences[p] === 2) violations.push({ code: 'DOUBLE_PLACE', participant: id, tour }); + return p; + }; + for (let i = 0; i < listes.length; i += 1) { + const t = i < tableDeListe.length ? tableDeListe[i] : SANS_TABLE; + for (const id of listes[i]) { + const p = compter(id); + if (p < 0) continue; + aUneTable[p] = 1; + if (fixe[p * R + r] === t) aSaTable[p] = 1; + } + } + for (const id of plan.reserves[r]) compter(id); + + for (let i = 0; i < listes.length && i < tableDeListe.length; i += 1) { + const t = tableDeListe[i]; + if (t !== SANS_TABLE && listes[i].length > capacite[t]) { + violations.push({ code: 'CAPACITE', table: idsTables[t], tour }); + } + } + + for (let p = 0; p < N; p += 1) { + if (!aUneTable[p] && (presences[p] === 0 || !reserveAutorisee)) { + violations.push({ code: 'NON_ASSIS', participant: ids[p], tour }); + } + const imposee = fixe[p * R + r]; + if (imposee !== LIBRE && presences[p] > 0 && !aSaTable[p]) { + violations.push({ code: 'RESERVATION', participant: ids[p], table: idsTables[imposee], tour }); + } + } + } + return violations; +} + +// Vrai quand gauche ≥ droite et que les deux sont des nombres : NaN, ou une +// valeur d'un autre type, fait tomber l'inégalité au lieu d'y être convertie. +const tient = (gauche, droite) => + typeof gauche === 'number' && typeof droite === 'number' && gauche >= droite; + +// Lève TypeError quand valeurs n'a pas de longueur, n'étant pas une liste, +// et RangeError quand elle n'a pas N cases. attendu nomme ce que le paramètre +// nom admet. +function exigerLongueur(valeurs, N, nom, attendu) { + if (typeof valeurs?.length !== 'number') throw new TypeError(`${nom} : ${attendu} attendue`); + if (valeurs.length !== N) { + throw new RangeError(`${nom} : ${valeurs.length} cases, ${N} attendues comme mesures.rencontres`); + } +} + +/** + * Garde du § 12.3 : éprouve, pour chacun, la ligne de décomposition du + * § 12.10.5, N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres, une + * inégalité entre deux termes voisins à la fois ; ensemble, elles ordonnent + * toute la ligne. Une violation désigne un calcul faux, sur lequel le profil + * ne se dessine pas. + * + * Les trois listes suivent l'ordre canonique, celui de instance.ids, et N est + * la longueur de mesures.rencontres. plafondsAPriori vaut null quand le + * plafond a priori est inconnu (§ 12.6) : la ligne perd ce terme, et seule la + * chaîne N − 1 ≥ réalisé ≥ rencontres est éprouvée. Une inégalité ne tient + * qu'entre deux nombres : NaN, ou une valeur d'un autre type, la fait tomber + * au lieu d'y être convertie. + * + * Codes : PLAFOND_A_PRIORI (a priori > N − 1), ORDRE_PLAFONDS (a priori < + * réalisé), PLAFOND_REALISE (réalisé > N − 1, a priori inconnu), DEPASSEMENT + * (rencontres > réalisé). La fonction ne reçoit pas les identifiants : une + * violation désigne la personne par index, son rang dans l'ordre canonique, + * que instance.ids[index] traduit. Les violations se rangent par index + * croissant, puis de gauche à droite sur la ligne. + * + * Lève RangeError quand une liste de plafonds n'a pas N cases : elle décrit + * une autre population, et l'écart est une faute de code. Lève TypeError + * quand plafondsRealises n'est pas une liste, ou plafondsAPriori ni une liste + * ni null : seul null dit l'a priori inconnu. undefined, la valeur d'un champ + * manquant, ne passe pas pour lui : il ôterait deux inégalités à la garde + * sans rien signaler. + * + * @param {{rencontres: ArrayLike}} mesures les mesures d'un plan ; + * seule la liste rencontres est lue + * @param {ArrayLike|null} plafondsAPriori + * @param {ArrayLike} plafondsRealises + * @returns {ViolationIndicateur[]} + */ +export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises) { + const { rencontres } = mesures; + const N = rencontres.length; + exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste'); + if (plafondsAPriori !== null) exigerLongueur(plafondsAPriori, N, 'plafondsAPriori', 'liste ou null'); + const violations = []; + for (let p = 0; p < N; p += 1) { + const realise = plafondsRealises[p]; + if (plafondsAPriori === null) { + if (!tient(N - 1, realise)) violations.push({ code: 'PLAFOND_REALISE', index: p }); + } else { + const aPriori = plafondsAPriori[p]; + if (!tient(N - 1, aPriori)) violations.push({ code: 'PLAFOND_A_PRIORI', index: p }); + if (!tient(aPriori, realise)) violations.push({ code: 'ORDRE_PLAFONDS', index: p }); + } + if (!tient(realise, rencontres[p])) violations.push({ code: 'DEPASSEMENT', index: p }); + } + return violations; +} diff --git a/src/moteur/verification.test.js b/src/moteur/verification.test.js new file mode 100644 index 0000000..465ae3e --- /dev/null +++ b/src/moteur/verification.test.js @@ -0,0 +1,829 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des deux vérificateurs du moteur (§ 14.12), par mutation : chaque +// épreuve part d'un état qui tient, et chaque mutation y produit la violation +// annoncée et aucune autre. Un vérificateur qui rend toujours la liste vide +// échoue à chacune. +// +// verifierInvariants part du plan parfait de la petite démonstration +// (§ 15.3), recopié ci-dessous. La configuration ajoute à la démonstration ce +// que deux codes demandent : un participant exclu, 13, dont la réservation +// est suspendue (§ 4.4), et une réservation de tour désigné que le plan +// honore, 6 à la table de numéro 1 au tour 2. Les épreuves désignent +// participants et tables par leur numéro ; participant() et table() en +// donnent l'identifiant. Les participants de numéro 1 à 13 portent les +// identifiants 107 à 191, de 7 en 7 : ni contigus, ni égaux à leur index plus +// un. Les tables de numéro 1 à 4 portent les identifiants 21 à 24, que +// l'instance range dans l'ordre 23, 21, 24, 22 et le plan dans l'ordre des +// numéros. Confondre identifiant, numéro, index et position, d'un participant +// comme d'une table, fait échouer une épreuve au lieu de passer inaperçu. +// +// verifierIndicateurs part des chiffres de ce même plan : N = 12, et 8 pour +// chacun en plafond a priori, en plafond réalisé et en rencontres (§ 15.3). +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { indexerPlan, normaliser } from './configuration.js'; +import { ErreurConfiguration } from './erreurs.js'; +import { verifierIndicateurs, verifierInvariants } from './verification.js'; + +const SANS_CONTRAINTE = { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, +}; + +// Identifiant du participant de numéro donné. +const participant = (numero) => 100 + 7 * numero; +// Identifiants des participants de numéros donnés, dans l'ordre donné. +const participants = (...numeros) => numeros.map(participant); +// Identifiant de la table de numéro donné. +const table = (numero) => 20 + numero; +// Identifiant qui n'est celui d'aucun participant, exclu compris, ni d'aucune +// table. +const ETRANGER = 99; + +// Numéros des membres de chaque appartenance de la petite démonstration +// (§ 15.3). +const MEMBRES = [ + ['A', [1, 5, 8, 12]], + ['B', [2, 4, 9, 11]], + ['C', [3, 6, 7, 10]], +]; +const appartenanceDe = (numero) => MEMBRES.find(([, numeros]) => numeros.includes(numero))[0]; + +const RESERVATION_HONOREE = { participant: participant(6), table: table(1), portee: 'tour', tour: 2 }; +const RESERVATION_SUSPENDUE = { participant: participant(13), table: table(2), portee: 'tous' }; + +function configuration(reservations = [RESERVATION_HONOREE, RESERVATION_SUSPENDUE]) { + const membres = Array.from({ length: 12 }, (_, i) => ({ + id: participant(i + 1), + nom: `P${i + 1}`, + appartenance: appartenanceDe(i + 1), + })); + return { + participants: [...membres, { id: participant(13), nom: 'P13', appartenance: null, exclu: true }], + tables: [3, 1, 4, 2].map((numero) => ({ id: table(numero), numero, capacite: 3 })), + tours: 4, + reservations, + contraintes: SANS_CONTRAINTE, + }; +} + +// Le plan parfait de la petite démonstration (§ 15.3), écrit en numéros : à +// chaque tour, les tables de numéro 1 à 4, chacune réunissant un membre de +// chaque appartenance ; personne en réserve. Gelé, comme chaque plan que les +// épreuves passent à verifierInvariants : une écriture du vérificateur y lève +// TypeError, quelle que soit la branche qui la fait. +const PLAN_PARFAIT = gelerPlan({ + tables: [table(1), table(2), table(3), table(4)], + tours: [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], + [[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]], + [[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]], + [[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]], + ].map((listes) => listes.map((numeros) => participants(...numeros))), + reserves: [[], [], [], []], +}); + +const INSTANCE = normaliser(configuration()); + +// Copie du plan parfait, mutée par modifier puis gelée. +function planMute(modifier) { + const plan = structuredClone(PLAN_PARFAIT); + modifier(plan); + return gelerPlan(plan); +} + +// Violations du plan parfait, une fois que modifier a muté une copie. +function apresMutation(modifier, options) { + return verifierInvariants(INSTANCE, planMute(modifier), options); +} + +// Retire id de la liste de table qui le porte. +function retirer(listes, id) { + for (const liste of listes) { + if (liste.includes(id)) liste.splice(liste.indexOf(id), 1); + } +} + +// Chaque cas [libellé, calcul, attendu] dont le résultat diffère de +// l'attendu, en une ligne lisible ; une exception est un écart. +function ecarts(cas) { + assert.ok(cas.length > 0, 'aucun cas examiné'); + return cas.flatMap(([libelle, calcul, attendu]) => { + let obtenu; + try { + obtenu = calcul(); + } catch (erreur) { + return [`${libelle} : ${erreur?.name} ${erreur?.message}`]; + } + try { + assert.deepEqual(obtenu, attendu); + return []; + } catch { + return [`${libelle} : ${JSON.stringify(obtenu)}`]; + } + }); +} + +// Gèle un plan et chacune de ses listes, puis le rend : une écriture faite +// depuis un module ES, toujours en mode strict, y lève TypeError. +function gelerPlan(plan) { + const geler = (valeur) => { + if (Array.isArray(valeur)) valeur.forEach(geler); + return Object.freeze(valeur); + }; + geler(plan.tables); + geler(plan.tours); + geler(plan.reserves); + return Object.freeze(plan); +} + +const CODES = [ + 'TOURS', + 'TABLE_INCONNUE', + 'INCONNU', + 'EXCLU_PLACE', + 'DOUBLE_PLACE', + 'NON_ASSIS', + 'CAPACITE', + 'RESERVATION', +]; + +// [libellé, mutation du plan parfait, options, violations attendues]. Chaque +// code a au moins une mutation qui le produit seul. +const MUTATIONS = [ + ['un tour de moins', (plan) => { + plan.tours.pop(); + plan.reserves.pop(); + }, undefined, [{ code: 'TOURS' }]], + ['un tour de trop', (plan) => { + plan.tours.push(structuredClone(plan.tours[0])); + plan.reserves.push([]); + }, undefined, [{ code: 'TOURS' }]], + ['une réserve de moins', (plan) => { + plan.reserves.pop(); + }, undefined, [{ code: 'TOURS' }]], + + ['une table inconnue, vide à chaque tour', (plan) => { + plan.tables.push(ETRANGER); + plan.tours.forEach((listes) => listes.push([])); + }, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]], + ['une table inconnue déclarée deux fois, vide à chaque tour', (plan) => { + plan.tables.push(ETRANGER, ETRANGER); + plan.tours.forEach((listes) => listes.push([], [])); + }, undefined, [{ code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }]], + ['une table déclarée deux fois, vide à sa seconde position', (plan) => { + plan.tables.push(table(3)); + plan.tours.forEach((listes) => listes.push([])); + }, undefined, [{ code: 'TABLE_INCONNUE', table: table(3), detail: 'doublon' }]], + // La table de numéro 1 déclarée une seconde fois. Au tour 1, sa seconde + // liste reçoit ses trois convives et 4 : quatre entrées, qu'aucune capacité + // ne borne. Au tour 2, elle reçoit 6, réservé à la table de numéro 1 : la + // réservation ne s'y honore pas. + ['une table déclarée deux fois, sa seconde liste peuplée', (plan) => { + plan.tables.push(table(1)); + plan.tours.forEach((listes) => listes.push([])); + retirer(plan.tours[0], participant(4)); + plan.tours[0][4] = [...plan.tours[0][0], participant(4)]; + plan.tours[0][0] = []; + retirer(plan.tours[1], participant(6)); + plan.tours[1][4].push(participant(6)); + }, undefined, [ + { code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' }, + { code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }, + ]], + ['une table déclarée à la place d’une autre', (plan) => { + plan.tables[1] = table(1); + }, undefined, [ + { code: 'TABLE_INCONNUE', table: table(1), detail: 'doublon' }, + { code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' }, + ]], + ['une table non déclarée, ses occupants en réserve permise', (plan) => { + plan.tables.pop(); + plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.pop())); + }, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', table: table(4), detail: 'absente' }]], + ['une liste de trop au tour 3', (plan) => { + plan.tours[2].push([]); + }, undefined, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]], + ['une liste de moins au tour 3, ses occupants en réserve permise', (plan) => { + plan.reserves[2].push(...plan.tours[2].pop()); + }, { reserveAutorisee: true }, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]], + // Le nombre de listes d'un tour se lit avant ses présences, dans l'ordre où + // indexerPlan refuse : l'épreuve croisée compare les deux sur ce plan. + ['une liste de trop au tour 2, un inconnu en réserve au même tour', (plan) => { + plan.tours[1].push([]); + plan.reserves[1].push(ETRANGER); + }, undefined, [ + { code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' }, + { code: 'INCONNU', participant: ETRANGER, tour: 2 }, + ]], + + ['un inconnu en réserve', (plan) => { + plan.reserves[1].push(ETRANGER); + }, undefined, [{ code: 'INCONNU', participant: ETRANGER, tour: 2 }]], + + ['l’exclu en réserve', (plan) => { + plan.reserves[0].push(participant(13)); + }, undefined, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 1 }]], + ['l’exclu à la place de 11, passé en réserve permise', (plan) => { + plan.tours[3][0] = participants(5, 7, 13); + plan.reserves[3].push(participant(11)); + }, { reserveAutorisee: true }, [{ code: 'EXCLU_PLACE', participant: participant(13), tour: 4 }]], + + // 6, assis à la table de numéro 1 au tour 2, occupe aussi la place de 11 à + // la table de numéro 2 ; 11 attend en réserve. + ['à deux tables au tour 2', (plan) => { + plan.tours[1][1] = participants(3, 6, 8); + plan.reserves[1].push(participant(11)); + }, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]], + ['deux sièges à la même table', (plan) => { + plan.tours[1][0] = participants(6, 6, 12); + plan.reserves[1].push(participant(9)); + }, { reserveAutorisee: true }, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]], + ['assis et en réserve', (plan) => { + plan.reserves[1].push(participant(6)); + }, undefined, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]], + + ['absent d’un tour', (plan) => { + retirer(plan.tours[2], participant(7)); + }, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]], + ['absent d’un tour, la réserve permise', (plan) => { + retirer(plan.tours[2], participant(7)); + }, { reserveAutorisee: true }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]], + ['en réserve, la réserve interdite par défaut', (plan) => { + retirer(plan.tours[2], participant(7)); + plan.reserves[2].push(participant(7)); + }, undefined, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]], + ['en réserve, la réserve interdite nommément', (plan) => { + retirer(plan.tours[2], participant(7)); + plan.reserves[2].push(participant(7)); + }, { reserveAutorisee: false }, [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]], + + ['quatre à la table de numéro 2, de trois places', (plan) => { + retirer(plan.tours[3], participant(11)); + plan.tours[3][1].push(participant(11)); + }, undefined, [{ code: 'CAPACITE', table: table(2), tour: 4 }]], + + // 6, réservé à la table de numéro 1 au tour 2, échange sa place avec 3, + // assis à la table de numéro 2. + ['le réservé échangé avec un convive d’une autre table', (plan) => { + plan.tours[1][0] = participants(3, 9, 12); + plan.tours[1][1] = participants(6, 8, 11); + }, undefined, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]], + ['le réservé en réserve permise', (plan) => { + retirer(plan.tours[1], participant(6)); + plan.reserves[1].push(participant(6)); + }, { reserveAutorisee: true }, [{ code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }]], + // La même réserve, interdite : 6 n'est pas assis, et sa réservation n'est + // pas honorée. Deux règles enfreintes : NON_ASSIS, puis RESERVATION. + ['le réservé en réserve, la réserve interdite', (plan) => { + retirer(plan.tours[1], participant(6)); + plan.reserves[1].push(participant(6)); + }, undefined, [ + { code: 'NON_ASSIS', participant: participant(6), tour: 2 }, + { code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }, + ]], +]; + +describe('verifierInvariants : le plan parfait de la petite démonstration (§ 15.3)', () => { + test('ne viole rien, la réserve interdite ou permise', () => { + assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT), []); + assert.deepEqual(verifierInvariants(INSTANCE, PLAN_PARFAIT, { reserveAutorisee: true }), []); + }); + + test('ne viole rien, ses tables déclarées dans un autre ordre et ses listes dans le désordre', () => { + // L'ordre de l'instance, 23, 21, 24, 22 : chaque liste suit sa table. + const ordre = [2, 0, 3, 1]; + const permute = gelerPlan({ + tables: ordre.map((i) => PLAN_PARFAIT.tables[i]), + tours: PLAN_PARFAIT.tours.map((listes) => ordre.map((i) => [...listes[i]].reverse())), + reserves: [[], [], [], []], + }); + assert.deepEqual(verifierInvariants(INSTANCE, permute), []); + }); +}); + +describe('verifierInvariants : chaque mutation, sa violation et aucune autre (§ 14.12)', () => { + test('chaque code a sa mutation, et chaque mutation produit exactement ses violations', () => { + const seuls = new Set(MUTATIONS + .filter(([, , , attendu]) => attendu.length === 1) + .map(([, , , [{ code }]]) => code)); + assert.deepEqual(CODES.filter((code) => !seuls.has(code)), [], 'code sans mutation qui le produise seul'); + const cas = MUTATIONS.map(([libelle, modifier, options, attendu]) => [ + libelle, + () => apresMutation(modifier, options), + attendu, + ]); + assert.deepEqual(ecarts(cas), []); + }); + + test('NON_ASSIS : chaque participant retiré de chaque tour, et lui seul, la réserve interdite ou permise', () => { + // La réserve est une liste du plan (§ 8.9) : un participant qui n'y + // figure pas plus qu'à une table n'attend pas, il manque. + const cas = []; + for (const reserveAutorisee of [false, true]) { + for (let tour = 1; tour <= 4; tour += 1) { + for (let numero = 1; numero <= 12; numero += 1) { + const id = participant(numero); + cas.push([ + `${numero} retiré du tour ${tour}, réserve ${reserveAutorisee ? 'permise' : 'interdite'}`, + () => apresMutation((plan) => retirer(plan.tours[tour - 1], id), { reserveAutorisee }), + [{ code: 'NON_ASSIS', participant: id, tour }], + ]); + } + } + } + assert.equal(cas.length, 96); + assert.deepEqual(ecarts(cas), []); + }); + + test('DOUBLE_PLACE : chaque participant ajouté à la réserve de chaque tour, et lui seul', () => { + const cas = []; + for (let tour = 1; tour <= 4; tour += 1) { + for (let numero = 1; numero <= 12; numero += 1) { + const id = participant(numero); + cas.push([ + `${numero} en réserve au tour ${tour}`, + () => apresMutation((plan) => plan.reserves[tour - 1].push(id)), + [{ code: 'DOUBLE_PLACE', participant: id, tour }], + ]); + } + } + assert.equal(cas.length, 48); + assert.deepEqual(ecarts(cas), []); + }); + + test('CAPACITE : chaque table de chaque tour, nommée par son identifiant', () => { + // Le dernier convive de la table suivante rejoint la table examinée. Le + // dernier n'est jamais 6 au tour 2 : la réservation reste honorée. + const cas = []; + for (let tour = 1; tour <= 4; tour += 1) { + for (let i = 0; i < 4; i += 1) { + cas.push([ + `tour ${tour}, table de numéro ${i + 1}`, + () => apresMutation((plan) => { + const listes = plan.tours[tour - 1]; + listes[i].push(listes[(i + 1) % 4].pop()); + }), + [{ code: 'CAPACITE', table: table(i + 1), tour }], + ]); + } + } + assert.equal(cas.length, 16); + assert.deepEqual(ecarts(cas), []); + }); + + test('RESERVATION : chaque participant, réservé à chaque tour, à sa table puis à la suivante', () => { + // Une seule réservation de tour désigné par configuration : à la table + // où le plan parfait assoit la personne, elle est honorée ; à la table + // de numéro suivant, elle ne l'est pas. + const cas = []; + for (let tour = 1; tour <= 4; tour += 1) { + for (let numero = 1; numero <= 12; numero += 1) { + const id = participant(numero); + const sienne = PLAN_PARFAIT.tours[tour - 1].findIndex((liste) => liste.includes(id)) + 1; + const ailleurs = (sienne % 4) + 1; + const reserve = (numeroTable) => normaliser(configuration([ + { participant: id, table: table(numeroTable), portee: 'tour', tour }, + ])); + cas.push([ + `${numero} réservé à sa table au tour ${tour}`, + () => verifierInvariants(reserve(sienne), PLAN_PARFAIT), + [], + ]); + cas.push([ + `${numero} réservé à une autre table au tour ${tour}`, + () => verifierInvariants(reserve(ailleurs), PLAN_PARFAIT), + [{ code: 'RESERVATION', participant: id, table: table(ailleurs), tour }], + ]); + } + } + assert.equal(cas.length, 96); + assert.deepEqual(ecarts(cas), []); + }); +}); + +describe('verifierInvariants : ce que chaque code couvre', () => { + test('une personne en réserve n’est signalée que si la réserve est interdite, une personne absente l’est toujours (point d’attention 1)', () => { + const enReserve = (plan) => { + retirer(plan.tours[2], participant(7)); + plan.reserves[2].push(participant(7)); + }; + const absente = (plan) => retirer(plan.tours[2], participant(7)); + const nonAssis = [{ code: 'NON_ASSIS', participant: participant(7), tour: 3 }]; + assert.deepEqual(apresMutation(enReserve), nonAssis); + assert.deepEqual(apresMutation(enReserve, { reserveAutorisee: true }), []); + assert.deepEqual(apresMutation(absente), nonAssis); + assert.deepEqual(apresMutation(absente, { reserveAutorisee: true }), nonAssis); + }); + + test('une réservation « tous les tours » se juge à chacun des tours', () => { + // 1 est réservé à la table de numéro 1 à chaque tour ; le plan parfait + // l'y assoit au tour 1, puis aux tables de numéro 4, 3 et 2. + const instance = normaliser(configuration([ + RESERVATION_HONOREE, + { participant: participant(1), table: table(1), portee: 'tous' }, + ])); + assert.deepEqual(verifierInvariants(instance, PLAN_PARFAIT), [2, 3, 4].map((tour) => ( + { code: 'RESERVATION', participant: participant(1), table: table(1), tour }))); + }); + + test('CAPACITE se juge contre la capacité de la table que la liste désigne', () => { + // Deux tables de capacités différentes, que le plan déclare dans l'ordre + // inverse de l'instance. + const instance = normaliser({ + participants: [1, 2, 3, 4, 5, 6].map((id) => ({ id, nom: `P${id}`, appartenance: null })), + tables: [{ id: 7, numero: 1, capacite: 2 }, { id: 5, numero: 2, capacite: 4 }], + tours: 1, + reservations: [], + contraintes: SANS_CONTRAINTE, + }); + const plan = (listes) => gelerPlan({ tables: [5, 7], tours: [listes], reserves: [[]] }); + assert.deepEqual(verifierInvariants(instance, plan([[1, 2, 3, 4], [5, 6]])), []); + assert.deepEqual( + verifierInvariants(instance, plan([[1, 2, 3], [4, 5, 6]])), + [{ code: 'CAPACITE', table: 7, tour: 1 }], + ); + }); + + test('une table inconnue : ses occupants sont placés, hors capacité, sans réservation honorée', () => { + // La table de numéro 1 déclarée sous un identifiant étranger : il est + // inconnu, 21 absente. Ses occupants restent placés ; au tour 1, elle en + // reçoit quatre ; au tour 2, 6 y est assis alors que sa réservation le + // fixe à la table 21. + const obtenu = apresMutation((plan) => { + plan.tables[0] = ETRANGER; + retirer(plan.tours[0], participant(4)); + plan.tours[0][0].push(participant(4)); + }); + assert.deepEqual(obtenu, [ + { code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }, + { code: 'TABLE_INCONNUE', table: table(1), detail: 'absente' }, + { code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }, + ]); + }); + + test('les occupants d’une liste au-delà des tables déclarées sont placés', () => { + const obtenu = apresMutation((plan) => { + retirer(plan.tours[2], participant(7)); + plan.tours[2].push([participant(7)]); + }); + assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }]); + }); + + test('CAPACITE compte chaque entrée de la liste, étrangère ou répétée', () => { + // Le siège est la position dans la liste : un id étranger ou répété + // occupe un siège comme un autre. + const obtenu = apresMutation((plan) => { + plan.tours[0][0].push(ETRANGER); + plan.tours[1][0].push(participant(12)); + }); + assert.deepEqual(obtenu, [ + { code: 'INCONNU', participant: ETRANGER, tour: 1 }, + { code: 'CAPACITE', table: table(1), tour: 1 }, + { code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 }, + { code: 'CAPACITE', table: table(1), tour: 2 }, + ]); + }); + + test('un participant présent trois fois dans un tour se signale une fois', () => { + // 6 occupe aussi la place de 11 à la table de numéro 2 et figure en + // réserve ; 11 attend en réserve. + const obtenu = apresMutation((plan) => { + plan.tours[1][1] = participants(3, 6, 8); + plan.reserves[1].push(participant(6), participant(11)); + }, { reserveAutorisee: true }); + assert.deepEqual(obtenu, [{ code: 'DOUBLE_PLACE', participant: participant(6), tour: 2 }]); + }); + + test('un identifiant étranger à l’instance se signale une fois par tour où il figure', () => { + const obtenu = apresMutation((plan) => { + plan.reserves[0].push(ETRANGER, participant(13), ETRANGER); + plan.tours[0][0].push(participant(13)); + retirer(plan.tours[0], participant(3)); + plan.reserves[0].push(participant(3)); + plan.reserves[2].push(ETRANGER); + }, { reserveAutorisee: true }); + assert.deepEqual(obtenu, [ + { code: 'EXCLU_PLACE', participant: participant(13), tour: 1 }, + { code: 'INCONNU', participant: ETRANGER, tour: 1 }, + { code: 'INCONNU', participant: ETRANGER, tour: 3 }, + ]); + }); + + test('une table déclarée trois fois se signale une fois', () => { + const obtenu = apresMutation((plan) => { + plan.tables.push(table(2), table(2)); + plan.tours.forEach((listes) => listes.push([], [])); + }); + assert.deepEqual(obtenu, [{ code: 'TABLE_INCONNUE', table: table(2), detail: 'doublon' }]); + }); +}); + +describe('verifierInvariants : la liste rendue', () => { + test('les violations se rangent dans l’ordre de lecture du plan', () => { + // Un tour de moins : seuls les tours 1 à 3 sont lus. Une table inconnue, + // vide. Au tour 2 : une liste de trop, 6 échangé avec 3, 5 retiré, 2 + // passé de la table de numéro 3 à la table de numéro 4, et en réserve un + // étranger, l'exclu et 12. Au tour 3, une liste de trop encore. Le nombre + // de listes ouvre les violations de son tour : celle du tour 2 précède ses + // présences, celle du tour 3 suit tout le tour 2. + const obtenu = apresMutation((plan) => { + plan.tours.pop(); + plan.reserves.pop(); + plan.tables.push(ETRANGER); + plan.tours.forEach((listes) => listes.push([])); + plan.tours[1][0] = participants(3, 9, 12); + plan.tours[1][1] = participants(6, 8, 11); + plan.tours[1][2] = participants(10); + plan.tours[1][3] = participants(1, 2, 4, 7); + plan.reserves[1].push(ETRANGER, participant(13), participant(12)); + plan.tours[1].push([]); + plan.tours[2].push([]); + }); + assert.deepEqual(obtenu, [ + { code: 'TOURS' }, + { code: 'TABLE_INCONNUE', table: ETRANGER, detail: 'inconnue' }, + { code: 'TABLE_INCONNUE', tour: 2, detail: 'listes' }, + { code: 'INCONNU', participant: ETRANGER, tour: 2 }, + { code: 'EXCLU_PLACE', participant: participant(13), tour: 2 }, + { code: 'DOUBLE_PLACE', participant: participant(12), tour: 2 }, + { code: 'CAPACITE', table: table(4), tour: 2 }, + { code: 'NON_ASSIS', participant: participant(5), tour: 2 }, + { code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }, + { code: 'TABLE_INCONNUE', tour: 3, detail: 'listes' }, + ]); + }); + + test('les tables absentes se rangent dans l’ordre de l’instance', () => { + // Les tables de numéro 2 et 3 retirées du plan, leurs occupants en + // réserve permise. L'instance range 23 avant 22, à l'inverse du plan et + // des numéros. + const obtenu = apresMutation((plan) => { + plan.tables.splice(1, 2); + plan.tours.forEach((listes, r) => plan.reserves[r].push(...listes.splice(1, 2).flat())); + }, { reserveAutorisee: true }); + assert.deepEqual(obtenu, [ + { code: 'TABLE_INCONNUE', table: table(3), detail: 'absente' }, + { code: 'TABLE_INCONNUE', table: table(2), detail: 'absente' }, + ]); + }); + + test('NON_ASSIS et RESERVATION se rangent participant par participant', () => { + // Au tour 2, 6 échangé avec 3, et 7 retiré : la violation de 6 précède + // celle de 7, quel que soit son code. + const obtenu = apresMutation((plan) => { + plan.tours[1][0] = participants(3, 9, 12); + plan.tours[1][1] = participants(6, 8, 11); + retirer(plan.tours[1], participant(7)); + }); + assert.deepEqual(obtenu, [ + { code: 'RESERVATION', participant: participant(6), table: table(1), tour: 2 }, + { code: 'NON_ASSIS', participant: participant(7), tour: 2 }, + ]); + }); + + test('ne modifie ni le plan ni l’instance, sous chaque mutation', () => { + // planMute rend un plan gelé. L'instance est bâtie pour chaque cas et + // copiée avant tout appel : la copie d'une instance déjà passée au + // vérificateur porterait ce qu'il y aurait écrit, et la comparaison ne le + // verrait pas. + const cas = MUTATIONS.map(([libelle, modifier, options]) => [libelle, () => { + const instance = normaliser(configuration()); + const copie = structuredClone(instance); + verifierInvariants(instance, planMute(modifier), options); + assert.deepEqual(instance, copie, 'instance modifiée'); + return 'intacte'; + }, 'intacte']); + assert.deepEqual(ecarts(cas), []); + }); +}); + +describe('verifierInvariants et indexerPlan : une même lecture du plan (§ 8.9)', () => { + // Code sous lequel indexerPlan refuse chaque violation de forme, désignée + // par son code, suivi de son detail quand elle en porte un. CAPACITE et + // RESERVATION n'en ont pas : indexerPlan n'examine ni les capacités ni les + // réservations. + const cle = ({ code, detail }) => (detail === undefined ? code : `${code} ${detail}`); + const REFUS = new Map([ + ['TOURS', 'PLAN_TOURS'], + ['TABLE_INCONNUE inconnue', 'PLAN_TABLE_INCONNUE'], + ['TABLE_INCONNUE doublon', 'PLAN_TABLE_DOUBLON'], + ['TABLE_INCONNUE absente', 'PLAN_TABLE_ABSENTE'], + ['TABLE_INCONNUE listes', 'PLAN_LISTES'], + ['INCONNU', 'PLAN_INCONNU'], + ['EXCLU_PLACE', 'PLAN_EXCLU_PLACE'], + ['DOUBLE_PLACE', 'PLAN_DOUBLE_PLACE'], + ['NON_ASSIS', 'PLAN_NON_ASSIS'], + ]); + const SANS_REFUS = new Set(['CAPACITE', 'RESERVATION']); + + // Ce qu'indexerPlan fait du plan selon verifierInvariants, la réserve + // permise comme la forme indexée la porte : refuser sous le code de la + // première violation de forme, en désignant le même participant, la même + // table et le même tour ; accepter quand il n'y en a aucune. + function refusAttendu(plan) { + const forme = verifierInvariants(INSTANCE, plan, { reserveAutorisee: true }) + .filter(({ code }) => !SANS_REFUS.has(code)); + if (forme.length === 0) return 'accepté'; + const [premiere] = forme; + const { participant: id, table: idTable, tour } = premiere; + return { code: REFUS.get(cle(premiere)), participant: id, table: idTable, tour }; + } + + // Ce qu'indexerPlan fait du plan, sous la même forme. Une erreur qui n'est + // pas un refus du contenu se propage. + function refusObtenu(plan) { + try { + indexerPlan(INSTANCE, plan); + return 'accepté'; + } catch (erreur) { + if (!(erreur instanceof ErreurConfiguration)) throw erreur; + const { participant: id, table: idTable, tour } = erreur.details; + return { code: erreur.code, participant: id, table: idTable, tour }; + } + } + + // Le plan parfait, chaque plan du tableau des mutations, et pour chaque + // participant à chaque tour : retiré, passé en réserve, ajouté à la réserve. + function plansEprouves() { + const plans = [['le plan parfait', PLAN_PARFAIT]]; + for (const [libelle, modifier] of MUTATIONS) plans.push([libelle, planMute(modifier)]); + for (let tour = 1; tour <= 4; tour += 1) { + for (let numero = 1; numero <= 12; numero += 1) { + const id = participant(numero); + plans.push([`${numero} retiré du tour ${tour}`, planMute((plan) => { + retirer(plan.tours[tour - 1], id); + })]); + plans.push([`${numero} passé en réserve au tour ${tour}`, planMute((plan) => { + retirer(plan.tours[tour - 1], id); + plan.reserves[tour - 1].push(id); + })]); + plans.push([`${numero} ajouté à la réserve du tour ${tour}`, planMute((plan) => { + plan.reserves[tour - 1].push(id); + })]); + } + } + return plans; + } + + test('indexerPlan refuse la première violation de forme que relève verifierInvariants, et accepte le plan qui n’en a aucune', () => { + const plans = plansEprouves(); + const acceptes = plans.filter(([, plan]) => refusAttendu(plan) === 'accepté').length; + assert.ok(acceptes > 0 && acceptes < plans.length, `${acceptes} plans acceptés sur ${plans.length}`); + const cas = plans.map(([libelle, plan]) => [libelle, () => refusObtenu(plan), refusAttendu(plan)]); + assert.deepEqual(ecarts(cas), []); + }); +}); + +describe('verifierIndicateurs : la ligne de décomposition (§ 12.3, § 12.10.5)', () => { + // Chiffres du plan parfait, dans l'ordre canonique : N − 1 = 11, puis 8 pour + // chacun. Chaque inégalité y tient, les deux dernières avec égalité. + const N = 12; + const huit = () => Array.from({ length: N }, () => 8); + // Copie de huit() dont la case index prend la valeur donnée. + const sauf = (index, valeur) => huit().map((v, p) => (p === index ? valeur : v)); + + // Mutations d'une seule case, de rang index : [libellé, rencontres, + // a priori, réalisé, code attendu]. null en a priori : inconnu. + const mutations = (index) => [ + ['a priori 12 > N − 1', huit(), sauf(index, 12), huit(), 'PLAFOND_A_PRIORI'], + ['a priori 7 < réalisé 8', huit(), sauf(index, 7), huit(), 'ORDRE_PLAFONDS'], + ['réalisé 9 > a priori 8', huit(), huit(), sauf(index, 9), 'ORDRE_PLAFONDS'], + ['rencontres 9 > réalisé 8', sauf(index, 9), huit(), huit(), 'DEPASSEMENT'], + ['réalisé 7 < rencontres 8', huit(), huit(), sauf(index, 7), 'DEPASSEMENT'], + ['a priori inconnu, réalisé 12 > N − 1', huit(), null, sauf(index, 12), 'PLAFOND_REALISE'], + ['a priori inconnu, rencontres 9 > réalisé 8', sauf(index, 9), null, huit(), 'DEPASSEMENT'], + ]; + + test('les chiffres du plan parfait tiennent, le plafond a priori connu ou inconnu', () => { + assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, huit(), huit()), []); + assert.deepEqual(verifierIndicateurs({ rencontres: huit() }, null, huit()), []); + }); + + test('ne modifie ni les mesures ni les plafonds, que la ligne tienne ou qu’elle tombe', () => { + // Entrées gelées : une écriture, un tri en place ou un champ ajouté y lève + // TypeError. La copie, prise avant l'appel, verrait de plus ce qu'aurait + // changé une entrée qu'on aurait oublié de geler. + const cas = [ + ['la ligne tient', huit(), huit(), huit()], + ['la ligne tient, a priori inconnu', huit(), null, huit()], + ...mutations(5).map(([libelle, rencontres, aPriori, realises]) => [libelle, rencontres, aPriori, realises]), + ].map(([libelle, rencontres, aPriori, realises]) => [libelle, () => { + const mesures = Object.freeze({ rencontres: Object.freeze(rencontres) }); + if (aPriori !== null) Object.freeze(aPriori); + Object.freeze(realises); + const copie = structuredClone({ mesures, aPriori, realises }); + verifierIndicateurs(mesures, aPriori, realises); + assert.deepEqual({ mesures, aPriori, realises }, copie); + return 'intactes'; + }, 'intactes']); + assert.deepEqual(ecarts(cas), []); + }); + + test('chaque mutation fait tomber une inégalité de la ligne, et aucune autre', () => { + const cas = mutations(5).map(([libelle, rencontres, aPriori, realises, code]) => [ + libelle, + () => verifierIndicateurs({ rencontres }, aPriori, realises), + [{ code, index: 5 }], + ]); + assert.deepEqual(ecarts(cas), []); + }); + + test('chaque personne est éprouvée, sur chaque inégalité', () => { + const cas = []; + for (let index = 0; index < N; index += 1) { + for (const [libelle, rencontres, aPriori, realises, code] of mutations(index)) { + cas.push([ + `${libelle}, rang ${index}`, + () => verifierIndicateurs({ rencontres }, aPriori, realises), + [{ code, index }], + ]); + } + } + assert.equal(cas.length, N * 7); + assert.deepEqual(ecarts(cas), []); + }); + + test('chaque inégalité est large : l’égalité tient sur toute la ligne', () => { + const onze = Array.from({ length: N }, () => 11); + assert.deepEqual(verifierIndicateurs({ rencontres: onze }, onze, onze), []); + assert.deepEqual(verifierIndicateurs({ rencontres: onze }, null, onze), []); + }); + + test('les violations se rangent par index croissant, puis de gauche à droite sur la ligne', () => { + // Au rang 2, les trois inégalités tombent : a priori 12 > N − 1, réalisé + // 13 > a priori 12, rencontres 14 > réalisé 13. Au rang 7, la dernière + // seule : rencontres 9 > réalisé 8. + const rencontres = sauf(2, 14); + rencontres[7] = 9; + const realises = sauf(2, 13); + assert.deepEqual(verifierIndicateurs({ rencontres }, sauf(2, 12), realises), [ + { code: 'PLAFOND_A_PRIORI', index: 2 }, + { code: 'ORDRE_PLAFONDS', index: 2 }, + { code: 'DEPASSEMENT', index: 2 }, + { code: 'DEPASSEMENT', index: 7 }, + ]); + assert.deepEqual(verifierIndicateurs({ rencontres }, null, realises), [ + { code: 'PLAFOND_REALISE', index: 2 }, + { code: 'DEPASSEMENT', index: 2 }, + { code: 'DEPASSEMENT', index: 7 }, + ]); + }); + + test('les tableaux typés du moteur se lisent comme des listes', () => { + const type = (valeurs) => Int32Array.from(valeurs); + assert.deepEqual(verifierIndicateurs({ rencontres: type(huit()) }, type(huit()), type(huit())), []); + assert.deepEqual( + verifierIndicateurs({ rencontres: type(huit()) }, type(sauf(3, 7)), type(huit())), + [{ code: 'ORDRE_PLAFONDS', index: 3 }], + ); + }); + + test('une valeur qui n’est pas un nombre fait tomber les inégalités qui la lisent', () => { + // Converti, un texte tiendrait l'inégalité face à un 8 : '8' en terme de + // droite, '9' en terme de gauche. Chaque côté a donc son contrôle de type + // éprouvé. + const cas = [ + ['rencontres NaN', sauf(3, Number.NaN), huit(), huit(), ['DEPASSEMENT']], + ['réalisé NaN', huit(), huit(), sauf(3, Number.NaN), ['ORDRE_PLAFONDS', 'DEPASSEMENT']], + ['a priori NaN', huit(), sauf(3, Number.NaN), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']], + ['a priori null', huit(), sauf(3, null), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']], + ['réalisé indéfini', huit(), huit(), sauf(3, undefined), ['ORDRE_PLAFONDS', 'DEPASSEMENT']], + ['rencontres en texte', sauf(3, '8'), huit(), huit(), ['DEPASSEMENT']], + ['réalisé en texte', huit(), huit(), sauf(3, '9'), ['ORDRE_PLAFONDS', 'DEPASSEMENT']], + ['a priori en texte', huit(), sauf(3, '9'), huit(), ['PLAFOND_A_PRIORI', 'ORDRE_PLAFONDS']], + ['a priori inconnu, réalisé null', huit(), null, sauf(3, null), ['PLAFOND_REALISE', 'DEPASSEMENT']], + ].map(([libelle, rencontres, aPriori, realises, codes]) => [ + libelle, + () => verifierIndicateurs({ rencontres }, aPriori, realises), + codes.map((code) => ({ code, index: 3 })), + ]); + assert.deepEqual(ecarts(cas), []); + }); + + test('un tableau de plafonds d’une autre longueur que les rencontres lève RangeError', () => { + const court = huit().slice(1); + const long = [...huit(), 8]; + assert.throws(() => verifierIndicateurs({ rencontres: huit() }, huit(), court), RangeError); + assert.throws(() => verifierIndicateurs({ rencontres: huit() }, long, huit()), RangeError); + assert.throws(() => verifierIndicateurs({ rencontres: huit() }, null, long), RangeError); + }); + + test('un plafond qui n’est pas une liste lève TypeError, et seul null dit l’a priori inconnu', () => { + // undefined est la valeur d'un champ manquant : lu comme l'a priori + // inconnu, il ôterait deux inégalités à la garde sans rien signaler. + const rencontres = huit(); + for (const valeur of [undefined, 8, {}]) { + assert.throws(() => verifierIndicateurs({ rencontres }, valeur, huit()), + { name: 'TypeError', message: /plafondsAPriori/ }, `a priori ${String(valeur)}`); + } + for (const valeur of [undefined, null, 8, {}]) { + assert.throws(() => verifierIndicateurs({ rencontres }, huit(), valeur), + { name: 'TypeError', message: /plafondsRealises/ }, `réalisé ${String(valeur)}`); + } + }); +});