From ffce294cec10970ae9edc334e3d33e80ae39ed5a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 13 Oct 2026 08:07:19 -0400 Subject: [PATCH] [ADD] event list: retained placement's chosen repeated meetings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event list summed up the retained placement by its collisions alone; the chosen repeated meetings, the other figure the proposals table ranks on, was only visible after opening the event. Both now sit under one « Placement retenu » group header, read from a single measurement of the retained placement, with the same « — » cases and lazy loading. The column group span is tested, so assistive technology keeps both columns under that header. The user guide drops the stale redundancy target. Checked: node 5152, browser 1154, node-long 129; no capture changes. --- FR --- [ADD] liste des événements : rencontres répétées choisies du retenu La liste des événements résumait le retenu par ses seules collisions ; les rencontres répétées choisies, l'autre nombre que classe le tableau des propositions, ne se voyaient qu'en ouvrant l'événement. Les deux tiennent sous un même en-tête « Placement retenu », lues d'une seule mesure du retenu, avec les mêmes cas « — » et le même chargement différé. L'étendue du groupe de colonnes est éprouvée : les aides techniques gardent les deux colonnes sous cet en-tête. Le guide d'usage quitte la cible périmée. Vérifié : node 5152, navigateur 1154, node-long 129 ; aucune capture. Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 13 +-- GUIDE-USAGE.md | 5 +- spec.md | 18 +++-- src/application/evaluation.js | 35 ++++---- src/application/evaluation.test.js | 35 +++++++- src/application/evenements.test.js | 8 +- .../accueil/Accueil.navigateur.test.js | 81 ++++++++++++++----- src/interface/accueil/ListeEvenements.svelte | 27 ++++--- src/interface/accueil/modele.js | 60 ++++++++------ src/interface/accueil/modele.test.js | 47 ++++++++--- src/interface/libelles/accueil.js | 17 +++- test/navigateur/ecrans.js | 25 +++--- 12 files changed, 254 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7377a5..687a245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,12 +166,13 @@ page de qualité. - Le guide d'usage montre la page de qualité, ses vues et ses cas limites, captures à l'appui. -- Dans la liste des événements, la colonne du placement retenu montre ses - « Collisions cumulées (paire et tour) », le même nombre que la colonne du - tableau des propositions, au lieu de « Diff somme d'appartenance ». Le réglage - « Cible de la somme de redondance », qui ne servait qu'à cette colonne, quitte - la section des propositions : un fichier qui le porte s'ouvre sans message, et - le perd au premier enregistrement. +- Dans la liste des événements, le placement retenu montre ses « Collisions + cumulées (paire et tour) » et ses « Rencontres répétées choisies (paires) », + les mêmes nombres que les colonnes du tableau des propositions, au lieu de + « Diff somme d'appartenance ». Le réglage « Cible de la somme de redondance », + qui ne servait qu'à cette ancienne colonne, quitte la section des + propositions : un fichier qui le porte s'ouvre sans message, et le perd au + premier enregistrement. - La section « Propositions » offre « Garder les 10 meilleures » : les dix premières du classement restent, et celle d'où vient le placement retenu aussi, les autres s'effacent. Le nombre se règle dans le champ voisin. Une diff --git a/GUIDE-USAGE.md b/GUIDE-USAGE.md index b76dcef..a2e8f09 100644 --- a/GUIDE-USAGE.md +++ b/GUIDE-USAGE.md @@ -378,8 +378,9 @@ réservations imposent de ce que la recherche a choisi. proposition alors que le retenu a été ajusté à la main détruit ces ajustements : une fenêtre le dit avant. -La liste des événements résume ensuite le retenu par l’écart de sa somme de -redondance d’appartenance à la cible de la soirée. +La liste des événements résume ensuite le retenu par ses collisions cumulées +(paire et tour) et ses rencontres répétées choisies (paires), les nombres du +tableau des propositions. Retenir n’est pas obligatoire, mais tout ce qui s’imprime sans placement retenu porte la mention BROUILLON (étape 12). diff --git a/spec.md b/spec.md index 2d3a394..b2bb330 100644 --- a/spec.md +++ b/spec.md @@ -201,7 +201,7 @@ du calcul qui a produit la proposition. Le logiciel sépare ce que le moteur a décidé de ce que les réservations imposent (§ 5.4). Retenir fait passer le plan à l'état *Retenu* (§ 9) ; les propositions non retenues restent disponibles. Le retenu se résume ensuite dans la liste des événements, par ses collisions -cumulées (§ 5.4, § 8.1). +cumulées et ses rencontres répétées choisies (§ 5.4, § 8.1). **11. Ajuster à la main.** *Facultative, reprenable.* Le même geste qu'à l'étape 7 déplace maintenant une personne réelle dans le placement retenu. **Le @@ -1950,12 +1950,16 @@ pastille de l'état et son compte —, ou ne les groupe pas, au choix ; ses en-têtes de colonnes se groupent en « Événement », « Placement retenu » et « Meilleure proposition du classement », et le nom de chaque ligne en est l'en-tête, la date et le chemin en petite ligne dessous. Sous le tableau, des -cartes offrent la création, le dossier de travail et les démonstrations. Une -colonne résume en plus le **placement retenu** : « Collisions cumulées (paire et -tour) », les collisions cumulées du retenu (§ 5.4) — le nombre que la colonne du -même intitulé du tableau des propositions porte sur la ligne du retenu. Elle se -lit dans la même mesure que le tableau, sans rien écrire ; un événement sans -retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte « — ». +cartes offrent la création, le dossier de travail et les démonstrations. Deux +colonnes résument en plus le **placement retenu**, sous l'en-tête du groupe qui +les couvre toutes deux : « Collisions cumulées (paire et tour) », les collisions +cumulées du retenu, puis « Rencontres répétées choisies (paires) », les paires +réunies plus d'une fois que le placement a choisies, sans celles que les +réservations imposent (§ 5.4) — dans chacune, le nombre que la colonne du même +intitulé du tableau des propositions porte sur la ligne du retenu. Les deux se +lisent dans une seule mesure du retenu, celle du tableau, sans rien écrire ; un +événement sans retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), +porte « — » dans l'une et l'autre. 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) — la même diff --git a/src/application/evaluation.js b/src/application/evaluation.js index be61bf6..0f3d7e6 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -26,9 +26,10 @@ // dans ses mesures, à part des choisies. // // resumeDuRetenu donne à la liste des événements les collisions cumulées -// du retenu, lues 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 +// et les rencontres répétées choisies du retenu, lues dans une seule +// mesure, celle de 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 en donne l'identifiant à la liste, sur la même table // d'évaluation, diagnostic compris — le premier critère du classement lit @@ -217,17 +218,23 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) { } /** - * Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1) : - * collisionsCumulees, les occurrences (paire, tour) où deux personnes d'une - * même appartenance partagent une table, celles de mesurer — la valeur que - * la ligne du retenu d'evaluer porte, et que la colonne « Collisions - * cumulées (paire et tour) » du tableau des propositions écrit. null sans - * retenu, pour un retenu fautif ou en dérive, et quand la configuration ne - * se normalise pas : rien ne s'y mesure, et la liste écrit « — ». Ne - * modifie pas la charge. + * Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1), + * lu dans un seul appel à mesurer, la mesure que la ligne du retenu + * d'evaluer porte : + * - collisionsCumulees, les occurrences (paire, tour) où deux personnes + * d'une même appartenance partagent une table — la valeur que la colonne + * « Collisions cumulées (paire et tour) » du tableau des propositions + * écrit ; + * - rencontresRepetees, les paires réunies à plus d'un tour que le placement + * a choisies, sans celles que les réservations imposent + * (mesures.rencontresRepetees.choisies) — la valeur que la colonne + * « Rencontres répétées choisies (paires) » écrit. + * null sans retenu, pour un retenu fautif ou en dérive, et quand la + * configuration ne se normalise pas : rien ne s'y mesure, et la liste écrit + * « — » dans les deux colonnes. Ne modifie pas la charge. * * @param {import('../stockage/types.js').Charge} charge - * @returns {{collisionsCumulees: number}|null} + * @returns {{collisionsCumulees: number, rencontresRepetees: number}|null} */ export function resumeDuRetenu(charge) { const { retenu } = charge; @@ -239,8 +246,8 @@ export function resumeDuRetenu(charge) { if (erreur instanceof ErreurConfiguration) return null; throw erreur; } - const { collisionsCumulees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu))); - return { collisionsCumulees }; + const { collisionsCumulees, rencontresRepetees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu))); + return { collisionsCumulees, rencontresRepetees: rencontresRepetees.choisies }; } /** diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js index 99534fe..4fc6ecc 100644 --- a/src/application/evaluation.test.js +++ b/src/application/evaluation.test.js @@ -484,23 +484,52 @@ describe("évaluation : une configuration qui ne se tient pas", () => { }); }); -describe('resumeDuRetenu : les collisions cumulées du retenu que lit la liste des événements (§ 5.4, § 8.1)', () => { +describe('resumeDuRetenu : les collisions cumulées et les rencontres répétées choisies du retenu que lit la liste des événements (§ 5.4, § 8.1)', () => { // La petite démonstration, trois propositions générées, la deuxième retenue. function retenue() { const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 }); return executer(generees, 'retenirProposition', { id: generees.propositions[1].id }); } - test("les seules collisions cumulées (paire et tour), celles de la ligne du retenu d'evaluer et de mesurer", () => { + test("les collisions cumulées (paire et tour) et les rencontres répétées choisies, celles de la ligne du retenu d'evaluer et de mesurer, et rien d'autre", () => { const charge = figer(retenue()); const resume = resumeDuRetenu(charge); const { mesures } = evaluer(charge).retenu; assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions'); + assert.ok(mesures.rencontresRepetees.choisies > 0, 'le retenu porte des rencontres répétées choisies'); assert.notEqual(mesures.collisionsCumulees, mesures.totalRedondance); - assert.deepEqual(resume, { collisionsCumulees: mesures.collisionsCumulees }); + assert.notEqual(mesures.rencontresRepetees.choisies, mesures.collisionsCumulees); + assert.deepEqual(resume, { + collisionsCumulees: mesures.collisionsCumulees, + rencontresRepetees: mesures.rencontresRepetees.choisies, + }); const instance = normaliser(configurationDe(charge)); const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu))); assert.equal(attendues.collisionsCumulees, mesures.collisionsCumulees); + assert.deepEqual(attendues.rencontresRepetees, mesures.rencontresRepetees); + }); + + test('les rencontres répétées du résumé sont les seules choisies : une paire réservée ensemble, imposée, n’y entre pas', () => { + const charge = structuredClone(chargeDemo('petite')); + charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + const avec = generee(charge, { graine: 6, nombre: 3, arret: 500 }); + for (const proposition of avec.propositions) { + const retenue = figer(executer(avec, 'retenirProposition', { id: proposition.id })); + const { mesures } = evaluer(retenue).retenu; + assert.equal(mesures.rencontresRepetees.imposees, 1); + assert.deepEqual(resumeDuRetenu(retenue), { + collisionsCumulees: mesures.collisionsCumulees, + rencontresRepetees: mesures.rencontresRepetees.choisies, + }); + } + }); + + test('les deux valeurs sortent d’une seule mesure du retenu : un seul appel à mesurer, dont le résumé lit les deux', () => { + const source = readFileSync(new URL('./evaluation.js', import.meta.url), 'utf8'); + const debut = source.indexOf('export function resumeDuRetenu('); + assert.ok(debut >= 0); + const corps = source.slice(debut, source.indexOf('\n}\n', debut)); + assert.equal(corps.match(/\bmesurer\(/g)?.length, 1); }); test('aucune cible : le résumé ne dépend pas d’un champ cibleRedondance resté dans les réglages, et le module n’en exporte plus', () => { diff --git a/src/application/evenements.test.js b/src/application/evenements.test.js index 67871c1..f587266 100644 --- a/src/application/evenements.test.js +++ b/src/application/evenements.test.js @@ -639,7 +639,7 @@ describe('lister (§ 8.1, § 19.4)', () => { return serialiser(modifier(charge), { revision: 3, produitVersion: V1 }); } - test('les mesures du retenu : ses seules collisions cumulées, celles de resumeDuRetenu ; null sans retenu ; rien d’écrit', async () => { + test('les mesures du retenu : ses collisions cumulées et ses rencontres répétées choisies, celles de resumeDuRetenu ; null sans retenu ; rien d’écrit', async () => { const { fs, facade } = await deuxEvenements(); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); const avant = ecritures(fs); @@ -649,7 +649,11 @@ describe('lister (§ 8.1, § 19.4)', () => { const charge = analyser(texteRetenu()).charge; const { mesures } = evaluer(charge).retenu; assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions'); - assert.deepEqual(par.retenue.mesuresDuRetenu, { collisionsCumulees: mesures.collisionsCumulees }); + assert.ok(mesures.rencontresRepetees.choisies > 0, 'le retenu porte des rencontres répétées choisies'); + assert.deepEqual(par.retenue.mesuresDuRetenu, { + collisionsCumulees: mesures.collisionsCumulees, + rencontresRepetees: mesures.rencontresRepetees.choisies, + }); assert.deepEqual(par.retenue.mesuresDuRetenu, resumeDuRetenu(charge)); assert.equal(par['soiree-a'].mesuresDuRetenu, null); assert.equal(par.petite.mesuresDuRetenu, null); diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index 33ebfe0..9c8c92d 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -1238,10 +1238,12 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => { return mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu))); } - test('un événement retenu : ses collisions cumulées (paire et tour), sous l’intitulé du tableau des propositions ; sans retenu : « — » ; rien d’écrit', async () => { + test('un retenu : ses collisions cumulées et ses rencontres répétées choisies, sous les intitulés du tableau des propositions ; sans retenu, « — »', async () => { const charge = chargeRetenue(); const mesures = mesuresDe(charge); expect(mesures.collisionsCumulees).toBeGreaterThan(0); + expect(mesures.rencontresRepetees.choisies).toBeGreaterThan(0); + expect(mesures.rencontresRepetees.choisies).not.toBe(mesures.collisionsCumulees); expect(mesures.collisionsCumulees).not.toBe(Math.abs(1000 - mesures.totalRedondance)); const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))], @@ -1252,27 +1254,39 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => { const retenue = ligneDe(m, 'retenue'); expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees)); expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(evaluer(charge).retenu.mesures.collisionsCumulees)); + expect(colonne(retenue, 'rencontresRepetees')).toBe(format.nombre(mesures.rencontresRepetees.choisies)); + expect(colonne(retenue, 'rencontresRepetees')).toBe(format.nombre(evaluer(charge).retenu.mesures.rencontresRepetees.choisies)); const club = ligneDe(m, 'club'); expect(colonne(club, 'collisionsCumulees')).toBe(TIRET); - // L'intitulé est un en-tête de colonne, une seule fois pour toute la - // liste, au texte de la colonne du tableau des propositions ; plus rien - // ne nomme la différence à la cible. - expect(tous(m, '[data-terme="collisionsCumulees"]').length).toBe(1); - expect(terme(accueil(m), 'collisionsCumulees').closest('thead')).not.toBeNull(); - expect(terme(accueil(m), 'collisionsCumulees').textContent).toBe(t('propositions.colonne.collisionsCumulees')); + expect(colonne(club, 'rencontresRepetees')).toBe(TIRET); + // Chaque intitulé est un en-tête de colonne, une seule fois pour toute la + // liste, au texte de la colonne du tableau des propositions, dans l'ordre + // des valeurs ; plus rien ne nomme la différence à la cible. + for (const nom of ['collisionsCumulees', 'rencontresRepetees']) { + expect(tous(m, `[data-terme="${nom}"]`).length, nom).toBe(1); + expect(terme(accueil(m), nom).closest('thead'), nom).not.toBeNull(); + expect(terme(accueil(m), nom).textContent, nom).toBe(t(`propositions.colonne.${nom}`)); + expect(visible(retenue.querySelector(`[data-colonne="${nom}"]`)), nom).toBe(true); + } + expect([...retenue.querySelectorAll('[data-colonne]')].map((cellule) => cellule.dataset.colonne).slice(-2)).toEqual([ + 'collisionsCumulees', + 'rencontresRepetees', + ]); expect(tous(m, '[data-colonne="diffRedondance"], [data-terme="diffRedondance"]')).toEqual([]); expect(accueil(m).textContent).not.toContain("Diff somme d'appartenance"); - expect(visible(retenue.querySelector('[data-colonne="collisionsCumulees"]'))).toBe(true); expect(ecritures(m)).toBe(avant); }); test('chaque intitulé s’explique : le survol puis le focus ouvrent son infobulle, Échap et la sortie la ferment', async () => { const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] }); await vi.waitFor(() => expect(lignes(m).length).toBe(1)); - await eprouverTermes(accueil(m), [['collisionsCumulees', 'accueil.explication.collisionsCumulees']]); + await eprouverTermes(accueil(m), [ + ['collisionsCumulees', 'accueil.explication.collisionsCumulees'], + ['rencontresRepetees', 'accueil.explication.rencontresRepetees'], + ]); }); - test('un état écrit avec la cible de redondance d’un code antérieur : la ligne se lit sans signalement, ses collisions comme sans elle ; l’ouvrir n’écrit rien', async () => { + test('un état écrit avec la cible de redondance d’un code antérieur : la ligne se lit sans signalement, ses mesures du retenu comme sans elle ; l’ouvrir n’écrit rien', async () => { const charge = chargeRetenue(); const lu = JSON.parse(etat(charge)); lu.charge.reglages.cibleRedondance = 25; @@ -1282,6 +1296,7 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => { const ligne = ligneDe(m, 'retenue'); expect(ligne.querySelector('[data-signalement]')).toBeNull(); expect(colonne(ligne, 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees)); + expect(colonne(ligne, 'rencontresRepetees')).toBe(format.nombre(mesuresDe(charge).rencontresRepetees.choisies)); const avant = ecritures(m); await ouvrirLigne(m, 'retenue'); await attendreEvenement(m); @@ -1418,8 +1433,15 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, expect(tableau.parentElement.scrollWidth).toBeLessThanOrEqual(tableau.parentElement.clientWidth); expect(tableau.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right); expect(carte.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); + // Chaque intitulé de colonne tient dans sa cellule, ceux du placement + // retenu compris. + for (const entete of tous(m, '[data-ecran="accueil"] thead th')) { + expect(entete.scrollWidth, entete.textContent).toBeLessThanOrEqual(entete.clientWidth); + expect(entete.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right); + } for (const ligne of lignes(m)) { expect(lu(ligne).id).toMatch(/\d/); + for (const nom of ['collisionsCumulees', 'rencontresRepetees']) expect(colonne(ligne, nom), nom).toMatch(/\d/); for (const cellule of ligne.cells) { expect(cellule.scrollWidth, cellule.textContent).toBeLessThanOrEqual(cellule.clientWidth); expect(cellule.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); @@ -1446,15 +1468,16 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', ( return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; } - // Les deux valeurs de résumé d'une ligne, dans l'ordre du rendu. - const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]']; + // Les trois valeurs de résumé d'une ligne, dans l'ordre du rendu : les + // deux mesures du retenu, puis la meilleure proposition. + const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-colonne="rencontresRepetees"]', '[data-meilleure-id]']; const cellules = (ligne) => CELLULES.map((selecteur) => ligne.querySelector(selecteur)); // Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée // une seconde fois (§ 19.1). function attendues(charge) { const table = evaluer(charge); const [premiere] = table.propositions; - return [table.retenu.mesures.collisionsCumulees, premiere.id].map((n) => format.nombre(n)); + return [table.retenu.mesures.collisionsCumulees, table.retenu.mesures.rencontresRepetees.choisies, premiere.id].map((n) => format.nombre(n)); } // Chaque valeur de la ligne attend : la cellule se dit occupée, porte un // seul signe, muet pour l'accessibilité (aria-hidden, sans rôle ni nom), @@ -1500,12 +1523,12 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', ( expect(colonne(ligne, 'participants')).toBe(format.nombre(charges[ligne.dataset.evenement].participants.length)); enAttente(ligne); } - expect(signes(m).length).toBe(4); + expect(signes(m).length).toBe(6); const region = regionEtat(m); expect(region.textContent).toBe(t('accueil.chargement')); for (let image = 0; image < 3; image += 1) await uneImage(); flushSync(); - expect(signes(m).length).toBe(4); + expect(signes(m).length).toBe(6); m.horloge.avancer(0); await vi.waitFor(() => resolue(ligneDe(m, 'alpha'), attendues(charges.alpha))); enAttente(ligneDe(m, 'beta')); @@ -1523,7 +1546,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', ( test('démontée avant le premier pas, l’application arrête le flux des résumés : aucun état ne se relit ensuite', async () => { const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') }; const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) }); - expect(signes(m).length).toBe(4); + expect(signes(m).length).toBe(6); const relus = []; const lireTexte = m.fs.lireTexte; m.fs.lireTexte = (racine, chemin) => { @@ -1540,7 +1563,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', ( expect(relus).toEqual([]); }); - test('un état qui ne se relit pas pendant le chargement : « — » dans ses deux valeurs, et l’autre ligne résolue', async () => { + test('un état qui ne se relit pas pendant le chargement : « — » dans ses trois valeurs, et l’autre ligne résolue', async () => { const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') }; const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) }); const lireTexte = m.fs.lireTexte; @@ -1549,7 +1572,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', ( ? Promise.reject(new ErreurStockage('LECTURE', { chemin, cause: 'EIO' })) : lireTexte.call(m.fs, racine, chemin); await chargerResumes(m); - resolue(ligneDe(m, 'alpha'), [TIRET, TIRET]); + resolue(ligneDe(m, 'alpha'), [TIRET, TIRET, TIRET]); resolue(ligneDe(m, 'beta'), attendues(charges.beta)); }); @@ -1582,8 +1605,10 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', ( test('le signe se voit : il tient 3:1 sur ce qui l’entoure dans les deux thèmes, sans atténuation', async () => { const m = await monter({ fichiers: [fichier(`alpha${SUFFIXES.etat}`, etat(chargeResumee('evt-alpha')))] }); + // Une ligne qui attend : un signe par valeur de résumé, un tracé par + // signe. const traces = tous(m, '[data-chargement] path'); - expect(traces.length).toBe(2); + expect(traces.length).toBe(3); for (const theme of ['clair', 'sombre']) { await sousTheme(theme, async () => { for (const trace of traces) { @@ -1794,7 +1819,17 @@ describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, § await chargerResumes(m); const entetes = tous(m, '[data-ecran="accueil"] thead th[scope="colgroup"]'); expect(entetes.map(texte)).toEqual([t('accueil.entete.evenement'), t('accueil.entete.retenu'), t('generation.meilleure.titre')]); - expect(entetes.map((th) => th.colSpan)).toEqual([4, 1, 1]); + expect(entetes.map((th) => th.colSpan)).toEqual([4, 2, 1]); + // Les groupes de colonnes, que lisent les techniques d'assistance pour + // rattacher une cellule à son en-tête de groupe : un par en-tête, chacun + // de la largeur de son en-tête, plus la colonne « Ouvrir », et leur + // somme fait une ligne entière. + const table = entetes[0].closest('table'); + const groupesDeColonnes = [...table.querySelectorAll(':scope > colgroup')].map((groupe) => groupe.span); + expect(groupesDeColonnes).toEqual([4, 2, 1, 1]); + expect(groupesDeColonnes.slice(0, 3)).toEqual(entetes.map((th) => th.colSpan)); + const largeurDeLigne = [...ligneDe(m, 'gala').cells].reduce((somme, cellule) => somme + cellule.colSpan, 0); + expect(groupesDeColonnes.reduce((somme, span) => somme + span, 0)).toBe(largeurDeLigne); expect(fond(entetes[1])).toEqual(jeton('--couleur-etat-retenu-fond')); expect(fond(entetes[2])).toEqual(jeton('--couleur-etat-propose-fond')); // Le groupe porte le signe de son état, de la marque de l'état. @@ -1807,7 +1842,11 @@ describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, § }; const ligne = ligneDe(m, 'gala'); for (const nom of ['nom', 'etat', 'participants', 'modification']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[0]), nom).toBe(true); - expect(dans2(ligne.querySelector('[data-colonne="collisionsCumulees"]'), entetes[1])).toBe(true); + for (const nom of ['collisionsCumulees', 'rencontresRepetees']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[1]), nom).toBe(true); + // Le groupe du retenu couvre ses deux colonnes, d'un bord à l'autre. + const bornes = ['collisionsCumulees', 'rencontresRepetees'].map((nom) => ligne.querySelector(`[data-colonne="${nom}"]`).getBoundingClientRect()); + expect(Math.abs(bornes[0].left - entetes[1].getBoundingClientRect().left)).toBeLessThanOrEqual(0.5); + expect(Math.abs(bornes[1].right - entetes[1].getBoundingClientRect().right)).toBeLessThanOrEqual(0.5); expect(dans2(ligne.querySelector('[data-meilleure-id]'), entetes[2])).toBe(true); // Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent, // dessous, en plus petit. diff --git a/src/interface/accueil/ListeEvenements.svelte b/src/interface/accueil/ListeEvenements.svelte index 18dd0e3..c9dd094 100644 --- a/src/interface/accueil/ListeEvenements.svelte +++ b/src/interface/accueil/ListeEvenements.svelte @@ -14,13 +14,14 @@ // // Les en-têtes de colonnes sont groupés : l'événement — nom, état, // participants, dernière modification —, le placement retenu, à la teinte - // de Retenu — ses collisions cumulées (COLONNES_DU_RETENU) —, la - // meilleure proposition du classement, à la teinte de Proposé, sous le - // titre et le libellé du bloc de la génération (MEILLEURE) — son - // identifiant —, puis les actions. Chaque valeur est dans son propre - // nœud : data-colonne, data-meilleure-id, « — » sans valeur. Le nom est - // l'en-tête de sa ligne ; la date et le chemin le suivent en petite - // ligne, chacun précédé de son intitulé, que + // de Retenu — ses collisions cumulées et ses rencontres répétées choisies, + // une colonne chacune (COLONNES_DU_RETENU), que l'en-tête du groupe + // couvre —, la meilleure proposition du classement, à la teinte de + // Proposé, sous le titre et le libellé du bloc de la génération + // (MEILLEURE) — son identifiant —, puis les actions. Chaque valeur est + // dans son propre nœud : data-colonne, data-meilleure-id, « — » sans + // valeur. Le nom est l'en-tête de sa ligne ; la date et le chemin le + // suivent en petite ligne, chacun précédé de son intitulé, que // lisent les technologies d'assistance et que l'œil ne voit pas, puis ses // signalements traduits — les bases qu'un doublon nomme suivent son // texte, chacune dans son propre nœud (data-doublon). L'état est une @@ -33,7 +34,7 @@ // // lignes : LigneListe[] de la façade ; resumes : les résumés reçus de // facade.resumer, par base — une ligne sans le sien attend, et chacune de - // ses deux valeurs de résumé porte le signe du chargement (Chargement), + // ses trois valeurs de résumé porte le signe du chargement (Chargement), // décoratif, dans une cellule qui se dit occupée (aria-busy), jusqu'à ce // qu'il arrive. La région d'état qui suit le tableau // (data-chargement-liste) existe tant que la liste a des lignes, et écrit @@ -44,7 +45,7 @@ // du dernier échec, ou null ; apresOperation() : à appeler quand une // opération a changé le dossier. // - // L'intitulé de la mesure du retenu et le titre de la meilleure + // L'intitulé de chaque mesure du retenu et le titre de la meilleure // proposition s'expliquent dans l'infobulle de la liste, une seule // (Infobulle, primitive) : chaque terme (data-terme, que pose le fragment termeExplique), un en-tête de colonne, // se focalise au clavier, son survol ou son focus ouvre l'explication, le @@ -72,9 +73,9 @@ const enChargement = $derived(lignes.some((ligne) => ligneAffichee(ligne, format, resumes.get(ligne.base)).enAttente)); const groupes = $derived(groupesDeLaListe(lignes, vue.filtre, vue.groupement)); // Le nombre de colonnes du tableau, que l'intitulé d'un groupe couvre : - // les quatre de l'événement, celle du retenu, celle de la meilleure + // les quatre de l'événement, les deux du retenu, celle de la meilleure // proposition, les actions. - const COLONNES = 7; + const COLONNES = 8; // Le terme expliqué : {ancre, terme, cle, details} — son élément, son nom // et la clé de son explication — ; null sans infobulle. @@ -136,13 +137,13 @@
- + - } format - * @param {{mesuresDuRetenu: {collisionsCumulees: number}|null, + * @param {{mesuresDuRetenu: {collisionsCumulees: number, rencontresRepetees: number}|null, * meilleureDuClassement: {id: number}|null}} [resume] * @returns {{nom: string, date: string, etat: string, participants: string, * modification: string, chemin: string, enAttente: boolean, - * collisionsCumulees: string, meilleure: {id: string}, + * collisionsCumulees: string, rencontresRepetees: string, + * meilleure: {id: string}, * signalements: Array<{cle: string, details: Object, noms: string[]}>}} */ export function ligneAffichee(ligne, format, resume) { @@ -167,6 +178,7 @@ export function ligneAffichee(ligne, format, resume) { chemin: ligne.chemin, enAttente: resume === undefined, collisionsCumulees: format.nombre(resume?.mesuresDuRetenu?.collisionsCumulees ?? null), + rencontresRepetees: format.nombre(resume?.mesuresDuRetenu?.rencontresRepetees ?? null), meilleure: { id: format.nombre(resume?.meilleureDuClassement?.id ?? null) }, signalements: signalementsDe(ligne), }; diff --git a/src/interface/accueil/modele.test.js b/src/interface/accueil/modele.test.js index 2d71f6f..256a8e2 100644 --- a/src/interface/accueil/modele.test.js +++ b/src/interface/accueil/modele.test.js @@ -88,6 +88,7 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => { 'modification', 'nom', 'participants', + 'rencontresRepetees', 'signalements', ]); assert.equal(affichee.nom, 'Bal des Grisailles'); @@ -335,59 +336,85 @@ describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', () }); describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1)', () => { - test('les collisions cumulées du retenu, celles de la façade, mises en forme, sans calcul ici', () => { - for (const collisionsCumulees of [500, 7, 0]) { - const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees } }); + test('les collisions cumulées et les rencontres répétées choisies du retenu, celles de la façade, mises en forme chacune à sa place, sans calcul ici', () => { + for (const [collisionsCumulees, rencontresRepetees] of [ + [500, 3], + [7, 12], + [0, 0], + ]) { + const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees, rencontresRepetees } }); assert.equal(affichee.collisionsCumulees, format.nombre(collisionsCumulees), String(collisionsCumulees)); + assert.equal(affichee.rencontresRepetees, format.nombre(rencontresRepetees), String(rencontresRepetees)); } }); - test('sans retenu mesurable, et pour une ligne illisible, les collisions valent « — »', () => { + test('sans retenu mesurable, et pour une ligne illisible, les collisions et les rencontres répétées valent « — »', () => { for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) { const affichee = ligneAffichee(ligne(champs), format, AUCUN); assert.equal(affichee.collisionsCumulees, VIDE); + assert.equal(affichee.rencontresRepetees, VIDE); } }); - test('COLONNES_DU_RETENU : les seules collisions cumulées, sous l’intitulé de la colonne du tableau des propositions, avec l’explication propre à la liste ; plus rien de la différence à la cible', () => { + test('COLONNES_DU_RETENU : les collisions cumulées puis les rencontres répétées choisies, chacune sous l’intitulé de sa colonne du tableau des propositions, avec son explication propre à la liste ; plus rien de la différence à la cible', () => { assert.deepEqual(COLONNES_DU_RETENU, [ { colonne: 'collisionsCumulees', cle: 'propositions.colonne.collisionsCumulees', explication: 'accueil.explication.collisionsCumulees', }, + { + colonne: 'rencontresRepetees', + cle: 'propositions.colonne.rencontresRepetees', + explication: 'accueil.explication.rencontresRepetees', + }, ]); assert.ok(Object.isFrozen(COLONNES_DU_RETENU)); - assert.ok(Object.isFrozen(COLONNES_DU_RETENU[0])); + for (const colonne of COLONNES_DU_RETENU) assert.ok(Object.isFrozen(colonne), colonne.colonne); assert.equal(t('propositions.colonne.collisionsCumulees'), 'Collisions cumulées (paire et tour)'); + assert.equal(t('propositions.colonne.rencontresRepetees'), 'Rencontres répétées choisies (paires)'); const explication = t('accueil.explication.collisionsCumulees'); for (const fragment of ['paire', 'tour', 'Collisions cumulées (paire et tour)', '\u{2014}']) { assert.ok(explication.includes(fragment), fragment); } - for (const cle of ['accueil.colonne.diffRedondance', 'accueil.explication.diffRedondance', 'accueil.colonne.collisionsCumulees']) { + // L'explication des rencontres répétées dit ce qu'elle compte, d'où elle + // vient et quand elle vaut « — », et n'est pas celle des collisions. + const repetees = t('accueil.explication.rencontresRepetees'); + for (const fragment of ['paires', "plus d'un tour", 'réservations', 'Rencontres répétées choisies (paires)', 'retenu', '\u{2014}']) { + assert.ok(repetees.includes(fragment), fragment); + } + assert.notEqual(repetees, explication); + for (const cle of [ + 'accueil.colonne.diffRedondance', + 'accueil.explication.diffRedondance', + 'accueil.colonne.collisionsCumulees', + 'accueil.colonne.rencontresRepetees', + ]) { assert.throws(() => t(cle), RangeError, cle); } }); }); describe('ligneAffichee : les résumés en attente (§ 8.1)', () => { - test('sans résumé reçu, la ligne est en attente et ses deux valeurs de résumé ne disent rien ; reçu, elle ne l’est plus', () => { + test('sans résumé reçu, la ligne est en attente et ses trois valeurs de résumé ne disent rien ; reçu, elle ne l’est plus', () => { const attente = ligneAffichee(ligne(), format); assert.equal(attente.enAttente, true); assert.equal(attente.collisionsCumulees, VIDE); + assert.equal(attente.rencontresRepetees, VIDE); assert.deepEqual(attente.meilleure, { id: VIDE }); assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false); const recu = ligneAffichee(ligne(), format, { - mesuresDuRetenu: { collisionsCumulees: 10 }, + mesuresDuRetenu: { collisionsCumulees: 10, rencontresRepetees: 4 }, meilleureDuClassement: { id: 3 }, }); assert.equal(recu.enAttente, false); assert.equal(recu.collisionsCumulees, format.nombre(10)); + assert.equal(recu.rencontresRepetees, format.nombre(4)); assert.deepEqual(recu.meilleure, { id: format.nombre(3) }); }); test('les six informations ne dépendent pas des résumés : en attente ou non, elles sont les mêmes', () => { - const sans = ({ enAttente, collisionsCumulees, meilleure, ...reste }) => reste; + const sans = ({ enAttente, collisionsCumulees, rencontresRepetees, meilleure, ...reste }) => reste; assert.deepEqual(sans(ligneAffichee(ligne(), format)), sans(ligneAffichee(ligne(), format, AUCUN))); }); }); diff --git a/src/interface/libelles/accueil.js b/src/interface/libelles/accueil.js index 952e8d0..cc1b8e2 100644 --- a/src/interface/libelles/accueil.js +++ b/src/interface/libelles/accueil.js @@ -73,10 +73,10 @@ export const TABLE = new Map([ ['accueil.colonne.participants', 'Participants'], ['accueil.colonne.modification', 'Dernière modification'], ['accueil.colonne.chemin', 'Fichier'], - // La mesure du placement retenu (§ 5.4, § 8.1) : son intitulé est celui de - // la colonne du tableau des propositions qui porte la même mesure ; - // l'explication que l'infobulle de l'intitulé donne dit ce que compte le - // nombre, d'où il vient, et quand il vaut « — ». + // Les mesures du placement retenu (§ 5.4, § 8.1) : l'intitulé de chacune + // est celui de la colonne du tableau des propositions qui porte la même + // mesure ; l'explication que l'infobulle de l'intitulé donne dit ce que + // compte le nombre, d'où il vient, et quand il vaut « — ». [ 'accueil.explication.collisionsCumulees', 'Les collisions du placement retenu. Chaque fois que deux personnes de la même appartenance partagent une ' + @@ -85,6 +85,15 @@ export const TABLE = new Map([ "Plus il est bas, mieux c'est. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit " + "plus la configuration, ou se contredit.", ], + [ + 'accueil.explication.rencontresRepetees', + "Les rencontres répétées du placement retenu : les paires de personnes réunies à la même table à plus d'un " + + 'tour que le placement a choisies, comptées une fois par paire : deux personnes ensemble à trois tours font 1. ' + + "Une paire que les réservations réunissent à chacune de ses rencontres n'y entre pas. Le même nombre que " + + 'la colonne « Rencontres répétées choisies (paires) » du tableau des propositions, sur la ligne du retenu. ' + + "Plus il est bas, mieux c'est. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit " + + 'plus la configuration, ou se contredit.', + ], // La meilleure proposition du classement de chaque événement (§ 5.4, // § 5.7) : son titre et son identifiant s'écrivent sous les libellés du diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index e5812dc..396dbc6 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -643,13 +643,14 @@ export const ECRANS = Object.freeze([ mode: null, async atteindre(contexte) { // La liste paraît avant ses résumés : tant que l'horloge pilotée - // n'avance pas, chaque valeur de résumé porte le signe du chargement. - // Rien ici ne l'avance : les signes restent pendant les relevés des - // deux thèmes, où le trait de chacun se mesure comme la marque - // « chargement ». + // n'avance pas, chaque valeur de résumé porte le signe du chargement : + // trois par ligne, les deux mesures du retenu et la meilleure + // proposition. Rien ici ne l'avance : les signes restent pendant les + // relevés des deux thèmes, où le trait de chacun se mesure comme la + // marque « chargement ». const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] }); await attendre('le signe du chargement dans chaque valeur de résumé', () => - [BASE, 'gelee'].every((base) => m.racine.querySelectorAll(`[data-evenement="${base}"] [aria-busy="true"] [data-chargement]`).length === 2), + [BASE, 'gelee'].every((base) => m.racine.querySelectorAll(`[data-evenement="${base}"] [aria-busy="true"] [data-chargement]`).length === 3), ); return m.racine; }, @@ -659,17 +660,19 @@ export const ECRANS = Object.freeze([ mode: null, async atteindre(contexte) { // La principale, sa proposition retenue : la ligne montre les - // collisions cumulées du retenu et la meilleure proposition du - // classement, et le pointeur réel sur l'intitulé des collisions — un - // en-tête de colonne du tableau, une fois pour la liste — ouvre son - // infobulle. + // collisions cumulées et les rencontres répétées choisies du retenu et + // la meilleure proposition du classement, et le pointeur réel sur + // l'intitulé des collisions — un en-tête de colonne du tableau, une + // fois pour la liste — ouvre son infobulle. const principale = chargePrincipale(); const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id }); const m = await contexte.monter({ fichiers: [fichierEtat(charge)] }); const ligne = `[data-evenement="${BASE}"]`; await chargerResumes(m); - await attendre('les collisions du retenu et la meilleure proposition', () => - ['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')), + await attendre('les mesures du retenu et la meilleure proposition', () => + ['[data-colonne="collisionsCumulees"]', '[data-colonne="rencontresRepetees"]', '[data-meilleure-id]'].every((valeur) => + /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? ''), + ), ); const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="collisionsCumulees"]')); await pointer('bouger', x, y);
{t('accueil.entete.evenement')}{t('accueil.entete.retenu')}