247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
|
|
// © 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
|
|||
|
|
// réservations ; redondance est r(p) pour chacun.
|
|||
|
|
function proposition(
|
|||
|
|
id,
|
|||
|
|
{
|
|||
|
|
rencontres = [8, 8, 8],
|
|||
|
|
collisions = [0, 0, 0],
|
|||
|
|
repetees = 0,
|
|||
|
|
imposees = 0,
|
|||
|
|
redondance = [0, 0, 0],
|
|||
|
|
} = {},
|
|||
|
|
) {
|
|||
|
|
const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions;
|
|||
|
|
return {
|
|||
|
|
id,
|
|||
|
|
mesures: {
|
|||
|
|
rencontres,
|
|||
|
|
collisionsCumulees,
|
|||
|
|
pairesDistinctes,
|
|||
|
|
excedentCollisions,
|
|||
|
|
rencontresRepetees: { choisies: repetees, imposees },
|
|||
|
|
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, [
|
|||
|
|
'ecartAPrioriMax',
|
|||
|
|
'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
|
|||
|
|
proposition(5, { redondance: [1, 0, 0] }), // 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]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('la redondance se somme sur les personnes', () => {
|
|||
|
|
// 1 : 1, 1, 1, somme 3 et plus grande 1 ; 2 : 2, 0, 0, somme 2 et plus grande 2.
|
|||
|
|
const entrees = [proposition(1, { redondance: [1, 1, 1] }), proposition(2, { redondance: [2, 0, 0] })];
|
|||
|
|
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.
|
|||
|
|
const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAPrioriMax' };
|
|||
|
|
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) =>
|
|||
|
|
proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: [1, 0, 0] }),
|
|||
|
|
);
|
|||
|
|
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,
|
|||
|
|
critereSaute: 'ecartAPrioriMax',
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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é.
|
|||
|
|
const vide = (id) => ({ ...proposition(id, { rencontres: [], redondance: [] }), 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 = {
|
|||
|
|
...proposition(2, { rencontres: [8, 8, 8, 8], redondance: [0, 0, 0, 0] }),
|
|||
|
|
plafondsAPriori: [8, 8, 8, 8],
|
|||
|
|
};
|
|||
|
|
assert.throws(() => classer([proposition(1), quatre]), RangeError);
|
|||
|
|
});
|
|||
|
|
});
|