diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js index ab2ca7a..3dacca4 100644 --- a/src/interface/libelles/propositions.js +++ b/src/interface/libelles/propositions.js @@ -64,6 +64,136 @@ export const TABLE = new Map([ ['propositions.colonne.imposes', 'Imposé par les réservations'], ['propositions.colonne.commandes', 'Commandes'], + // --- Les explications, une par colonne (§ 5.4, § 5.5, § 5.7, § 12.10.4) -------- + // Le texte de l'infobulle d'un en-tête, et celui du nom de critère qui + // désigne la colonne dans la phrase de l'ordre : un critère et sa colonne + // partagent la clé. Chacune dit ce que la colonne compte, sur quelle + // population, et dans quel sens elle sert le mieux quand ce sens existe ; + // les termes sont ceux du glossaire du § 5.5. + [ + 'propositions.explication.rang', + "La place de la proposition dans le classement, 1 pour la première, selon l'ordre écrit au-dessus du tableau. " + + "Une proposition hors classement n'a pas de rang.", + ], + [ + 'propositions.explication.proposition', + 'Le numéro de la proposition, donné à sa génération ; un numéro effacé ne revient jamais. ' + + 'Il sert de dernier départage : deux propositions égales sur tous les critères se rangent par numéro croissant.', + ], + [ + 'propositions.explication.rencontresTous', + 'Le nombre de personnes différentes que rencontre, sur toute la soirée, la personne la moins bien servie : ' + + "quelqu'un revu à plusieurs tours compte une fois. Il porte sur toutes les personnes placées, ancrées comprises. " + + "Plus il est grand, mieux c'est.", + ], + [ + 'propositions.explication.rencontresMobiles', + "Le même minimum, sur les seules personnes mobiles, celles qu'aucune réservation n'assied à la même table à tous les tours, " + + "partiellement fixées comprises ; « \u{2014} » s'il n'y en a aucune. Comparé aux deux autres, il dit si un minimum bas vient " + + "du placement ou des réservations. Plus il est grand, mieux c'est.", + ], + [ + 'propositions.explication.rencontresAncres', + "Le même minimum, sur les seules personnes ancrées, celles qu'une réservation assied à la même table à chaque tour ; " + + "« \u{2014} » s'il n'y en a aucune. Plus il est grand, mieux c'est.", + ], + [ + 'propositions.explication.collisionsCumulees', + 'Chaque fois que deux personnes de la même appartenance partagent une table, compté une fois par paire et par tour : ' + + "deux collègues ensemble aux quatre tours font 4. Critère du classement après l'excédent, il départage deux " + + "propositions de même excédent. Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.pairesDistinctes', + 'Le nombre de paires différentes de personnes de la même appartenance réunies au moins une fois : deux collègues ' + + "ensemble aux quatre tours font 1. Ce n'est pas un critère du classement : il vaut les collisions cumulées moins " + + "l'excédent.", + ], + [ + 'propositions.explication.excedentCollisions', + 'Les retrouvailles entre personnes de la même appartenance au-delà de leur première rencontre, soit les collisions ' + + 'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3, six collègues croisés une ' + + 'fois chacun font 0. Critère du classement avant les collisions cumulées, car passer la soirée avec le même ' + + "collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.rencontresRepetees', + "Le nombre de paires de personnes réunies à plus d'un tour que le placement a choisies ; celles qu'imposent les " + + 'réservations sont dans la colonne des imposés. Critère du classement après les collisions cumulées. ' + + "Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.maxRencontresPaire', + 'Le plus grand nombre de tours que passe une même paire de personnes à la même table, réservations comprises ; ' + + "à 1, personne ne retrouve personne. Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.retoursChoisis', + "Le nombre de fois où une personne revient à une table où elle s'est déjà assise, additionné sur toutes les personnes. " + + "Seuls comptent les retours que le placement a choisis ; ceux qu'imposent les réservations sont dans la colonne " + + "des imposés. Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.appartenancesVues', + "A(p) : le nombre d'appartenances différentes parmi les personnes que p rencontre et qui en ont une, la sienne " + + "comptée dès qu'elle rencontre un collègue. La colonne donne le plus petit A(p), celui de la personne qui en voit " + + "le moins. Plus il est grand, mieux c'est.", + ], + [ + 'propositions.explication.totalRedondance', + "r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances " + + "différentes qu'elles portent ; à 0, aucune appartenance n'est recroisée. La colonne donne la somme sur toutes les " + + "personnes, Σ r(p), dernier critère du classement avant le numéro. Plus elle est basse, mieux c'est.", + ], + [ + 'propositions.explication.diversite', + "d(p) : A(p) divisé par le nombre de personnes que p rencontre et qui ont une appartenance ; à 1, elles portent " + + "toutes des appartenances différentes. La colonne donne le plus petit d(p), « \u{2014} » quand personne ne rencontre " + + "quelqu'un qui en a une. Plus il est grand, mieux c'est.", + ], + [ + 'propositions.explication.manqueMax', + "Le manque d'une personne : son plafond réalisé, le plus de rencontres que permettent les tables où la proposition " + + "l'assoit et leur occupation, moins ses rencontres. La colonne donne le plus grand manque ; un manque nul ne dit " + + "rien d'un itinéraire bas, que l'écart d'itinéraire montre. Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.effectifManque', + "Le nombre de personnes dont le manque vaut au moins 1 ; 0 veut dire que personne n'a de manque. " + + "Il dit si le plus grand manque touche une personne ou beaucoup : déplacer quelqu'un, ou ajouter une table. " + + "Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.ecartItineraireMax', + "L'écart d'itinéraire d'une personne : son plafond a priori moins son plafond réalisé, ce que son itinéraire lui " + + 'retire avant toute rencontre \u{2014} moins de tables différentes, des tables incomplètes, des tours en réserve. ' + + "La colonne donne le plus grand, « inconnu » quand le plafond a priori n'a pas pu être calculé. " + + "Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.ecartAuPlafondAPriori', + "L'écart au plafond a priori d'une personne : son plafond a priori, le plus de rencontres que permettent les " + + "capacités des tables sur le meilleur itinéraire, moins ses rencontres, soit son manque plus son écart d'itinéraire. " + + "La colonne donne le plus grand, premier critère du classement, sauté quand le plafond a priori n'a pas pu être " + + "calculé, la cellule écrivant alors « inconnu ». Plus il est bas, mieux c'est.", + ], + [ + 'propositions.explication.certificat', + '« minimum atteint » quand chaque personne rencontre autant de monde que son plafond a priori le permet : ' + + 'personne ne peut en rencontrer davantage. Sinon, « atteignabilité inconnue » : le logiciel ne sait pas si un ' + + 'placement peut y parvenir pour tous.', + ], + [ + 'propositions.explication.imposes', + 'Ce que les réservations imposent, compté à part et jamais ajouté aux colonnes du placement : les retours à une ' + + 'table que fixent les ancrages, et les rencontres répétées entre personnes réservées ensemble à la même table.', + ], + [ + 'propositions.explication.commandes', + '« Voir sur le plan » montre la proposition sur le plan de salle, sans rien écrire. « Retenir » en fait le ' + + "placement retenu, celui qu'on ajuste à la main ; les autres propositions restent dans la liste.", + ], + // --- Les valeurs écrites en mots (§ 5.5, § 12.6) ------------------------------ ['propositions.valeur.inconnu', 'inconnu'], ['propositions.certificat.atteint', 'minimum atteint'], diff --git a/src/interface/primitives/Infobulle.svelte b/src/interface/primitives/Infobulle.svelte new file mode 100644 index 0000000..4ff7074 --- /dev/null +++ b/src/interface/primitives/Infobulle.svelte @@ -0,0 +1,113 @@ + + + + + + + + + diff --git a/src/interface/primitives/infobulle.js b/src/interface/primitives/infobulle.js new file mode 100644 index 0000000..20e40d9 --- /dev/null +++ b/src/interface/primitives/infobulle.js @@ -0,0 +1,51 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La place d'une infobulle à côté du terme qu'elle explique, pure : +// Infobulle.svelte la calcule à chaque ouverture, défilement et +// redimensionnement, sur les rectangles relus du rendu. Toutes les +// grandeurs sont en px CSS, dans le repère de la fenêtre. +// +// L'infobulle se pose sous le terme, à ECART_INFOBULLE_PX de lui, quand +// elle y tient ; au-dessus sinon : elle ne couvre jamais le terme qu'elle +// explique dès qu'un des deux côtés la reçoit. Quand aucun ne la reçoit, elle +// prend le plus grand et reste dans le cadre, quitte à couvrir le terme. +// Horizontalement, elle s'aligne sur le bord gauche du terme, puis recule +// pour garder MARGE_INFOBULLE_PX entre elle et chaque bord du cadre ; plus +// large que le cadre, elle se colle à la marge gauche. + +/** Distance entre le terme et l'infobulle, en px. */ +export const ECART_INFOBULLE_PX = 6; + +/** Distance minimale entre l'infobulle et chaque bord du cadre, en px. */ +export const MARGE_INFOBULLE_PX = 8; + +/** + * La place d'une infobulle. + * + * @param {{gauche: number, haut: number, droite: number, bas: number}} ancre + * le rectangle du terme expliqué + * @param {{largeur: number, hauteur: number}} taille celle de l'infobulle + * @param {{largeur: number, hauteur: number}} cadre la partie visible de la + * fenêtre, barres de défilement exclues + * @returns {{gauche: number, haut: number, cote: 'dessous'|'dessus'}} le coin + * haut gauche de l'infobulle, et le côté du terme où elle se pose + */ +export function placerInfobulle(ancre, { largeur, hauteur }, cadre) { + const gaucheMax = cadre.largeur - MARGE_INFOBULLE_PX - largeur; + const gauche = Math.max(MARGE_INFOBULLE_PX, Math.min(ancre.gauche, gaucheMax)); + + const dessous = ancre.bas + ECART_INFOBULLE_PX; + const dessus = ancre.haut - ECART_INFOBULLE_PX - hauteur; + const basMax = cadre.hauteur - MARGE_INFOBULLE_PX; + if (dessous + hauteur <= basMax) return { gauche, haut: dessous, cote: 'dessous' }; + if (dessus >= MARGE_INFOBULLE_PX) return { gauche, haut: dessus, cote: 'dessus' }; + + // Aucun côté ne la reçoit : le plus grand, la boîte ramenée dans le cadre. + const placeDessous = basMax - dessous; + const placeDessus = ancre.haut - ECART_INFOBULLE_PX - MARGE_INFOBULLE_PX; + const cote = placeDessous >= placeDessus ? 'dessous' : 'dessus'; + const voulu = cote === 'dessous' ? dessous : dessus; + const haut = Math.max(MARGE_INFOBULLE_PX, Math.min(voulu, basMax - hauteur)); + return { gauche, haut, cote }; +} diff --git a/src/interface/primitives/infobulle.test.js b/src/interface/primitives/infobulle.test.js new file mode 100644 index 0000000..714cdae --- /dev/null +++ b/src/interface/primitives/infobulle.test.js @@ -0,0 +1,84 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la place d'une infobulle (§ 12.7, § 14.5) : sous le terme +// quand elle y tient, au-dessus sinon ; jamais sur le terme qu'elle +// explique quand un côté la reçoit ; toujours dans le cadre, à la marge +// près. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { MARGE_INFOBULLE_PX, ECART_INFOBULLE_PX, placerInfobulle } from './infobulle.js'; + +const CADRE = { largeur: 1366, hauteur: 700 }; +const TAILLE = { largeur: 300, hauteur: 90 }; + +// Le rectangle d'un terme de largeur et de hauteur données, coin haut gauche +// en (gauche, haut). +const terme = (gauche, haut, largeur = 120, hauteur = 20) => ({ gauche, haut, droite: gauche + largeur, bas: haut + hauteur }); + +// La boîte de l'infobulle placée. +const boite = ({ gauche, haut }, { largeur, hauteur }) => ({ gauche, haut, droite: gauche + largeur, bas: haut + hauteur }); + +const recouvre = (a, b) => a.gauche < b.droite && b.gauche < a.droite && a.haut < b.bas && b.haut < a.bas; + +const dansLeCadre = (b, cadre) => + b.gauche >= MARGE_INFOBULLE_PX && + b.haut >= MARGE_INFOBULLE_PX && + b.droite <= cadre.largeur - MARGE_INFOBULLE_PX && + b.bas <= cadre.hauteur - MARGE_INFOBULLE_PX; + +describe('placerInfobulle : sous le terme, au-dessus, dans le cadre', () => { + test('la place sous le terme suffit : l’infobulle s’y pose, alignée sur son bord gauche, à l’écart du terme', () => { + const ancre = terme(200, 100); + const place = placerInfobulle(ancre, TAILLE, CADRE); + assert.deepEqual(place, { gauche: 200, haut: 120 + ECART_INFOBULLE_PX, cote: 'dessous' }); + assert.ok(!recouvre(boite(place, TAILLE), ancre)); + }); + + test('trop bas pour elle : l’infobulle passe au-dessus du terme', () => { + const ancre = terme(200, 640); + const place = placerInfobulle(ancre, TAILLE, CADRE); + assert.deepEqual(place, { gauche: 200, haut: 640 - ECART_INFOBULLE_PX - TAILLE.hauteur, cote: 'dessus' }); + assert.ok(!recouvre(boite(place, TAILLE), ancre)); + }); + + test('près du bord droit, l’infobulle recule dans le cadre ; un terme coupé par le bord gauche la laisse à la marge', () => { + const droite = placerInfobulle(terme(1300, 100, 60), TAILLE, CADRE); + assert.equal(droite.gauche, CADRE.largeur - MARGE_INFOBULLE_PX - TAILLE.largeur); + const gauche = placerInfobulle(terme(-50, 100), TAILLE, CADRE); + assert.equal(gauche.gauche, MARGE_INFOBULLE_PX); + }); + + test('plus large que le cadre : collée à la marge gauche', () => { + const place = placerInfobulle(terme(400, 100), { largeur: 2000, hauteur: 90 }, CADRE); + assert.equal(place.gauche, MARGE_INFOBULLE_PX); + }); + + test('aucun côté ne la reçoit : le côté le plus grand, et la boîte reste dans le cadre', () => { + const petit = { largeur: 1366, hauteur: 200 }; + const haute = { largeur: 300, hauteur: 150 }; + const bas = placerInfobulle(terme(200, 40), haute, petit); + assert.equal(bas.cote, 'dessous'); + assert.ok(dansLeCadre(boite(bas, haute), petit), JSON.stringify(bas)); + const haut = placerInfobulle(terme(200, 140), haute, petit); + assert.equal(haut.cote, 'dessus'); + assert.ok(dansLeCadre(boite(haut, haute), petit), JSON.stringify(haut)); + }); + + test('sur une grille de termes qui couvre le cadre : dès qu’un côté la reçoit, l’infobulle reste dans le cadre et ne couvre jamais le terme', () => { + let essais = 0; + for (let gauche = -100; gauche <= CADRE.largeur; gauche += 37) { + for (let haut = 0; haut <= CADRE.hauteur - 20; haut += 23) { + for (const largeur of [10, 120, 400]) { + const ancre = terme(gauche, haut, largeur); + const place = placerInfobulle(ancre, TAILLE, CADRE); + const b = boite(place, TAILLE); + assert.ok(dansLeCadre(b, CADRE), `${gauche}, ${haut}, ${largeur} : ${JSON.stringify(place)}`); + assert.ok(!recouvre(b, ancre), `${gauche}, ${haut}, ${largeur} : ${JSON.stringify(place)}`); + essais += 1; + } + } + } + assert.ok(essais > 1000, String(essais)); + }); +}); diff --git a/src/interface/propositions/Propositions.navigateur.test.js b/src/interface/propositions/Propositions.navigateur.test.js index 59e91ce..fdd84a7 100644 --- a/src/interface/propositions/Propositions.navigateur.test.js +++ b/src/interface/propositions/Propositions.navigateur.test.js @@ -689,3 +689,192 @@ describe('la phrase de l’ordre désigne les colonnes (§ 5.7)', () => { expect(cible.map((cellule) => [getComputedStyle(cellule).color, getComputedStyle(cellule).backgroundColor])).toEqual(avant); }); }); + +// --- Les infobulles d'explication (§ 5.4, § 5.5, § 5.7, § 12.7) ------------------- + +// La clé de l'explication d'une colonne, celle du contrat de modele.js, que +// l'épreuve node du modèle confronte à COLONNES ; t lève sur une clé absente. +const explicationDeColonne = (nom) => `propositions.explication.${nom}`; + +// Les colonnes du tableau, dans l'ordre d'affichage. +const NOMS_DES_COLONNES = [ + 'rang', + 'proposition', + 'rencontresTous', + 'rencontresMobiles', + 'rencontresAncres', + 'collisionsCumulees', + 'pairesDistinctes', + 'excedentCollisions', + 'rencontresRepetees', + 'maxRencontresPaire', + 'retoursChoisis', + 'appartenancesVues', + 'totalRedondance', + 'diversite', + 'manqueMax', + 'effectifManque', + 'ecartItineraireMax', + 'ecartAuPlafondAPriori', + 'certificat', + 'imposes', + 'commandes', +]; + +// Les infobulles du document : une seule, [role=tooltip], que chaque nom de +// la phrase et chaque en-tête de colonne ouvrent tour à tour. +const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')]; + +// L'infobulle unique, ouverte sur l'ancre donnée : visible, au texte de la +// clé, désignée par l'aria-describedby de l'ancre et de nulle autre, dans +// la fenêtre, et hors du rectangle de l'ancre. +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(texteDe(bulle), cle).toBe(t(cle)); + expect(bulle.id, cle).not.toBe(''); + 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, cle).toBeGreaterThan(0); + expect(b.height, cle).toBeGreaterThan(0); + const racine = document.documentElement; + expect(b.left >= 0 && b.top >= 0 && b.right <= racine.clientWidth && b.bottom <= racine.clientHeight, `${cle} dans la fenêtre`).toBe(true); + const recouvre = b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom; + expect(recouvre, `${cle} sur son ancre`).toBe(false); +} + +// L'infobulle fermée : cachée, et aucun élément ne la désigne. +function exigerFermee() { + const bulles = infobulles(); + expect(bulles.length).toBe(1); + const [bulle] = bulles; + expect(bulle.hidden || bulle.getBoundingClientRect().width === 0).toBe(true); + expect(document.querySelectorAll(`[aria-describedby~="${bulle.id}"]`).length).toBe(0); +} + +const enTetes = (m) => [...section(m).querySelectorAll('thead th[data-colonne]')]; + +describe('les infobulles d’explication : chaque nom de critère, chaque en-tête de colonne', () => { + const chargeRetenue = () => executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }); + + // Le pointeur réel part du coin du cadre, hors de la phrase et du tableau. + beforeEach(async () => { + await pointer('bouger', 0, 0); + }); + + test('survoler chaque nom de critère ouvre l’explication de sa colonne, qui se surligne toujours ; le quitter la ferme', async () => { + const m = await ouvrir(chargeRetenue()); + exigerFermee(); + expect(nomsDesignants(m).length).toBe(COLONNE_DESIGNEE.length); + for (const [critere, nomColonne] of COLONNE_DESIGNEE) { + const nom = section(m).querySelector(`[data-ordre] [data-critere="${critere}"]`); + const { x, y } = await centre(nom); + await pointer('bouger', x, y); + await attendre(() => exigerInfobulle(nom, explicationDeColonne(nomColonne))); + await attendre(() => expect(surlignees(m), critere).toEqual(colonne(m, nomColonne))); + } + const { x, y } = await centre(section(m).querySelector('h2')); + await pointer('bouger', x, y); + await attendre(() => exigerFermee()); + await attendre(() => expect(surlignees(m)).toEqual([])); + }); + + test('survoler chaque en-tête de colonne ouvre son explication, dans la fenêtre, hors de l’en-tête ; le quitter la ferme', async () => { + const m = await ouvrir(chargeRetenue()); + const ths = enTetes(m); + expect(ths.map((th) => th.dataset.colonne)).toEqual(NOMS_DES_COLONNES); + // Le pointeur quitte le tableau pour le titre de la section : aucun + // terme n'est plus survolé, l'infobulle se ferme. + const sortir = async () => { + const { x, y } = await centre(section(m).querySelector('h2')); + await pointer('bouger', x, y); + await attendre(() => exigerFermee()); + }; + for (const th of ths) { + const { x, y } = await centre(th); + await pointer('bouger', x, y); + await attendre(() => exigerInfobulle(th, explicationDeColonne(th.dataset.colonne))); + } + await sortir(); + // Le dernier en-tête, collé au bord droit de la fenêtre : l'infobulle + // recule dans la fenêtre. + const dernier = ths.at(-1); + dernier.scrollIntoView({ block: 'nearest', inline: 'end' }); + const b = dernier.getBoundingClientRect(); + await pointer('bouger', b.right - 4, b.top + b.height / 2); + await attendre(() => exigerInfobulle(dernier, explicationDeColonne(dernier.dataset.colonne))); + await sortir(); + }); + + test('au clavier : Tab focalise chaque nom puis chaque en-tête et ouvre son explication ; Échap la ferme, le focus reste ; sortir la ferme', async () => { + const m = await ouvrir(chargeRetenue()); + const ancres = [...nomsDesignants(m), ...enTetes(m)]; + expect(ancres.length).toBe(COLONNE_DESIGNEE.length + NOMS_DES_COLONNES.length); + for (const ancre of ancres) expect(ancre.tabIndex).toBe(0); + const cleDe = (ancre) => + explicationDeColonne(ancre.dataset.critere === undefined ? ancre.dataset.colonne : COLONNE_DESIGNEE.find(([critere]) => critere === ancre.dataset.critere)[1]); + ancres[0].focus(); + for (const [rang, ancre] of ancres.entries()) { + if (rang > 0) await touche('Tab'); + await attendre(() => expect(document.activeElement).toBe(ancre)); + await attendre(() => exigerInfobulle(ancre, cleDe(ancre))); + if (rang === 2 || rang === COLONNE_DESIGNEE.length + 1) { + await touche('Escape'); + await attendre(() => exigerFermee()); + expect(document.activeElement).toBe(ancre); + } + } + await touche('Tab'); + await attendre(() => expect(ancres).not.toContain(document.activeElement)); + await attendre(() => exigerFermee()); + }); + + test('le tableau défile sous un en-tête focalisé : l’infobulle suit son en-tête', async () => { + const m = await ouvrir(chargeRetenue()); + const th = enTetes(m).find((cellule) => cellule.dataset.colonne === 'retoursChoisis'); + const defilement = th.closest('table').parentElement; + expect(defilement.scrollWidth).toBeGreaterThan(defilement.clientWidth + 200); + th.scrollIntoView({ block: 'nearest', inline: 'center' }); + th.focus({ preventScroll: true }); + await attendre(() => exigerInfobulle(th, explicationDeColonne('retoursChoisis'))); + const [bulle] = infobulles(); + const decalage = () => bulle.getBoundingClientRect().left - th.getBoundingClientRect().left; + expect(decalage()).toBe(0); + const avant = th.getBoundingClientRect().left; + defilement.scrollLeft += 150; + await attendre(() => expect(th.getBoundingClientRect().left).not.toBe(avant)); + await attendre(() => expect(decalage()).toBe(0)); + exigerInfobulle(th, explicationDeColonne('retoursChoisis')); + }); + + test('un nom trop près du bas de la fenêtre : l’infobulle s’ouvre au-dessus de lui', async () => { + const m = await ouvrir(chargeRetenue()); + const nom = () => section(m).querySelector('[data-ordre] [data-critere="ecartAuPlafondAPrioriMax"]'); + await fixerCadre(1366, Math.ceil(nom().getBoundingClientRect().bottom) + 40); + await attendre(() => expect(nom().getBoundingClientRect().bottom).toBeLessThanOrEqual(document.documentElement.clientHeight)); + const { x, y } = await centre(nom()); + await pointer('bouger', x, y); + await attendre(() => exigerInfobulle(nom(), explicationDeColonne('ecartAuPlafondAPriori'))); + const [bulle] = infobulles(); + expect(bulle.getBoundingClientRect().bottom).toBeLessThanOrEqual(nom().getBoundingClientRect().top); + }); + + test('Échap ferme l’infobulle ouverte au survol ; un autre nom survolé la rouvre sur le sien', async () => { + const m = await ouvrir(chargeRetenue()); + const premier = section(m).querySelector('[data-ordre] [data-critere="excedentCollisions"]'); + const second = enTetes(m).find((th) => th.dataset.colonne === 'pairesDistinctes'); + let point = await centre(premier); + await pointer('bouger', point.x, point.y); + await attendre(() => exigerInfobulle(premier, explicationDeColonne('excedentCollisions'))); + await touche('Escape'); + await attendre(() => exigerFermee()); + point = await centre(second); + await pointer('bouger', point.x, point.y); + await attendre(() => exigerInfobulle(second, explicationDeColonne('pairesDistinctes'))); + }); +}); diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte index e99f48e..dd61d60 100644 --- a/src/interface/propositions/SectionPropositions.svelte +++ b/src/interface/propositions/SectionPropositions.svelte @@ -29,12 +29,20 @@ // focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne // cette colonne dans le tableau ; le quitter — pointeur sorti, focus perdu — // efface le surlignage qu'il a posé, et laisse celui d'un autre nom. + // + // Chaque nom de critère et chaque en-tête de colonne s'expliquent dans + // l'infobulle de la section, une seule (Infobulle, primitive) : le survol + // ou le focus l'ouvre sur le terme, le quitter la referme quand elle est + // encore la sienne, Échap aussi. Un critère et sa colonne partagent leur + // explication (explicationDeColonne, modele.js). Le terme expliqué porte + // aria-describedby vers elle, et lui seul. import { getContext } from 'svelte'; import Commande from '../primitives/Commande.svelte'; import Confirmation from '../primitives/Confirmation.svelte'; + import Infobulle from '../primitives/Infobulle.svelte'; import Refus from '../primitives/Refus.svelte'; import TableauPropositions from './TableauPropositions.svelte'; - import { demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau } from './modele.js'; + import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, tableau } from './modele.js'; const t = getContext('t'); const seance = getContext('seance'); @@ -82,6 +90,33 @@ if (surlignee === colonne) surlignee = null; } + // Le terme expliqué : {ancre, terme, cle, details} — son élément, sa + // désignation, 'critere:' pour un nom de la phrase ou + // 'colonne:' pour un en-tête, et la clé de son explication — ; null + // sans infobulle. Ouvrir sur un terme remplace l'explication d'un autre ; + // quitter un terme ne ferme que la sienne. + const uid = $props.id(); + const idExplication = `${uid}-explication`; + let expliquee = $state(null); + function expliquer(evenement, terme, colonne) { + expliquee = { ancre: evenement.currentTarget, terme, cle: explicationDeColonne(colonne), details: {} }; + } + function taire(terme) { + if (expliquee?.terme === terme) expliquee = null; + } + const fermerExplication = () => (expliquee = null); + // L'aria-describedby d'un terme : l'infobulle quand elle l'explique. + const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined); + + function entrer(evenement, { critere, colonne }) { + surligner(colonne); + expliquer(evenement, `critere:${critere}`, colonne); + } + function sortir({ critere, colonne }) { + quitter(colonne); + taire(`critere:${critere}`); + } + const effacer = () => seance.executer('effacerPropositions', {}); const nommer = (id) => nomDe(charge, id); @@ -114,19 +149,22 @@ class:actif={surlignee === morceau.colonne} data-critere={morceau.critere} tabindex="0" - onmouseenter={() => surligner(morceau.colonne)} - onmouseleave={() => quitter(morceau.colonne)} - onfocus={() => surligner(morceau.colonne)} - onblur={() => quitter(morceau.colonne)}>{t(morceau.cle, morceau.details)} entrer(evenement, morceau)} + onmouseleave={() => sortir(morceau)} + onfocus={(evenement) => entrer(evenement, morceau)} + onblur={() => sortir(morceau)}>{t(morceau.cle, morceau.details)} {/if} {/each}

{/each} - + {/if} + + {#if charge !== null && charge.propositions.length > 0}
diff --git a/src/interface/propositions/TableauPropositions.svelte b/src/interface/propositions/TableauPropositions.svelte index 22c1f65..aec572d 100644 --- a/src/interface/propositions/TableauPropositions.svelte +++ b/src/interface/propositions/TableauPropositions.svelte @@ -20,6 +20,12 @@ // la cellule des raisons d'une ligne hors classement, qui en couvre // plusieurs. // + // Chaque en-tête explique sa colonne dans l'infobulle de la section : il se + // focalise au clavier (tabindex 0), et son survol ou son focus appelle + // expliquer(evenement, 'colonne:', nom), le quitter taire('colonne:'). + // decrit('colonne:') rend l'identifiant de l'infobulle quand elle + // explique cet en-tête, pour son aria-describedby, undefined sinon. + // // Désignations : data-retenu sur la ligne du retenu, data-proposition sur // chaque autre ligne, data-colonne sur chaque cellule et chaque en-tête ; // data-classement et data-hors-classement sur les deux groupes de lignes. @@ -30,7 +36,7 @@ import Commande from '../primitives/Commande.svelte'; import { texteCellule } from './modele.js'; - let { vue, surlignee = null, nommer, voir, retenir } = $props(); + let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props(); // L'attribut data-marque d'une cellule de la colonne nommée : 'selection' // quand elle est la colonne surlignée, undefined sinon, qui l'omet. @@ -98,7 +104,21 @@ {#each vue.colonnes as colonne (colonne.nom)} - {t(colonne.cle)} + {@const terme = `colonne:${colonne.nom}`} + + expliquer(evenement, terme, colonne.nom)} + onmouseleave={() => taire(terme)} + onfocus={(evenement) => expliquer(evenement, terme, colonne.nom)} + onblur={() => taire(terme)}>{t(colonne.cle)} {/each} @@ -227,6 +247,13 @@ border-right: 2px solid var(--couleur-selection); } + /* L'anneau de focus se pose dans la cellule : le conteneur défilant ne le + rogne pas. */ + thead th:focus-visible { + outline: 2px solid var(--couleur-focus); + outline-offset: -2px; + } + thead th.surlignee { text-decoration: underline; text-decoration-thickness: 2px; diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js index df876a3..f4960dc 100644 --- a/src/interface/propositions/modele.js +++ b/src/interface/propositions/modele.js @@ -64,14 +64,30 @@ const DECIMALES = new Map([['diversite', 2]]); /** Les colonnes du tableau, dans l'ordre d'affichage : le rang et * l'identifiant, puis les mesures — les trois minimums des rencontres en - * tête —, le certificat, les imposés et les commandes. Chacune porte son nom - * et la clé de son en-tête, qui nomme son unité. */ + * tête —, le certificat, les imposés et les commandes. Chacune porte son nom, + * la clé de son en-tête, qui nomme son unité, et celle de son explication, + * le texte de l'infobulle de son en-tête et du critère qui la désigne. */ export const COLONNES = Object.freeze( ['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) => - Object.freeze({ nom, cle: `propositions.colonne.${nom}` }), + Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}` }), ), ); +/** + * La clé de l'explication d'une colonne : le texte de l'infobulle de son + * en-tête, et celui du nom de critère qui la désigne dans la phrase de + * l'ordre (colonneDuCritere) — un critère et sa colonne partagent la même. + * Lève TypeError pour un nom qui n'est pas une colonne de COLONNES. + * + * @param {string} nom le nom d'une colonne + * @returns {string} + */ +export function explicationDeColonne(nom) { + const colonne = COLONNES.find((candidate) => candidate.nom === nom); + if (colonne === undefined) throw new TypeError(`colonne inconnue : ${nom}`); + return colonne.explication; +} + // Une cellule de nombre : null s'écrit « — ». const nombre = (colonne, valeur) => ({ colonne, diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js index f9efb48..ce98922 100644 --- a/src/interface/propositions/modele.test.js +++ b/src/interface/propositions/modele.test.js @@ -29,7 +29,17 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../. import { planDepuisFichier } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; import { TABLES_INTERFACE } from '../libelles.js'; -import { COLONNES, colonneDuCritere, demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau, texteCellule } from './modele.js'; +import { + COLONNES, + colonneDuCritere, + demandeConfirmation, + evaluation, + explicationDeColonne, + nomDe, + retenuAjuste, + tableau, + texteCellule, +} from './modele.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const format = creerFormat(t); @@ -656,3 +666,96 @@ describe('evaluation : la table d’une charge, ou ce qui l’empêche', () => { assert.equal(refus.code, 'SURRESERVATION'); }); }); + +describe('les explications des colonnes et des critères (infobulles, § 5.4, § 5.5, § 5.7)', () => { + // Le sens de chaque colonne : 'bas' quand la plus faible valeur sert le + // mieux, 'grand' quand c'est la plus forte ; null quand l'explication ne + // dit aucun sens — le rang, le numéro, les paires distinctes, que l'ordre + // ne lit pas et dont le compte le plus faible n'est pas le meilleur + // (§ 5.4), le certificat, les imposés et les commandes. + const SENS = new Map([ + ['rang', null], + ['proposition', null], + ['rencontresTous', 'grand'], + ['rencontresMobiles', 'grand'], + ['rencontresAncres', 'grand'], + ['collisionsCumulees', 'bas'], + ['pairesDistinctes', null], + ['excedentCollisions', 'bas'], + ['rencontresRepetees', 'bas'], + ['maxRencontresPaire', 'bas'], + ['retoursChoisis', 'bas'], + ['appartenancesVues', 'grand'], + ['totalRedondance', 'bas'], + ['diversite', 'grand'], + ['manqueMax', 'bas'], + ['effectifManque', 'bas'], + ['ecartItineraireMax', 'bas'], + ['ecartAuPlafondAPriori', 'bas'], + ['certificat', null], + ['imposes', null], + ['commandes', null], + ]); + const MIEUX = new Map([ + ['bas', /Plus (?:il|elle) est (?:bas|basse), mieux c'est\./], + ['grand', /Plus (?:il|elle) est (?:grand|grande), mieux c'est\./], + ]); + // Les phrases d'un texte : un point, puis un blanc, puis une capitale ou + // un guillemet ouvrant. + const phrases = (texte) => texte.split(/(?<=\.)\s+(?=[\p{Lu}\u{00AB}])/u); + + test('chaque colonne de COLONNES, sans exception, porte la clé de son explication : elle existe, compte une à trois phrases, et diffère de l’en-tête et des autres explications', () => { + assert.equal(COLONNES.length, 21); + assert.deepEqual(COLONNES.map(({ nom }) => nom), [...SENS.keys()]); + const textes = new Set(); + for (const { nom, cle, explication } of COLONNES) { + assert.equal(explication, `propositions.explication.${nom}`, nom); + assert.equal(explicationDeColonne(nom), explication, nom); + const texte = t(explication); + const nombre = phrases(texte).length; + assert.ok(nombre >= 1 && nombre <= 3, `${nom} : ${nombre} phrases`); + assert.ok(texte.length <= 420, `${nom} : ${texte.length} caractères`); + assert.notEqual(texte, t(cle), nom); + textes.add(texte); + } + assert.equal(textes.size, COLONNES.length); + }); + + test('chaque explication dit dans quel sens la colonne sert le mieux, et seulement quand ce sens existe', () => { + assert.ok(SENS.size > 0); + for (const [nom, sens] of SENS) { + const texte = t(explicationDeColonne(nom)); + for (const [autre, motif] of MIEUX) { + if (autre === sens) assert.match(texte, motif, nom); + else assert.doesNotMatch(texte, motif, nom); + } + if (sens === null) assert.doesNotMatch(texte, /mieux c'est/, nom); + } + }); + + test('chaque critère, et l’identifiant croissant, partage l’explication de la colonne qu’il désigne ; seule l’explication d’un critère le dit critère du classement', () => { + const criteres = new Set(CRITERES.map(colonneDuCritere)); + assert.equal(criteres.size, 5); + for (const { nom } of COLONNES) { + const texte = t(explicationDeColonne(nom)); + if (criteres.has(nom)) assert.match(texte, /critère du classement/i, nom); + else assert.doesNotMatch(texte, /(? colonne !== null); + assert.ok(designants.length >= 5); + for (const { critere, colonne } of designants) { + assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDuCritere(critere)), critere); + assert.ok(t(explicationDeColonne(colonne)).length > 0, critere); + } + } + }); + + test('explicationDeColonne lève pour un nom qui n’est pas une colonne', () => { + assert.equal(explicationDeColonne('rang'), 'propositions.explication.rang'); + assert.throws(() => explicationDeColonne('redondance'), TypeError); + assert.throws(() => explicationDeColonne('moyenneRencontres'), TypeError); + }); +}); diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index f9b3707..97316f0 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -620,6 +620,27 @@ export const ECRANS = Object.freeze([ return m.racine; }, }, + { + nom: 'propositions.infobulleColonne', + mode: 'lecture', + async atteindre(contexte) { + // Le pointeur réel sur un en-tête de colonne : l'infobulle qui + // l'explique paraît, visible, de largeur non nulle. + const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'propositions' }); + const { x, y } = await centre(await element(m, 'thead th[data-colonne="excedentCollisions"]')); + await pointer('bouger', x, y); + await attendre("l'infobulle de la colonne", () => { + const bulle = m.racine.querySelector('[data-section="propositions"] [role="tooltip"]'); + return ( + bulle !== null && + !bulle.hidden && + bulle.getBoundingClientRect().width > 0 && + getComputedStyle(bulle).visibility === 'visible' + ); + }); + return m.racine; + }, + }, { nom: 'refus.modeLecture', mode: 'lecture',