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',