From 654cde4f1025ed21ef96cc4240836a2ba230eb38 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 8 Oct 2026 05:27:32 -0400 Subject: [PATCH] [ADD] generation: best proposition block with its cumulated collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../generation/Meilleure.navigateur.test.js | 358 ++++++++++++++++++ .../generation/SectionGeneration.svelte | 112 +++++- src/interface/generation/meilleure.js | 35 ++ src/interface/generation/meilleure.test.js | 143 +++++++ src/interface/libelles/generation.js | 16 +- 5 files changed, 660 insertions(+), 4 deletions(-) create mode 100644 src/interface/generation/Meilleure.navigateur.test.js create mode 100644 src/interface/generation/meilleure.js create mode 100644 src/interface/generation/meilleure.test.js diff --git a/src/interface/generation/Meilleure.navigateur.test.js b/src/interface/generation/Meilleure.navigateur.test.js new file mode 100644 index 0000000..db91938 --- /dev/null +++ b/src/interface/generation/Meilleure.navigateur.test.js @@ -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'); + }); +}); diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte index d65b062..5ca8d67 100644 --- a/src/interface/generation/SectionGeneration.svelte +++ b/src/interface/generation/SectionGeneration.svelte @@ -3,7 +3,8 @@