diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js index 81781dc..ab2ca7a 100644 --- a/src/interface/libelles/propositions.js +++ b/src/interface/libelles/propositions.js @@ -19,12 +19,20 @@ export const TABLE = new Map([ // --- La section (§ 5.7) ------------------------------------------------------ ['propositions.tableau.titre', 'Propositions classées'], ['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."], + // La phrase des critères se compose de morceaux, chacun sa clé : le début, + // les noms, leur séparateur, l'identifiant croissant et la fin. L'affichage + // les met bout à bout, chaque nom dans son propre élément ; + // propositions.ordre.criteres les assemble en une seule chaîne, la même. + ['propositions.ordre.debut', "Ordre du classement, chaque critère au plus faible d'abord : "], + ['propositions.ordre.separateur', ', puis '], + ['propositions.ordre.identifiant', "l'identifiant croissant"], + ['propositions.ordre.fin', '.'], [ 'propositions.ordre.criteres', ({ criteres }, texte) => - `Ordre du classement, chaque critère au plus faible d'abord : ${criteres - .map((nom) => critere(nom, texte)) - .join(', puis ')}, puis l'identifiant croissant.`, + texte('propositions.ordre.debut') + + [...criteres.map((nom) => critere(nom, texte)), texte('propositions.ordre.identifiant')].join(texte('propositions.ordre.separateur')) + + texte('propositions.ordre.fin'), ], [ 'propositions.ordre.saute', diff --git a/src/interface/propositions/Propositions.navigateur.test.js b/src/interface/propositions/Propositions.navigateur.test.js index 2fd4972..59e91ce 100644 --- a/src/interface/propositions/Propositions.navigateur.test.js +++ b/src/interface/propositions/Propositions.navigateur.test.js @@ -34,7 +34,8 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../. import { SUFFIXES } from '../../stockage/noms.js'; import { planDepuisFichier } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; -import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { centre } from '../../../test/navigateur/ecrans.js'; +import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { TABLES_INTERFACE } from '../libelles.js'; @@ -533,3 +534,158 @@ describe('en lecture et sur un plan bloqué', () => { expect(m.trace.length).toBe(ecritures); }); }); + +// --- La phrase de l'ordre désigne les colonnes (§ 5.7) --------------------------- + +// Chaque nom de la phrase et la colonne qu'il désigne, dans l'ordre de la +// phrase : les cinq critères, puis l'identifiant croissant. +const COLONNE_DESIGNEE = [ + ['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'], + ['excedentCollisions', 'excedentCollisions'], + ['collisionsCumulees', 'collisionsCumulees'], + ['rencontresRepetees', 'rencontresRepetees'], + ['redondance', 'totalRedondance'], + ['identifiant', 'proposition'], +]; + +// La couleur calculée d'un jeton, lue sur une sonde posée le temps d'une +// lecture. +function couleurDuJeton(jeton) { + const sonde = document.createElement('span'); + sonde.style.color = `var(${jeton})`; + document.body.append(sonde); + try { + return getComputedStyle(sonde).color; + } finally { + sonde.remove(); + } +} + +// Une cellule porte-t-elle, à gauche et à droite, le filet de 2 px de la +// couleur de sélection ? Un voisin n'en partage qu'un. +function porteFilets(cellule, selection) { + const style = getComputedStyle(cellule); + return ['Left', 'Right'].every((cote) => style[`border${cote}Width`] === '2px' && style[`border${cote}Color`] === selection); +} + +// Les cellules du tableau, en-têtes compris, que le rendu montre surlignées : +// les deux filets de sélection, et l'en-tête souligné en plus. +function surlignees(m) { + const selection = couleurDuJeton('--couleur-selection'); + const cellulesLues = [...section(m).querySelectorAll('th[data-colonne], td[data-colonne]')]; + expect(cellulesLues.length).toBeGreaterThan(0); + return cellulesLues.filter((cellule) => { + const souligne = cellule.closest('thead') === null || getComputedStyle(cellule).textDecorationLine.includes('underline'); + return porteFilets(cellule, selection) && souligne; + }); +} + +// Les éléments du tableau que data-marque désigne : ceux que la garde des +// marques (§ 14.5) mesure. +const marquees = (m) => [...section(m).querySelectorAll('table [data-marque]')]; + +// Les cellules d'une colonne : son en-tête et chacune de ses cellules. +const colonne = (m, nom) => [...section(m).querySelectorAll(`th[data-colonne="${nom}"], td[data-colonne="${nom}"]`)]; + +// Les noms de la phrase qui désignent une colonne, dans leur ordre. +const nomsDesignants = (m) => [...section(m).querySelectorAll('[data-ordre] [data-critere]')]; + +describe('la phrase de l’ordre désigne les colonnes (§ 5.7)', () => { + // Le retenu, sur sa ligne, au-dessus des trois propositions classées. + const chargeRetenue = () => executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 }); + + // Le pointeur réel reste où l'épreuve précédente l'a laissé : posé sur un + // nom de la phrase, il surlignerait sa colonne dès le montage suivant. + // Chaque épreuve part du coin du cadre, hors de la phrase. + beforeEach(async () => { + await pointer('bouger', 0, 0); + }); + + test('survoler chaque nom de critère, au pointeur réel, surligne sa colonne seule — en-tête, retenu et lignes classées — ; le quitter l’efface', async () => { + const m = await ouvrir(chargeRetenue()); + const noms = nomsDesignants(m); + expect(noms.map((nom) => nom.dataset.critere)).toEqual(COLONNE_DESIGNEE.map(([critere]) => critere)); + expect(surlignees(m)).toEqual([]); + 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); + const attendues = colonne(m, nomColonne); + expect(attendues.length, nomColonne).toBe(5); + await attendre(() => expect(surlignees(m), critere).toEqual(attendues)); + // Chaque cellule surlignée, et elle seule, se désigne comme marque de + // sélection : la garde transversale mesure son filet à 3:1. + expect(marquees(m), critere).toEqual(attendues); + for (const cellule of attendues) expect(cellule.dataset.marque, critere).toBe('selection'); + } + const titre = section(m).querySelector('h2'); + const { x, y } = await centre(titre); + await pointer('bouger', x, y); + await attendre(() => expect(surlignees(m)).toEqual([])); + expect(marquees(m)).toEqual([]); + }); + + test('avec des propositions hors classement : l’identifiant surligne aussi leur cellule « proposition » ; un critère de mesure ne surligne jamais la cellule de leurs raisons', async () => { + let chargeLue = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 }); + chargeLue = executer(chargeLue, 'exclureParticipant', { id: 4 }); + chargeLue = generee(chargeLue, { graine: 2, nombre: 2, arret: 200 }); + const m = await ouvrir(chargeLue); + const hors = [...section(m).querySelectorAll('[data-hors-classement] tr[data-proposition]')]; + expect(hors.length).toBe(2); + const identites = hors.map((tr) => tr.querySelector('td[data-colonne="proposition"]')); + const raisons = hors.map((tr) => tr.querySelector('td[colspan]')); + for (const cellule of [...identites, ...raisons]) expect(cellule).not.toBeNull(); + + const nomIdentifiant = section(m).querySelector('[data-ordre] [data-critere="identifiant"]'); + let point = await centre(nomIdentifiant); + await pointer('bouger', point.x, point.y); + const attendues = colonne(m, 'proposition'); + for (const cellule of identites) expect(attendues).toContain(cellule); + await attendre(() => expect(surlignees(m)).toEqual(attendues)); + expect(marquees(m)).toEqual(attendues); + + const selection = couleurDuJeton('--couleur-selection'); + for (const [critere, nomColonne] of COLONNE_DESIGNEE.filter(([, nomColonne]) => nomColonne !== 'proposition')) { + point = await centre(section(m).querySelector(`[data-ordre] [data-critere="${critere}"]`)); + await pointer('bouger', point.x, point.y); + await attendre(() => expect(surlignees(m), critere).toEqual(colonne(m, nomColonne))); + for (const cellule of raisons) { + expect(porteFilets(cellule, selection), critere).toBe(false); + expect(cellule.dataset.marque, critere).toBeUndefined(); + } + } + }); + + test('au clavier : chaque nom se focalise (tabindex 0) et surligne sa colonne seule ; Tab passe au suivant ; quitter le dernier l’efface', async () => { + const m = await ouvrir(chargeRetenue()); + const noms = nomsDesignants(m); + expect(noms.length).toBe(COLONNE_DESIGNEE.length); + for (const nom of noms) expect(nom.tabIndex).toBe(0); + noms[0].focus(); + for (const [rang, [critere, nomColonne]] of COLONNE_DESIGNEE.entries()) { + if (rang > 0) await touche('Tab'); + await attendre(() => expect(document.activeElement?.dataset?.critere).toBe(critere)); + await attendre(() => expect(surlignees(m), critere).toEqual(colonne(m, nomColonne))); + } + await touche('Tab'); + await attendre(() => expect(document.activeElement?.dataset?.critere).toBeUndefined()); + await attendre(() => expect(surlignees(m)).toEqual([])); + }); + + test('le texte de la phrase reste celui de l’ordre, mot pour mot, et le surlignage ne change aucune couleur de texte', async () => { + const m = await ouvrir(chargeRetenue()); + // Le texte brut, espaces compris : aucun blanc ne s'ajoute entre deux + // morceaux. + expect(section(m).querySelectorAll('[data-ordre] p').length).toBe(1); + expect(section(m).querySelector('[data-ordre] p').textContent).toBe( + "Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis excédent de collisions, " + + "puis collisions cumulées, puis rencontres répétées, puis redondance d'appartenance, puis l'identifiant croissant.", + ); + const cible = colonne(m, 'excedentCollisions'); + const avant = cible.map((cellule) => [getComputedStyle(cellule).color, getComputedStyle(cellule).backgroundColor]); + const { x, y } = await centre(section(m).querySelector('[data-ordre] [data-critere="excedentCollisions"]')); + await pointer('bouger', x, y); + await attendre(() => expect(surlignees(m)).toEqual(cible)); + expect(cible.map((cellule) => [getComputedStyle(cellule).color, getComputedStyle(cellule).backgroundColor])).toEqual(avant); + }); +}); diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte index 00b0425..e99f48e 100644 --- a/src/interface/propositions/SectionPropositions.svelte +++ b/src/interface/propositions/SectionPropositions.svelte @@ -23,6 +23,12 @@ // s'exécute sans fenêtre, et son refus s'affiche. « Effacer » ne détruit // aucune saisie — chaque proposition se régénère de sa graine, et le geste // se défait — : il s'exécute sans fenêtre. + // + // La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) ; + // chaque nom de critère, et l'identifiant croissant, est un élément + // 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. import { getContext } from 'svelte'; import Commande from '../primitives/Commande.svelte'; import Confirmation from '../primitives/Confirmation.svelte'; @@ -68,6 +74,14 @@ aConfirmer = null; } + // La colonne surlignée, celle du dernier nom survolé ou focalisé ; null + // sans surlignage. + let surlignee = $state(null); + const surligner = (colonne) => (surlignee = colonne); + function quitter(colonne) { + if (surlignee === colonne) surlignee = null; + } + const effacer = () => seance.executer('effacerPropositions', {}); const nommer = (id) => nomDe(charge, id); @@ -85,11 +99,32 @@ {#if vue !== null}
- {#each vue.ordre as phrase (phrase.cle)} -

{t(phrase.cle, phrase.details)}

+ {#each vue.ordre as morceaux, rang (rang)} +

+ {#each morceaux as morceau, place (place)} + {#if morceau.colonne === null} + {t(morceau.cle, morceau.details)} + {:else} + + + surligner(morceau.colonne)} + onmouseleave={() => quitter(morceau.colonne)} + onfocus={() => surligner(morceau.colonne)} + onblur={() => quitter(morceau.colonne)}>{t(morceau.cle, morceau.details)} + {/if} + {/each} +

{/each}
- + {/if} {#if charge !== null && charge.propositions.length > 0} @@ -132,6 +167,25 @@ gap: var(--espace-1); } + /* Un nom qui désigne une colonne se signale par un soulignement pointillé ; + survolé ou focalisé, par un soulignement plein. Il ne se coupe pas d'une + ligne à l'autre : sa boîte, que le pointeur survole, reste d'un tenant. */ + .critere { + white-space: nowrap; + text-decoration: underline dotted; + text-underline-offset: 0.2em; + } + + .critere.actif { + text-decoration-style: solid; + text-decoration-thickness: 2px; + } + + .critere:focus-visible { + outline: 2px solid var(--couleur-focus); + outline-offset: 2px; + } + .effacer { display: flex; } diff --git a/src/interface/propositions/TableauPropositions.svelte b/src/interface/propositions/TableauPropositions.svelte index 558e32f..22c1f65 100644 --- a/src/interface/propositions/TableauPropositions.svelte +++ b/src/interface/propositions/TableauPropositions.svelte @@ -14,16 +14,27 @@ // la charge n'a plus. voir(id) et // retenir(id) : les gestes d'une ligne, que la section câble ; voir(null) // montre le retenu. Un refus de « Retenir » se pose à côté du bouton, par - // la Commande. + // la Commande. surlignee : le nom de la colonne que la phrase de l'ordre + // désigne — une colonne de mesure, ou celle des propositions —, null sans + // surlignage ; son en-tête et chacune de ses cellules se surlignent, hors + // la cellule des raisons d'une ligne hors classement, qui en couvre + // plusieurs. // // 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. + // Une cellule surlignée porte data-marque="selection" : ses filets + // portent l'information, et la garde des marques (§ 14.5) les mesure à + // 3:1 contre le fond, dans les deux thèmes. Les autres n'en portent pas. import { getContext } from 'svelte'; import Commande from '../primitives/Commande.svelte'; import { texteCellule } from './modele.js'; - let { vue, nommer, voir, retenir } = $props(); + let { vue, surlignee = null, nommer, voir, retenir } = $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. + const marque = (nommee) => (nommee === surlignee ? 'selection' : undefined); const t = getContext('t'); const format = getContext('format'); @@ -46,7 +57,7 @@ {#snippet mesures(ligne)} {#if ligne.comparable} {#each ligne.cellules as cellule (cellule.colonne)} - {texteCellule(cellule, outils)} + {texteCellule(cellule, outils)} {/each} {#each ligne.imposes as phrase, rang (rang)} @@ -59,7 +70,7 @@ {/snippet} {#snippet identite(ligne)} - + {format.nombre(ligne.id)} {#if ligne.retenue} {t('propositions.marque.retenue')} @@ -87,7 +98,7 @@ {#each vue.colonnes as colonne (colonne.nom)} - {t(colonne.cle)} + {t(colonne.cle)} {/each} @@ -95,7 +106,7 @@ {t('propositions.retenu.titre')} - + {format.nombre(vue.retenu.id)}

{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}

@@ -205,6 +216,23 @@ border-left: 4px solid var(--couleur-page-texte); } + /* La colonne que désigne la phrase de l'ordre : un filet de 2 px de la + couleur de sélection de chaque côté de son en-tête et de ses cellules, + plus épais que le filet ordinaire, et l'en-tête souligné. Ni le fond ni + la couleur du texte ne changent : les contrastes restent ceux du + tableau. */ + th.surlignee, + td.surlignee { + border-left: 2px solid var(--couleur-selection); + border-right: 2px solid var(--couleur-selection); + } + + thead th.surlignee { + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 0.2em; + } + .marque { margin-left: var(--espace-1); padding: 0 var(--espace-1); diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js index 6cbfd2f..df876a3 100644 --- a/src/interface/propositions/modele.js +++ b/src/interface/propositions/modele.js @@ -148,14 +148,63 @@ function ligneDe(ligne, table, idRetenu, rang = null) { }; } -// La phrase de l'ordre (§ 5.7) : le critère sauté et celui appliqué à sa -// place quand le plafond a priori manque, puis les critères appliqués. +// La colonne que désigne chaque critère du classement (§ 5.7) : celle qui +// porte la grandeur qu'il compare. La Map ne sert qu'à retrouver la colonne +// d'un critère ; la phrase suit l'ordre que le classement rend. +const COLONNE_DU_CRITERE = new Map([ + ['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'], + ['excedentCollisions', 'excedentCollisions'], + ['collisionsCumulees', 'collisionsCumulees'], + ['rencontresRepetees', 'rencontresRepetees'], + ['redondance', 'totalRedondance'], + ['identifiant', 'proposition'], +]); + +/** + * La colonne du tableau que désigne un critère du classement, ou + * 'identifiant', le dernier départage, qui désigne la colonne des + * propositions. Lève TypeError pour un critère sans colonne : un critère + * ajouté au classement doit dire laquelle il compare. + * + * @param {string} critere un nom de CRITERES (moteur), ou 'identifiant' + * @returns {string} le nom d'une colonne de COLONNES + */ +export function colonneDuCritere(critere) { + const colonne = COLONNE_DU_CRITERE.get(critere); + if (colonne === undefined) throw new TypeError(`critère sans colonne : ${critere}`); + return colonne; +} + +// Un morceau de phrase : un texte de liaison, ou un nom qui désigne une +// colonne. +const liaison = (cle, details = {}) => ({ cle, details, critere: null, colonne: null }); +const designant = (critere, cle) => ({ cle, details: {}, critere, colonne: colonneDuCritere(critere) }); + +// La phrase des critères appliqués, morceau par morceau : le début, chaque +// critère par son nom moteur.critere., puis l'identifiant croissant, +// séparés, et la fin. Bout à bout, les morceaux traduits composent le texte +// de propositions.ordre.criteres. +function phraseDesCriteres(criteresAppliques) { + const noms = [ + ...criteresAppliques.map((critere) => designant(critere, `moteur.critere.${critere}`)), + designant('identifiant', 'propositions.ordre.identifiant'), + ]; + return [ + liaison('propositions.ordre.debut'), + ...noms.flatMap((nom, rang) => (rang === 0 ? [nom] : [liaison('propositions.ordre.separateur'), nom])), + liaison('propositions.ordre.fin'), + ]; +} + +// Les phrases de l'ordre (§ 5.7), chacune une liste de morceaux : le critère +// sauté et celui appliqué à sa place quand le plafond a priori manque, puis +// les critères appliqués. function ordre({ criteresAppliques, critereSaute }) { const phrases = []; if (critereSaute !== null) { - phrases.push({ cle: 'propositions.ordre.saute', details: { saute: critereSaute, applique: criteresAppliques[0] } }); + phrases.push([liaison('propositions.ordre.saute', { saute: critereSaute, applique: criteresAppliques[0] })]); } - phrases.push({ cle: 'propositions.ordre.criteres', details: { criteres: [...criteresAppliques] } }); + phrases.push(phraseDesCriteres(criteresAppliques)); return phrases; } @@ -166,10 +215,15 @@ function ordre({ criteresAppliques, critereSaute }) { * @param {{ajuste?: boolean}} [options] ajuste : le retenu ne porte plus le * placement de sa proposition d'origine (retenuAjuste) ; il choisit le * libellé de la ligne du retenu - * @returns {{ordre: Array<{cle: string, details: Object}>, colonnes: typeof COLONNES, + * @returns {{ordre: Array>, colonnes: typeof COLONNES, * etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}} - * lignes : les comparables dans l'ordre du classement, chacune - * { id, rang, retenue, comparable: true, cellules, imposes, violations } ; + * ordre : les phrases de l'ordre, chacune une liste de morceaux que t + * traduit et que l'affichage met bout à bout ; un morceau qui nomme un + * critère, ou l'identifiant croissant, porte critere et la colonne qu'il + * désigne, null pour un texte de liaison ; lignes : les comparables dans + * l'ordre du classement, chacune { id, rang, retenue, comparable: true, + * cellules, imposes, violations } ; * horsClassement : les autres par identifiant, { id, rang: null, retenue, * comparable: false, statut, raisons } — statut la clé de « non comparable », * raisons leurs codes, chacune { cle, details, participant }, participant diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js index 3020ae5..f9efb48 100644 --- a/src/interface/propositions/modele.test.js +++ b/src/interface/propositions/modele.test.js @@ -29,7 +29,7 @@ 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, demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau, texteCellule } from './modele.js'; +import { COLONNES, colonneDuCritere, demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau, texteCellule } from './modele.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const format = creerFormat(t); @@ -365,29 +365,81 @@ describe('tableau : les unités (§ 5.4)', () => { }); describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => { - test('la phrase cite les cinq critères dans l’ordre, puis l’identifiant ; les trois plans d’A6 : (6, 6), (4, 2), (4, 1)', () => { + // La phrase de l'ordre telle qu'elle s'affiche : chaque morceau traduit, + // bout à bout. + const phraseDe = (morceaux) => morceaux.map(({ cle, details }) => t(cle, details)).join(''); + // Les morceaux qui désignent une colonne : leur critère et leur colonne. + const designes = (morceaux) => morceaux.filter(({ colonne }) => colonne !== null).map(({ critere, colonne }) => [critere, colonne]); + + test('la phrase cite les cinq critères dans l’ordre, puis l’identifiant, mot pour mot ; les trois plans d’A6 : (6, 6), (4, 2), (4, 1)', () => { const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT); const vue = tableau(evaluer(charge)); assert.deepEqual(vue.lignes.map(({ id }) => id), [3, 2, 1]); - assert.deepEqual(vue.ordre, [{ cle: 'propositions.ordre.criteres', details: { criteres: [...CRITERES] } }]); - const phrase = t(vue.ordre[0].cle, vue.ordre[0].details); + assert.equal(vue.ordre.length, 1); + const phrase = phraseDe(vue.ordre[0]); + assert.equal( + phrase, + "Ordre du classement, chaque critère au plus faible d'abord : écart au plafond a priori, puis excédent de collisions, " + + "puis collisions cumulées, puis rencontres répétées, puis redondance d'appartenance, puis l'identifiant croissant.", + ); + assert.equal(phrase, t('propositions.ordre.criteres', { criteres: [...CRITERES] })); const positions = CRITERES.map((nom) => phrase.indexOf(t(`moteur.critere.${nom}`))); assert.equal(positions.length, 5); assert.ok(positions.every((position, rang) => position >= 0 && (rang === 0 || position > positions[rang - 1])), phrase); assert.ok(phrase.indexOf('identifiant') > positions[4], phrase); }); - test('a priori inconnu : la phrase nomme le critère sauté et « excédent de collisions » appliqué à sa place', () => { + test('chaque nom de critère, et l’identifiant croissant, désigne sa colonne du tableau, dans l’ordre de la phrase', () => { + const vue = tableau(evaluer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT))); + assert.deepEqual(designes(vue.ordre[0]), [ + ['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'], + ['excedentCollisions', 'excedentCollisions'], + ['collisionsCumulees', 'collisionsCumulees'], + ['rencontresRepetees', 'rencontresRepetees'], + ['redondance', 'totalRedondance'], + ['identifiant', 'proposition'], + ]); + for (const morceau of vue.ordre[0]) { + if (morceau.colonne === null) assert.equal(morceau.critere, null, morceau.cle); + else assert.equal(t(morceau.cle, morceau.details), morceau.critere === 'identifiant' ? "l'identifiant croissant" : t(`moteur.critere.${morceau.critere}`)); + } + }); + + test('colonneDuCritere : chaque critère de CRITERES désigne une colonne de COLONNES ; un critère sans colonne lève', () => { + const noms = new Set(COLONNES.map(({ nom }) => nom)); + assert.equal(CRITERES.length, 5); + for (const critere of CRITERES) assert.ok(noms.has(colonneDuCritere(critere)), critere); + assert.ok(noms.has(colonneDuCritere('identifiant'))); + assert.equal(colonneDuCritere('identifiant'), 'proposition'); + assert.throws(() => colonneDuCritere('moyenneRencontres'), TypeError); + }); + + test('a priori inconnu : la phrase nomme le critère sauté et « excédent de collisions » appliqué à sa place ; les critères appliqués désignent toujours leur colonne', () => { const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT); const vue = tableau(evaluer(charge, { plafondsAPriori: null })); assert.equal(vue.ordre.length, 2); - assert.deepEqual(vue.ordre[0], { - cle: 'propositions.ordre.saute', - details: { saute: 'ecartAuPlafondAPrioriMax', applique: 'excedentCollisions' }, - }); - const saute = t(vue.ordre[0].cle, vue.ordre[0].details); + assert.deepEqual(vue.ordre[0], [ + { + cle: 'propositions.ordre.saute', + details: { saute: 'ecartAuPlafondAPrioriMax', applique: 'excedentCollisions' }, + critere: null, + colonne: null, + }, + ]); + const saute = phraseDe(vue.ordre[0]); assert.ok(saute.indexOf('écart au plafond a priori') >= 0 && saute.indexOf('excédent de collisions') > saute.indexOf('écart au plafond a priori'), saute); - assert.equal(vue.ordre[1].details.criteres[0], 'excedentCollisions'); + assert.equal( + phraseDe(vue.ordre[1]), + "Ordre du classement, chaque critère au plus faible d'abord : excédent de collisions, " + + "puis collisions cumulées, puis rencontres répétées, puis redondance d'appartenance, puis l'identifiant croissant.", + ); + assert.deepEqual(designes(vue.ordre[1]), [ + ['excedentCollisions', 'excedentCollisions'], + ['collisionsCumulees', 'collisionsCumulees'], + ['rencontresRepetees', 'rencontresRepetees'], + ['redondance', 'totalRedondance'], + ['identifiant', 'proposition'], + ]); for (const ligne of vue.lignes) assert.equal(texte(cellule(ligne, 'ecartItineraireMax')), 'inconnu'); }); }); diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index f153e2f..8106c61 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -541,6 +541,21 @@ export const ECRANS = Object.freeze([ return m.racine; }, }, + { + nom: 'propositions.critereSurvole', + mode: 'lecture', + async atteindre(contexte) { + // Le pointeur réel sur un nom de critère de la phrase de l'ordre : la + // colonne qu'il désigne se surligne, en-tête souligné. + const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'propositions' }); + const { x, y } = await centre(await element(m, '[data-ordre] [data-critere="excedentCollisions"]')); + await pointer('bouger', x, y); + await attendre('la colonne surlignée', () => + getComputedStyle(m.racine.querySelector('thead th[data-colonne="excedentCollisions"]')).textDecorationLine.includes('underline'), + ); + return m.racine; + }, + }, { nom: 'refus.modeLecture', mode: 'lecture',