[ADD] event list: retained placement's chosen repeated meetings

The event list summed up the retained placement by its collisions alone;
the chosen repeated meetings, the other figure the proposals table ranks
on, was only visible after opening the event. Both now sit under one
« Placement retenu » group header, read from a single measurement of the
retained placement, with the same « — » cases and lazy loading. The
column group span is tested, so assistive technology keeps both columns
under that header. The user guide drops the stale redundancy target.
Checked: node 5152, browser 1154, node-long 129; no capture changes.

--- FR ---

[ADD] liste des événements : rencontres répétées choisies du retenu

La liste des événements résumait le retenu par ses seules collisions ; les
rencontres répétées choisies, l'autre nombre que classe le tableau des
propositions, ne se voyaient qu'en ouvrant l'événement. Les deux tiennent
sous un même en-tête « Placement retenu », lues d'une seule mesure du
retenu, avec les mêmes cas « — » et le même chargement différé. L'étendue
du groupe de colonnes est éprouvée : les aides techniques gardent les deux
colonnes sous cet en-tête. Le guide d'usage quitte la cible périmée.
Vérifié : node 5152, navigateur 1154, node-long 129 ; aucune capture.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-13 08:07:19 -04:00
parent 4f9c550e44
commit ffce294cec
12 changed files with 254 additions and 117 deletions

View file

@ -166,12 +166,13 @@
page de qualité.
- Le guide d'usage montre la page de qualité, ses vues et ses cas limites,
captures à l'appui.
- Dans la liste des événements, la colonne du placement retenu montre ses
« Collisions cumulées (paire et tour) », le même nombre que la colonne du
tableau des propositions, au lieu de « Diff somme d'appartenance ». Le réglage
« Cible de la somme de redondance », qui ne servait qu'à cette colonne, quitte
la section des propositions : un fichier qui le porte s'ouvre sans message, et
le perd au premier enregistrement.
- Dans la liste des événements, le placement retenu montre ses « Collisions
cumulées (paire et tour) » et ses « Rencontres répétées choisies (paires) »,
les mêmes nombres que les colonnes du tableau des propositions, au lieu de
« Diff somme d'appartenance ». Le réglage « Cible de la somme de redondance »,
qui ne servait qu'à cette ancienne colonne, quitte la section des
propositions : un fichier qui le porte s'ouvre sans message, et le perd au
premier enregistrement.
- La section « Propositions » offre « Garder les 10 meilleures » : les dix
premières du classement restent, et celle d'où vient le placement retenu
aussi, les autres s'effacent. Le nombre se règle dans le champ voisin. Une

View file

@ -378,8 +378,9 @@ réservations imposent de ce que la recherche a choisi.
proposition alors que le retenu a été ajusté à la main détruit ces ajustements :
une fenêtre le dit avant.
La liste des événements résume ensuite le retenu par l’écart de sa somme de
redondance d’appartenance à la cible de la soirée.
La liste des événements résume ensuite le retenu par ses collisions cumulées
(paire et tour) et ses rencontres répétées choisies (paires), les nombres du
tableau des propositions.
Retenir n’est pas obligatoire, mais tout ce qui s’imprime sans placement retenu
porte la mention BROUILLON (étape 12).

18
spec.md
View file

@ -201,7 +201,7 @@ du calcul qui a produit la proposition. Le logiciel sépare ce que le moteur a
décidé de ce que les réservations imposent (§ 5.4). Retenir fait passer le plan
à l'état *Retenu* (§ 9) ; les propositions non retenues restent disponibles. Le
retenu se résume ensuite dans la liste des événements, par ses collisions
cumulées (§ 5.4, § 8.1).
cumulées et ses rencontres répétées choisies (§ 5.4, § 8.1).
**11. Ajuster à la main.** *Facultative, reprenable.* Le même geste qu'à
l'étape 7 déplace maintenant une personne réelle dans le placement retenu. **Le
@ -1950,12 +1950,16 @@ pastille de l'état et son compte —, ou ne les groupe pas, au choix ; ses
en-têtes de colonnes se groupent en « Événement », « Placement retenu » et
« Meilleure proposition du classement », et le nom de chaque ligne en est
l'en-tête, la date et le chemin en petite ligne dessous. Sous le tableau, des
cartes offrent la création, le dossier de travail et les démonstrations. Une
colonne résume en plus le **placement retenu** : « Collisions cumulées (paire et
tour) », les collisions cumulées du retenu (§ 5.4) — le nombre que la colonne du
même intitulé du tableau des propositions porte sur la ligne du retenu. Elle se
lit dans la même mesure que le tableau, sans rien écrire ; un événement sans
retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte « — ».
cartes offrent la création, le dossier de travail et les démonstrations. Deux
colonnes résument en plus le **placement retenu**, sous l'en-tête du groupe qui
les couvre toutes deux : « Collisions cumulées (paire et tour) », les collisions
cumulées du retenu, puis « Rencontres répétées choisies (paires) », les paires
réunies plus d'une fois que le placement a choisies, sans celles que les
réservations imposent (§ 5.4) — dans chacune, le nombre que la colonne du même
intitulé du tableau des propositions porte sur la ligne du retenu. Les deux se
lisent dans une seule mesure du retenu, celle du tableau, sans rien écrire ; un
événement sans retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9),
porte « — » dans l'une et l'autre.
Chaque ligne porte aussi la **« Meilleure proposition du classement »**, sous
les libellés du bloc que la section de la génération montre en tête :
l'identifiant de la proposition au rang 1 du classement (§ 5.7) — la même

View file

