gestion_table_tournante_libre/src/moteur/manque.test.js

706 lines
30 KiB
JavaScript
Raw Normal View History

// © 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);
});
});