[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é. page de qualité.
- Le guide d'usage montre la page de qualité, ses vues et ses cas limites, - Le guide d'usage montre la page de qualité, ses vues et ses cas limites,
captures à l'appui. captures à l'appui.
- Dans la liste des événements, la colonne du placement retenu montre ses - Dans la liste des événements, le placement retenu montre ses « Collisions
« Collisions cumulées (paire et tour) », le même nombre que la colonne du cumulées (paire et tour) » et ses « Rencontres répétées choisies (paires) »,
tableau des propositions, au lieu de « Diff somme d'appartenance ». Le réglage les mêmes nombres que les colonnes du tableau des propositions, au lieu de
« Cible de la somme de redondance », qui ne servait qu'à cette colonne, quitte « Diff somme d'appartenance ». Le réglage « Cible de la somme de redondance »,
la section des propositions : un fichier qui le porte s'ouvre sans message, et qui ne servait qu'à cette ancienne colonne, quitte la section des
le perd au premier enregistrement. 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 - La section « Propositions » offre « Garder les 10 meilleures » : les dix
premières du classement restent, et celle d'où vient le placement retenu 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 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 : proposition alors que le retenu a été ajusté à la main détruit ces ajustements :
une fenêtre le dit avant. une fenêtre le dit avant.
La liste des événements résume ensuite le retenu par l’écart de sa somme de La liste des événements résume ensuite le retenu par ses collisions cumulées
redondance d’appartenance à la cible de la soirée. (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 Retenir n’est pas obligatoire, mais tout ce qui s’imprime sans placement retenu
porte la mention BROUILLON (étape 12). 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 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 à 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 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'à **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 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 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 « 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 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 cartes offrent la création, le dossier de travail et les démonstrations. Deux
colonne résume en plus le **placement retenu** : « Collisions cumulées (paire et colonnes résument en plus le **placement retenu**, sous l'en-tête du groupe qui
tour) », les collisions cumulées du retenu (§ 5.4) — le nombre que la colonne du les couvre toutes deux : « Collisions cumulées (paire et tour) », les collisions
même intitulé du tableau des propositions porte sur la ligne du retenu. Elle se cumulées du retenu, puis « Rencontres répétées choisies (paires) », les paires
lit dans la même mesure que le tableau, sans rien écrire ; un événement sans réunies plus d'une fois que le placement a choisies, sans celles que les
retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte « — ». 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 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 : 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 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. // dans ses mesures, à part des choisies.
// //
// resumeDuRetenu donne à la liste des événements les collisions cumulées // 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 // et les rencontres répétées choisies du retenu, lues dans une seule
// diagnostic ni les plafonds : la liste en résume chaque événement du // mesure, celle de sa ligne du tableau, sans le diagnostic ni les
// dossier. premiereDuClassement est la seule définition de la proposition // 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, // 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 // resumeDuMeilleur en donne l'identifiant à la liste, sur la même table
// d'évaluation, diagnostic compris — le premier critère du classement lit // 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) : * 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 * lu dans un seul appel à mesurer, la mesure que la ligne du retenu
* même appartenance partagent une table, celles de mesurer — la valeur que * d'evaluer porte :
* la ligne du retenu d'evaluer porte, et que la colonne « Collisions * - collisionsCumulees, les occurrences (paire, tour) où deux personnes
* cumulées (paire et tour) » du tableau des propositions écrit. null sans * d'une même appartenance partagent une table — la valeur que la colonne
* retenu, pour un retenu fautif ou en dérive, et quand la configuration ne * « Collisions cumulées (paire et tour) » du tableau des propositions
* se normalise pas : rien ne s'y mesure, et la liste écrit « — ». Ne * écrit ;
* modifie pas la charge. * - 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 * @param {import('../stockage/types.js').Charge} charge
* @returns {{collisionsCumulees: number}|null} * @returns {{collisionsCumulees: number, rencontresRepetees: number}|null}
*/ */
export function resumeDuRetenu(charge) { export function resumeDuRetenu(charge) {
const { retenu } = charge; const { retenu } = charge;
@ -239,8 +246,8 @@ export function resumeDuRetenu(charge) {
if (erreur instanceof ErreurConfiguration) return null; if (erreur instanceof ErreurConfiguration) return null;
throw erreur; throw erreur;
} }
const { collisionsCumulees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu))); const { collisionsCumulees, rencontresRepetees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
return { collisionsCumulees }; 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. // La petite démonstration, trois propositions générées, la deuxième retenue.
function retenue() { function retenue() {
const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 }); const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 });
return executer(generees, 'retenirProposition', { id: generees.propositions[1].id }); 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 charge = figer(retenue());
const resume = resumeDuRetenu(charge); const resume = resumeDuRetenu(charge);
const { mesures } = evaluer(charge).retenu; const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions'); 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.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 instance = normaliser(configurationDe(charge));
const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu))); const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(attendues.collisionsCumulees, mesures.collisionsCumulees); 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', () => { 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 }); 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(); const { fs, facade } = await deuxEvenements();
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu());
const avant = ecritures(fs); const avant = ecritures(fs);
@ -649,7 +649,11 @@ describe('lister (§ 8.1, § 19.4)', () => {
const charge = analyser(texteRetenu()).charge; const charge = analyser(texteRetenu()).charge;
const { mesures } = evaluer(charge).retenu; const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions'); 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.deepEqual(par.retenue.mesuresDuRetenu, resumeDuRetenu(charge));
assert.equal(par['soiree-a'].mesuresDuRetenu, null); assert.equal(par['soiree-a'].mesuresDuRetenu, null);
assert.equal(par.petite.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))); 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 charge = chargeRetenue();
const mesures = mesuresDe(charge); const mesures = mesuresDe(charge);
expect(mesures.collisionsCumulees).toBeGreaterThan(0); 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)); expect(mesures.collisionsCumulees).not.toBe(Math.abs(1000 - mesures.totalRedondance));
const m = await monter({ const m = await monter({
fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))], 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'); const retenue = ligneDe(m, 'retenue');
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees)); expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees));
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(evaluer(charge).retenu.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'); const club = ligneDe(m, 'club');
expect(colonne(club, 'collisionsCumulees')).toBe(TIRET); expect(colonne(club, 'collisionsCumulees')).toBe(TIRET);
// L'intitulé est un en-tête de colonne, une seule fois pour toute la expect(colonne(club, 'rencontresRepetees')).toBe(TIRET);
// liste, au texte de la colonne du tableau des propositions ; plus rien // Chaque intitulé est un en-tête de colonne, une seule fois pour toute la
// ne nomme la différence à la cible. // liste, au texte de la colonne du tableau des propositions, dans l'ordre
expect(tous(m, '[data-terme="collisionsCumulees"]').length).toBe(1); // des valeurs ; plus rien ne nomme la différence à la cible.
expect(terme(accueil(m), 'collisionsCumulees').closest('thead')).not.toBeNull(); for (const nom of ['collisionsCumulees', 'rencontresRepetees']) {
expect(terme(accueil(m), 'collisionsCumulees').textContent).toBe(t('propositions.colonne.collisionsCumulees')); 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(tous(m, '[data-colonne="diffRedondance"], [data-terme="diffRedondance"]')).toEqual([]);
expect(accueil(m).textContent).not.toContain("Diff somme d'appartenance"); expect(accueil(m).textContent).not.toContain("Diff somme d'appartenance");
expect(visible(retenue.querySelector('[data-colonne="collisionsCumulees"]'))).toBe(true);
expect(ecritures(m)).toBe(avant); 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 () => { 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()))] }); const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1)); 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 charge = chargeRetenue();
const lu = JSON.parse(etat(charge)); const lu = JSON.parse(etat(charge));
lu.charge.reglages.cibleRedondance = 25; 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'); const ligne = ligneDe(m, 'retenue');
expect(ligne.querySelector('[data-signalement]')).toBeNull(); expect(ligne.querySelector('[data-signalement]')).toBeNull();
expect(colonne(ligne, 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees)); 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); const avant = ecritures(m);
await ouvrirLigne(m, 'retenue'); await ouvrirLigne(m, 'retenue');
await attendreEvenement(m); 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.parentElement.scrollWidth).toBeLessThanOrEqual(tableau.parentElement.clientWidth);
expect(tableau.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right); expect(tableau.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right);
expect(carte.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); 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)) { for (const ligne of lignes(m)) {
expect(lu(ligne).id).toMatch(/\d/); 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) { for (const cellule of ligne.cells) {
expect(cellule.scrollWidth, cellule.textContent).toBeLessThanOrEqual(cellule.clientWidth); expect(cellule.scrollWidth, cellule.textContent).toBeLessThanOrEqual(cellule.clientWidth);
expect(cellule.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); 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; return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge;
} }
// Les deux valeurs de résumé d'une ligne, dans l'ordre du rendu. // Les trois valeurs de résumé d'une ligne, dans l'ordre du rendu : les
const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]']; // 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)); const cellules = (ligne) => CELLULES.map((selecteur) => ligne.querySelector(selecteur));
// Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée // Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée
// une seconde fois (§ 19.1). // une seconde fois (§ 19.1).
function attendues(charge) { function attendues(charge) {
const table = evaluer(charge); const table = evaluer(charge);
const [premiere] = table.propositions; 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 // 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), // 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)); expect(colonne(ligne, 'participants')).toBe(format.nombre(charges[ligne.dataset.evenement].participants.length));
enAttente(ligne); enAttente(ligne);
} }
expect(signes(m).length).toBe(4); expect(signes(m).length).toBe(6);
const region = regionEtat(m); const region = regionEtat(m);
expect(region.textContent).toBe(t('accueil.chargement')); expect(region.textContent).toBe(t('accueil.chargement'));
for (let image = 0; image < 3; image += 1) await uneImage(); for (let image = 0; image < 3; image += 1) await uneImage();
flushSync(); flushSync();
expect(signes(m).length).toBe(4); expect(signes(m).length).toBe(6);
m.horloge.avancer(0); m.horloge.avancer(0);
await vi.waitFor(() => resolue(ligneDe(m, 'alpha'), attendues(charges.alpha))); await vi.waitFor(() => resolue(ligneDe(m, 'alpha'), attendues(charges.alpha)));
enAttente(ligneDe(m, 'beta')); 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 () => { 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 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 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 relus = [];
const lireTexte = m.fs.lireTexte; const lireTexte = m.fs.lireTexte;
m.fs.lireTexte = (racine, chemin) => { 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([]); 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 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 m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) });
const lireTexte = m.fs.lireTexte; 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' })) ? Promise.reject(new ErreurStockage('LECTURE', { chemin, cause: 'EIO' }))
: lireTexte.call(m.fs, racine, chemin); : lireTexte.call(m.fs, racine, chemin);
await chargerResumes(m); await chargerResumes(m);
resolue(ligneDe(m, 'alpha'), [TIRET, TIRET]); resolue(ligneDe(m, 'alpha'), [TIRET, TIRET, TIRET]);
resolue(ligneDe(m, 'beta'), attendues(charges.beta)); 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 () => { 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')))] }); 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'); const traces = tous(m, '[data-chargement] path');
expect(traces.length).toBe(2); expect(traces.length).toBe(3);
for (const theme of ['clair', 'sombre']) { for (const theme of ['clair', 'sombre']) {
await sousTheme(theme, async () => { await sousTheme(theme, async () => {
for (const trace of traces) { 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); await chargerResumes(m);
const entetes = tous(m, '[data-ecran="accueil"] thead th[scope="colgroup"]'); 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(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[1])).toEqual(jeton('--couleur-etat-retenu-fond'));
expect(fond(entetes[2])).toEqual(jeton('--couleur-etat-propose-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. // 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'); 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); 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); 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, // Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent,
// dessous, en plus petit. // dessous, en plus petit.

View file

@ -14,13 +14,14 @@
// //
// Les en-têtes de colonnes sont groupés : l'événement — nom, état, // Les en-têtes de colonnes sont groupés : l'événement — nom, état,
// participants, dernière modification —, le placement retenu, à la teinte // participants, dernière modification —, le placement retenu, à la teinte
// de Retenu — ses collisions cumulées (COLONNES_DU_RETENU) —, la // de Retenu — ses collisions cumulées et ses rencontres répétées choisies,
// meilleure proposition du classement, à la teinte de Proposé, sous le // une colonne chacune (COLONNES_DU_RETENU), que l'en-tête du groupe
// titre et le libellé du bloc de la génération (MEILLEURE) — son // couvre —, la meilleure proposition du classement, à la teinte de
// identifiant —, puis les actions. Chaque valeur est dans son propre // Proposé, sous le titre et le libellé du bloc de la génération
// nœud : data-colonne, data-meilleure-id, « — » sans valeur. Le nom est // (MEILLEURE) — son identifiant —, puis les actions. Chaque valeur est
// l'en-tête de sa ligne ; la date et le chemin le suivent en petite // dans son propre nœud : data-colonne, data-meilleure-id, « — » sans
// ligne, chacun précédé de son intitulé, que // 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 // lisent les technologies d'assistance et que l'œil ne voit pas, puis ses
// signalements traduits — les bases qu'un doublon nomme suivent son // signalements traduits — les bases qu'un doublon nomme suivent son
// texte, chacune dans son propre nœud (data-doublon). L'état est une // 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 // 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 // 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 // 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 // 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 // (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 // du dernier échec, ou null ; apresOperation() : à appeler quand une
// opération a changé le dossier. // 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 // 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, // (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 // 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 enChargement = $derived(lignes.some((ligne) => ligneAffichee(ligne, format, resumes.get(ligne.base)).enAttente));
const groupes = $derived(groupesDeLaListe(lignes, vue.filtre, vue.groupement)); const groupes = $derived(groupesDeLaListe(lignes, vue.filtre, vue.groupement));
// Le nombre de colonnes du tableau, que l'intitulé d'un groupe couvre : // 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. // proposition, les actions.
const COLONNES = 7; const COLONNES = 8;
// Le terme expliqué : {ancre, terme, cle, details} — son élément, son nom // Le terme expliqué : {ancre, terme, cle, details} — son élément, son nom
// et la clé de son explication — ; null sans infobulle. // et la clé de son explication — ; null sans infobulle.
@ -136,13 +137,13 @@
<div class="defilement"> <div class="defilement">
<table> <table>
<colgroup span="4"></colgroup> <colgroup span="4"></colgroup>
<colgroup></colgroup> <colgroup span={COLONNES_DU_RETENU.length}></colgroup>
<colgroup></colgroup> <colgroup></colgroup>
<colgroup></colgroup> <colgroup></colgroup>
<thead> <thead>
<tr> <tr>
<th scope="colgroup" colspan="4" class="groupe">{t('accueil.entete.evenement')}</th> <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="entete-groupe"
><span class="signe"><Icone nom="coche" taille={14} /></span><span>{t('accueil.entete.retenu')}</span></span ><span class="signe"><Icone nom="coche" taille={14} /></span><span>{t('accueil.entete.retenu')}</span></span
></th ></th

View file

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

View file

@ -88,6 +88,7 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => {
'modification', 'modification',
'nom', 'nom',
'participants', 'participants',
'rencontresRepetees',
'signalements', 'signalements',
]); ]);
assert.equal(affichee.nom, 'Bal des Grisailles'); 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)', () => { 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', () => { 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 of [500, 7, 0]) { for (const [collisionsCumulees, rencontresRepetees] of [
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees } }); [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.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 } }]) { for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
const affichee = ligneAffichee(ligne(champs), format, AUCUN); const affichee = ligneAffichee(ligne(champs), format, AUCUN);
assert.equal(affichee.collisionsCumulees, VIDE); 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, [ assert.deepEqual(COLONNES_DU_RETENU, [
{ {
colonne: 'collisionsCumulees', colonne: 'collisionsCumulees',
cle: 'propositions.colonne.collisionsCumulees', cle: 'propositions.colonne.collisionsCumulees',
explication: 'accueil.explication.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));
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.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'); const explication = t('accueil.explication.collisionsCumulees');
for (const fragment of ['paire', 'tour', 'Collisions cumulées (paire et tour)', '\u{2014}']) { for (const fragment of ['paire', 'tour', 'Collisions cumulées (paire et tour)', '\u{2014}']) {
assert.ok(explication.includes(fragment), fragment); 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); assert.throws(() => t(cle), RangeError, cle);
} }
}); });
}); });
describe('ligneAffichee : les résumés en attente (§ 8.1)', () => { 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); const attente = ligneAffichee(ligne(), format);
assert.equal(attente.enAttente, true); assert.equal(attente.enAttente, true);
assert.equal(attente.collisionsCumulees, VIDE); assert.equal(attente.collisionsCumulees, VIDE);
assert.equal(attente.rencontresRepetees, VIDE);
assert.deepEqual(attente.meilleure, { id: VIDE }); assert.deepEqual(attente.meilleure, { id: VIDE });
assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false); assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false);
const recu = ligneAffichee(ligne(), format, { const recu = ligneAffichee(ligne(), format, {
mesuresDuRetenu: { collisionsCumulees: 10 }, mesuresDuRetenu: { collisionsCumulees: 10, rencontresRepetees: 4 },
meilleureDuClassement: { id: 3 }, meilleureDuClassement: { id: 3 },
}); });
assert.equal(recu.enAttente, false); assert.equal(recu.enAttente, false);
assert.equal(recu.collisionsCumulees, format.nombre(10)); assert.equal(recu.collisionsCumulees, format.nombre(10));
assert.equal(recu.rencontresRepetees, format.nombre(4));
assert.deepEqual(recu.meilleure, { id: format.nombre(3) }); 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', () => { 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))); 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.participants', 'Participants'],
['accueil.colonne.modification', 'Dernière modification'], ['accueil.colonne.modification', 'Dernière modification'],
['accueil.colonne.chemin', 'Fichier'], ['accueil.colonne.chemin', 'Fichier'],
// La mesure du placement retenu (§ 5.4, § 8.1) : son intitulé est celui de // Les mesures du placement retenu (§ 5.4, § 8.1) : l'intitulé de chacune
// la colonne du tableau des propositions qui porte la même mesure ; // est celui de la colonne du tableau des propositions qui porte la même
// l'explication que l'infobulle de l'intitulé donne dit ce que compte le // mesure ; l'explication que l'infobulle de l'intitulé donne dit ce que
// nombre, d'où il vient, et quand il vaut « — ». // compte le nombre, d'où il vient, et quand il vaut « — ».
[ [
'accueil.explication.collisionsCumulees', 'accueil.explication.collisionsCumulees',
'Les collisions du placement retenu. Chaque fois que deux personnes de la même appartenance partagent une ' + '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 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.", "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, // 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 // § 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, mode: null,
async atteindre(contexte) { async atteindre(contexte) {
// La liste paraît avant ses résumés : tant que l'horloge pilotée // 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. // 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 // trois par ligne, les deux mesures du retenu et la meilleure
// deux thèmes, où le trait de chacun se mesure comme la marque // proposition. Rien ici ne l'avance : les signes restent pendant les
// « chargement ». // 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')] }); const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] });
await attendre('le signe du chargement dans chaque valeur de résumé', () => 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; return m.racine;
}, },
@ -659,17 +660,19 @@ export const ECRANS = Object.freeze([
mode: null, mode: null,
async atteindre(contexte) { async atteindre(contexte) {
// La principale, sa proposition retenue : la ligne montre les // La principale, sa proposition retenue : la ligne montre les
// collisions cumulées du retenu et la meilleure proposition du // collisions cumulées et les rencontres répétées choisies du retenu et
// classement, et le pointeur réel sur l'intitulé des collisions — un // la meilleure proposition du classement, et le pointeur réel sur
// en-tête de colonne du tableau, une fois pour la liste — ouvre son // l'intitulé des collisions — un en-tête de colonne du tableau, une
// infobulle. // fois pour la liste — ouvre son infobulle.
const principale = chargePrincipale(); const principale = chargePrincipale();
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id }); const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] }); const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
const ligne = `[data-evenement="${BASE}"]`; const ligne = `[data-evenement="${BASE}"]`;
await chargerResumes(m); await chargerResumes(m);
await attendre('les collisions du retenu et la meilleure proposition', () => await attendre('les mesures du retenu et la meilleure proposition', () =>
['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')), ['[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"]')); const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="collisionsCumulees"]'));
await pointer('bouger', x, y); await pointer('bouger', x, y);