[ADD] propositions: a ranking criterion highlights its table column

The order sentence names the ranking criteria, but nothing tied a name to
the column holding its value. Each criterion name, and « l'identifiant
croissant », now highlights its column when hovered or focused: the header
and every cell get heavier side borders and the header an underline, so
text contrast is unchanged and the cue is not colour alone. The mapping
from criterion to column lives in the propositions model, and a criterion
without a column fails there. The sentence still reads word for word.
Checked: 2780 node, 554 browser tests, from the index alone.

--- FR ---

[ADD] propositions : un critère du classement surligne sa colonne

La phrase de l'ordre nomme les critères, mais rien ne reliait un nom à la
colonne qui porte sa valeur. Chaque nom de critère, et « l'identifiant
croissant », surligne maintenant sa colonne au survol ou au focus : l'en-
tête et chaque cellule prennent des bords latéraux plus épais et l'en-tête
un soulignement ; le contraste du texte ne change pas, le signe ne tient
pas qu'à la couleur. Le lien critère-colonne vit dans le modèle, et un
critère sans colonne y lève. La phrase se lit mot pour mot comme avant.
Vérifié : 2780 node, 554 navigateur, depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-08 03:39:25 -04:00
parent e59878d650
commit 57a2642b99
7 changed files with 398 additions and 31 deletions

View file

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

View file

@ -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);
});
});

View file

@ -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);
</script>
@ -85,11 +99,32 @@
{#if vue !== null}
<div class="ordre" data-ordre>
{#each vue.ordre as phrase (phrase.cle)}
<p>{t(phrase.cle, phrase.details)}</p>
{#each vue.ordre as morceaux, rang (rang)}
<p>
{#each morceaux as morceau, place (place)}
{#if morceau.colonne === null}
{t(morceau.cle, morceau.details)}
{:else}
<!-- Le nom est un terme, non une commande : aucun rôle n'annonce
d'action, et le focus ne sert qu'à montrer au clavier la
colonne que le pointeur montre au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<span
class="critere"
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)}</span
>
{/if}
{/each}
</p>
{/each}
</div>
<TableauPropositions {vue} {nommer} {voir} {retenir} />
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} />
{/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;
}

View file

@ -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)}
<td data-colonne={cellule.colonne} class="nombre">{texteCellule(cellule, outils)}</td>
<td data-colonne={cellule.colonne} data-marque={marque(cellule.colonne)} class="nombre" class:surlignee={cellule.colonne === surlignee}>{texteCellule(cellule, outils)}</td>
{/each}
<td data-colonne="imposes">
{#each ligne.imposes as phrase, rang (rang)}
@ -59,7 +70,7 @@
{/snippet}
{#snippet identite(ligne)}
<td data-colonne="proposition">
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
<span class="identifiant">{format.nombre(ligne.id)}</span>
{#if ligne.retenue}
<span class="marque">{t('propositions.marque.retenue')}</span>
@ -87,7 +98,7 @@
<thead>
<tr>
{#each vue.colonnes as colonne (colonne.nom)}
<th scope="col" data-colonne={colonne.nom}>{t(colonne.cle)}</th>
<th scope="col" data-colonne={colonne.nom} data-marque={marque(colonne.nom)} class:surlignee={colonne.nom === surlignee}>{t(colonne.cle)}</th>
{/each}
</tr>
</thead>
@ -95,7 +106,7 @@
<tbody>
<tr data-retenu={vue.retenu.id} class="retenu">
<th scope="row" data-colonne="rang">{t('propositions.retenu.titre')}</th>
<td data-colonne="proposition">
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
<span class="identifiant">{format.nombre(vue.retenu.id)}</span>
<p>{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}</p>
</td>
@ -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);

View file

@ -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.<nom>, 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<Array<{cle: string, details: Object, critere: string|null,
* colonne: string|null}>>, 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

View file

@ -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');
});
});

View file

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