gestion_table_tournante_libre/src/moteur/classement.test.js

251 lines
11 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 classement des propositions (§ 5.7). Une proposition
// synthétique ne porte que les champs que classer lit, pour trois personnes
// au plafond a priori de 8. Chaque ordre attendu est écrit en clair. Hors des
// épreuves où seul l'identifiant départage, chaque épreuve qui attend un
// ordre non vide en attend au moins un qui diffère de l'ordre des
// identifiants : un classement qui ne lirait aucun critère, et rangerait par
// identifiant, y échoue.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { CRITERES, classer } from './classement.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
// Les critères appliqués quand le premier est sauté.
const SANS_A_PRIORI = ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance'];
// 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);
}
// Proposition synthétique de trois personnes. rencontres fixe l'écart au
// plafond a priori de chacun, 8 − rencontres ; collisions est le triplet
// (cumulées, distinctes, excédent) du § 5.4 ; repetees et imposees comptent
// les rencontres répétées que le moteur a choisies et celles qu'imposent les
2026-10-06 04:23:02 -04:00
// réservations ; redondance est Σ_p r(p), le total que mesurer rend sous le
// nom totalRedondance. La liste des r(p) n'y figure pas : classer ne la lit
// pas.
function proposition(
id,
{
rencontres = [8, 8, 8],
collisions = [0, 0, 0],
repetees = 0,
imposees = 0,
2026-10-06 04:23:02 -04:00
redondance = 0,
} = {},
) {
const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions;
return {
id,
mesures: {
rencontres,
collisionsCumulees,
pairesDistinctes,
excedentCollisions,
rencontresRepetees: { choisies: repetees, imposees },
2026-10-06 04:23:02 -04:00
totalRedondance: redondance,
},
plafondsAPriori: [8, 8, 8],
};
}
// La même proposition, son plafond a priori inconnu.
const inconnu = (entree) => ({ ...entree, plafondsAPriori: null });
// Les six ordres d'une liste de trois éléments.
const permutations = ([a, b, c]) => [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]];
describe('classer : les critères et leur ordre (§ 5.7)', () => {
test('CRITERES nomme les cinq critères dans leur ordre, et ne se modifie pas', () => {
assert.deepEqual(CRITERES, [
2026-10-06 04:23:02 -04:00
'ecartAuPlafondAPrioriMax',
'excedentCollisions',
'collisionsCumulees',
'rencontresRepetees',
'redondance',
]);
assert.ok(Object.isFrozen(CRITERES));
});
test("l'ordre de l'opérateur (§ 5.4) : l'excédent d'abord, le volume ensuite", () => {
// Les trois plans du § 5.4, au même écart au plafond a priori, en
// (cumulées, distinctes, excédent) : un collègue retrouvé quatre fois,
// deux collègues retrouvés deux fois chacun, six collègues retrouvés une
// fois chacun. Les rencontres répétées suivent : une paire réunie quatre
// fois, deux paires réunies deux fois, aucune.
const concentre = proposition(1, { collisions: [4, 1, 3], repetees: 1 });
const deuxFois = proposition(2, { collisions: [4, 2, 2], repetees: 2 });
const reparti = proposition(3, { collisions: [6, 6, 0] });
const entrees = [concentre, deuxFois, reparti];
for (const ordreRecu of permutations(entrees)) {
assert.deepEqual(classer(ordreRecu).ordre, [3, 2, 1]);
}
// La raison de la clé : un tri sur les seules collisions cumulées, ou sur
// les seules paires distinctes, met le plan réparti en dernier.
const triSur = (champ) =>
[...entrees].sort((a, b) => a.mesures[champ] - b.mesures[champ] || a.id - b.id).map(({ id }) => id);
assert.equal(triSur('collisionsCumulees').at(-1), 3);
assert.equal(triSur('pairesDistinctes').at(-1), 3);
});
test('chaque critère départage à son rang, avant tous les suivants', () => {
// Chaque proposition perd sur un seul critère et gagne sur tous les
// autres ; la proposition qui la suit perd sur le critère suivant. Seul
// l'ordre des critères les range, à l'envers de leurs identifiants.
const entrees = [
proposition(1, { rencontres: [7, 8, 8] }), // écart au plafond a priori 1
proposition(2, { collisions: [2, 1, 1], repetees: 1 }), // excédent 1
proposition(3, { collisions: [3, 3, 0] }), // 3 cumulées, excédent 0
proposition(4, { repetees: 1 }), // une rencontre répétée
2026-10-06 04:23:02 -04:00
proposition(5, { redondance: 1 }), // redondance 1
proposition(6),
];
assert.deepEqual(classer(figer(entrees)), {
ordre: [6, 5, 4, 3, 2, 1],
criteresAppliques: [...CRITERES],
critereSaute: null,
});
});
test('le premier critère est le plus grand écart au plafond a priori, ni sa somme ni son minimum', () => {
// Écarts au plafond a priori, personne par personne :
// 1 : 2, 0, 0 ; le plus grand 2, la somme 2, le minimum 0.
// 2 : 1, 1, 1 ; le plus grand 1, la somme 3, le minimum 1.
const entrees = [proposition(1, { rencontres: [6, 8, 8] }), proposition(2, { rencontres: [7, 7, 7] })];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
});
test('seules les rencontres répétées que le moteur a choisies comptent (§ 5.4)', () => {
// 1 : une répétition choisie ; 2 : cinq qu'imposent les réservations.
const entrees = [proposition(1, { repetees: 1 }), proposition(2, { imposees: 5 })];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
});
2026-10-06 04:23:02 -04:00
test('la redondance se lit sur totalRedondance, la somme que mesurer rend', () => {
// 1 : r(p) vaut 1, 1, 1, somme 3 et plus grand 1 ; 2 : r(p) vaut 2, 0,
// 0, somme 2 et plus grand 2. La somme range 2 devant 1, le plus grand
// ferait l'inverse.
const entrees = [proposition(1, { redondance: 3 }), proposition(2, { redondance: 2 })];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
});
});
describe('classer : le critère sauté (§ 5.7, § 12.6)', () => {
test('un plafond a priori inconnu saute le premier critère pour toutes, et classer le nomme', () => {
// 1 : écart au plafond a priori 0, excédent 2.
// 2 : écart au plafond a priori 1, excédent 0.
const premiere = proposition(1, { collisions: [4, 2, 2], repetees: 2 });
const seconde = proposition(2, { rencontres: [7, 8, 8] });
assert.deepEqual(classer([premiere, seconde]), {
ordre: [1, 2],
criteresAppliques: [...CRITERES],
critereSaute: null,
});
// Un seul a priori inconnu suffit : l'excédent ouvre alors le classement
// de toutes, y compris de celle dont l'a priori est connu.
2026-10-06 04:23:02 -04:00
const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' };
assert.deepEqual(classer([premiere, inconnu(seconde)]), attendu);
assert.deepEqual(classer([inconnu(premiere), seconde]), attendu);
assert.deepEqual(classer([inconnu(premiere), inconnu(seconde)]), attendu);
});
});
describe('classer : le départage par identifiant (§ 15.5, point 4)', () => {
test("à égalité sur les cinq critères, l'identifiant croissant, quel que soit l'ordre reçu", () => {
const egales = [7, 3, 5].map((id) =>
2026-10-06 04:23:02 -04:00
proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: 1 }),
);
for (const ordreRecu of permutations(egales)) {
assert.deepEqual(classer(ordreRecu).ordre, [3, 5, 7]);
}
});
});
// Un plan de la variante « conflit inévitable » (§ 15.3), où A = {1, 5, 8,
// 10, 12}, B = {2, 4, 9, 11} et C = {3, 6, 7}. 1 et 5 restent ensemble aux
// quatre tours, avec un membre de B ; chaque autre table réunit un membre de
// A, de B et de C. Quatre collisions cumulées sur une seule paire.
const CONCENTRE = {
tables: [1, 2, 3, 4],
tours: [
[[1, 2, 5], [3, 4, 8], [6, 9, 10], [7, 11, 12]],
[[1, 4, 5], [6, 8, 9], [7, 10, 11], [2, 3, 12]],
[[1, 5, 9], [7, 8, 11], [2, 3, 10], [4, 6, 12]],
[[1, 5, 11], [2, 6, 8], [3, 4, 10], [7, 9, 12]],
],
reserves: [[], [], [], []],
};
describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15.3)', () => {
test('le plan qui répartit ses quatre collisions passe devant celui qui les concentre', () => {
const instance = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite-conflit').construire());
const mesures = (plan) => mesurer(instance, indexerPlan(instance, plan));
const collisions = (m) => [m.collisionsCumulees, m.pairesDistinctes, m.excedentCollisions];
// Le plan parfait de la petite démonstration répartit les siennes : 10
// rencontre 12 au tour 1, 5 au tour 2, 8 au tour 3 et 1 au tour 4.
const concentre = mesures(CONCENTRE);
const reparti = mesures(PLAN_PARFAIT_PETITE);
assert.deepEqual(collisions(concentre), [4, 1, 3]);
assert.deepEqual(collisions(reparti), [4, 4, 0]);
// Nul ne rencontre plus de 8 personnes sur 11 (§ 15.3).
const huit = new Array(12).fill(8);
const entrees = [
{ id: 1, mesures: concentre, plafondsAPriori: huit },
{ id: 2, mesures: reparti, plafondsAPriori: huit },
];
assert.deepEqual(classer(entrees).ordre, [2, 1]);
// Le plafond a priori inconnu, l'excédent décide seul : 0 contre 3.
assert.deepEqual(classer(entrees.map(inconnu)), {
ordre: [2, 1],
criteresAppliques: SANS_A_PRIORI,
2026-10-06 04:23:02 -04:00
critereSaute: 'ecartAuPlafondAPrioriMax',
});
});
});
describe('classer : les entrées reçues', () => {
test("une liste vide rend un ordre vide, tous les critères appliqués", () => {
assert.deepEqual(classer([]), { ordre: [], criteresAppliques: [...CRITERES], critereSaute: null });
});
test("population vide, plafond a priori connu : le premier critère s'applique, sans rien départager", () => {
// Une population vide n'a pas de plus grand écart au plafond a priori ;
// ce n'est pas un plafond a priori inconnu, et rien n'est sauté.
2026-10-06 04:23:02 -04:00
const vide = (id) => ({ ...proposition(id, { rencontres: [] }), plafondsAPriori: [] });
assert.deepEqual(classer([vide(2), vide(1)]), {
ordre: [1, 2],
criteresAppliques: [...CRITERES],
critereSaute: null,
});
});
test('un identifiant répété lève RangeError ; un identifiant non entier, TypeError', () => {
assert.throws(() => classer([proposition(1), proposition(2), proposition(1)]), RangeError);
assert.throws(() => classer([proposition('1')]), TypeError);
assert.throws(() => classer([proposition(1.5)]), TypeError);
});
test("un plafond a priori undefined lève TypeError ; d'une autre longueur que les rencontres, RangeError", () => {
const sansAPriori = { ...proposition(2), plafondsAPriori: undefined };
assert.throws(() => classer([proposition(1), sansAPriori]), TypeError);
assert.throws(() => classer([{ ...proposition(1), plafondsAPriori: [8, 8] }]), RangeError);
});
test('des mesures de populations de tailles différentes lèvent RangeError', () => {
const quatre = {
2026-10-06 04:23:02 -04:00
...proposition(2, { rencontres: [8, 8, 8, 8] }),
plafondsAPriori: [8, 8, 8, 8],
};
assert.throws(() => classer([proposition(1), quatre]), RangeError);
});
});