From 9eb1275f533db37f5184f0cb884b42db1591124b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 8 Oct 2026 06:06:21 -0400 Subject: [PATCH] [ADD] home: best ranked proposition on every event line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each event line of the home lists now names its rank-1 proposition and its cumulated collisions, whether or not a plan is retained, with the same label and tooltip as the generation block. One application function picks the rank-1 proposition for both places, so the list, the generation block and the propositions table show the same proposition and number. The list reads the examined charge, as opening the event does, so a file with a repeated proposition identifier no longer hides the whole folder. Checked: 2923 node, 609 browser, 62 long tests, from the index alone. --- FR --- [ADD] accueil : meilleure proposition classée sur chaque événement Chaque ligne des listes de l'accueil nomme sa proposition de rang 1 et ses collisions cumulées, retenu ou non, avec le même libellé et la même infobulle que le bloc de la génération. Une seule fonction d'application désigne le rang 1 aux deux endroits : la liste, le bloc et le tableau des propositions montrent la même proposition et le même nombre. La liste lit la charge examinée, comme l'ouverture : un fichier dont une proposition répète un identifiant ne fait plus disparaître tout le dossier. Vérifié : 2923 node, 609 navigateur, 62 longues, depuis l'index seul. Assisted-by: Claude Opus 5.5 --- spec.md | 9 +- src/application/evaluation.js | 43 ++++ src/application/evaluation.test.js | 74 +++++- src/application/evenements.js | 47 ++-- src/application/evenements.test.js | 98 +++++++- .../accueil/Accueil.navigateur.test.js | 235 ++++++++++++++---- src/interface/accueil/ListeEvenements.svelte | 116 +++++++-- src/interface/accueil/modele.js | 43 +++- src/interface/accueil/modele.test.js | 66 +++++ src/interface/generation/meilleure.js | 12 +- src/interface/libelles/accueil.js | 13 + test/navigateur/ecrans.js | 14 +- 12 files changed, 659 insertions(+), 111 deletions(-) diff --git a/spec.md b/spec.md index 750b454..bd08d2c 100644 --- a/spec.md +++ b/spec.md @@ -1211,7 +1211,14 @@ tableau des propositions — et la cible celle de l'événement (§ 8.9), 1000 p défaut, réglable dans la section des propositions. Les deux se lisent dans la même mesure que le tableau, au moment de lister, sans rien écrire ; un événement sans retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte -« — ». Chaque intitulé s'explique dans une infobulle. +« — ». Chaque ligne porte aussi la **« Meilleure proposition du classement »**, +sous les libellés du bloc que la section de la génération montre en tête : +l'identifiant de la proposition au rang 1 du classement (§ 5.7) et ses +collisions cumulées — la même proposition et le même nombre que ce bloc et que +la première ligne du tableau des propositions, retenue ou non ; une proposition +fautive, que l'ouverture écarte (§ 8.9, point 3), n'y entre pas. Sans proposition +classée — aucune, ou aucune qui décrive encore la configuration —, l'un et +l'autre valent « — ». Chaque intitulé s'explique dans une infobulle. **Un répertoire vide n'affiche pas une liste vide** : il présente la commande de création et les démonstrations — une liste vide sans issue est le premier écran que voit un opérateur qui lance l'exécutable. diff --git a/src/application/evaluation.js b/src/application/evaluation.js index 23c4637..24574f3 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -21,6 +21,10 @@ // resumeDuRetenu donne à la liste des événements deux nombres du retenu, // lus dans la même mesure que sa ligne du tableau, sans le diagnostic ni // les plafonds : la liste en résume chaque événement du dossier. +// premiereDuClassement est la seule définition de la proposition au rang 1 +// : la section de la génération la lit dans sa table, resumeDuMeilleur la +// donne à la liste, sur la même table d'évaluation, diagnostic compris — le +// premier critère du classement lit le plafond a priori. import { classer } from '../moteur/classement.js'; import { indexerPlan, normaliser } from '../moteur/configuration.js'; import { diagnostiquer } from '../moteur/diagnostic.js'; @@ -178,3 +182,42 @@ export function resumeDuRetenu(charge) { const cible = cibleRedondance(charge); return { collisionsCumulees, totalRedondance, cibleRedondance: cible, diffRedondance: Math.abs(cible - totalRedondance) }; } + +/** + * La proposition au rang 1 du classement d'une table d'évaluation (§ 5.7) : + * la première de ses propositions quand elle se compare — les comparables + * ouvrent la liste, dans l'ordre du classement —, null quand aucune ne se + * classe. + * + * @param {TableEvaluation} table + * @returns {LigneEvaluation|null} + */ +export function premiereDuClassement(table) { + const [premiere] = table.propositions; + return premiere !== undefined && premiere.statut === 'comparable' ? premiere : null; +} + +/** + * La meilleure proposition du classement que montre la liste des + * événements (§ 5.4, § 5.7, § 8.1) : l'identifiant de la proposition au + * rang 1 (premiereDuClassement) et ses collisions cumulées, celles de sa + * ligne d'evaluer. null sans proposition, quand aucune ne se compare — + * toutes fautives ou en dérive —, et quand la configuration ne se normalise + * pas : la liste écrit alors « — ». Le retenu n'y entre pas. Ne modifie pas + * la charge. + * + * @param {import('../stockage/types.js').Charge} charge + * @returns {{id: number, collisionsCumulees: number}|null} + */ +export function resumeDuMeilleur(charge) { + if (charge.propositions.length === 0) return null; + let table; + try { + table = evaluer(charge); + } catch (erreur) { + if (erreur instanceof ErreurConfiguration) return null; + throw erreur; + } + const premiere = premiereDuClassement(table); + return premiere === null ? null : { id: premiere.id, collisionsCumulees: premiere.mesures.collisionsCumulees }; +} diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js index c0d4ad3..3749b6d 100644 --- a/src/application/evaluation.test.js +++ b/src/application/evaluation.test.js @@ -23,7 +23,14 @@ import { analyser, capacite, configurationDepuisCharge } from '../stockage/docum import { planDepuisFichier } from '../stockage/placements.js'; import { VERSION } from '../version.genere.js'; import { appliquerCommande } from './commandes.js'; -import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance, evaluer, resumeDuRetenu } from './evaluation.js'; +import { + CIBLE_REDONDANCE_PAR_DEFAUT, + cibleRedondance, + evaluer, + premiereDuClassement, + resumeDuMeilleur, + resumeDuRetenu, +} from './evaluation.js'; const V1 = VERSION.affichee; @@ -424,3 +431,68 @@ describe('resumeDuRetenu : les mesures du retenu que lit la liste des événemen assert.equal(resumeDuRetenu(charge), null); }); }); + +describe('resumeDuMeilleur : la proposition au rang 1 que lit la liste des événements (§ 5.4, § 5.7, § 8.1)', () => { + test("le rang 1 du classement, ni le plus petit identifiant ni le moins de collisions : son identifiant et ses collisions cumulées", () => { + // Le classement range les plans écrits à la main 3, 2, 1, 4 : la 3 vient + // en tête avec 6 collisions cumulées, quand la 1 n'en porte que 4. + const charge = structuredClone(chargeDemo('petite-conflit')); + charge.propositions = PLANS_CONFLIT.map(({ tours }, rang) => propositionDe(charge, rang + 1, tours)); + charge.prochainsIds.proposition = 5; + figer(charge); + const table = evaluer(charge); + assert.deepEqual(table.classement.ordre, [3, 2, 1, 4]); + assert.deepEqual(resumeDuMeilleur(charge), { id: 3, collisionsCumulees: 6 }); + assert.equal(premiereDuClassement(table), table.propositions[0]); + assert.equal(premiereDuClassement(table).mesures.collisionsCumulees, 6); + }); + + test("après des générations : la première ligne d'evaluer, comparable, et le retenu n'y change rien", () => { + let charge = generee(chargeDemo('petite-conflit'), { graine: 4, nombre: 3, arret: 300 }); + charge = generee(charge, { graine: 9, nombre: 2, arret: 300 }); + const [premiere] = evaluer(charge).propositions; + assert.equal(premiere.statut, 'comparable'); + const attendu = { id: premiere.id, collisionsCumulees: premiere.mesures.collisionsCumulees }; + assert.deepEqual(resumeDuMeilleur(figer(charge)), attendu); + const derniere = charge.propositions.at(-1).id; + const retenue = executer(charge, 'retenirProposition', { id: derniere }); + assert.deepEqual(resumeDuMeilleur(retenue), attendu); + }); + + test('sans proposition, null, retenu ou non ; premiereDuClassement d’une table sans comparable, null', () => { + assert.equal(resumeDuMeilleur(chargeDemo('petite')), null); + const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 2, arret: 300 }); + const retenue = executer(generees, 'retenirProposition', { id: generees.propositions[0].id }); + // Un retenu seul : la liste vidée à la main, la commande gardant la + // proposition d'origine du retenu. + const retenuSeul = structuredClone(retenue); + retenuSeul.propositions = []; + assert.equal(evaluer(retenuSeul).retenu.statut, 'comparable'); + assert.equal(resumeDuMeilleur(retenuSeul), null); + assert.equal(premiereDuClassement(evaluer(retenuSeul)), null); + }); + + test('des propositions toutes hors du classement — population changée, ou fautives — : null, sans lever', () => { + const generees = generee(chargeDemo('petite'), { graine: 3, nombre: 2, arret: 300 }); + assert.notEqual(resumeDuMeilleur(generees), null); + const exclue = executer(generees, 'exclureParticipant', { id: generees.participants[0].id }); + const table = evaluer(exclue); + assert.equal(table.propositions.length, 2); + assert.ok(table.propositions.every(({ statut }) => statut === 'derive')); + assert.equal(premiereDuClassement(table), null); + assert.equal(resumeDuMeilleur(exclue), null); + + const fautives = structuredClone(generees); + for (const proposition of fautives.propositions) proposition.placement[0].reserve.push(proposition.participants[0]); + assert.ok(evaluer(fautives).propositions.every(({ statut }) => statut === 'fautif')); + assert.equal(resumeDuMeilleur(fautives), null); + }); + + test('une configuration qui ne se normalise pas rend null là où evaluer lève', () => { + const charge = structuredClone(generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 2, arret: 300 })); + const table = charge.tables[0].id; + charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table, siege: null, portee: 'tous', tour: null })); + assert.throws(() => evaluer(charge), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION'); + assert.equal(resumeDuMeilleur(charge), null); + }); +}); diff --git a/src/application/evenements.js b/src/application/evenements.js index 2d160b6..af53b50 100644 --- a/src/application/evenements.js +++ b/src/application/evenements.js @@ -15,10 +15,12 @@ // épreuve la remplace après le montage. // // Lister ne fait que lire. Chaque ligne lisible porte le résumé de son -// retenu (resumeDuRetenu, evaluation.js), lu dans l'état de l'événement au -// moment de lister. Une opération qui écrit dérive ses refus lisibles -// sans écrire — nom vide, plan bloqué, format plus récent — avant toute -// écriture, sonde de verrou comprise. Renommer et supprimer ferment d'abord +// retenu (resumeDuRetenu, evaluation.js) et la meilleure proposition de son +// classement (resumeDuMeilleur), lus dans une seule lecture de l'état de +// l'événement au moment de lister, sur sa charge examinée comme l'ouverture +// l'examine. Une opération qui écrit dérive ses refus lisibles sans écrire +// — nom vide, plan bloqué, format plus récent — avant toute écriture, sonde +// de verrou comprise. Renommer et supprimer ferment d'abord // l'événement visé quand il est ouvert : la séance rend son verrou, et la // façade sonde alors celui du disque sous son propre identifiant de séance, // tiré une fois à sa création ; une séance vivante, ou dont la vie ne se @@ -45,9 +47,10 @@ import { renommer as renommerSurDisque, supprimer as supprimerSurDisque, } from '../stockage/operations.js'; +import { examiner } from '../stockage/placements.js'; import { lireReglages } from '../stockage/reglages_locaux.js'; import { ErreurCommande, refus } from './erreurs.js'; -import { resumeDuRetenu } from './evaluation.js'; +import { resumeDuMeilleur, resumeDuRetenu } from './evaluation.js'; import { libelle } from './libelles.js'; /** Sièges par défaut et tours d'un événement neuf (§ 2.1, étapes 4 et 5). */ @@ -130,20 +133,30 @@ async function derniereModification(fs, racine, base) { return lireJournal(texte).entrees.at(-1)?.horodatage ?? null; } -// Le résumé du retenu de l'événement base (resumeDuRetenu), lu dans son -// état, sans rien écrire ; null pour un état illisible au moment de la -// liste — un résumé illisible ne se relit pas —, et pour un état qui a -// cessé de se lire depuis : la ligne en porte déjà la cause, ou la portera -// à la liste suivante. -async function mesuresDuRetenuDe(fs, racine, { base, illisible }) { - if (illisible !== null) return null; +// Les deux résumés de l'événement base, lus dans une seule lecture de son +// état, sans rien écrire : mesuresDuRetenu (resumeDuRetenu) et +// meilleureDuClassement (resumeDuMeilleur). Ils lisent la charge examinée +// (examiner, § 8.9, point 3), celle que l'ouverture de l'état garde : une +// proposition fautive, dont l'identifiant se répète ou atteint le compteur, +// n'entre pas au classement, qui lève sur un identifiant répété, et la +// liste nomme la proposition au rang 1 que le bloc de génération de +// l'événement ouvert nomme. Chacun vaut null pour un état +// illisible au moment de la liste — un résumé illisible ne se relit pas —, +// et pour un état qui a cessé de se lire depuis : la ligne en porte déjà la +// cause, ou la portera à la liste suivante. +const AUCUN_RESUME = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null }); +async function resumesDe(fs, racine, { base, illisible }) { + if (illisible !== null) return AUCUN_RESUME; + let charge; try { const texte = await fs.lireTexte(racine, base + SUFFIXES.etat); - return texte === null ? null : resumeDuRetenu(analyser(texte).charge); + if (texte === null) return AUCUN_RESUME; + charge = examiner(analyser(texte).charge).charge; } catch (erreur) { - if (erreur instanceof ErreurStockage) return null; + if (erreur instanceof ErreurStockage) return AUCUN_RESUME; throw erreur; } + return { mesuresDuRetenu: resumeDuRetenu(charge), meilleureDuClassement: resumeDuMeilleur(charge) }; } // Pourquoi l'état d'un résumé illisible ne s'ouvre pas : les détails de @@ -303,7 +316,7 @@ export function creerFacade({ ? resumes.filter((autre) => autre !== resume && autre.doublon && autre.id === id).map((autre) => autre.base) : [], verrou: await verrouDe(fs, courante, base), - mesuresDuRetenu: await mesuresDuRetenuDe(fs, courante, resume), + ...(await resumesDe(fs, courante, resume)), }); } return lignes; @@ -485,7 +498,9 @@ export function creerFacade({ /** * Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien. * mesuresDuRetenu porte resumeDuRetenu de l'état, null sans retenu - * mesurable ou pour un état illisible. + * mesurable ou pour un état illisible ; meilleureDuClassement, + * resumeDuMeilleur de l'état, null sans proposition classée ou pour un + * état illisible. */ lister, /** Crée et ouvre en lecture l'événement { nom, date } (§ 2.1, étape 1). */ diff --git a/src/application/evenements.test.js b/src/application/evenements.test.js index 1949d03..94afa22 100644 --- a/src/application/evenements.test.js +++ b/src/application/evenements.test.js @@ -20,9 +20,10 @@ import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/do import { ErreurStockage } from '../stockage/erreurs.js'; import { lireJournal } from '../stockage/journal.js'; import { SUFFIXES } from '../stockage/noms.js'; +import { examiner } from '../stockage/placements.js'; import { VERSION } from '../version.genere.js'; import { appliquerCommande } from './commandes.js'; -import { evaluer, resumeDuRetenu } from './evaluation.js'; +import { evaluer, resumeDuMeilleur, resumeDuRetenu } from './evaluation.js'; import { creerFacade } from './evenements.js'; import { creerHorlogePilotee } from './horloge.js'; import { libelle } from './libelles.js'; @@ -432,6 +433,7 @@ describe('lister (§ 8.1, § 19.4)', () => { doublonAvec: [], verrou: null, mesuresDuRetenu: null, + meilleureDuClassement: null, }, { base: 'petite', @@ -447,6 +449,7 @@ describe('lister (§ 8.1, § 19.4)', () => { doublonAvec: [], verrou: null, mesuresDuRetenu: null, + meilleureDuClassement: null, }, ]); assert.ok(petite.participants.length > 0); @@ -601,6 +604,99 @@ describe('lister (§ 8.1, § 19.4)', () => { assert.notEqual(par.tronquee.illisible, null); assert.equal(par.tronquee.mesuresDuRetenu, null); }); + + test('la meilleure proposition du classement : celle de resumeDuMeilleur, retenu ou non ; null sans proposition ; rien d’écrit', async () => { + const { fs, facade } = await deuxEvenements(); + // Les mêmes propositions, sans retenu, sous un autre identifiant : deux + // événements distincts, aucun doublon. + const sansRetenu = texteRetenu((charge) => ({ + ...charge, + retenu: null, + evenement: { ...charge.evenement, id: 'evt-proposee', etat: 'propose' }, + })); + fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); + fs.deposer('portable', `proposee${SUFFIXES.etat}`, sansRetenu); + const avant = ecritures(fs); + const lignes = await facade.lister(); + assert.equal(ecritures(fs), avant); + assert.equal(lignes.length, 4); + const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); + for (const [base, texte] of [ + ['retenue', texteRetenu()], + ['proposee', sansRetenu], + ]) { + const charge = analyser(texte).charge; + const [premiere] = evaluer(charge).propositions; + assert.equal(premiere.statut, 'comparable', base); + assert.deepEqual(par[base].meilleureDuClassement, { id: premiere.id, collisionsCumulees: premiere.mesures.collisionsCumulees }, base); + assert.deepEqual(par[base].meilleureDuClassement, resumeDuMeilleur(charge), base); + } + assert.equal(par.proposee.mesuresDuRetenu, null); + assert.equal(par['soiree-a'].meilleureDuClassement, null); + assert.equal(par.petite.meilleureDuClassement, null); + }); + + test('le résumé du retenu et la meilleure proposition se lisent dans une seule lecture de l’état, après celle du dépôt', async () => { + const { fs, facade } = await deuxEvenements(); + fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); + const lireTexte = fs.lireTexte; + const lus = []; + fs.lireTexte = (racine, chemin) => { + lus.push(chemin); + return lireTexte.call(fs, racine, chemin); + }; + const ligne = (await facade.lister()).find(({ base }) => base === 'retenue'); + assert.notEqual(ligne.mesuresDuRetenu, null); + assert.notEqual(ligne.meilleureDuClassement, null); + assert.equal(lus.filter((chemin) => chemin === `retenue${SUFFIXES.etat}`).length, 2, lus.join()); + }); + + // Un état que l'analyse admet et qu'examiner corrige (§ 8.9, point 3) : + // la ligne lit la charge que l'ouverture lit, examinée, et non la charge + // brute. Les propositions, sans retenu, sous modifier(charge). + const texteExamine = (modifier) => texteRetenu((charge) => modifier({ ...charge, retenu: null })); + const meilleureOuverte = (texte) => resumeDuMeilleur(examiner(analyser(texte).charge).charge); + + test('un identifiant répété : la ligne rendue, avec la meilleure proposition de la charge examinée', async () => { + const { fs, facade } = await deuxEvenements(); + const texte = texteExamine((charge) => { + const [premiere, seconde, ...reste] = charge.propositions; + return { ...charge, propositions: [premiere, { ...seconde, id: premiere.id }, ...reste] }; + }); + assert.equal(examiner(analyser(texte).charge).fautives[0].fautes[0].code, 'IDENTIFIANT_REPETE'); + fs.deposer('portable', `repetee${SUFFIXES.etat}`, texte); + const ligne = (await facade.lister()).find(({ base }) => base === 'repetee'); + assert.notEqual(ligne, undefined); + assert.notEqual(meilleureOuverte(texte), null); + assert.deepEqual(ligne.meilleureDuClassement, meilleureOuverte(texte)); + }); + + test('une proposition hors du compteur : la ligne nomme la meilleure de la charge examinée, celle du bloc de génération', async () => { + const { fs, facade } = await deuxEvenements(); + // Le compteur abaissé à l'identifiant de la proposition au rang 1 de la + // charge brute : examiner l'écarte, et le rang 1 change. + const rangUn = resumeDuMeilleur(analyser(texteExamine((charge) => charge)).charge); + const texte = texteExamine((charge) => ({ ...charge, prochainsIds: { ...charge.prochainsIds, proposition: rangUn.id } })); + assert.equal(examiner(analyser(texte).charge).fautives[0].fautes[0].code, 'IDENTIFIANT_HORS_COMPTEUR'); + assert.notDeepEqual(meilleureOuverte(texte), resumeDuMeilleur(analyser(texte).charge)); + fs.deposer('portable', `abaissee${SUFFIXES.etat}`, texte); + const ligne = (await facade.lister()).find(({ base }) => base === 'abaissee'); + assert.deepEqual(ligne.meilleureDuClassement, meilleureOuverte(texte)); + }); + + test('des propositions toutes en dérive, et un état illisible : null, la liste entière rendue', async () => { + const { fs, facade } = await deuxEvenements(); + const derivee = texteRetenu((charge) => appliquerCommande('changerTours', charge, { tours: '3' }).charge); + assert.ok(evaluer(analyser(derivee).charge).propositions.every(({ statut }) => statut === 'derive')); + fs.deposer('portable', `derivee${SUFFIXES.etat}`, derivee); + fs.deposer('portable', `tronquee${SUFFIXES.etat}`, texteRetenu().slice(0, 40)); + const lignes = await facade.lister(); + assert.equal(lignes.length, 4); + const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); + assert.equal(par.derivee.meilleureDuClassement, null); + assert.notEqual(par.tronquee.illisible, null); + assert.equal(par.tronquee.meilleureDuClassement, null); + }); }); // --- Créer (§ 2.1, § 8.3) ---------------------------------------------------- diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index 4fe88cb..32cba27 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -22,6 +22,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { flushSync, tick } from 'svelte'; import { appliquerCommande } from '../../application/commandes.js'; +import { evaluer } from '../../application/evaluation.js'; import { creerFormat } from '../../application/format.js'; import { creerHorlogePilotee } from '../../application/horloge.js'; import { TABLES_APPLICATION, libelle } from '../../application/libelles.js'; @@ -41,7 +42,7 @@ import { planDepuisFichier } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; import { creerFichiersSimules } from '../../../test/fichiers_simules.js'; import { centre } from '../../../test/navigateur/ecrans.js'; -import { pointer, touche } from '../../../test/navigateur/entrees.js'; +import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { visible as visibleDansLaFenetre } from '../../../test/navigateur/rendu.js'; import { TABLES_INTERFACE } from '../libelles.js'; @@ -1087,45 +1088,68 @@ describe('la racine neutre et la liste qui défile (§ 18.6, § 19.4)', () => { // --- Les mesures du retenu (§ 5.4, § 8.1, § 8.9) ------------------------------------ -describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { - // La petite démonstration au conflit inévitable, deux propositions du - // moteur, la seconde retenue par la commande. - function chargeRetenue() { - let charge = analyser(texteLivre('petite-conflit')).charge; - const configuration = configurationDepuisCharge(charge); - const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 }); - charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; - return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; - } +// La petite démonstration au conflit inévitable, deux propositions du +// moteur, la seconde retenue par la commande. +function chargeRetenue() { + let charge = analyser(texteLivre('petite-conflit')).charge; + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 }); + charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; + return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; +} +const etat = (charge) => serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); +const TIRET = '\u{2014}'; +const terme = (ligne, nom) => ligne.querySelector(`[data-terme="${nom}"]`); +const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')]; + +// L'infobulle unique, ouverte sur le terme : visible, au texte de la clé, +// désignée par l'aria-describedby du terme et de nul autre. +function exigerInfobulle(ancre, cle) { + const bulles = infobulles(); + expect(bulles.length).toBe(1); + const [bulle] = bulles; + expect(bulle.hidden, cle).toBe(false); + expect(getComputedStyle(bulle).visibility, cle).toBe('visible'); + expect(bulle.textContent.trim(), cle).toBe(t(cle)); + expect(ancre.getAttribute('aria-describedby'), cle).toBe(bulle.id); + expect(document.querySelectorAll(`[aria-describedby~="${bulle.id}"]`).length, cle).toBe(1); + const b = bulle.getBoundingClientRect(); + const a = ancre.getBoundingClientRect(); + expect(b.width).toBeGreaterThan(0); + expect(b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom, `${cle} sur son terme`).toBe(false); +} + +// Chaque terme de la ligne, survolé puis focalisé, ouvre l'infobulle sur sa +// clé ; la sortie du pointeur et Échap la ferment, le focus reste au terme. +async function eprouverTermes(ligne, cas) { + expect(cas.length).toBeGreaterThan(0); + await pointer('bouger', 0, 0); + for (const [nom, cle] of cas) { + const ancre = terme(ligne, nom); + ancre.scrollIntoView({ block: 'center' }); + const { x, y } = await centre(ancre); + await pointer('bouger', x, y); + await vi.waitFor(() => exigerInfobulle(ancre, cle)); + await pointer('bouger', 0, 0); + await vi.waitFor(() => expect(infobulles()[0].hidden).toBe(true)); + expect(ancre.tabIndex).toBe(0); + ancre.focus(); + await vi.waitFor(() => exigerInfobulle(ancre, cle)); + await touche('Escape'); + await vi.waitFor(() => expect(infobulles()[0].hidden).toBe(true)); + expect(document.activeElement).toBe(ancre); + ancre.blur(); + } +} + +describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { // Les mesures du retenu, par le moteur sollicité une seconde fois (§ 19.1). function mesuresDe(charge) { const instance = normaliser(configurationDepuisCharge(charge)); return mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu))); } - const etat = (charge) => serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); - const TIRET = '\u{2014}'; - const terme = (ligne, colonne) => ligne.querySelector(`[data-terme="${colonne}"]`); - const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')]; - - // L'infobulle unique, ouverte sur le terme : visible, au texte de la clé, - // désignée par l'aria-describedby du terme et de nul autre. - function exigerInfobulle(ancre, cle) { - const bulles = infobulles(); - expect(bulles.length).toBe(1); - const [bulle] = bulles; - expect(bulle.hidden, cle).toBe(false); - expect(getComputedStyle(bulle).visibility, cle).toBe('visible'); - expect(bulle.textContent.trim(), cle).toBe(t(cle)); - expect(ancre.getAttribute('aria-describedby'), cle).toBe(bulle.id); - expect(document.querySelectorAll(`[aria-describedby~="${bulle.id}"]`).length, cle).toBe(1); - const b = bulle.getBoundingClientRect(); - const a = ancre.getBoundingClientRect(); - expect(b.width).toBeGreaterThan(0); - expect(b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom, `${cle} sur son terme`).toBe(false); - } - test('un événement retenu : ses collisions cumulées et |1000 \u{2212} Σ r(p)| ; sans retenu : « — » ; rien d’écrit', async () => { const charge = chargeRetenue(); const mesures = mesuresDe(charge); @@ -1151,28 +1175,10 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] }); await vi.waitFor(() => expect(lignes(m).length).toBe(1)); const retenue = ligneDe(m, 'retenue'); - const cas = [ + await eprouverTermes(retenue, [ ['collisionsCumulees', 'accueil.explication.collisionsCumulees'], ['diffRedondance', 'accueil.explication.diffRedondance'], - ]; - expect(cas.length).toBeGreaterThan(0); - await pointer('bouger', 0, 0); - for (const [nom, cle] of cas) { - const ancre = terme(retenue, nom); - ancre.scrollIntoView({ block: 'center' }); - const { x, y } = await centre(ancre); - await pointer('bouger', x, y); - await vi.waitFor(() => exigerInfobulle(ancre, cle)); - await pointer('bouger', 0, 0); - await vi.waitFor(() => expect(infobulles()[0].hidden).toBe(true)); - expect(ancre.tabIndex).toBe(0); - ancre.focus(); - await vi.waitFor(() => exigerInfobulle(ancre, cle)); - await touche('Escape'); - await vi.waitFor(() => expect(infobulles()[0].hidden).toBe(true)); - expect(document.activeElement).toBe(ancre); - ancre.blur(); - } + ]); }); test('la cible changée dans les propositions, puis la liste : la différence se lit contre la nouvelle cible', async () => { @@ -1197,3 +1203,126 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { expect(colonne(ligneDe(m, 'retenue'), 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees)); }); }); + +// --- La meilleure proposition du classement (§ 5.4, § 5.7, § 8.1) ------------------ + +describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, § 8.1)', () => { + // Trois propositions du moteur sur la petite démonstration au conflit + // inévitable, sous l'identifiant d'événement id ; retenir : une autre que + // la première du classement est retenue. + function chargeClassee({ id = 'evt-classee', retenir = true } = {}) { + let charge = analyser(texteLivre('petite-conflit')).charge; + charge.evenement.id = id; + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 6, nombre: 3, arret: 300 }); + charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; + if (!retenir) return charge; + const [premiere] = evaluer(charge).propositions; + const autre = charge.propositions.find((proposition) => proposition.id !== premiere.id); + return appliquerCommande('retenirProposition', charge, { id: autre.id }).charge; + } + + const blocDe = (ligne) => ligne.querySelector('[data-meilleure]'); + // Ce que le bloc d'une ligne, ou celui de la génération, écrit. + const lu = (bloc) => ({ + id: bloc.querySelector('[data-meilleure-id]')?.textContent ?? null, + collisions: bloc.querySelector('[data-meilleure-collisions]')?.textContent ?? null, + }); + const blocGeneration = (m) => dans(m, '[data-section="generation"] [data-meilleure]'); + + test('un événement classé : le rang 1, son identifiant et ses collisions cumulées, à part du retenu ; sans proposition, « — » ; rien d’écrit', async () => { + const charge = chargeClassee(); + const table = evaluer(charge); + const [premiere] = table.propositions; + expect(premiere.statut).toBe('comparable'); + expect(premiere.id).not.toBe(charge.retenu.proposition); + const m = await monter({ + fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))], + }); + await vi.waitFor(() => expect(lignes(m).length).toBe(2)); + const avant = ecritures(m); + const classee = ligneDe(m, 'classee'); + const bloc = blocDe(classee); + expect(lu(bloc)).toEqual({ id: format.nombre(premiere.id), collisions: format.nombre(premiere.mesures.collisionsCumulees) }); + expect(colonne(classee, 'collisionsCumulees')).toBe(format.nombre(table.retenu.mesures.collisionsCumulees)); + expect(terme(classee, 'meilleure').textContent).toBe(t('generation.meilleure.titre')); + expect(bloc.querySelector('dt').textContent).toBe(t('generation.meilleure.proposition')); + expect(terme(classee, 'meilleureCollisions').textContent).toBe(t('propositions.colonne.collisionsCumulees')); + expect(bloc.getAttribute('role')).toBe('group'); + expect(document.getElementById(bloc.getAttribute('aria-labelledby'))).toBe(terme(classee, 'meilleure')); + for (const valeur of ['[data-meilleure-id]', '[data-meilleure-collisions]']) { + expect(visible(bloc.querySelector(valeur)), valeur).toBe(true); + expect(atteint(bloc.querySelector(valeur)), valeur).toBe(true); + } + expect(lu(blocDe(ligneDe(m, 'club')))).toEqual({ id: TIRET, collisions: TIRET }); + expect(ecritures(m)).toBe(avant); + }); + + test('le titre et l’intitulé des collisions s’expliquent : le survol puis le focus ouvrent l’infobulle, Échap et la sortie la ferment', async () => { + const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] }); + await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + await eprouverTermes(ligneDe(m, 'classee'), [ + ['meilleure', 'accueil.explication.meilleure'], + ['meilleureCollisions', 'propositions.explication.collisionsCumulees'], + ]); + }); + + test('la ligne écrit ce que le bloc de la génération écrit du même événement, sous les mêmes libellés, et de nouveau au retour', async () => { + const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] }); + await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + const ligne = ligneDe(m, 'classee'); + const deLaListe = lu(blocDe(ligne)); + expect(deLaListe.id).toMatch(/\d/); + const libelles = [terme(ligne, 'meilleure'), blocDe(ligne).querySelector('dt'), terme(ligne, 'meilleureCollisions')].map( + (element) => element.textContent, + ); + await ouvrirLigne(m, 'classee'); + await attendreEvenement(m); + m.ui.allerA('generation'); + await vi.waitFor(() => expect(blocGeneration(m)?.querySelector('[data-meilleure-id]')).not.toBeNull()); + const bloc = blocGeneration(m); + expect(lu(bloc)).toEqual(deLaListe); + expect( + [bloc.querySelector('h3'), bloc.querySelector('[data-meilleure-libelle-id]'), bloc.querySelector('[data-terme]')].map( + (element) => element.textContent, + ), + ).toEqual(libelles); + await revenirALaListe(m, 1); + await vi.waitFor(() => expect(lu(blocDe(ligneDe(m, 'classee')))).toEqual(deLaListe)); + }); + + test('des propositions toutes hors du classement : « — » à la liste, là où le bloc de la génération le dit en mots', async () => { + const classee = chargeClassee({ retenir: false }); + const charge = appliquerCommande('exclureParticipant', classee, { id: classee.participants[0].id }).charge; + const statuts = evaluer(charge).propositions.map(({ statut }) => statut); + expect(statuts.length).toBe(3); + expect(statuts.every((statut) => statut !== 'comparable')).toBe(true); + const m = await monter({ fichiers: [fichier(`horsclassement${SUFFIXES.etat}`, etat(charge))] }); + await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + expect(lu(blocDe(ligneDe(m, 'horsclassement')))).toEqual({ id: TIRET, collisions: TIRET }); + await ouvrirLigne(m, 'horsclassement'); + await attendreEvenement(m); + m.ui.allerA('generation'); + await vi.waitFor(() => expect(blocGeneration(m)?.querySelector('[data-meilleure-vide]')).not.toBeNull()); + }); + + test('à 1366 × 700, six événements classés : la page ne défile pas en largeur, chaque bloc tient dans la sienne', async () => { + const avant = { largeur: innerWidth, hauteur: innerHeight }; + await fixerCadre(1366, 700); + try { + const bases = ['un', 'deux', 'trois', 'quatre', 'cinq', 'six']; + const m = await monter({ fichiers: bases.map((base) => fichier(`${base}${SUFFIXES.etat}`, etat(chargeClassee({ id: `evt-${base}` })))) }); + await vi.waitFor(() => expect(lignes(m).length).toBe(bases.length)); + const blocs = lignes(m).map(blocDe); + expect(blocs.length).toBe(bases.length); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth); + for (const bloc of blocs) { + expect(lu(bloc).id).toMatch(/\d/); + expect(bloc.scrollWidth).toBeLessThanOrEqual(bloc.clientWidth); + expect(bloc.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); + } + } finally { + await fixerCadre(avant.largeur, avant.hauteur); + } + }); +}); diff --git a/src/interface/accueil/ListeEvenements.svelte b/src/interface/accueil/ListeEvenements.svelte index 246b10a..4aa0a9f 100644 --- a/src/interface/accueil/ListeEvenements.svelte +++ b/src/interface/accueil/ListeEvenements.svelte @@ -6,8 +6,12 @@ // travail, dans l'ordre de la façade, que désigne data-evenement. Chaque // ligne porte la commande qui l'ouvre en lecture — son nom —, ses cinq // autres informations et les deux mesures de son retenu (COLONNES_DU_RETENU) - // sous leur intitulé, chacune dans son propre nœud (data-colonne), ses - // signalements traduits — les bases qu'un doublon + // sous leur intitulé, chacune dans son propre nœud (data-colonne), la + // meilleure proposition de son classement (MEILLEURE) — un groupe + // data-meilleure sous le titre et les libellés du bloc de la génération, + // l'identifiant dans data-meilleure-id, les collisions cumulées dans + // data-meilleure-collisions, « — » l'un et l'autre sans proposition + // classée —, ses signalements traduits — les bases qu'un doublon // nomme suivent son texte, chacune dans son propre nœud (data-doublon) —, // le refus de son ouverture, et ses opérations. Un dossier sans événement n'affiche pas de liste vide : // la création et les démonstrations de l'accueil en tiennent lieu. @@ -16,8 +20,10 @@ // l'accueil ; refusOuverture : { base, refus } du dernier échec, ou null ; // apresOperation() : à appeler quand une opération a changé le dossier. // - // L'intitulé de chaque mesure du retenu s'explique dans l'infobulle de la - // liste, une seule (Infobulle, primitive) : son terme (data-terme) se + // L'intitulé de chaque mesure du retenu, le titre de la meilleure + // proposition et l'intitulé de ses collisions s'expliquent dans + // l'infobulle de la liste, une seule (Infobulle, primitive) : chaque terme + // (data-terme, que pose le fragment termeExplique) se // focalise au clavier, son survol ou son focus ouvre l'explication, le // quitter la referme quand elle est encore la sienne, Échap aussi. Le // terme expliqué porte aria-describedby vers elle, et lui seul. @@ -25,7 +31,7 @@ import Infobulle from '../primitives/Infobulle.svelte'; import Refus from '../primitives/Refus.svelte'; import Operations from './Operations.svelte'; - import { COLONNES_DU_RETENU, ligneAffichee } from './modele.js'; + import { COLONNES_DU_RETENU, MEILLEURE, ligneAffichee } from './modele.js'; let { lignes, ouvrir, refusOuverture, apresOperation } = $props(); @@ -49,10 +55,32 @@ const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined); + +{#snippet termeExplique(base, nom, cle, explication, id)} + {@const terme = `${base}:${nom}`} + + + expliquer(evenement, terme, explication)} + onmouseleave={() => taire(terme)} + onfocus={(evenement) => expliquer(evenement, terme, explication)} + onblur={() => taire(terme)}>{t(cle)} +{/snippet} + {#if lignes.length > 0}