gestion_table_tournante_libre/src/moteur/integration.test.js
Mathieu Benoit 4795114093 [ADD] tests: budget reporter, one import parser, key-order guard
The time budget of § 14.14 had no enforcement: a reporter now prints the
total and the ten slowest tests, and fails a run whose node project
exceeds 30 s or node-long 60 s. Two guards parsed imports with two
patterns, one blind past an apostrophe in a comment; both now use one
parser anchored on « from ». The determinism guard refuses key-order
walks, and one test binds the application name across its three
declarations. The end-to-end generations drop to 100 000 moves.

Checked: each ceiling, the apostrophe case and a key walk are shown
failing before the guard that catches them.

--- FR ---

[ADD] épreuves : rapporteur de budget, imports, ordre des clés

Le budget de temps du § 14.14 n'avait aucune garde : un rapporteur
imprime désormais la durée totale et les dix épreuves les plus lentes,
et fait échouer une exécution dont le projet node dépasse 30 s ou
node-long 60 s. Deux gardes lisaient les imports par deux motifs, l'un
aveugle après une apostrophe en commentaire ; les deux passent par un
seul analyseur ancré sur « from ». La garde de déterminisme refuse les
parcours de clés, et une épreuve lie le nom de l'application à ses
trois déclarations. Les générations de bout en bout passent à 100 000.

Vérifié : chaque plafond, le cas de l'apostrophe et un parcours de clés
sont montrés en échec avant la garde qui les attrape.

Assisted-by: Claude Opus 5.5
2026-10-06 04:23:13 -04:00

227 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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).
// À 100 000 mouvements, avec cette graine, les trois propositions de chaque
// démonstration rencontrent 8 pour les douze, sans rencontre répétée, et ne
// portent que les collisions que le diagnostic impose : aucune dans la
// petite démonstration, 4 dans sa variante. Une proposition de la variante
// garde 15 retours choisis, les cinq autres aucun. Les assertions des
// épreuves tiennent avec cette graine dès 80 000 mouvements, soit une marge
// de 1,25 ; en deçà, les trois propositions de la petite démonstration
// gardent des retours choisis. La marge est celle de cette graine : sur les
// graines 1 à 64, les assertions tiennent pour 43 à 100 000, pour toutes à
// 200 000.
//
// Le compte pèse sur la relance sous surveillance (§ 14.14) : toute
// modification du moteur, ou du générateur que lit le catalogue, rejoue ce
// fichier. Sur deux cœurs, la relance qui suit une modification de
// recherche.js tient en 1,6 s au mur, contre 2,0 s à 200 000 mouvements ;
// celle qui suit indicateurs.js, qui rejoue sept fichiers, prend 2,6 s,
// contre 2,9 s.
const REGLAGES = Object.freeze({ graine: 314_159, arret: 100_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]);
});
});