gestion_table_tournante_libre/src/moteur/integration.test.js

218 lines
12 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve d'intégration du moteur, de la configuration au classement, sur la
// petite démonstration et sa variante conflit (§ 5.5, § 5.6, § 5.7,
// § 15.3). Chaque proposition de rechercher traverse la chaîne entière :
// verifierInvariants juge son plan ; mesurer et plafondsRealises le
// mesurent ; verifierIndicateurs éprouve la ligne de chacun contre le
// plafond a priori ; classer ordonne les propositions, et minimumAtteint lit
// le certificat de la première ; diagnostiquer fournit les planchers que la
// meilleure respecte. Chaque quantité vient de son module (§ 13.2) :
// l'épreuve n'en recalcule aucune.
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 { diagnostiquer } from './diagnostic.js';
import { mesurer } from './indicateurs.js';
import { minimumAtteint } from './manque.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { rechercher } from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
// Réglages de la génération : une graine fixée, écrite ici (§ 15.5, point 2).
// À 200 000 mouvements, avec cette graine, les trois propositions de chaque
// démonstration rencontrent 8 pour les douze, sans rencontre répétée ni
// retour choisi, et ne portent que les collisions que le diagnostic impose :
// aucune dans la petite démonstration, 4 dans sa variante. Les assertions
// des épreuves tiennent avec cette graine dès 80 000 mouvements : 200 000
// leur laisse une marge de 2,5. Sur les graines 1 à 64, elles tiennent
// toutes à 200 000 ; à 150 000, deux graines en font échouer.
const REGLAGES = Object.freeze({ graine: 314_159, arret: 200_000, nombre: 3 });
// La même génération, arrêtée au premier mouvement. Aucune de ses
// propositions n'atteint son plafond a priori : avec cette graine, leurs
// écarts au plafond a priori maximaux valent 2, 3 et 3, et sur les graines
// 1 à 64, aucune proposition brève ne l'atteint non plus.
const REGLAGES_BREVES = Object.freeze({ ...REGLAGES, arret: 1 });
// Configuration neuve d'une démonstration du catalogue.
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
// Génère les propositions d'une configuration et les fait traverser la
// chaîne. Chaque plan est jugé par verifierInvariants avant d'être indexé :
// indexerPlan n'examine ni capacités ni réservations et lève à la première
// faute de forme, là où le vérificateur les nomme toutes. Vient ensuite la
// ligne N − 1 ≥ a priori ≥ réalisé ≥ rencontres de chacun. decalage
// s'ajoute aux identifiants que rend rechercher, comme l'appelant qui
// accumule des générations donne à chaque proposition un identifiant unique
// (§ 5.7). Rend le plafond a priori de chacun, et les propositions dans
// l'ordre de leurs identifiants, avec leurs mesures.
function generer(configuration, etiquette, reglages, decalage = 0) {
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, reglages).map(({ id, plan }) => {
const nom = `${etiquette}, proposition ${id + decalage}`;
assert.deepEqual(verifierInvariants(instance, plan), [], nom);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], nom);
return { id: id + decalage, mesures };
});
// Un parcours vide ne prouverait rien (§ 14.2).
assert.equal(propositions.length, reglages.nombre, etiquette);
return { aPriori, propositions };
}
// Le classement de propositions mesurées sur une même configuration, et
// celle qu'il met en tête.
function classerPropositions({ aPriori, propositions }) {
const classement = classer(
propositions.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })),
);
const premiere = propositions.find(({ id }) => id === classement.ordre[0]);
return { classement, premiere };
}
// La génération de la variante conflit aux réglages de l'épreuve, calculée
// au premier appel puis partagée par les deux épreuves qui la lisent : la
// recherche en est le coût.
let variante = null;
function genererVariante() {
variante ??= generer(demo('petite-conflit'), 'petite-conflit', REGLAGES);
return variante;
}
// Le plan écrit du § 15.3 fait visiter les quatre tables à chacun : aucun
// retour choisi. Le classement ne lit pas les retours (§ 5.7) : entre
// propositions égales sur ses cinq critères, l'identifiant désigne la
// première, qu'elle garde des retours ou non. L'épreuve exige donc le zéro
// d'une proposition au moins, la preuve que la recherche tient compte de
// nouvelleTable.
function exigerUneSansRetourChoisi(etiquette, propositions) {
const retours = propositions.map(({ mesures }) => mesures.totalRetoursChoisis);
assert.ok(retours.includes(0), `${etiquette} : retours choisis par proposition ${retours.join(', ')}`);
}
describe('intégration : de la configuration au classement (§ 5.5, § 5.7, § 15.3)', () => {
test('petite : chaque proposition tient invariants et indicateurs et ne porte aucune collision, une au moins aucun retour choisi ; la première du classement porte « minimum atteint »', () => {
const generation = generer(demo('petite'), 'petite', REGLAGES);
const { classement, premiere } = classerPropositions(generation);
// Le plafond a priori est connu : aucun critère n'est sauté, et le
// certificat se lit contre lui (§ 5.5).
assert.deepEqual(classement.criteresAppliques, [...CRITERES]);
assert.equal(classement.critereSaute, null);
assert.equal(minimumAtteint(premiere.mesures, generation.aPriori), true);
// Le certificat ne lit que les rencontres : il se décerne aussi à un plan
// qui réunit des collègues. Chaque appartenance de quatre membres peut
// se répartir sur les quatre tables à chaque tour, comme dans le plan
// écrit du § 15.3 : aucune proposition ne réunit de collègues, et le
// compteur de collisions se mesure à zéro (§ 15.3).
for (const { id, mesures } of generation.propositions) {
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
}
exigerUneSansRetourChoisi('petite', generation.propositions);
});
test('petite-conflit : chaque proposition porte les 4 collisions du plancher du diagnostic, une au moins aucun retour choisi ; la meilleure respecte les planchers, rencontre 8 pour les douze, sur 4 paires, excédent 0', () => {
const configuration = demo('petite-conflit');
// Le groupe de cinq ne se répartit pas sur quatre tables : au moins une
// collision par tour, 4 cumulées sur les 4 tours, portées par une paire
// distincte au moins (§ 15.3). Le diagnostic les chiffre ; une liste vide
// laisserait la boucle ci-dessous sans rien éprouver.
const { collisions } = diagnostiquer(configuration);
assert.deepEqual(
collisions.map(({ plancherCumulees, plancherPairesDistinctes }) => [
plancherCumulees,
plancherPairesDistinctes,
]),
[[4, 1]],
);
const generation = genererVariante();
const { premiere } = classerPropositions(generation);
// Tout plan valide respecte ces planchers : ils éprouvent l'accord du
// diagnostic et de la mesure, non la recherche.
for (const plancher of collisions) {
const mesure = premiere.mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe);
assert.ok(
mesure.collisionsCumulees >= plancher.plancherCumulees,
`${plancher.groupe} : ${mesure.collisionsCumulees} collisions cumulées, plancher ${plancher.plancherCumulees}`,
);
assert.ok(
mesure.pairesDistinctes >= plancher.plancherPairesDistinctes,
`${plancher.groupe} : ${mesure.pairesDistinctes} paires distinctes, plancher ${plancher.plancherPairesDistinctes}`,
);
}
assert.deepEqual(premiere.mesures.rencontres, Array(12).fill(8));
// La recherche se juge à ses collisions : chaque proposition atteint le
// plancher des collisions cumulées sans le dépasser. La meilleure les
// porte sur 4 paires distinctes, comme le plan écrit du § 15.3 (épreuve
// suivante).
for (const { id, mesures } of generation.propositions) {
assert.equal(mesures.collisionsCumulees, collisions[0].plancherCumulees, `proposition ${id}`);
}
assert.equal(premiere.mesures.pairesDistinctes, 4);
// Deux voisins par tour, quatre tours : qui rencontre 8 personnes n'en
// revoit aucune. Chaque paire se réunit donc une fois au plus, et
// l'excédent 0 découle de 8 pour les douze. Il éprouve mesurer, non la
// recherche.
assert.equal(premiere.mesures.excedentCollisions, 0);
exigerUneSansRetourChoisi('petite-conflit', generation.propositions);
});
test('le plan écrit du § 15.3, mesuré sur la variante conflit : 8 pour les douze, 4 collisions sur 4 paires, excédent 0', () => {
// Le témoin de l'épreuve précédente : la personne passée de C à A
// retrouve un membre de A à chacun des quatre tours, chaque fois un
// autre. Ce que l'épreuve exige de la recherche est donc atteignable.
const instance = normaliser(demo('petite-conflit'));
const mesures = mesurer(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE));
assert.deepEqual(mesures.rencontres, Array(12).fill(8));
assert.equal(mesures.collisionsCumulees, 4);
assert.equal(mesures.pairesDistinctes, 4);
assert.equal(mesures.excedentCollisions, 0);
});
test('petite-conflit, deux générations accumulées : classer met la plus longue en tête, numérotée après la brève, et départage ses propositions égales par identifiant croissant (§ 5.7, § 15.5)', () => {
// La génération brève prend les identifiants 1 à 3, la longue 4 à 6 :
// l'ordre attendu contredit l'ordre des identifiants, et seul un
// classement qui applique ses critères peut le rendre.
const breve = generer(demo('petite-conflit'), 'petite-conflit, génération brève', REGLAGES_BREVES);
const variante = genererVariante();
const longue = {
aPriori: variante.aPriori,
propositions: variante.propositions.map(({ id, mesures }) => ({ id: id + REGLAGES.nombre, mesures })),
};
// Ce que l'ordre départage, mesuré avant d'être lu. Chaque proposition
// longue atteint son plafond a priori, et les trois sont égales sur les
// quatre autres critères du classement ; aucune brève n'atteint le sien.
// Sur une même population, la redondance moyenne ordonne comme la somme
// que lit le classement.
const autresCriteres = ({ mesures }) => [
mesures.excedentCollisions,
mesures.collisionsCumulees,
mesures.rencontresRepetees.choisies,
mesures.aggRedondance.tous.moyenne,
];
for (const proposition of longue.propositions) {
const nom = `proposition ${proposition.id}`;
assert.equal(minimumAtteint(proposition.mesures, longue.aPriori), true, nom);
assert.deepEqual(autresCriteres(proposition), autresCriteres(longue.propositions[0]), nom);
}
for (const { id, mesures } of breve.propositions) {
assert.equal(minimumAtteint(mesures, breve.aPriori), false, `proposition ${id}`);
}
// Le premier critère renvoie les brèves derrière les longues ; les
// longues, égales sur les cinq, se départagent par identifiant croissant
// (§ 15.5, point 4).
const { classement } = classerPropositions({
aPriori: longue.aPriori,
propositions: [...breve.propositions, ...longue.propositions],
});
assert.deepEqual(classement.ordre.slice(0, REGLAGES.nombre), [4, 5, 6]);
assert.deepEqual([...classement.ordre.slice(REGLAGES.nombre)].sort((a, b) => a - b), [1, 2, 3]);
});
});