@ -26,9 +26,10 @@
// dans ses mesures, à part des choisies.
//
// resumeDuRetenu donne à la liste des événements les collisions cumulées
// du retenu, lues dans la même mesure que sa ligne du tableau, sans le
// diagnostic ni les plafonds : la liste en résume chaque événement du
// dossier. premiereDuClassement est la seule définition de la proposition
// et les rencontres répétées choisies du retenu, lues dans une seule
// mesure, celle de sa ligne du tableau, sans le diagnostic ni les
// plafonds : la liste en résume chaque événement du dossier.
// premiereDuClassement est la seule définition de la proposition
// au rang 1 : la section de la génération la lit dans sa table,
// resumeDuMeilleur en donne l'identifiant à la liste, sur la même table
// d'évaluation, diagnostic compris — le premier critère du classement lit
@ -217,17 +218,23 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
}
/**
* Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1) :
* collisionsCumulees, les occurrences (paire, tour) où deux personnes d'une
* même appartenance partagent une table, celles de mesurer — la valeur que
* la ligne du retenu d'evaluer porte, et que la colonne « Collisions
* cumulées (paire et tour) » du tableau des propositions écrit. null sans
* retenu, pour un retenu fautif ou en dérive, et quand la configuration ne
* se normalise pas : rien ne s'y mesure, et la liste écrit « — ». Ne
* modifie pas la charge.
* Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1),
* lu dans un seul appel à mesurer, la mesure que la ligne du retenu
* d'evaluer porte :
* - collisionsCumulees, les occurrences (paire, tour) où deux personnes
* d'une même appartenance partagent une table — la valeur que la colonne
* « Collisions cumulées (paire et tour) » du tableau des propositions
* écrit ;
* - rencontresRepetees, les paires réunies à plus d'un tour que le placement
* a choisies, sans celles que les réservations imposent
* (mesures.rencontresRepetees.choisies) — la valeur que la colonne
* « Rencontres répétées choisies (paires) » écrit.
* null sans retenu, pour un retenu fautif ou en dérive, et quand la
* configuration ne se normalise pas : rien ne s'y mesure, et la liste écrit
* « — » dans les deux colonnes. Ne modifie pas la charge.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {{collisionsCumulees: number}|null}
* @returns {{collisionsCumulees: number, rencontresRepetees: number}|null}
*/
export function resumeDuRetenu(charge) {
const { retenu } = charge;
@ -239,8 +246,8 @@ export function resumeDuRetenu(charge) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;
}
const { collisionsCumulees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
return { collisionsCumulees };
const { collisionsCumulees, rencontresRepetees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
return { collisionsCumulees, rencontresRepetees: rencontresRepetees.choisies };
}
/**

View file

@ -484,23 +484,52 @@ describe("évaluation : une configuration qui ne se tient pas", () => {
});
});
describe('resumeDuRetenu : les collisions cumulées du retenu que lit la liste des événements (§ 5.4, § 8.1)', () => {
describe('resumeDuRetenu : les collisions cumulées et les rencontres répétées choisies du retenu que lit la liste des événements (§ 5.4, § 8.1)', () => {
// La petite démonstration, trois propositions générées, la deuxième retenue.
function retenue() {
const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 });
return executer(generees, 'retenirProposition', { id: generees.propositions[1].id });
}
test("les seules collisions cumulées (paire et tour), celles de la ligne du retenu d'evaluer et de mesurer", () => {
test("les collisions cumulées (paire et tour) et les rencontres répétées choisies, celles de la ligne du retenu d'evaluer et de mesurer, et rien d'autre", () => {
const charge = figer(retenue());
const resume = resumeDuRetenu(charge);
const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions');
assert.ok(mesures.rencontresRepetees.choisies > 0, 'le retenu porte des rencontres répétées choisies');
assert.notEqual(mesures.collisionsCumulees, mesures.totalRedondance);
assert.deepEqual(resume, { collisionsCumulees: mesures.collisionsCumulees });
assert.notEqual(mesures.rencontresRepetees.choisies, mesures.collisionsCumulees);
assert.deepEqual(resume, {
collisionsCumulees: mesures.collisionsCumulees,
rencontresRepetees: mesures.rencontresRepetees.choisies,
});
const instance = normaliser(configurationDe(charge));
const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(attendues.collisionsCumulees, mesures.collisionsCumulees);
assert.deepEqual(attendues.rencontresRepetees, mesures.rencontresRepetees);
});
test('les rencontres répétées du résumé sont les seules choisies : une paire réservée ensemble, imposée, n’y entre pas', () => {
const charge = structuredClone(chargeDemo('petite'));
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
const avec = generee(charge, { graine: 6, nombre: 3, arret: 500 });
for (const proposition of avec.propositions) {
const retenue = figer(executer(avec, 'retenirProposition', { id: proposition.id }));
const { mesures } = evaluer(retenue).retenu;
assert.equal(mesures.rencontresRepetees.imposees, 1);
assert.deepEqual(resumeDuRetenu(retenue), {
collisionsCumulees: mesures.collisionsCumulees,
rencontresRepetees: mesures.rencontresRepetees.choisies,
});
}
});
test('les deux valeurs sortent d’une seule mesure du retenu : un seul appel à mesurer, dont le résumé lit les deux', () => {
const source = readFileSync(new URL('./evaluation.js', import.meta.url), 'utf8');
const debut = source.indexOf('export function resumeDuRetenu(');
assert.ok(debut >= 0);
const corps = source.slice(debut, source.indexOf('\n}\n', debut));
assert.equal(corps.match(/\bmesurer\(/g)?.length, 1);
});
test('aucune cible : le résumé ne dépend pas d’un champ cibleRedondance resté dans les réglages, et le module n’en exporte plus', () => {

View file

@ -639,7 +639,7 @@ describe('lister (§ 8.1, § 19.4)', () => {
return serialiser(modifier(charge), { revision: 3, produitVersion: V1 });
}
test('les mesures du retenu : ses seules collisions cumulées, celles de resumeDuRetenu ; null sans retenu ; rien d’écrit', async () => {
test('les mesures du retenu : ses collisions cumulées et ses rencontres répétées choisies, celles de resumeDuRetenu ; null sans retenu ; rien d’écrit', async () => {
const { fs, facade } = await deuxEvenements();
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu());
const avant = ecritures(fs);
@ -649,7 +649,11 @@ describe('lister (§ 8.1, § 19.4)', () => {
const charge = analyser(texteRetenu()).charge;
const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions');
assert.deepEqual(par.retenue.mesuresDuRetenu, { collisionsCumulees: mesures.collisionsCumulees });
assert.ok(mesures.rencontresRepetees.choisies > 0, 'le retenu porte des rencontres répétées choisies');
assert.deepEqual(par.retenue.mesuresDuRetenu, {
collisionsCumulees: mesures.collisionsCumulees,
rencontresRepetees: mesures.rencontresRepetees.choisies,
});
assert.deepEqual(par.retenue.mesuresDuRetenu, resumeDuRetenu(charge));
assert.equal(par['soiree-a'].mesuresDuRetenu, null);
assert.equal(par.petite.mesuresDuRetenu, null);

View file

@ -1238,10 +1238,12 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => {
return mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
}
test('un événement retenu : ses collisions cumulées (paire et tour), sous l’intitulé du tableau des propositions ; sans retenu : « — » ; rien d’écrit', async () => {
test('un retenu : ses collisions cumulées et ses rencontres répétées choisies, sous les intitulés du tableau des propositions ; sans retenu, « — »', async () => {
const charge = chargeRetenue();
const mesures = mesuresDe(charge);
expect(mesures.collisionsCumulees).toBeGreaterThan(0);
expect(mesures.rencontresRepetees.choisies).toBeGreaterThan(0);
expect(mesures.rencontresRepetees.choisies).not.toBe(mesures.collisionsCumulees);
expect(mesures.collisionsCumulees).not.toBe(Math.abs(1000 - mesures.totalRedondance));
const m = await monter({
fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))],
@ -1252,27 +1254,39 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => {
const retenue = ligneDe(m, 'retenue');
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees));
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(evaluer(charge).retenu.mesures.collisionsCumulees));
expect(colonne(retenue, 'rencontresRepetees')).toBe(format.nombre(mesures.rencontresRepetees.choisies));
expect(colonne(retenue, 'rencontresRepetees')).toBe(format.nombre(evaluer(charge).retenu.mesures.rencontresRepetees.choisies));
const club = ligneDe(m, 'club');
expect(colonne(club, 'collisionsCumulees')).toBe(TIRET);
// L'intitulé est un en-tête de colonne, une seule fois pour toute la
// liste, au texte de la colonne du tableau des propositions ; plus rien
// ne nomme la différence à la cible.
expect(tous(m, '[data-terme="collisionsCumulees"]').length).toBe(1);
expect(terme(accueil(m), 'collisionsCumulees').closest('thead')).not.toBeNull();
expect(terme(accueil(m), 'collisionsCumulees').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
expect(colonne(club, 'rencontresRepetees')).toBe(TIRET);
// Chaque intitulé est un en-tête de colonne, une seule fois pour toute la
// liste, au texte de la colonne du tableau des propositions, dans l'ordre
// des valeurs ; plus rien ne nomme la différence à la cible.
for (const nom of ['collisionsCumulees', 'rencontresRepetees']) {
expect(tous(m, `[data-terme="${nom}"]`).length, nom).toBe(1);
expect(terme(accueil(m), nom).closest('thead'), nom).not.toBeNull();
expect(terme(accueil(m), nom).textContent, nom).toBe(t(`propositions.colonne.${nom}`));
expect(visible(retenue.querySelector(`[data-colonne="${nom}"]`)), nom).toBe(true);
}
expect([...retenue.querySelectorAll('[data-colonne]')].map((cellule) => cellule.dataset.colonne).slice(-2)).toEqual([
'collisionsCumulees',
'rencontresRepetees',
]);
expect(tous(m, '[data-colonne="diffRedondance"], [data-terme="diffRedondance"]')).toEqual([]);
expect(accueil(m).textContent).not.toContain("Diff somme d'appartenance");
expect(visible(retenue.querySelector('[data-colonne="collisionsCumulees"]'))).toBe(true);
expect(ecritures(m)).toBe(avant);
});
test('chaque intitulé s’explique : le survol puis le focus ouvrent son infobulle, Échap et la sortie la ferment', async () => {
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
await eprouverTermes(accueil(m), [['collisionsCumulees', 'accueil.explication.collisionsCumulees']]);
await eprouverTermes(accueil(m), [
['collisionsCumulees', 'accueil.explication.collisionsCumulees'],
['rencontresRepetees', 'accueil.explication.rencontresRepetees'],
]);
});
test('un état écrit avec la cible de redondance d’un code antérieur : la ligne se lit sans signalement, ses collisions comme sans elle ; l’ouvrir n’écrit rien', async () => {
test('un état écrit avec la cible de redondance d’un code antérieur : la ligne se lit sans signalement, ses mesures du retenu comme sans elle ; l’ouvrir n’écrit rien', async () => {
const charge = chargeRetenue();
const lu = JSON.parse(etat(charge));
lu.charge.reglages.cibleRedondance = 25;
@ -1282,6 +1296,7 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => {
const ligne = ligneDe(m, 'retenue');
expect(ligne.querySelector('[data-signalement]')).toBeNull();
expect(colonne(ligne, 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees));
expect(colonne(ligne, 'rencontresRepetees')).toBe(format.nombre(mesuresDe(charge).rencontresRepetees.choisies));
const avant = ecritures(m);
await ouvrirLigne(m, 'retenue');
await attendreEvenement(m);
@ -1418,8 +1433,15 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
expect(tableau.parentElement.scrollWidth).toBeLessThanOrEqual(tableau.parentElement.clientWidth);
expect(tableau.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right);
expect(carte.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
// Chaque intitulé de colonne tient dans sa cellule, ceux du placement
// retenu compris.
for (const entete of tous(m, '[data-ecran="accueil"] thead th')) {
expect(entete.scrollWidth, entete.textContent).toBeLessThanOrEqual(entete.clientWidth);
expect(entete.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right);
}
for (const ligne of lignes(m)) {
expect(lu(ligne).id).toMatch(/\d/);
for (const nom of ['collisionsCumulees', 'rencontresRepetees']) expect(colonne(ligne, nom), nom).toMatch(/\d/);
for (const cellule of ligne.cells) {
expect(cellule.scrollWidth, cellule.textContent).toBeLessThanOrEqual(cellule.clientWidth);
expect(cellule.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
@ -1446,15 +1468,16 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge;
}
// Les deux valeurs de résumé d'une ligne, dans l'ordre du rendu.
const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]'];
// Les trois valeurs de résumé d'une ligne, dans l'ordre du rendu : les
// deux mesures du retenu, puis la meilleure proposition.
const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-colonne="rencontresRepetees"]', '[data-meilleure-id]'];
const cellules = (ligne) => CELLULES.map((selecteur) => ligne.querySelector(selecteur));
// Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée
// une seconde fois (§ 19.1).
function attendues(charge) {
const table = evaluer(charge);
const [premiere] = table.propositions;
return [table.retenu.mesures.collisionsCumulees, premiere.id].map((n) => format.nombre(n));
return [table.retenu.mesures.collisionsCumulees, table.retenu.mesures.rencontresRepetees.choisies, premiere.id].map((n) => format.nombre(n));
}
// Chaque valeur de la ligne attend : la cellule se dit occupée, porte un
// seul signe, muet pour l'accessibilité (aria-hidden, sans rôle ni nom),
@ -1500,12 +1523,12 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
expect(colonne(ligne, 'participants')).toBe(format.nombre(charges[ligne.dataset.evenement].participants.length));
enAttente(ligne);
}
expect(signes(m).length).toBe(4);
expect(signes(m).length).toBe(6);
const region = regionEtat(m);
expect(region.textContent).toBe(t('accueil.chargement'));
for (let image = 0; image < 3; image += 1) await uneImage();
flushSync();
expect(signes(m).length).toBe(4);
expect(signes(m).length).toBe(6);
m.horloge.avancer(0);
await vi.waitFor(() => resolue(ligneDe(m, 'alpha'), attendues(charges.alpha)));
enAttente(ligneDe(m, 'beta'));
@ -1523,7 +1546,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
test('démontée avant le premier pas, l’application arrête le flux des résumés : aucun état ne se relit ensuite', async () => {
const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') };
const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) });
expect(signes(m).length).toBe(4);
expect(signes(m).length).toBe(6);
const relus = [];
const lireTexte = m.fs.lireTexte;
m.fs.lireTexte = (racine, chemin) => {
@ -1540,7 +1563,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
expect(relus).toEqual([]);
});
test('un état qui ne se relit pas pendant le chargement : « — » dans ses deux valeurs, et l’autre ligne résolue', async () => {
test('un état qui ne se relit pas pendant le chargement : « — » dans ses trois valeurs, et l’autre ligne résolue', async () => {
const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') };
const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) });
const lireTexte = m.fs.lireTexte;
@ -1549,7 +1572,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
? Promise.reject(new ErreurStockage('LECTURE', { chemin, cause: 'EIO' }))
: lireTexte.call(m.fs, racine, chemin);
await chargerResumes(m);
resolue(ligneDe(m, 'alpha'), [TIRET, TIRET]);
resolue(ligneDe(m, 'alpha'), [TIRET, TIRET, TIRET]);
resolue(ligneDe(m, 'beta'), attendues(charges.beta));
});
@ -1582,8 +1605,10 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
test('le signe se voit : il tient 3:1 sur ce qui l’entoure dans les deux thèmes, sans atténuation', async () => {
const m = await monter({ fichiers: [fichier(`alpha${SUFFIXES.etat}`, etat(chargeResumee('evt-alpha')))] });
// Une ligne qui attend : un signe par valeur de résumé, un tracé par
// signe.
const traces = tous(m, '[data-chargement] path');
expect(traces.length).toBe(2);
expect(traces.length).toBe(3);
for (const theme of ['clair', 'sombre']) {
await sousTheme(theme, async () => {
for (const trace of traces) {
@ -1794,7 +1819,17 @@ describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, §
await chargerResumes(m);
const entetes = tous(m, '[data-ecran="accueil"] thead th[scope="colgroup"]');
expect(entetes.map(texte)).toEqual([t('accueil.entete.evenement'), t('accueil.entete.retenu'), t('generation.meilleure.titre')]);
expect(entetes.map((th) => th.colSpan)).toEqual([4, 1, 1]);
expect(entetes.map((th) => th.colSpan)).toEqual([4, 2, 1]);
// Les groupes de colonnes, que lisent les techniques d'assistance pour
// rattacher une cellule à son en-tête de groupe : un par en-tête, chacun
// de la largeur de son en-tête, plus la colonne « Ouvrir », et leur
// somme fait une ligne entière.
const table = entetes[0].closest('table');
const groupesDeColonnes = [...table.querySelectorAll(':scope > colgroup')].map((groupe) => groupe.span);
expect(groupesDeColonnes).toEqual([4, 2, 1, 1]);
expect(groupesDeColonnes.slice(0, 3)).toEqual(entetes.map((th) => th.colSpan));
const largeurDeLigne = [...ligneDe(m, 'gala').cells].reduce((somme, cellule) => somme + cellule.colSpan, 0);
expect(groupesDeColonnes.reduce((somme, span) => somme + span, 0)).toBe(largeurDeLigne);
expect(fond(entetes[1])).toEqual(jeton('--couleur-etat-retenu-fond'));
expect(fond(entetes[2])).toEqual(jeton('--couleur-etat-propose-fond'));
// Le groupe porte le signe de son état, de la marque de l'état.
@ -1807,7 +1842,11 @@ describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, §
};
const ligne = ligneDe(m, 'gala');
for (const nom of ['nom', 'etat', 'participants', 'modification']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[0]), nom).toBe(true);
expect(dans2(ligne.querySelector('[data-colonne="collisionsCumulees"]'), entetes[1])).toBe(true);
for (const nom of ['collisionsCumulees', 'rencontresRepetees']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[1]), nom).toBe(true);
// Le groupe du retenu couvre ses deux colonnes, d'un bord à l'autre.
const bornes = ['collisionsCumulees', 'rencontresRepetees'].map((nom) => ligne.querySelector(`[data-colonne="${nom}"]`).getBoundingClientRect());
expect(Math.abs(bornes[0].left - entetes[1].getBoundingClientRect().left)).toBeLessThanOrEqual(0.5);
expect(Math.abs(bornes[1].right - entetes[1].getBoundingClientRect().right)).toBeLessThanOrEqual(0.5);
expect(dans2(ligne.querySelector('[data-meilleure-id]'), entetes[2])).toBe(true);
// Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent,
// dessous, en plus petit.

View file

@ -14,13 +14,14 @@
//
// Les en-têtes de colonnes sont groupés : l'événement — nom, état,
// participants, dernière modification —, le placement retenu, à la teinte
// de Retenu — ses collisions cumulées (COLONNES_DU_RETENU) —, la
// meilleure proposition du classement, à la teinte de Proposé, sous le
// titre et le libellé du bloc de la génération (MEILLEURE) — son
// identifiant —, puis les actions. Chaque valeur est dans son propre
// nœud : data-colonne, data-meilleure-id, « — » sans valeur. Le nom est
// l'en-tête de sa ligne ; la date et le chemin le suivent en petite
// ligne, chacun précédé de son intitulé, que
// de Retenu — ses collisions cumulées et ses rencontres répétées choisies,
// une colonne chacune (COLONNES_DU_RETENU), que l'en-tête du groupe
// couvre —, la meilleure proposition du classement, à la teinte de
// Proposé, sous le titre et le libellé du bloc de la génération
// (MEILLEURE) — son identifiant —, puis les actions. Chaque valeur est
// dans son propre nœud : data-colonne, data-meilleure-id, « — » sans
// valeur. Le nom est l'en-tête de sa ligne ; la date et le chemin le
// suivent en petite ligne, chacun précédé de son intitulé, que
// lisent les technologies d'assistance et que l'œil ne voit pas, puis ses
// signalements traduits — les bases qu'un doublon nomme suivent son
// texte, chacune dans son propre nœud (data-doublon). L'état est une
@ -33,7 +34,7 @@
//
// lignes : LigneListe[] de la façade ; resumes : les résumés reçus de
// facade.resumer, par base — une ligne sans le sien attend, et chacune de
// ses deux valeurs de résumé porte le signe du chargement (Chargement),
// ses trois valeurs de résumé porte le signe du chargement (Chargement),
// décoratif, dans une cellule qui se dit occupée (aria-busy), jusqu'à ce
// qu'il arrive. La région d'état qui suit le tableau
// (data-chargement-liste) existe tant que la liste a des lignes, et écrit
@ -44,7 +45,7 @@
// du dernier échec, ou null ; apresOperation() : à appeler quand une
// opération a changé le dossier.
//
// L'intitulé de la mesure du retenu et le titre de la meilleure
// L'intitulé de chaque mesure du retenu et le titre de la meilleure
// proposition s'expliquent dans l'infobulle de la liste, une seule
// (Infobulle, primitive) : chaque terme (data-terme, que pose le fragment termeExplique), un en-tête de colonne,
// se focalise au clavier, son survol ou son focus ouvre l'explication, le
@ -72,9 +73,9 @@
const enChargement = $derived(lignes.some((ligne) => ligneAffichee(ligne, format, resumes.get(ligne.base)).enAttente));
const groupes = $derived(groupesDeLaListe(lignes, vue.filtre, vue.groupement));
// Le nombre de colonnes du tableau, que l'intitulé d'un groupe couvre :
// les quatre de l'événement, celle du retenu, celle de la meilleure
// les quatre de l'événement, les deux du retenu, celle de la meilleure
// proposition, les actions.
const COLONNES = 7;
const COLONNES = 8;
// Le terme expliqué : {ancre, terme, cle, details} — son élément, son nom
// et la clé de son explication — ; null sans infobulle.
@ -136,13 +137,13 @@
<div class="defilement">
<table>
<colgroup span="4"></colgroup>
<colgroup></colgroup>
<colgroup span={COLONNES_DU_RETENU.length}></colgroup>
<colgroup></colgroup>
<colgroup></colgroup>
<thead>
<tr>
<th scope="colgroup" colspan="4" class="groupe">{t('accueil.entete.evenement')}</th>
<th scope="colgroup" class="groupe retenu"
<th scope="colgroup" colspan={COLONNES_DU_RETENU.length} class="groupe retenu"
><span class="entete-groupe"
><span class="signe"><Icone nom="coche" taille={14} /></span><span>{t('accueil.entete.retenu')}</span></span
></th

View file

@ -5,13 +5,13 @@
// de la liste des événements affichent, sans arithmétique ni texte. Une
// ligne de la liste (LigneListe de la façade, evenements.js) devient ses six
// informations et ses signalements, mis en forme, puis, son résumé reçu, les
// collisions cumulées de son retenu et l'identifiant de la meilleure
// proposition de son classement ; les lignes de la liste, les tuiles de ses
// filtres par état du plan, le résumé de son en-tête et les groupes de son
// tableau ; une question du dossier de travail, ses réponses ; un état
// illisible, ses secours ; un refus de la façade, le refus affiché sur la
// liste. Chaque clé rendue est écrite ici en entier : aucun composant ne
// compose une clé.
// collisions cumulées et les rencontres répétées choisies de son retenu et
// l'identifiant de la meilleure proposition de son classement ; les lignes
// de la liste, les tuiles de ses filtres par état du plan, le résumé de son
// en-tête et les groupes de son tableau ; une question du dossier de
// travail, ses réponses ; un état illisible, ses secours ; un refus de la
// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici
// en entier : aucun composant ne compose une clé.
// Les états du plan (§ 9) et la clé de leur nom, dans l'ordre du § 9.
const CLES_ETATS = new Map([
@ -25,12 +25,15 @@ const CLES_ETATS = new Map([
export const ETATS = Object.freeze([...CLES_ETATS.keys()]);
/**
* La mesure du placement retenu qu'une ligne montre (§ 5.4, § 8.1) : ses
* collisions cumulées, { colonne, cle, explication } — le nom de sa valeur
* dans ligneAffichee et son data-colonne, la clé de son intitulé, celle de
* la colonne du tableau des propositions qui porte la même mesure, et celle
* de son explication, propre à la liste, que l'infobulle de l'intitulé
* montre.
* Les mesures du placement retenu qu'une ligne montre (§ 5.4, § 8.1), dans
* l'ordre de leurs colonnes : ses collisions cumulées, puis ses rencontres
* répétées choisies. Chacune est { colonne, cle, explication } — le nom de
* sa valeur dans ligneAffichee et dans le résumé du retenu, qui est aussi son
* data-colonne et son data-terme, la clé de son intitulé, celle de la
* colonne du tableau des propositions qui porte la même mesure, et celle de
* son explication, propre à la liste, que l'infobulle de l'intitulé montre.
* L'en-tête du groupe « Placement retenu » couvre autant de colonnes que la
* liste en compte.
*/
export const COLONNES_DU_RETENU = Object.freeze([
Object.freeze({
@ -38,6 +41,11 @@ export const COLONNES_DU_RETENU = Object.freeze([
cle: 'propositions.colonne.collisionsCumulees',
explication: 'accueil.explication.collisionsCumulees',
}),
Object.freeze({
colonne: 'rencontresRepetees',
cle: 'propositions.colonne.rencontresRepetees',
explication: 'accueil.explication.rencontresRepetees',
}),
]);
/**
@ -130,8 +138,9 @@ export function signalementsDe(ligne) {
}
/**
* Les six informations d'une ligne (§ 8.1), les collisions cumulées de son
* retenu et l'identifiant de la meilleure proposition de son classement,
* Les six informations d'une ligne (§ 8.1), les collisions cumulées et les
* rencontres répétées choisies de son retenu et l'identifiant de la
* meilleure proposition de son classement,
* mis en forme, et ses signalements. nom : celui inscrit dans le fichier, ou la base quand l'état
* ne se lit pas ; date, participants et dernière modification par format,
* la valeur absente quand ils manquent — la dernière modification est
@ -140,21 +149,23 @@ export function signalementsDe(ligne) {
* clé de son nom, que le composant traduit ; chemin : tel que la façade
* l'affiche, en entier.
*
* Les deux valeurs de résumé lisent resume, ce que facade.resumer a rendu
* pour la ligne : collisionsCumulees, celles de mesuresDuRetenu, par format,
* la valeur absente sans retenu mesurable — elles se lisent telles quelles,
* jamais refaites ; meilleure.id, l'identifiant de meilleureDuClassement, par
* format, la valeur absente sans proposition classée. Sans resume, la ligne
* est enAttente : son résumé se calcule encore, et les deux valeurs valent la
* valeur absente, que le composant remplace par le signe du chargement.
* Les trois valeurs de résumé lisent resume, ce que facade.resumer a rendu
* pour la ligne : collisionsCumulees et rencontresRepetees, celles de
* mesuresDuRetenu, par format, la valeur absente sans retenu mesurable —
* elles se lisent telles quelles, jamais refaites ; meilleure.id,
* l'identifiant de meilleureDuClassement, par format, la valeur absente sans
* proposition classée. Sans resume, la ligne est enAttente : son résumé se
* calcule encore, et les trois valeurs valent la valeur absente, que le
* composant remplace par le signe du chargement.
*
* @param {Object} ligne LigneListe (evenements.js)
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
* @param {{mesuresDuRetenu: {collisionsCumulees: number}|null,
* @param {{mesuresDuRetenu: {collisionsCumulees: number, rencontresRepetees: number}|null,
* meilleureDuClassement: {id: number}|null}} [resume]
* @returns {{nom: string, date: string, etat: string, participants: string,
* modification: string, chemin: string, enAttente: boolean,
* collisionsCumulees: string, meilleure: {id: string},
* collisionsCumulees: string, rencontresRepetees: string,
* meilleure: {id: string},
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
*/
export function ligneAffichee(ligne, format, resume) {
@ -167,6 +178,7 @@ export function ligneAffichee(ligne, format, resume) {
chemin: ligne.chemin,
enAttente: resume === undefined,
collisionsCumulees: format.nombre(resume?.mesuresDuRetenu?.collisionsCumulees ?? null),
rencontresRepetees: format.nombre(resume?.mesuresDuRetenu?.rencontresRepetees ?? null),
meilleure: { id: format.nombre(resume?.meilleureDuClassement?.id ?? null) },
signalements: signalementsDe(ligne),
};

View file

@ -88,6 +88,7 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => {
'modification',
'nom',
'participants',
'rencontresRepetees',
'signalements',
]);
assert.equal(affichee.nom, 'Bal des Grisailles');
@ -335,59 +336,85 @@ describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', ()
});
describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1)', () => {
test('les collisions cumulées du retenu, celles de la façade, mises en forme, sans calcul ici', () => {
for (const collisionsCumulees of [500, 7, 0]) {
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees } });
test('les collisions cumulées et les rencontres répétées choisies du retenu, celles de la façade, mises en forme chacune à sa place, sans calcul ici', () => {
for (const [collisionsCumulees, rencontresRepetees] of [
[500, 3],
[7, 12],
[0, 0],
]) {
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees, rencontresRepetees } });
assert.equal(affichee.collisionsCumulees, format.nombre(collisionsCumulees), String(collisionsCumulees));
assert.equal(affichee.rencontresRepetees, format.nombre(rencontresRepetees), String(rencontresRepetees));
}
});
test('sans retenu mesurable, et pour une ligne illisible, les collisions valent « — »', () => {
test('sans retenu mesurable, et pour une ligne illisible, les collisions et les rencontres répétées valent « — »', () => {
for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
const affichee = ligneAffichee(ligne(champs), format, AUCUN);
assert.equal(affichee.collisionsCumulees, VIDE);
assert.equal(affichee.rencontresRepetees, VIDE);
}
});
test('COLONNES_DU_RETENU : les seules collisions cumulées, sous l’intitulé de la colonne du tableau des propositions, avec l’explication propre à la liste ; plus rien de la différence à la cible', () => {
test('COLONNES_DU_RETENU : les collisions cumulées puis les rencontres répétées choisies, chacune sous l’intitulé de sa colonne du tableau des propositions, avec son explication propre à la liste ; plus rien de la différence à la cible', () => {
assert.deepEqual(COLONNES_DU_RETENU, [
{
colonne: 'collisionsCumulees',
cle: 'propositions.colonne.collisionsCumulees',
explication: 'accueil.explication.collisionsCumulees',
},
{
colonne: 'rencontresRepetees',
cle: 'propositions.colonne.rencontresRepetees',
explication: 'accueil.explication.rencontresRepetees',
},
]);
assert.ok(Object.isFrozen(COLONNES_DU_RETENU));
assert.ok(Object.isFrozen(COLONNES_DU_RETENU[0]));
for (const colonne of COLONNES_DU_RETENU) assert.ok(Object.isFrozen(colonne), colonne.colonne);
assert.equal(t('propositions.colonne.collisionsCumulees'), 'Collisions cumulées (paire et tour)');
assert.equal(t('propositions.colonne.rencontresRepetees'), 'Rencontres répétées choisies (paires)');
const explication = t('accueil.explication.collisionsCumulees');
for (const fragment of ['paire', 'tour', 'Collisions cumulées (paire et tour)', '\u{2014}']) {
assert.ok(explication.includes(fragment), fragment);
}
for (const cle of ['accueil.colonne.diffRedondance', 'accueil.explication.diffRedondance', 'accueil.colonne.collisionsCumulees']) {
// L'explication des rencontres répétées dit ce qu'elle compte, d'où elle
// vient et quand elle vaut « — », et n'est pas celle des collisions.
const repetees = t('accueil.explication.rencontresRepetees');
for (const fragment of ['paires', "plus d'un tour", 'réservations', 'Rencontres répétées choisies (paires)', 'retenu', '\u{2014}']) {
assert.ok(repetees.includes(fragment), fragment);
}
assert.notEqual(repetees, explication);
for (const cle of [
'accueil.colonne.diffRedondance',
'accueil.explication.diffRedondance',
'accueil.colonne.collisionsCumulees',
'accueil.colonne.rencontresRepetees',
]) {
assert.throws(() => t(cle), RangeError, cle);
}
});
});
describe('ligneAffichee : les résumés en attente (§ 8.1)', () => {
test('sans résumé reçu, la ligne est en attente et ses deux valeurs de résumé ne disent rien ; reçu, elle ne l’est plus', () => {
test('sans résumé reçu, la ligne est en attente et ses trois valeurs de résumé ne disent rien ; reçu, elle ne l’est plus', () => {
const attente = ligneAffichee(ligne(), format);
assert.equal(attente.enAttente, true);
assert.equal(attente.collisionsCumulees, VIDE);
assert.equal(attente.rencontresRepetees, VIDE);
assert.deepEqual(attente.meilleure, { id: VIDE });
assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false);
const recu = ligneAffichee(ligne(), format, {
mesuresDuRetenu: { collisionsCumulees: 10 },
mesuresDuRetenu: { collisionsCumulees: 10, rencontresRepetees: 4 },
meilleureDuClassement: { id: 3 },
});
assert.equal(recu.enAttente, false);
assert.equal(recu.collisionsCumulees, format.nombre(10));
assert.equal(recu.rencontresRepetees, format.nombre(4));
assert.deepEqual(recu.meilleure, { id: format.nombre(3) });
});
test('les six informations ne dépendent pas des résumés : en attente ou non, elles sont les mêmes', () => {
const sans = ({ enAttente, collisionsCumulees, meilleure, ...reste }) => reste;
const sans = ({ enAttente, collisionsCumulees, rencontresRepetees, meilleure, ...reste }) => reste;
assert.deepEqual(sans(ligneAffichee(ligne(), format)), sans(ligneAffichee(ligne(), format, AUCUN)));
});
});

View file

@ -73,10 +73,10 @@ export const TABLE = new Map([
['accueil.colonne.participants', 'Participants'],
['accueil.colonne.modification', 'Dernière modification'],
['accueil.colonne.chemin', 'Fichier'],
// La mesure du placement retenu (§ 5.4, § 8.1) : son intitulé est celui de
// la colonne du tableau des propositions qui porte la même mesure ;
// l'explication que l'infobulle de l'intitulé donne dit ce que compte le
// nombre, d'où il vient, et quand il vaut « — ».
// Les mesures du placement retenu (§ 5.4, § 8.1) : l'intitulé de chacune
// est celui de la colonne du tableau des propositions qui porte la même
// mesure ; l'explication que l'infobulle de l'intitulé donne dit ce que
// compte le nombre, d'où il vient, et quand il vaut « — ».
[
'accueil.explication.collisionsCumulees',
'Les collisions du placement retenu. Chaque fois que deux personnes de la même appartenance partagent une ' +
@ -85,6 +85,15 @@ export const TABLE = new Map([
"Plus il est bas, mieux c'est. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit " +
"plus la configuration, ou se contredit.",
],
[
'accueil.explication.rencontresRepetees',
"Les rencontres répétées du placement retenu : les paires de personnes réunies à la même table à plus d'un " +
'tour que le placement a choisies, comptées une fois par paire : deux personnes ensemble à trois tours font 1. ' +
"Une paire que les réservations réunissent à chacune de ses rencontres n'y entre pas. Le même nombre que " +
'la colonne « Rencontres répétées choisies (paires) » du tableau des propositions, sur la ligne du retenu. ' +
"Plus il est bas, mieux c'est. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit " +
'plus la configuration, ou se contredit.',
],
// La meilleure proposition du classement de chaque événement (§ 5.4,
// § 5.7) : son titre et son identifiant s'écrivent sous les libellés du

View file

@ -643,13 +643,14 @@ export const ECRANS = Object.freeze([
mode: null,
async atteindre(contexte) {
// La liste paraît avant ses résumés : tant que l'horloge pilotée
// n'avance pas, chaque valeur de résumé porte le signe du chargement.
// Rien ici ne l'avance : les signes restent pendant les relevés des
// deux thèmes, où le trait de chacun se mesure comme la marque
// « chargement ».
// n'avance pas, chaque valeur de résumé porte le signe du chargement :
// trois par ligne, les deux mesures du retenu et la meilleure
// proposition. Rien ici ne l'avance : les signes restent pendant les
// relevés des deux thèmes, où le trait de chacun se mesure comme la
// marque « chargement ».
const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] });
await attendre('le signe du chargement dans chaque valeur de résumé', () =>
[BASE, 'gelee'].every((base) => m.racine.querySelectorAll(`[data-evenement="${base}"] [aria-busy="true"] [data-chargement]`).length === 2),
[BASE, 'gelee'].every((base) => m.racine.querySelectorAll(`[data-evenement="${base}"] [aria-busy="true"] [data-chargement]`).length === 3),
);
return m.racine;
},
@ -659,17 +660,19 @@ export const ECRANS = Object.freeze([
mode: null,
async atteindre(contexte) {
// La principale, sa proposition retenue : la ligne montre les
// collisions cumulées du retenu et la meilleure proposition du
// classement, et le pointeur réel sur l'intitulé des collisions — un
// en-tête de colonne du tableau, une fois pour la liste — ouvre son
// infobulle.
// collisions cumulées et les rencontres répétées choisies du retenu et
// la meilleure proposition du classement, et le pointeur réel sur
// l'intitulé des collisions — un en-tête de colonne du tableau, une
// fois pour la liste — ouvre son infobulle.
const principale = chargePrincipale();
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
const ligne = `[data-evenement="${BASE}"]`;
await chargerResumes(m);
await attendre('les collisions du retenu et la meilleure proposition', () =>
['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')),
await attendre('les mesures du retenu et la meilleure proposition', () =>
['[data-colonne="collisionsCumulees"]', '[data-colonne="rencontresRepetees"]', '[data-meilleure-id]'].every((valeur) =>
/\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? ''),
),
);
const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="collisionsCumulees"]'));
await pointer('bouger', x, y);