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}