// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Épreuves du manque et de ce qui s'en lit (§ 5.5, § 12.10). Chaque valeur // attendue est écrite en clair : recopiée de la spécification, ou comptée à // la main sur le plan qui la précède, jamais calculée par le module éprouvé. // Un plan s'écrit par identifiants et passe par normaliser et indexerPlan // comme un plan enregistré ; ses rencontres et ses plafonds viennent de // indicateurs.js et de plafond.js, éprouvés à part. Quand une épreuve les // recopie en clair, ce sont les termes de la ligne de décomposition // (§ 12.10.5) dont ses chiffres sont les différences. 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 './configuration.js'; import { mesurer } from './indicateurs.js'; import { comparerProfils, ecartsAuPlafondAPriori, ecartsItineraire, manques, minimumAtteint, profilManque, troisChiffres, } from './manque.js'; import { plafondsAPriori, plafondsRealises } from './plafond.js'; const SANS_CONTRAINTE = { separerAppartenances: false, nouveauxVoisins: false, nouvelleTable: false, varierAppartenances: false, }; // Un chiffre sur trois populations vides (§ 5.4) : null, jamais 0. const AUCUN = { tous: null, mobiles: null, ancres: null }; // Huit pour chacun des douze participants de la petite démonstration : leur // plafond a priori (§ 15.3), et leur plafond réalisé sur tout plan qui // remplit les quatre tables à chaque tour. const HUIT = Object.freeze(new Array(12).fill(8)); // Ce qui n'est pas une liste, pour les épreuves des gardes : un nombre plutôt // qu'undefined. Lire undefined.length lève TypeError, garde ou non ; lire // (8).length rend undefined sans lever, et seule une garde de liste lève // alors TypeError. L'épreuve échoue donc quand la garde disparaît. const PAS_UNE_LISTE = 8; // Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle // contient : une écriture du module éprouvé y lève TypeError. function figer(valeur) { if (typeof valeur !== 'object' || valeur === null || ArrayBuffer.isView(valeur)) return valeur; for (const element of Object.values(valeur)) figer(element); return Object.freeze(valeur); } // Participants d'identifiants donnés, sans appartenance. const sansAppartenance = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null })); // Tables d'identifiant et de numéro 1 à n, n le nombre de capacités. const tablesDe = (capacites) => capacites.map((capacite, i) => ({ id: i + 1, numero: i + 1, capacite })); // La petite démonstration (§ 15.3) : douze participants, quatre tables de 3, // quatre tours, aucune réservation. const PETITE = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite').construire()); // Le plan parfait, 3 et 4 échangés au tour 1 : 1 retrouve 4 au tour 2, et 3 // retrouve 5 au tour 3. Ces quatre personnes rencontrent 7 personnes, les // huit autres toujours 8 ; les tables restent pleines, et le plafond réalisé // vaut 8 pour chacun. const PLAN_DEGRADE = { tables: [1, 2, 3, 4], tours: [[[1, 2, 4], [3, 5, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)], reserves: [[], [], [], []], }; // Le plan parfait, 1 et 5 échangés au tour 1. 1 y quitte 2 et 3, et 5 y // quitte 4 et 6, qu'ils ne voient à aucun autre tour. Chacun s'assoit près de // deux personnes qu'il revoit à un autre tour : 1 près de 4 et 6, revus aux // tours 2 et 3 ; 5 près de 2 et 3, revus aux tours 2 et 3. 2, 3, 4 et 6 // perdent celui des deux qui quitte leur table, et revoient ailleurs celui // qui y arrive. Les tables restent pleines, et le plafond réalisé vaut 8 pour // chacun. // // id 1 2 3 4 5 6 7 à 12 // rencontres 6 7 7 7 6 7 8 // manque 2 1 1 1 2 1 0 const PLAN_DEUX_MANQUES = { tables: [1, 2, 3, 4], tours: [[[2, 3, 5], [1, 4, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)], reserves: [[], [], [], []], }; // Deux tables de 3 sièges, quatre tours, six participants sans appartenance. // 1 est ancré à la table 1 et 2 à la table 2 ; 3, réservé à la table 1 au // tour 1, est partiellement fixé et compte parmi les mobiles (§ 5.4) ; 4, 5 et // 6 sont mobiles. 6, assis au seul tour 1, est en réserve ensuite (§ 12.6). const CONFIGURATION_ANCRAGES = { participants: sansAppartenance([1, 2, 3, 4, 5, 6]), tables: tablesDe([3, 3]), tours: 4, reservations: [ { participant: 1, table: 1, portee: 'tous' }, { participant: 2, table: 2, portee: 'tous' }, { participant: 3, table: 1, portee: 'tour', tour: 1 }, ], contraintes: SANS_CONTRAINTE, }; const ANCRAGES = normaliser(CONFIGURATION_ANCRAGES); const PLAN_ANCRAGES = { tables: [1, 2], tours: [ [[1, 3, 4], [2, 5, 6]], [[1, 3, 5], [2, 4]], [[1, 4], [2, 3, 5]], [[1, 3, 4], [2, 5]], ], reserves: [[], [6], [6], [6]], }; // Les termes de PLAN_ANCRAGES, comptés à la main. Occupation des tables 1 et // 2 : 3 et 3 au tour 1, 3 et 2 au tour 2, 2 et 3 au tour 3, 3 et 2 au tour 4. // N − 1 = 5 et n = 4 : n_p vaut 4 pour un ancré, 3 pour les autres. Plafond // réalisé = Σ a_t sur les tables visitées + min(n_p, Σ (o − 1 − a_t) sur les // tours assis), a_t valant 1 à chaque table, et 0 à la sienne pour un ancré. // // id rencontrés rencontres plafond réalisé a priori // 1 3, 4, 5 3 0 + min(4, 2+2+1+2) = 4 4 // 2 3, 4, 5, 6 4 0 + min(4, 2+1+2+1) = 4 4 // 3 1, 2, 4, 5 4 2 + min(3, 1+1+1+1) = 5 5 // 4 1, 2, 3 3 2 + min(3, 1+0+0+1) = 4 5 // 5 1, 2, 3, 6 4 2 + min(3, 1+1+1+0) = 5 5 // 6 2, 5 2 1 + min(3, 1) = 2 5 // // Le plafond a priori se lit sur les capacités : 0 + min(4, 4 × 2) pour un // ancré ; les deux tables visitées, 2 + min(3, 4 × 1), pour les autres. // Manque = réalisé − rencontres : 1, 0, 1, 1, 1, 0. Écart d'itinéraire = a // priori − réalisé : 0, 0, 0, 1, 0, 3. const RENCONTRES_ANCRAGES = [3, 4, 4, 3, 4, 2]; const REALISES_ANCRAGES = [4, 4, 5, 4, 5, 2]; const A_PRIORI_ANCRAGES = [4, 4, 5, 5, 5, 5]; // Quatre participants, deux tables de 3, un tour, deux à chaque table. Chacun // rencontre la seule personne que sa table lui offre : plafond réalisé 1, // manque 0. Une table pleine lui en offrait deux : plafond a priori 2, écart // d'itinéraire 1. const BAS = normaliser({ participants: sansAppartenance([1, 2, 3, 4]), tables: tablesDe([3, 3]), tours: 1, reservations: [], contraintes: SANS_CONTRAINTE, }); const PLAN_BAS = { tables: [1, 2], tours: [[[1, 2], [3, 4]]], reserves: [[]] }; // Aucun participant ; le plan n'a aucune case. const VIDE = normaliser({ participants: [], tables: tablesDe([2]), tours: 1, reservations: [], contraintes: SANS_CONTRAINTE, }); const PLAN_VIDE = { tables: [1], tours: [[[]]], reserves: [[]] }; // Mesures, plafonds réalisés et plafonds a priori d'un plan par // identifiants. function chiffrer(instance, plan) { const tableDe = indexerPlan(instance, plan); return { mesures: mesurer(instance, tableDe), realises: plafondsRealises(instance, tableDe), aPriori: plafondsAPriori(instance), }; } // Profil de manque d'un plan par identifiants. function profilDe(instance, plan) { const { mesures, realises } = chiffrer(instance, plan); return profilManque(instance, manques(realises, mesures.rencontres), realises); } // Profil écrit à la main : le manque de chaque rang, le rang 1 d'abord, et // l'identifiant de la personne à chaque rang, 1 à n par défaut. Le plafond // réalisé ne départage pas : comparerProfils ne lit que le manque. const profil = (manquesParRang, ids = manquesParRang.map((_, i) => i + 1)) => manquesParRang.map((manque, i) => ({ id: ids[i], manque, plafondRealise: 10 })); describe('manques (§ 12.10.2)', () => { test('plan parfait de la petite démonstration : douze manques nuls', () => { const { mesures, realises } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); assert.deepEqual(mesures.rencontres, HUIT); assert.deepEqual(realises, HUIT); assert.deepEqual(manques(figer(realises), figer(mesures.rencontres)), new Array(12).fill(0)); }); test('plan dégradé : un manque de 1 pour les quatre personnes qui perdent une rencontre', () => { const { mesures, realises } = chiffrer(PETITE, PLAN_DEGRADE); assert.deepEqual(mesures.rencontres, [7, 8, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8]); assert.deepEqual(realises, HUIT); assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]); }); test("ancrés et réserve : plafond réalisé − rencontres, personne par personne", () => { const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.deepEqual(mesures.rencontres, RENCONTRES_ANCRAGES); assert.deepEqual(realises, REALISES_ANCRAGES); assert.deepEqual(aPriori, A_PRIORI_ANCRAGES); assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0]); // Les tableaux typés se lisent comme des listes. assert.deepEqual( manques(Int32Array.from(REALISES_ANCRAGES), Int32Array.from(RENCONTRES_ANCRAGES)), [1, 0, 1, 1, 1, 0], ); }); test('deux listes de longueurs différentes lèvent RangeError', () => { assert.throws(() => manques([8, 8], [8]), RangeError); assert.throws(() => manques([8], [8, 8]), RangeError); }); test("une valeur qui n'est pas une liste lève TypeError, à l'une ou l'autre place", () => { assert.throws(() => manques(PAS_UNE_LISTE, [8]), TypeError); assert.throws(() => manques([8], PAS_UNE_LISTE), TypeError); }); }); describe('ecartsItineraire (§ 5.5)', () => { test('plafond a priori − plafond réalisé, personne par personne', () => { const { realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); // 4 − 4, 4 − 4, 5 − 5, 5 − 4, 5 − 5, 5 − 2 : 6, en réserve à trois tours, // porte l'écart d'itinéraire que son manque nul ne porte pas (§ 12.6). assert.deepEqual(ecartsItineraire(figer(aPriori), figer(realises)), [0, 0, 0, 1, 0, 3]); }); test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => { const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.equal(ecartsItineraire(null, realises), null); assert.throws(() => ecartsItineraire(undefined, realises), TypeError); }); test("un plafond a priori d'une autre longueur lève RangeError ; un nombre seul pour plafonds réalisés, TypeError", () => { const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.throws(() => ecartsItineraire(A_PRIORI_ANCRAGES.slice(1), realises), RangeError); assert.throws(() => ecartsItineraire(null, PAS_UNE_LISTE), TypeError); }); }); describe('ecartsAuPlafondAPriori (§ 5.5)', () => { test("plafond a priori − rencontres : le manque plus l'écart d'itinéraire, personne par personne", () => { const { mesures, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); // 1 + 0, 0 + 0, 1 + 0, 1 + 1, 1 + 0, 0 + 3. assert.deepEqual(ecartsAuPlafondAPriori(figer(mesures), figer(aPriori)), [1, 0, 1, 2, 1, 3]); }); test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => { const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.equal(ecartsAuPlafondAPriori(mesures, null), null); assert.throws(() => ecartsAuPlafondAPriori(mesures, undefined), TypeError); }); test("un plafond a priori d'une autre longueur que les rencontres lève RangeError", () => { const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.throws(() => ecartsAuPlafondAPriori(mesures, [4, 4, 5, 5, 5]), RangeError); }); test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => { assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, A_PRIORI_ANCRAGES), TypeError); assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, null), TypeError); }); }); describe('troisChiffres (§ 12.10.4)', () => { test('plan parfait de la petite démonstration : 0, un vrai 0, et 0 ; « — » pour les ancrés', () => { const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); // Douze personnes mesurées, aucune en manque : l'effectif du manque est un // zéro mesuré, non un « — ». Sans réservation, la population des ancrés // est vide, et chacun des trois chiffres s'y écrit « — » (§ 15.3). assert.deepEqual(troisChiffres(PETITE, figer(mesures), figer(aPriori), figer(realises)), { manqueMax: { tous: 0, mobiles: 0, ancres: null }, effectifManque: { tous: 0, mobiles: 0, ancres: null }, ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null }, }); }); test('plan dégradé : le plus grand manque vaut 1, et quatre personnes le portent', () => { const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEGRADE); assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), { manqueMax: { tous: 1, mobiles: 1, ancres: null }, effectifManque: { tous: 4, mobiles: 4, ancres: null }, ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null }, }); }); test("un manque de 2 : l'effectif compte les personnes, non la somme des manques (§ 12.10.8)", () => { const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEUX_MANQUES); assert.deepEqual(mesures.rencontres, [6, 7, 7, 7, 6, 7, 8, 8, 8, 8, 8, 8]); assert.deepEqual(realises, HUIT); // Manques 2, 1, 1, 1, 2, 1, puis six 0 : six personnes en manque, pour // une somme de 8. assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), { manqueMax: { tous: 2, mobiles: 2, ancres: null }, effectifManque: { tous: 6, mobiles: 6, ancres: null }, ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null }, }); }); test('le placement du § 12.10.4 : un manque de 13, porté par une seule personne', () => { // Chacun atteint un plafond réalisé de 27, sauf une personne qui ne // rencontre que 14 : le plus grand manque vaut 13, l'effectif 1. // troisChiffres lit des listes, non un plan : celles de l'exemple, écrites // à la main sur trois personnes, le plafond a priori inconnu. const trois = normaliser({ participants: sansAppartenance([1, 2, 3]), tables: tablesDe([3]), tours: 1, reservations: [], contraintes: SANS_CONTRAINTE, }); assert.deepEqual(troisChiffres(trois, { rencontres: [27, 27, 14] }, null, [27, 27, 27]), { manqueMax: { tous: 13, mobiles: 13, ancres: null }, effectifManque: { tous: 1, mobiles: 1, ancres: null }, ecartItineraireMax: null, }); }); test('ancrés, partiellement fixé et réserve : chaque chiffre sur ses trois populations', () => { const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); // Manque 1, 0, 1, 1, 1, 0 ; écart d'itinéraire 0, 0, 0, 1, 0, 3. Les // ancrés sont 1 et 2 ; les mobiles, 3, partiellement fixé, puis 4, 5 et 6. // 6, en réserve à trois tours sur quatre, a un manque nul : les deux // premiers chiffres ne le voient pas, le troisième le montre (§ 12.10.4). assert.deepEqual(troisChiffres(ANCRAGES, mesures, aPriori, realises), { manqueMax: { tous: 1, mobiles: 1, ancres: 1 }, effectifManque: { tous: 4, mobiles: 3, ancres: 1 }, ecartItineraireMax: { tous: 3, mobiles: 3, ancres: 0 }, }); }); test("plafond a priori inconnu : l'écart d'itinéraire maximal vaut null, les deux autres se mesurent", () => { const { mesures, realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.deepEqual(troisChiffres(ANCRAGES, mesures, null, realises), { manqueMax: { tous: 1, mobiles: 1, ancres: 1 }, effectifManque: { tous: 4, mobiles: 3, ancres: 1 }, ecartItineraireMax: null, }); }); test("itinéraires bas : aucun manque, et l'écart d'itinéraire le dit (§ 5.5)", () => { const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS); assert.deepEqual(mesures.rencontres, [1, 1, 1, 1]); assert.deepEqual(realises, [1, 1, 1, 1]); assert.deepEqual(aPriori, [2, 2, 2, 2]); assert.deepEqual(troisChiffres(BAS, mesures, aPriori, realises), { manqueMax: { tous: 0, mobiles: 0, ancres: null }, effectifManque: { tous: 0, mobiles: 0, ancres: null }, ecartItineraireMax: { tous: 1, mobiles: 1, ancres: null }, }); }); test('population vide : « — » pour chacun des trois chiffres, jamais 0 (§ 5.4)', () => { const { mesures, realises, aPriori } = chiffrer(VIDE, PLAN_VIDE); assert.deepEqual(aPriori, []); assert.deepEqual(troisChiffres(VIDE, mesures, aPriori, realises), { manqueMax: AUCUN, effectifManque: AUCUN, ecartItineraireMax: AUCUN, }); }); test('une liste sans une case par participant lève RangeError, seule ou avec les deux autres', () => { const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); const court = (liste) => liste.slice(1); assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, court(realises)), RangeError); assert.throws(() => troisChiffres(ANCRAGES, mesures, court(aPriori), realises), RangeError); assert.throws( () => troisChiffres(ANCRAGES, { rencontres: court(mesures.rencontres) }, aPriori, realises), RangeError, ); // Les trois listes ensemble, mesurées sur une autre population : accordées // entre elles, elles passent les gardes de manques et d'ecartsItineraire, // qui les comparent l'une à l'autre ; seules celles de troisChiffres les // confrontent à l'instance. D'abord une population plus petite d'une // personne, puis une proposition mesurée avant l'exclusion de 6 et relue // sur l'instance qui l'exclut (§ 9, § 12.6) ; chaque fois l'a priori // connu, puis inconnu. const plusPetite = { rencontres: court(mesures.rencontres) }; assert.throws(() => troisChiffres(ANCRAGES, plusPetite, court(aPriori), court(realises)), RangeError); assert.throws(() => troisChiffres(ANCRAGES, plusPetite, null, court(realises)), RangeError); const sansSix = normaliser({ ...CONFIGURATION_ANCRAGES, participants: CONFIGURATION_ANCRAGES.participants.map((p) => (p.id === 6 ? { ...p, exclu: true } : p)), }); assert.deepEqual(sansSix.ids, [1, 2, 3, 4, 5]); assert.throws(() => troisChiffres(sansSix, mesures, aPriori, realises), RangeError); assert.throws(() => troisChiffres(sansSix, mesures, null, realises), RangeError); }); test("une valeur qui n'est pas une liste lève TypeError ; un a priori undefined aussi", () => { const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, PAS_UNE_LISTE), TypeError); assert.throws(() => troisChiffres(ANCRAGES, { rencontres: PAS_UNE_LISTE }, aPriori, realises), TypeError); assert.throws(() => troisChiffres(ANCRAGES, mesures, undefined, realises), TypeError); }); }); describe('profilManque (§ 12.10.6)', () => { test('manque décroissant, puis plafond réalisé croissant, puis identifiant croissant', () => { // Identifiants 10 à 50, reçus dans le désordre ; les listes suivent // l'ordre canonique, celui de instance.ids. const instance = normaliser({ participants: sansAppartenance([30, 10, 50, 20, 40]), tables: tablesDe([5]), tours: 1, reservations: [], contraintes: SANS_CONTRAINTE, }); assert.deepEqual(instance.ids, [10, 20, 30, 40, 50]); // id 10 20 30 40 50 // manque 1 2 1 1 0 // plafond réalisé 5 3 4 5 2 assert.deepEqual(profilManque(instance, figer([1, 2, 1, 1, 0]), figer([5, 3, 4, 5, 2])), [ { id: 20, manque: 2, plafondRealise: 3 }, { id: 30, manque: 1, plafondRealise: 4 }, { id: 10, manque: 1, plafondRealise: 5 }, { id: 40, manque: 1, plafondRealise: 5 }, { id: 50, manque: 0, plafondRealise: 2 }, ]); }); test("plan dégradé : les quatre personnes en manque d'abord, chaque palier par identifiant", () => { const ids = profilDe(PETITE, PLAN_DEGRADE).map(({ id }) => id); assert.deepEqual(ids, [1, 3, 4, 5, 2, 6, 7, 8, 9, 10, 11, 12]); }); test("une liste sans une case par participant lève RangeError", () => { assert.throws(() => profilManque(PETITE, new Array(11).fill(0), HUIT), RangeError); assert.throws(() => profilManque(PETITE, new Array(12).fill(0), HUIT.slice(1)), RangeError); }); }); // Les plans voisins du plan parfait de la petite démonstration : à un tour, // deux personnes assises à deux tables différentes échangent leurs places. // Quatre tours, six paires de tables, neuf échanges par paire : 216 plans. function voisinsDuParfait() { const { tours } = PLAN_PARFAIT_PETITE; const voisins = []; for (let r = 0; r < tours.length; r += 1) { for (let i = 0; i < tours[r].length; i += 1) { for (let j = i + 1; j < tours[r].length; j += 1) { for (const x of tours[r][i]) { for (const y of tours[r][j]) { const tour = tours[r].map((liste, t) => { if (t === i) return liste.map((id) => (id === x ? y : id)); if (t === j) return liste.map((id) => (id === y ? x : id)); return liste; }); voisins.push({ nom: `${x} et ${y} échangés au tour ${r + 1}`, plan: { ...PLAN_PARFAIT_PETITE, tours: tours.map((t, q) => (q === r ? tour : t)) }, }); } } } } } return voisins; } // Deux autres plans parfaits : les tours du plan parfait dans l'ordre // inverse, et chaque tour décalé d'une table. Les rencontres restent les // mêmes. const AUTRES_PARFAITS = [ { nom: 'tours inversés', plan: { ...PLAN_PARFAIT_PETITE, tours: [...PLAN_PARFAIT_PETITE.tours].reverse() }, }, { nom: 'tables décalées', plan: { ...PLAN_PARFAIT_PETITE, tours: PLAN_PARFAIT_PETITE.tours.map((t) => [...t.slice(1), t[0]]) }, }, ]; // Vrai quand chacun des douze rencontre huit personnes distinctes, compté sur // les listes du plan sans passer par le moteur. Sur un plan qui remplit les // quatre tables à chaque tour, le plafond réalisé vaut 8 pour chacun : c'est // exactement le cas d'un manque nul pour tous. function chacunRencontreHuit(plan) { const rencontres = new Map(); for (const tour of plan.tours) { for (const liste of tour) { for (const a of liste) { if (!rencontres.has(a)) rencontres.set(a, new Set()); for (const b of liste) if (b !== a) rencontres.get(a).add(b); } } } return rencontres.size === 12 && [...rencontres.values()].every((vus) => vus.size === 8); } describe('comparerProfils : dominance de profil (§ 12.10.7)', () => { test("petite démonstration : le plan parfait domine tout plan qui n'est pas à manque nul, et rien ne le domine", () => { const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE); const concurrents = [...voisinsDuParfait(), ...AUTRES_PARFAITS]; // Le test refuse de passer sans concurrent, et sans concurrent de chaque // sorte (§ 14.2). Un échange retire à la personne déplacée deux voisins // qu'elle ne retrouve à aucun autre tour, et l'assoit près d'une personne // au moins qu'elle rencontre à un autre tour : elle perd deux rencontres // et en gagne une au plus. Aucun voisin n'est donc à manque nul. assert.equal(concurrents.length, 218); assert.deepEqual( concurrents.filter(({ plan }) => chacunRencontreHuit(plan)).map(({ nom }) => nom), ['tours inversés', 'tables décalées'], ); const ecarts = concurrents.flatMap(({ nom, plan }) => { const autre = profilDe(PETITE, plan); const nul = chacunRencontreHuit(plan); const lus = [ ['parfait contre lui', comparerProfils(parfait, autre).domine, nul ? null : 'A'], ['lui contre parfait', comparerProfils(autre, parfait).domine, nul ? null : 'B'], ]; return lus .filter(([, obtenu, attendu]) => obtenu !== attendu) .map(([sens, obtenu, attendu]) => `${nom}, ${sens} : ${obtenu} au lieu de ${attendu}`); }); assert.deepEqual(ecarts, []); }); test('plan parfait contre plan dégradé : quatre rangs à A, huit égalités, aucune bascule', () => { const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE); const degrade = profilDe(PETITE, PLAN_DEGRADE); assert.deepEqual(comparerProfils(figer(parfait), figer(degrade)), { comparable: true, rangsA: 4, rangsB: 0, egalite: 8, rangBascule: null, domine: 'A', }); }); test("deux profils tout à zéro ne se dominent pas", () => { assert.deepEqual(comparerProfils(profil([0, 0, 0]), profil([0, 0, 0], [3, 2, 1])), { comparable: true, rangsA: 0, rangsB: 0, egalite: 3, rangBascule: null, domine: null, }); }); test("A domine B quand il sert au moins aussi bien à chaque rang, et mieux à l'un d'eux", () => { // Manque de A − manque de B, rang par rang : 0, 0, −1. assert.deepEqual(comparerProfils(profil([3, 2, 0]), profil([3, 2, 1], [2, 3, 1])), { comparable: true, rangsA: 1, rangsB: 0, egalite: 2, rangBascule: null, domine: 'A', }); assert.deepEqual(comparerProfils(profil([3, 2, 1], [2, 3, 1]), profil([3, 2, 0])), { comparable: true, rangsA: 0, rangsB: 1, egalite: 2, rangBascule: null, domine: 'B', }); }); }); describe('comparerProfils : rangs et bascule (§ 12.10.6)', () => { test("un seul changement de signe : le rang de bascule, premier rang où l'autre sert mieux", () => { // −1, −1, +1, +1, 0 : A sert mieux aux rangs 1 et 2, B aux rangs 3 et 4. assert.deepEqual(comparerProfils(profil([5, 3, 2, 1, 0]), profil([6, 4, 1, 0, 0], [5, 4, 3, 2, 1])), { comparable: true, rangsA: 2, rangsB: 2, egalite: 1, rangBascule: 3, domine: null, }); // +1, +1, −1 : B sert mieux d'abord, A à partir du rang 3. assert.deepEqual(comparerProfils(profil([6, 4, 1]), profil([5, 3, 2])), { comparable: true, rangsA: 1, rangsB: 2, egalite: 0, rangBascule: 3, domine: null, }); }); test("une égalité au croisement n'est pas un changement de signe", () => { // −1, 0, +1, +1 : une seule bascule, au rang 3, où B sert strictement mieux. assert.deepEqual(comparerProfils(profil([5, 3, 2, 1]), profil([6, 3, 1, 0])), { comparable: true, rangsA: 1, rangsB: 2, egalite: 1, rangBascule: 3, domine: null, }); // 0, 0, −1, +1 : les égalités de tête ne fixent aucun signe ; bascule au rang 4. assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([4, 3, 2, 0])), { comparable: true, rangsA: 1, rangsB: 1, egalite: 2, rangBascule: 4, domine: null, }); }); test('deux changements de signe ou plus : aucun rang de bascule (§ 12.6)', () => { // −1, +1, −1, 0. assert.deepEqual(comparerProfils(profil([4, 3, 1, 0]), profil([5, 2, 2, 0])), { comparable: true, rangsA: 2, rangsB: 1, egalite: 1, rangBascule: null, domine: null, }); // −1, +1, −1, +1. assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([5, 2, 2, 0])), { comparable: true, rangsA: 2, rangsB: 2, egalite: 0, rangBascule: null, domine: null, }); }); }); describe('comparerProfils : populations (§ 12.6)', () => { test('populations différentes : { comparable: false }', () => { assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [1, 2, 4])), { comparable: false, }); assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0])), { comparable: false }); assert.deepEqual(comparerProfils(profil([1, 0]), profil([1, 0, 0])), { comparable: false }); }); test("les mêmes personnes à d'autres rangs restent comparables, et deux profils vides aussi", () => { assert.equal(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [3, 1, 2])).comparable, true); assert.deepEqual(comparerProfils([], []), { comparable: true, rangsA: 0, rangsB: 0, egalite: 0, rangBascule: null, domine: null, }); }); }); describe('minimumAtteint : le certificat du § 5.5', () => { test('vrai sur le plan parfait de la petite démonstration', () => { const { mesures, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); assert.deepEqual(aPriori, HUIT); assert.equal(minimumAtteint(figer(mesures), figer(aPriori)), true); }); test("faux après l'échange de deux personnes au tour 1, qui crée deux répétitions", () => { const { mesures, aPriori } = chiffrer(PETITE, PLAN_DEGRADE); assert.equal(mesures.rencontresRepetees.choisies, 2); assert.equal(minimumAtteint(mesures, aPriori), false); }); test('se lit contre le plafond a priori, jamais sur le seul manque', () => { // Des itinéraires bas : chacun atteint son plafond réalisé, aucun son // plafond a priori. const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS); assert.deepEqual(manques(realises, mesures.rencontres), [0, 0, 0, 0]); assert.equal(minimumAtteint(mesures, aPriori), false); }); test('égaler le plafond a priori, non le dépasser : un dépassement ne certifie rien', () => { // 9 rencontres pour un plafond a priori de 8 rompent la ligne de // décomposition (§ 12.10.5) : un calcul faux, que le certificat ne couvre // pas. assert.equal(minimumAtteint({ rencontres: [9, 8] }, [8, 8]), false); }); test('null quand le plafond a priori est inconnu, et sur une population vide', () => { assert.equal(minimumAtteint(chiffrer(PETITE, PLAN_PARFAIT_PETITE).mesures, null), null); const { mesures, aPriori } = chiffrer(VIDE, PLAN_VIDE); assert.equal(minimumAtteint(mesures, aPriori), null); }); test("un a priori undefined lève TypeError ; d'une autre longueur, RangeError", () => { const { mesures } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); assert.throws(() => minimumAtteint(mesures, undefined), TypeError); assert.throws(() => minimumAtteint(mesures, HUIT.slice(1)), RangeError); }); test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => { assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, HUIT), TypeError); assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, null), TypeError); }); });