[ADD] generation: best proposition block with its cumulated collisions

The generation screen gave no sign of where the ranking stood, so the
operator had to switch to the propositions to see whether another run was
worth it. A block at the top now names the rank-1 proposition and its
cumulated collisions (pair and tour), read from the same evaluation table
as the propositions, with the same explanation tooltip. It says so in
words when no proposition is ranked, and stays visible in reading mode,
on a blocked plan and during a generation.
Checked: 2886 node, 599 browser tests, from the index alone.

--- FR ---

[ADD] génération : bloc de la meilleure proposition, collisions cumulées

L'écran de génération ne disait rien du classement : l'opérateur devait
passer aux propositions pour juger si une autre génération valait la
peine. Un bloc en tête nomme la proposition de rang 1 et ses collisions
cumulées (paire et tour), lues dans la même table d'évaluation que les
propositions, avec la même infobulle d'explication. Il le dit en mots
quand aucune proposition n'est classée, et reste visible en lecture, sur
un plan bloqué et pendant une génération.
Vérifié : 2886 node, 599 navigateur, depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-08 05:27:32 -04:00
parent afdad31ef3
commit 654cde4f10
5 changed files with 660 additions and 4 deletions

View file

@ -0,0 +1,358 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La meilleure proposition en tête de la section de la génération, dans un
// vrai moteur de rendu (§ 5.4, § 5.7, § 14.5, § 14.6) : un bloc d'information
// qui nomme la proposition au rang 1 du classement et ses collisions
// cumulées, lues dans le tableau des propositions de la même épreuve ; les
// mots quand aucune ne se classe ; l'explication du libellé dans
// l'infobulle ; un bloc qui se lit en lecture, sur un plan bloqué et
// pendant un calcul ; son texte contrasté dans les deux thèmes.
//
// Chaque épreuve monte App par monterApp, sur le système de fichiers
// simulé, et nomme son exécuteur : le direct, ou un exécuteur que l'épreuve
// résout à la main pour garder un calcul en cours. Le rendu se lit par les
// attributs data-… ; le texte attendu vient de la même traduction que celle
// de l'application ; les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { creerFormat } from '../../application/format.js';
import { ErreurAnnulee, creerExecuteurDirect } from '../../application/generation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { configurationDepuisCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import {
attenuations,
contraste,
couleurTexte,
fondPeintSous,
porteursDeTexte,
sousTheme,
} from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { COLONNE_ANNONCEE } from './meilleure.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
// n'a rien reçu pendant l'épreuve.
let montes = [];
async function monter(options) {
const m = await monterApp(options);
montes.push(m);
return m;
}
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// Un exécuteur que l'épreuve résout à la main : chaque lancer est relevé,
// avec resoudre(propositions).
function executeurAMain() {
const executions = [];
const executeur = {
lancer(configuration, reglages) {
let resoudre;
let rejeter;
const resultat = new Promise((ok, ko) => {
resoudre = ok;
rejeter = ko;
});
executions.push({ configuration, reglages, resoudre });
return {
surAvancement: () => () => undefined,
resultat,
annuler: () => rejeter(new ErreurAnnulee()),
};
},
};
return { executeur, executions };
}
// --- Les lectures du rendu ----------------------------------------------------
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const section = (m) => dans(m, '[data-section="generation"]');
const bloc = (m) => section(m)?.querySelector('[data-meilleure]') ?? null;
const vide = (m) => bloc(m)?.querySelector('[data-meilleure-vide]') ?? null;
const identifiant = (m) => bloc(m)?.querySelector('[data-meilleure-id]') ?? null;
const collisions = (m) => bloc(m)?.querySelector('[data-meilleure-collisions]') ?? null;
const terme = (m) => bloc(m)?.querySelector(`[data-terme="${COLONNE_ANNONCEE}"]`) ?? null;
const infobulle = (m) => section(m).querySelector('[role="tooltip"]');
const generer = (m) => section(m).querySelector('[data-commande="generer"]');
const charge = (m) => m.seance.etat().charge;
// Attend que l'assertion passe, le rendu de Svelte à jour, image après image.
async function attendre(assertion, images = 600) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= images) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// Attend que la file de la séance ait passé ce qui s'y trouve, puis le rendu.
async function passer(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
flushSync();
}
// Le centre de la boîte rendue d'un élément, ramené dans la fenêtre.
function centre(element) {
element.scrollIntoView({ block: 'center' });
const { left, top, width, height } = element.getBoundingClientRect();
return { x: left + width / 2, y: top + height / 2 };
}
// Un clic réel au centre de l'élément, puis la file de la séance passée.
async function cliquer(m, element) {
expect(element, 'élément à cliquer').not.toBeNull();
const { x, y } = centre(element);
await pointer('presser', x, y);
await pointer('relacher', x, y);
await passer(m);
}
// Ce que le bloc annonce : l'identifiant et les collisions, tels qu'écrits.
const annonce = (m) => ({ id: identifiant(m).textContent, collisions: collisions(m).textContent });
// Ce que la section des propositions affiche au rang 1, l'en-tête de la
// colonne des collisions cumulées compris ; la section de la génération
// rouverte ensuite.
async function rangUnDuTableau(m) {
m.ui.allerA('propositions');
flushSync();
const lu = await attendre(() => {
const ligne = dans(m, '[data-section="propositions"] tbody[data-classement] tr[data-proposition]');
expect(ligne, 'la ligne au rang 1').not.toBeNull();
expect(ligne.querySelector('[data-colonne="rang"]').textContent).toBe(format.nombre(1));
return {
id: ligne.querySelector('[data-colonne="proposition"] .identifiant').textContent,
collisions: ligne.querySelector('[data-colonne="collisionsCumulees"]').textContent,
entete: dans(m, '[data-section="propositions"] thead th[data-colonne="collisionsCumulees"]').textContent,
idAttribut: ligne.getAttribute('data-proposition'),
};
});
m.ui.allerA('generation');
flushSync();
return lu;
}
// La petite démonstration chargée, en écriture sauf ecriture faux, la
// section de la génération ouverte.
async function demonstration(cle, { executeur = creerExecuteurDirect(), ecriture = true } = {}) {
const m = await monter({ executeur });
await m.facade.chargerDemonstration(cle);
if (ecriture) await m.seance.passerEnEcriture();
m.ui.allerA('generation');
flushSync();
return m;
}
// Une génération du moteur de plus, enregistrée par la séance.
async function enregistrer(m, reglages) {
const configuration = configurationDepuisCharge(charge(m));
const propositions = rechercher(configuration, { historique: 1000, ...reglages });
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
flushSync();
}
// --- Les épreuves -----------------------------------------------------------------
describe('la meilleure proposition, en tête de la génération (§ 5.4, § 5.7)', () => {
test('événement sans proposition : un bloc titré en tête de la section dit en mots qu’aucune proposition n’est classée', async () => {
const m = await demonstration('petite');
expect(charge(m).propositions).toEqual([]);
const region = bloc(m);
expect(region).not.toBeNull();
// En tête : avant tout réglage et avant « Générer ».
const premierChamp = section(m).querySelector('input');
expect(premierChamp).not.toBeNull();
expect(region.compareDocumentPosition(premierChamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(region.compareDocumentPosition(generer(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(region.getBoundingClientRect().bottom).toBeLessThanOrEqual(premierChamp.getBoundingClientRect().top);
// Une région nommée par son titre.
const titre = document.getElementById(region.getAttribute('aria-labelledby'));
expect(titre?.textContent).toBe(t('generation.meilleure.titre'));
expect(region.contains(titre)).toBe(true);
expect(vide(m)?.textContent).toBe(t('generation.meilleure.aucune'));
expect(identifiant(m)).toBeNull();
expect(collisions(m)).toBeNull();
expect(region.textContent).not.toContain(t('format.vide'));
});
test('après une génération : la proposition et ses collisions cumulées égalent le rang 1 du tableau des propositions ; une génération de plus les relit', async () => {
const m = await demonstration('petite-conflit');
expect(vide(m)).not.toBeNull();
await m.seance.executer('modifierReglage', { cle: 'generation.nombre', valeur: 3 });
await m.seance.executer('modifierReglage', { cle: 'generation.arret', valeur: 300 });
flushSync();
await cliquer(m, generer(m));
await attendre(() => expect(charge(m).propositions.length).toBe(3));
await attendre(() => expect(collisions(m)).not.toBeNull());
expect(vide(m)).toBeNull();
const premiere = annonce(m);
const tableau = await rangUnDuTableau(m);
expect(premiere).toEqual({ id: tableau.id, collisions: tableau.collisions });
expect(premiere.id).toBe(format.nombre(Number(tableau.idAttribut)));
expect(premiere.collisions).toMatch(/\d/);
// Le libellé est l'en-tête de la colonne du tableau.
expect(terme(m).textContent).toBe(tableau.entete);
expect(COLONNE_ANNONCEE).toBe('collisionsCumulees');
expect(terme(m).textContent).toBe(t('propositions.colonne.collisionsCumulees'));
expect(bloc(m).querySelector('[data-meilleure-libelle-id]').textContent).toBe(t('generation.meilleure.proposition'));
// Une seconde génération, sous la graine suivante : six propositions,
// et le bloc relit le rang 1 du nouveau classement.
await cliquer(m, generer(m));
await attendre(() => expect(charge(m).propositions.length).toBe(6));
const seconde = await attendre(() => annonce(m));
const tableau2 = await rangUnDuTableau(m);
expect(seconde).toEqual({ id: tableau2.id, collisions: tableau2.collisions });
});
test('effacer les propositions ramène les mots ; le rang 1 se relit à chaque charge écrite', async () => {
const m = await demonstration('petite-conflit');
await enregistrer(m, { graine: 5, nombre: 2, arret: 300 });
await attendre(() => expect(collisions(m)).not.toBeNull());
const tableau = await rangUnDuTableau(m);
expect(annonce(m)).toEqual({ id: tableau.id, collisions: tableau.collisions });
await m.seance.executer('effacerPropositions', {});
flushSync();
expect(charge(m).propositions).toEqual([]);
await attendre(() => expect(vide(m)?.textContent).toBe(t('generation.meilleure.aucune')));
expect(collisions(m)).toBeNull();
expect(identifiant(m)).toBeNull();
});
test('aucune comparable — une personne exclue après la génération — : les mots, comme sans proposition', async () => {
const m = await demonstration('petite');
await enregistrer(m, { graine: 3, nombre: 2, arret: 300 });
await attendre(() => expect(collisions(m)).not.toBeNull());
await m.seance.executer('exclureParticipant', { id: charge(m).participants[0].id });
flushSync();
expect(charge(m).propositions.length).toBe(2);
await attendre(() => expect(vide(m)?.textContent).toBe(t('generation.meilleure.aucune')));
expect(collisions(m)).toBeNull();
});
test('l’infobulle du libellé : au survol réel et au focus du clavier, l’explication de la colonne du tableau ; Échap et la sortie la referment', async () => {
const m = await demonstration('petite-conflit');
await enregistrer(m, { graine: 5, nombre: 1, arret: 300 });
const libelle = await attendre(() => {
expect(terme(m)).not.toBeNull();
return terme(m);
});
const bulle = infobulle(m);
expect(bulle).not.toBeNull();
expect(bulle.hidden).toBe(true);
expect(libelle.hasAttribute('aria-describedby')).toBe(false);
const { x, y } = centre(libelle);
await pointer('bouger', x, y);
await attendre(() => {
expect(bulle.hidden).toBe(false);
expect(getComputedStyle(bulle).visibility).toBe('visible');
expect(bulle.getBoundingClientRect().width).toBeGreaterThan(0);
});
expect(bulle.textContent).toBe(t('propositions.explication.collisionsCumulees'));
expect(libelle.getAttribute('aria-describedby')).toBe(bulle.id);
// L'infobulle ne couvre pas le terme qu'elle explique.
const a = libelle.getBoundingClientRect();
const b = bulle.getBoundingClientRect();
expect(b.top >= a.bottom || b.bottom <= a.top).toBe(true);
await pointer('bouger', 2, 2);
await attendre(() => expect(bulle.hidden).toBe(true));
expect(libelle.hasAttribute('aria-describedby')).toBe(false);
// Au clavier : le terme se focalise, l'infobulle s'ouvre ; Échap la
// referme, le focus restant sur le terme.
libelle.focus();
flushSync();
expect(document.activeElement).toBe(libelle);
await attendre(() => expect(bulle.hidden).toBe(false));
expect(bulle.textContent).toBe(t('propositions.explication.collisionsCumulees'));
await touche('Escape');
await attendre(() => expect(bulle.hidden).toBe(true));
expect(document.activeElement).toBe(libelle);
});
test('en lecture, sur un plan bloqué et pendant un calcul : le bloc se lit, et rien ne s’écrit pour lui', async () => {
const { executeur, executions } = executeurAMain();
const m = await demonstration('petite-conflit', { executeur });
await enregistrer(m, { graine: 5, nombre: 2, arret: 300 });
const attendu = await rangUnDuTableau(m);
const lu = () => ({ id: attendu.id, collisions: attendu.collisions });
await m.seance.passerEnLecture();
flushSync();
expect(m.seance.etat().mode).toBe('lecture');
const ecritures = m.trace.length;
await attendre(() => expect(annonce(m)).toEqual(lu()));
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
await m.seance.executer('retenirProposition', { id: charge(m).propositions[1].id });
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
expect(m.seance.etat().refus?.details.raison).toBe('BLOQUE');
await attendre(() => expect(annonce(m)).toEqual(lu()));
// Pendant un calcul : le classement tel qu'il est.
await m.seance.debloquer();
flushSync();
expect(m.seance.etat().refus).toBeNull();
await cliquer(m, generer(m));
await attendre(() => expect(executions.length).toBe(1));
expect(section(m).getAttribute('aria-busy')).toBe('true');
expect(annonce(m)).toEqual(lu());
m.app.generation().annuler();
await attendre(() => expect(section(m).getAttribute('aria-busy')).toBe('false'));
});
test('le texte du bloc tient 4,5:1 sur son fond, sans atténuation, dans les deux thèmes, avec et sans proposition', async () => {
const m = await demonstration('petite-conflit');
const mesurer = async (etat) => {
for (const theme of ['clair', 'sombre']) {
await sousTheme(theme, async () => {
flushSync();
bloc(m).scrollIntoView({ block: 'center' });
const porteurs = porteursDeTexte(bloc(m));
expect(porteurs.length, `${etat}, ${theme} : porteurs de texte`).toBeGreaterThan(0);
for (const porteur of porteurs) {
expect(attenuations(porteur), `${etat}, ${theme}`).toEqual([]);
const couleur = couleurTexte(porteur);
const fonds = fondPeintSous(porteur);
expect(fonds.length, `${etat}, ${theme} : fond`).toBeGreaterThan(0);
for (const fond of fonds) expect(contraste(couleur, fond), `${etat}, ${theme}`).toBeGreaterThanOrEqual(4.5);
}
});
}
};
await mesurer('sans proposition');
await enregistrer(m, { graine: 5, nombre: 1, arret: 300 });
await attendre(() => expect(collisions(m)).not.toBeNull());
await mesurer('avec proposition');
});
});

View file

@ -3,7 +3,8 @@
<script>
// La section de la génération (§ 2.1 étape 9, § 5.7, § 5.9, § 5.10,
// § 15.4) : les réglages, « Générer », la question d'une génération longue,
// § 15.4) : en tête, la meilleure proposition du classement ; puis les
// réglages, « Générer », la question d'une génération longue,
// l'avancement d'un calcul et l'issue du dernier. Tout passe par le
// contrôleur de génération du contexte, qui seul contrôle, lance, mesure et
// enregistre ; la section ne refait aucun de ses contrôles.
@ -29,14 +30,30 @@
// refus : l'état prêt revient, et l'issue le dit. Le contrôleur vit plus
// longtemps que la section : remontée pendant un calcul, elle en retrouve
// l'avancement par subscribe.
//
// La meilleure proposition (§ 5.4, § 5.7) est un bloc d'information en
// lecture seule : l'identifiant de la proposition au rang 1 du classement
// et ses collisions cumulées, la cellule même que le tableau des
// propositions porte à ce rang (meilleureProposition, meilleure.js), relue
// à chaque charge écrite — une génération enregistrée, des propositions
// effacées. Sans proposition classée, il le dit en mots. Il se lit en
// lecture, sur un plan bloqué et pendant un calcul, où il montre le
// classement tel qu'il est. Le libellé des collisions est l'en-tête de la
// colonne du tableau ; son survol ou son focus ouvre l'infobulle de la
// section, une seule, sur l'explication de cette colonne, et le quitter la
// referme ; le terme expliqué porte aria-describedby vers elle.
import { getContext, onDestroy, untrack } from 'svelte';
import { ErreurCommande } from '../../application/erreurs.js';
import { ErreurAnnulee } from '../../application/generation.js';
import Commande from '../primitives/Commande.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
import { explicationDeColonne, texteCellule } from '../propositions/modele.js';
import Avancement from './Avancement.svelte';
import Reglages from './Reglages.svelte';
import { COLONNE_ANNONCEE, meilleureProposition } from './meilleure.js';
const t = getContext('t');
const format = getContext('format');
const seance = getContext('seance');
const generation = getContext('generation');
@ -65,6 +82,21 @@
const charge = $derived($seance.charge);
// La proposition au rang 1, { id, cellule }, ou null sans proposition
// classée.
const meilleure = $derived(charge === null ? null : meilleureProposition(charge));
const uid = $props.id();
const idTitreMeilleure = `${uid}-meilleure`;
const idExplication = `${uid}-explication`;
// L'explication ouverte, {ancre, cle, details}, ou null : celle du libellé
// des collisions cumulées, le seul terme expliqué de la section.
let expliquee = $state.raw(null);
function expliquer(evenement) {
expliquee = { ancre: evenement.currentTarget, cle: explicationDeColonne(COLONNE_ANNONCEE), details: {} };
}
const taire = () => (expliquee = null);
// La question tombe quand la charge change.
$effect.pre(() => {
void charge;
@ -127,6 +159,37 @@
<section data-section="generation" aria-busy={$generation.enCours ? 'true' : 'false'}>
<h2>{t('coquille.section.generation')}</h2>
{#if charge !== null}
<section class="meilleure" data-meilleure aria-labelledby={idTitreMeilleure}>
<h3 id={idTitreMeilleure}>{t('generation.meilleure.titre')}</h3>
{#if meilleure === null}
<p data-meilleure-vide>{t('generation.meilleure.aucune')}</p>
{:else}
<dl>
<div>
<dt data-meilleure-libelle-id>{t('generation.meilleure.proposition')}</dt>
<dd data-meilleure-id>{format.nombre(meilleure.id)}</dd>
</div>
<div>
<!-- Le libellé est un terme, non une commande : le focus ne sert
qu'à montrer au clavier l'explication que le pointeur montre
au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<dt
class="terme"
data-terme={COLONNE_ANNONCEE}
tabindex="0"
aria-describedby={expliquee === null ? undefined : idExplication}
onmouseenter={expliquer}
onmouseleave={taire}
onfocus={expliquer}
onblur={taire}>{t('propositions.colonne.collisionsCumulees')}</dt
>
<dd data-meilleure-collisions>{texteCellule(meilleure.cellule, { t, format })}</dd>
</div>
</dl>
{/if}
</section>
<Infobulle id={idExplication} ouverte={expliquee} fermer={taire} />
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} bind:refusCoeurs />
<div class="lancer">
<Commande cle="generation.commande.generer" nom="generer" executer={() => lancer(false)} />
@ -164,6 +227,53 @@
font-size: var(--taille-titre);
}
/* La meilleure proposition : un bloc d'information encadré d'un filet,
sur le fond de la page ; chaque libellé suivi de sa valeur, en gras. */
.meilleure {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
h3 {
margin: 0;
font-size: var(--taille-texte);
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2) var(--espace-5);
margin: 0;
}
dl > div {
display: flex;
gap: var(--espace-2);
align-items: baseline;
}
dd {
margin: 0;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
/* Le terme expliqué se signale par un soulignement pointillé, comme un
en-tête de colonne du tableau des propositions. */
.terme {
text-decoration: underline dotted;
text-underline-offset: 0.2em;
}
.terme:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.question {
display: flex;
flex-direction: column;

View file

@ -0,0 +1,35 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La meilleure proposition, que la section de la génération annonce en tête
// (§ 5.4, § 5.7), pure : SectionGeneration.svelte la câble.
//
// Elle se lit dans le tableau des propositions, sur la table d'évaluation
// de la charge (evaluation et tableau, propositions/modele.js) : la même
// mesure, le même classement et la même cellule que la section des
// propositions affiche au rang 1 ; aucune valeur ne se recalcule ici.
import { evaluation, tableau } from '../propositions/modele.js';
/** La colonne du tableau que l'annonce reprend. */
export const COLONNE_ANNONCEE = 'collisionsCumulees';
/**
* La proposition au rang 1 du classement, et sa cellule des collisions
* cumulées.
*
* @param {Object} charge la charge de l'état observable de la séance
* @returns {null|{id: number, cellule: {colonne: string, valeur: number|null,
* cle: string|null, decimales: number}}} null quand aucune proposition ne
* se classe : aucune proposition, aucune comparable — population changée —,
* ou une configuration que la mesure refuse ; sinon l'identifiant de la
* première ligne du classement et sa cellule, telle que le tableau la porte,
* que texteCellule écrit
*/
export function meilleureProposition(charge) {
const { table } = evaluation(charge);
if (table === null) return null;
const [premiere] = tableau(table).lignes;
if (premiere === undefined) return null;
return { id: premiere.id, cellule: premiere.cellules.find(({ colonne }) => colonne === COLONNE_ANNONCEE) };
}

View file

@ -0,0 +1,143 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la meilleure proposition que la section de la génération
// annonce (§ 5.4, § 5.7) : la première du classement, son identifiant et sa
// cellule des collisions cumulées, celle du tableau des propositions. La
// valeur attendue vient de la table d'évaluation et du tableau, sollicités
// une seconde fois (§ 19.1). Les charges partent de la petite démonstration
// livrée ou s'écrivent ici ; les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { evaluer } from '../../application/evaluation.js';
import { texteLivre } from '../../demo/livrees.js';
import { rechercher } from '../../moteur/recherche.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { tableau } from '../propositions/modele.js';
import { meilleureProposition } from './meilleure.js';
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
// La charge, une génération du moteur de plus, enregistrée par la commande.
function generee(charge, reglages) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return executer(charge, 'enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee });
}
// Six personnes inventées, deux tables de 3, quatre tours ; les deux
// premières partagent une appartenance.
function chargeSix() {
const charge = creerCharge({ id: 'evt-meilleure', nom: 'Club des Merles', siegesParDefaut: 3, tours: 4 });
const noms = [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Grisaille', null], ['Bruyère', 'Anouk'], ['Sarcelle', 'Capucine']];
charge.participants = noms.map(([nom, prenom], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: rang < 2 ? 'Club des Merles' : null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
return charge;
}
// Une proposition écrite à la main, sièges non attribués, sur les deux
// tables de la charge ; ses listes données tour par tour.
function propositionDe(charge, id, tours) {
return {
id,
graine: id,
arret: 40,
historique: 1000,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: charge.tables.map(({ id: table }) => table),
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: tours.length,
participants: charge.participants.map(({ id: p }) => p),
placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste]), reserve: [] })),
};
}
// Les deux collègues ensemble aux quatre tours : 4 collisions cumulées.
const ENSEMBLE = [
[[1, 2, 3], [4, 5, 6]],
[[1, 2, 4], [3, 5, 6]],
[[1, 2, 5], [3, 4, 6]],
[[1, 2, 6], [3, 4, 5]],
];
// Les deux collègues séparés à chaque tour : aucune collision.
const SEPARES = [
[[1, 3, 4], [2, 5, 6]],
[[1, 5, 6], [2, 3, 4]],
[[1, 3, 5], [2, 4, 6]],
[[1, 4, 6], [2, 3, 5]],
];
// La cellule des collisions cumulées de la première ligne du tableau des
// propositions, et son identifiant : ce que la section des propositions
// affiche au rang 1.
function rangUn(charge) {
const [premiere] = tableau(evaluer(charge)).lignes;
return { id: premiere.id, cellule: premiere.cellules.find(({ colonne }) => colonne === 'collisionsCumulees') };
}
describe('la meilleure proposition (§ 5.4, § 5.7)', () => {
test('sans proposition ni retenu : null', () => {
assert.equal(meilleureProposition(chargeDemo('petite')), null);
assert.equal(meilleureProposition(chargeSix()), null);
});
test('après des générations : le rang 1 du tableau, son identifiant et sa cellule des collisions cumulées', () => {
let charge = generee(chargeDemo('petite-conflit'), { graine: 4, nombre: 3, arret: 300, historique: 1000 });
charge = generee(charge, { graine: 9, nombre: 2, arret: 300, historique: 1000 });
assert.equal(charge.propositions.length, 5);
const attendu = rangUn(charge);
const rendu = meilleureProposition(charge);
assert.deepEqual(rendu, attendu);
assert.equal(rendu.cellule.colonne, 'collisionsCumulees');
const ligne = evaluer(charge).propositions[0];
assert.equal(ligne.statut, 'comparable');
assert.equal(rendu.id, ligne.id);
assert.equal(rendu.cellule.valeur, ligne.mesures.collisionsCumulees);
assert.equal(typeof rendu.cellule.valeur, 'number');
});
test('le rang, ni le plus petit identifiant ni le moins de collisions : la 2, à écart au plafond nul, passe devant la 1, qui sépare les collègues', () => {
// Séparer les deux collègues leur retire une rencontre : la 1 porte un
// écart au plafond a priori de 1, la 2 aucun ; le premier critère du
// classement met la 2 en tête, avec ses 4 collisions cumulées.
const charge = chargeSix();
charge.propositions = [propositionDe(charge, 1, SEPARES), propositionDe(charge, 2, ENSEMBLE)];
charge.prochainsIds.proposition = 3;
const lignes = evaluer(charge).propositions;
assert.deepEqual(
lignes.map((ligne) => [ligne.id, ligne.statut, ligne.ecartAuPlafondAPrioriMax, ligne.mesures.collisionsCumulees]),
[
[2, 'comparable', 0, 4],
[1, 'comparable', 1, 0],
],
);
const rendu = meilleureProposition(charge);
assert.equal(rendu.id, 2);
assert.equal(rendu.cellule.valeur, 4);
assert.deepEqual(rendu, rangUn(charge));
});
test('aucune proposition comparable — population changée — : null', () => {
const charge = generee(chargeDemo('petite'), { graine: 3, nombre: 2, arret: 300, historique: 1000 });
assert.notEqual(meilleureProposition(charge), null);
const exclue = executer(charge, 'exclureParticipant', { id: charge.participants[0].id });
const statuts = evaluer(exclue).propositions.map(({ statut }) => statut);
assert.equal(statuts.length, 2);
assert.ok(statuts.every((statut) => statut !== 'comparable'), statuts.join());
assert.equal(meilleureProposition(exclue), null);
});
});

View file

@ -1,9 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les libellés de la génération : réglages, cœurs de calcul, durée observée,
// question d'une génération longue, avancement et issue (§ 5.7, § 5.10,
// § 14.6). Chaque
// Les libellés de la génération : meilleure proposition du classement,
// réglages, cœurs de calcul, durée observée, question d'une génération
// longue, avancement et issue (§ 5.4, § 5.7, § 5.10, § 14.6). Chaque
// clé commence par « generation. ». Le libellé d'un réglage porte son unité,
// entre parenthèses : l'étiquette du champ la dit, et le nom accessible du
// champ avec elle. Un nombre et une durée se mettent en forme par
@ -30,6 +30,16 @@ function calculMesure({ dureeMs, nombre, arret, coeurs }, texte) {
}
export const TABLE = new Map([
// --- La meilleure proposition, en tête de la section (§ 5.4, § 5.7) --------
// Le libellé de ses collisions cumulées et son explication sont ceux de la
// colonne du tableau des propositions, sous leurs clés propositions.….
['generation.meilleure.titre', 'Meilleure proposition du classement'],
['generation.meilleure.proposition', 'Proposition au rang 1'],
[
'generation.meilleure.aucune',
"Aucune proposition classée : sans proposition comparable, le classement n'en désigne aucune.",
],
// --- Les réglages (§ 5.7, § 5.10) ---------------------------------------------
['generation.reglage.graine', 'Graine (nombre entier, de 0 à 4\u{202F}294\u{202F}967\u{202F}295)'],
['generation.reglage.nombre', 'Propositions par génération (nombre de propositions)'],