[ADD] quality page views: header, profiles, memberships, pairs, goals

The Qualité tab now draws its five views from the placement's detail.
A numeric header with each figure's percentage and worst case; the
profile as counts with the diversity bound dotted, and as percentages
on a second chart; membership collisions and a table grid behind a
three-position selector, seats by default; repeated pairs with matrix
and a card section; goals per round on servable couples and
facilitators over the evening and per round; the course of the search.
Each chart has its twin table and keyboard access.
Checked: node 5083, navigateur 1102, node-long 128; captures stable.

--- FR ---

[ADD] vues de la qualité : en-tête, profils, appartenances, paires

L'onglet Qualité dessine désormais ses cinq vues à partir du détail du
placement : un en-tête chiffré avec le pourcentage et le pire cas de
chaque chiffre ; le profil en comptes, borne de diversité en pointillé,
et en pourcentages sur un second graphique ; les collisions et la grille
des tables derrière un sélecteur à trois positions, les sièges par
défaut ; les paires répétées, leur matrice et la fiche ; les objectifs
par tour et les animateurs sur la soirée et par tour ; la course. Chaque
graphique a son tableau jumeau et se parcourt au clavier.
Vérifié : node 5083, navigateur 1102, longues 128 ; captures stables.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-12 11:26:02 -04:00
parent 26f70cf8c3
commit 01c9296c73
47 changed files with 8887 additions and 115 deletions

View file

@ -342,17 +342,21 @@ export function colonnesJuxtaposees(largeur, n, ecart) {
* tient par colonne. Elle est dessinable quand elle a au moins une ligne et
* une colonne et que sa case tient la cible de CIBLE_MIN_PX et la hauteur
* du texte qu'elle porte : la largeur réelle de la carte décide, quel que
* soit le seuil mesuré au cadre du banc. largeur et hauteur sont celles des
* cases seules. Lève RangeError sur des comptes qui ne sont pas des entiers
* ≥ 0, ou une largeur ou une hauteur de texte qui n'est pas un nombre fini
* ≥ 0.
* soit le seuil mesuré au cadre du banc. coteMax borne la case par en
* haut, sans borne par défaut : peu de colonnes dans une carte large ne
* font pas de cases géantes ; une borne sous la cible rend la grille non
* dessinable, comme une carte trop étroite. largeur et hauteur sont celles
* des cases seules. Lève RangeError sur des comptes qui ne sont pas des
* entiers ≥ 0, une largeur ou une hauteur de texte qui n'est pas un nombre
* fini ≥ 0, ou une borne qui n'est ni un nombre fini ≥ 0 ni Infinity.
* @param {number} lignes
* @param {number} colonnes
* @param {number} largeurDispo px
* @param {number} hauteurTexte px, la hauteur de ligne du compte écrit
* @param {number} [coteMax] px, la plus grande case, Infinity par défaut
* @returns {{cote: number, dessinable: boolean, largeur: number, hauteur: number}}
*/
export function grille(lignes, colonnes, largeurDispo, hauteurTexte) {
export function grille(lignes, colonnes, largeurDispo, hauteurTexte, coteMax = Number.POSITIVE_INFINITY) {
exiger(
estEntierPositif(lignes) && estEntierPositif(colonnes),
`grille : comptes entiers ≥ 0 attendus, reçu ${lignes}, ${colonnes}`,
@ -361,7 +365,11 @@ export function grille(lignes, colonnes, largeurDispo, hauteurTexte) {
estPositif(largeurDispo) && estPositif(hauteurTexte),
`grille : largeur et texte ≥ 0 attendus, reçu ${largeurDispo}, ${hauteurTexte}`,
);
const cote = colonnes === 0 ? 0 : Math.floor(largeurDispo / colonnes);
exiger(
estPositif(coteMax) || coteMax === Number.POSITIVE_INFINITY,
`grille : borne de case ≥ 0 attendue, reçu ${coteMax}`,
);
const cote = colonnes === 0 ? 0 : Math.min(Math.floor(largeurDispo / colonnes), Math.floor(coteMax));
const dessinable = lignes >= 1 && colonnes >= 1 && cote >= CIBLE_MIN_PX && cote >= hauteurTexte;
return { cote, dessinable, largeur: cote * colonnes, hauteur: cote * lignes };
}

View file

@ -422,6 +422,19 @@ describe('graphique : la grille et ses niveaux (§ 12.5, D33, B29)', () => {
assert.equal(grille(30, 30, 700, 16).dessinable, false);
});
test('la borne de la case : peu de colonnes dans une carte large ne font pas de cases plus grandes qu’elle', () => {
assert.deepEqual(grille(4, 2, 1200, 16), { cote: 600, dessinable: true, largeur: 1200, hauteur: 2400 });
assert.deepEqual(grille(4, 2, 1200, 16, 40), { cote: 40, dessinable: true, largeur: 80, hauteur: 160 });
assert.deepEqual(grille(4, 2, 1200, 16, 40.7), { cote: 40, dessinable: true, largeur: 80, hauteur: 160 });
// Une carte étroite décide sous la borne, et une borne sous la cible
// rend la grille non dessinable.
assert.equal(grille(4, 40, 900, 16, 40).cote, 22);
assert.equal(grille(4, 2, 1200, 16, CIBLE_MIN_PX - 1).dessinable, false);
for (const borne of [-1, Number.NaN, Number.NEGATIVE_INFINITY]) {
assert.throws(() => grille(4, 2, 1200, 16, borne), RangeError, String(borne));
}
});
test('une case qui ne tient pas la hauteur de son texte, une grille vide, ne se dessinent pas', () => {
assert.equal(grille(4, 4, 120, 31).dessinable, false);
assert.equal(grille(4, 4, 120, 30).dessinable, true);

View file

@ -6,8 +6,10 @@
// demandée au plan et le numéro de sa demande, le texte d'un import en
// attente, les listes de noms, le plein écran, la place sélectionnée, la
// fiche ouverte, le choix en attente du plan, le refus posé sur une place,
// l'appartenance collante du formulaire d'ajout, l'aperçu avant impression
// et la page du tableau des propositions. Ce qu'il tient survit au
// l'appartenance collante du formulaire d'ajout, l'aperçu avant impression,
// la page du tableau des propositions et la position du sélecteur du
// mélange des tables de la page de qualité (§ 12.11, D54) — ce que la
// grille compte : sièges, assis ou doublons. Ce qu'il tient survit au
// démontage des sections.
// Regarder n'écrit jamais (§ 8.4) : cet état ne reçoit ni la séance, ni la
// façade, ni un système de fichiers, et rien de ce qu'il tient ne quitte la
@ -56,6 +58,9 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* @property {Object|null} pagePropositions le suivi de la page du tableau des propositions
* (SuiviDePage, propositions/modele.js), figé, que la section des propositions
* tient ; null au départ, et chaque événement qui s'ouvre repart de la première page
* @property {'sieges'|'assis'|'doublons'} melangeTables la position du sélecteur du
* mélange des tables (§ 12.11) : une de POSITIONS_MELANGE, 'sieges' au départ ;
* chaque événement qui s'ouvre en repart, et un changement de section la garde
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
@ -76,8 +81,14 @@ const DEPART = Object.freeze({
apercu: null,
visee: 0,
pagePropositions: null,
melangeTables: 'sieges',
});
/** Les positions du sélecteur du mélange des tables (§ 12.11, D35), dans
* l'ordre du sélecteur : les sièges, les assis, les doublons parmi les
* affiliés. */
export const POSITIONS_MELANGE = Object.freeze(['sieges', 'assis', 'doublons']);
/** Les vues que l'aperçu imprime (§ 11.7). */
export const VUES_IMPRESSION = Object.freeze(['plan', 'accueil', 'tables']);
@ -154,7 +165,7 @@ function choixValide(choix) {
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
* poserSurPlace: Function, garderAppartenance: Function,
* ouvrirApercu: Function, reglerApercu: Function, fermerApercu: Function,
* retenirPagePropositions: Function}}
* retenirPagePropositions: Function, reglerMelangeTables: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
@ -223,6 +234,15 @@ export function creerEtatUi() {
}
changer({ pagePropositions: suivi });
},
/** Règle la position du sélecteur du mélange des tables, l'une de
* POSITIONS_MELANGE ; RangeError sinon, sans rien changer. Regarder
* n'écrit rien : la position ne quitte pas cet état. */
reglerMelangeTables(position) {
if (!POSITIONS_MELANGE.includes(position)) {
throw new RangeError(`mélange des tables : position hors de ${POSITIONS_MELANGE.join(', ')}, reçu ${JSON.stringify(position)}`);
}
changer({ melangeTables: position });
},
/** Garde un texte à importer et ouvre la section de l'import. */
ouvrirImport({ texte, origine }) {
changer({ importEnAttente: Object.freeze({ texte, origine }), section: 'import' });

View file

@ -27,6 +27,7 @@ const DEPART = {
apercu: null,
visee: 0,
pagePropositions: null,
melangeTables: 'sieges',
};
// Un état et le relevé de ce que reçoit son abonné.
@ -98,6 +99,7 @@ describe('état de navigation : chaque transition', () => {
ui.choisir({ genre: 'personne', participant: 2, depuis: null });
ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] });
ui.retenirPagePropositions(Object.freeze({ page: 2 }));
ui.reglerMelangeTables('doublons');
ui.ouvrirApropos();
ui.reinitialiser();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
@ -257,6 +259,45 @@ describe('état de navigation : chaque transition', () => {
});
});
describe('état de navigation : la position du sélecteur du mélange des tables (§ 12.11, D54)', () => {
test('la position part de « sièges », se règle sur chacune des trois, et ne publie rien quand elle ne change pas', () => {
const { ui, recus } = suivi();
assert.equal(ui.etat().melangeTables, 'sieges');
for (const position of ['assis', 'doublons', 'sieges']) {
ui.reglerMelangeTables(position);
assert.deepEqual(ui.etat(), { ...DEPART, melangeTables: position });
}
const publies = recus.length;
ui.reglerMelangeTables('sieges');
assert.equal(recus.length, publies);
});
test('hors des trois positions, RangeError sans rien changer', () => {
const ui = creerEtatUi();
ui.reglerMelangeTables('assis');
const avant = ui.etat();
for (const faute of ['Sieges', 'doublon', '', null, undefined, 0, ['assis'], { position: 'assis' }]) {
assert.throws(() => ui.reglerMelangeTables(faute), RangeError, JSON.stringify(faute));
}
assert.equal(ui.etat(), avant);
});
test('la position survit au changement de section, au tour montré et à la proposition demandée ; reinitialiser la rend à « sièges »', () => {
const ui = creerEtatUi();
ui.reglerMelangeTables('doublons');
for (const section of SECTIONS) {
ui.allerA(section);
assert.equal(ui.etat().melangeTables, 'doublons', section);
}
ui.montrerTour(2);
ui.montrerProposition(3);
ui.ouvrirFiche(1);
assert.equal(ui.etat().melangeTables, 'doublons');
ui.reinitialiser();
assert.equal(ui.etat().melangeTables, 'sieges');
});
});
describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => {
const PERSONNE = { genre: 'personne', participant: 2, depuis: { table: 4, siege: 1 } };
const DEPUIS_LA_RESERVE = { genre: 'personne', participant: 3, depuis: null };

View file

@ -166,6 +166,24 @@ export const TABLE = new Map([
['participants.fiche.etiquettes', 'Étiquettes'],
['participants.fiche.aucuneEtiquette', 'Aucune étiquette.'],
// --- La rubrique « Rencontres » de la fiche (§ 12.5, D33) -----------------
// La ligne de la personne dans le placement que le plan montre, celui que
// la page de qualité mesure, nommé sur la ligne de source.
['participants.fiche.rencontres.titre', 'Rencontres'],
['participants.fiche.rencontres.intitule', 'Placement mesuré :'],
['participants.fiche.rencontres.retenu', 'Placement retenu'],
['participants.fiche.rencontres.proposition', ({ proposition }) => `Proposition n° ${proposition}`],
['participants.fiche.rencontres.personne', 'Personne rencontrée'],
['participants.fiche.rencontres.fois', 'Fois'],
['participants.fiche.rencontres.tours', 'Tours'],
['participants.fiche.rencontres.tour', ({ tour, table }) => `tour ${tour}, table ${table}`],
['participants.fiche.rencontres.aucune', 'Elle ne rencontre personne dans ce placement.'],
[
'participants.fiche.rencontres.nonMesurable',
'Ce placement ne se mesure pas : la page Qualité en donne les raisons.',
],
['participants.fiche.rencontres.exclue', 'Exclue : elle ne rencontre personne dans les mesures.'],
// --- Les étiquettes de la fiche (§ 4.5, § 10.2) ---------------------------
['participants.etiquettes.champ', 'Ajouter des étiquettes, séparées par une virgule'],
['participants.etiquettes.ajouter', 'Ajouter'],

View file

@ -145,6 +145,10 @@ export const TABLE = new Map([
['qualite.profil.colonne.appartenances', 'Appartenances rencontrées'],
['qualite.profil.colonne.borneDiversite', 'Borne de diversité'],
['qualite.profil.colonne.toursNonAssis', 'Tours non assis'],
['qualite.profil.colonne.marque', 'Place marquée'],
// La synthèse du second graphique : la personne au rang 1, que suivent son
// pourcentage et le compte du pire.
['qualite.profil.synthesePourcentage', ({ nom }) => `La personne au rang 1, ${nom}, rencontre`],
['qualite.profil.repartition.titre', 'Répartition des rencontres'],
['qualite.profil.repartition.valeur', 'Personnes distinctes rencontrées'],
['qualite.profil.repartition.effectif', 'Effectif'],
@ -154,15 +158,23 @@ export const TABLE = new Map([
'qualite.profil.uneTable',
"Une seule table : chacun y rencontre tous les autres, et le profil dirait la même chose de chacun.",
],
// À une appartenance, la borne de diversité ne se trace pas, mais le
// jumeau et l'infobulle l'écrivent (B5) : la phrase dit « tracée ».
[
'qualite.profil.uneAppartenance',
"Une seule appartenance : le profil n'a qu'une série, les rencontres, et pas de borne de diversité.",
"Une seule appartenance : le profil n'a qu'une série, les rencontres, et pas de borne de diversité tracée.",
],
[
'qualite.profil.aucuneAppartenance',
"Aucune appartenance : le profil n'a qu'une série, les rencontres, et pas de borne de diversité.",
],
['qualite.profil.plafondNul', 'Plafond réalisé nul : ce rang n’a pas de point en pourcentage.'],
// La synthèse du second graphique quand la personne au rang 1 a un
// plafond réalisé nul : aucun pourcentage, une phrase entière.
[
'qualite.profil.plafondNul',
({ nom }) =>
`La personne au rang 1, ${nom}, a un plafond réalisé nul : elle ne rencontre personne, et son rang n’a pas de point en pourcentage.`,
],
// --- Les collisions d'appartenance (§ 12.4, B13, D31) ----------------------
['qualite.collisions.graphique', "Collisions d'appartenance, par appartenance"],
@ -176,6 +188,12 @@ export const TABLE = new Map([
['qualite.collisions.colonne.reste', 'Reste'],
['qualite.collisions.colonne.pairesDistinctes', 'Paires distinctes'],
['qualite.collisions.colonne.excedent', 'Excédent de collisions'],
// Les trois autres chiffres d'une barre, en encre secondaire après elle.
[
'qualite.collisions.secondaire',
({ effectif, paires, excedent }) =>
`effectif ${effectif}, ${compte(paires, 'paire distincte', 'paires distinctes')}, excédent ${excedent}`,
],
[
'qualite.collisions.synthese',
({ appartenance, excedent }) => `L'excédent le plus grand est celui de « ${appartenance} » : ${compte(excedent, 'collision', 'collisions')}.`,
@ -211,9 +229,20 @@ export const TABLE = new Map([
['qualite.tables.graphique', 'Appartenances à chaque table, tables par tours'],
['qualite.tables.table', ({ numero }) => `Table ${numero}`],
['qualite.tables.tour', ({ tour }) => `Tour ${tour}`],
// Les colonnes du jumeau et de l'infobulle d'une case : les trois comptes,
// puis le pourcentage de chacun, δ sur c, sur o, sur α.
['qualite.tables.colonne.table', 'Table'],
['qualite.tables.colonne.tour', 'Tour'],
['qualite.tables.colonne.sieges', 'Sièges sans appartenance nouvelle'],
['qualite.tables.colonne.assis', 'Assis sans appartenance nouvelle'],
['qualite.tables.colonne.doublons', "Doublons d'appartenance"],
['qualite.tables.colonne.pourcentSieges', 'Appartenances par siège'],
['qualite.tables.colonne.pourcentAssis', 'Appartenances par personne assise'],
['qualite.tables.colonne.pourcentDoublons', 'Appartenances par affilié'],
['qualite.tables.colonne.occupants', 'Occupants'],
['qualite.tables.colonne.affilies', 'Affiliés'],
['qualite.tables.colonne.capacite', 'Capacité'],
['qualite.tables.sansAffilie', 'aucun affilié à ce tour'],
[
'qualite.tables.synthese',
({ numero, valeur }) => `La table ${numero} porte la case la plus haute : ${valeur}.`,
@ -229,16 +258,44 @@ export const TABLE = new Map([
['qualite.paires.colonne.fois', 'Rencontres de la paire'],
['qualite.paires.colonne.tours', 'Tours'],
['qualite.paires.premierTour', ({ tour }) => `Voir au tour ${tour}`],
['qualite.paires.tour', ({ tour }) => `Tour ${tour}`],
['qualite.paires.tour', ({ tour, table }) => `Tour ${tour}, table ${table}`],
['qualite.paires.fois', ({ fois }) => `${fois} fois`],
['qualite.paires.aucune', 'Aucune paire ne se revoit : chaque rencontre est unique.'],
['qualite.paires.uneTable', 'Une seule table : chaque paire se revoit à chaque tour.'],
['qualite.paires.unTour', 'Un seul tour : aucune paire ne peut se revoir.'],
// Le nom accessible d'une commande de ligne : son libellé, puis la paire,
// qui le distingue des commandes des autres lignes.
['qualite.paires.premierTourNom', ({ tour, a, b }) => `Voir au tour ${tour} : ${a} et ${b}`],
['qualite.paires.tourNom', ({ tour, table, a, b }) => `Tour ${tour}, table ${table} : ${a} et ${b}`],
['qualite.paires.homonyme', ({ id }) => `n° ${id}`],
// Un homonyme dans un nom accessible : son nom, puis son identifiant.
['qualite.paires.nomHomonyme', ({ nom, id }) => `${nom} (n° ${id})`],
['qualite.paires.pages.nom', 'Pages des paires répétées'],
['qualite.paires.pages.precedentes', 'Précédentes'],
['qualite.paires.pages.suivantes', 'Suivantes'],
[
'qualite.paires.pages.etendue',
({ premier, dernier, total }) => (premier === dernier ? `Paire ${premier} sur ${total}` : `Paires ${premier} à ${dernier} sur ${total}`),
],
['qualite.paires.matrice.titre', 'Qui a rencontré qui'],
['qualite.paires.matrice.diagonale', 'la personne elle-même'],
['qualite.paires.matrice.ligne', ({ rang, nom }) => `${rang}. ${nom}`],
['qualite.paires.matrice.personne', 'Personne'],
['qualite.paires.matrice.paire', ({ a, b }) => `${a} et ${b}`],
// La synthèse de la matrice : chaque liste du détail qui n'est pas vide,
// les choisies puis les imposées, son effectif et le fois de sa tête.
[
'qualite.paires.matrice.synthese',
({ choisies, imposees }) => {
const liste = ({ paires, fois }, singulier, pluriel) =>
paires === 1 ? `${compte(paires, singulier, pluriel)}, ${fois} fois` : `${compte(paires, singulier, pluriel)}, la plus revue ${fois} fois`;
const parties = [];
if (choisies !== null) parties.push(liste(choisies, 'paire choisie se revoit', 'paires choisies se revoient'));
if (imposees !== null) parties.push(liste(imposees, 'paire imposée se revoit', 'paires imposées se revoient'));
return `Chaque case porte le nombre de rencontres de la paire. ${parties.join(' ; ')}.`;
},
],
['qualite.paires.matrice.syntheseAucune', 'Chaque case porte le nombre de rencontres de la paire. Aucune paire ne se revoit.'],
[
'qualite.paires.matrice.seuil',
({ participants, seuil }) =>
@ -289,6 +346,31 @@ export const TABLE = new Map([
'qualite.objectifs.horsDomaine',
"Ce placement laisse quelqu'un en réserve ou n'honore pas une réservation : le plancher des tours ne s'y applique pas, et chaque barre n'a qu'un segment.",
],
// Le segment unique d'une barre hors du domaine des planchers, et la
// soirée sans plancher.
['qualite.objectifs.segment.hors', 'tours hors objectif'],
['qualite.objectifs.hors', ({ hors }) => compte(hors, 'tour hors objectif', 'tours hors objectif')],
// Le texte secondaire d'un tour sans couple possible : « — », puis ses
// assis sans table possible.
['qualite.objectifs.secondaireSansCouple', ({ assis }) => `\u{2014}, ${assis} assis sans table possible`],
// La phrase de synthèse des barres : le tour de tête, son reste — les
// tours hors objectif au-delà de son plancher — puis tous ses tours hors
// objectif ; hors du domaine des planchers, ces derniers seuls.
[
'qualite.objectifs.synthese',
({ tour, reste, hors }) =>
`En tête, le tour ${tour}, au plus grand reste : ${reste} au-delà de son plancher, sur ${compte(hors, 'tour hors objectif', 'tours hors objectif')}.`,
],
[
'qualite.objectifs.syntheseHorsDomaine',
({ tour, hors }) => `En tête, le tour ${tour} : ${compte(hors, 'tour hors objectif', 'tours hors objectif')}.`,
],
['qualite.objectifs.aucuneJamaisServie', 'Chaque personne concernée est servie à un tour au moins.'],
['qualite.objectifs.parTour', 'Tours possibles servis, tour par tour'],
['qualite.objectifs.jumeau.tours', 'Objectifs des tables, chaque tour puis la soirée'],
['qualite.objectifs.colonne.tour', 'Tour'],
['qualite.objectifs.colonne.personne', 'Personne'],
['qualite.objectifs.colonne.toursPossibles', 'Tours possibles'],
// --- Les animateurs rencontrés (§ 12.13, B22, D37) -------------------------
['qualite.animateurs.graphique', 'Animateurs rencontrés, de la personne la moins servie à la mieux servie'],
@ -308,6 +390,9 @@ export const TABLE = new Map([
'qualite.animateurs.aucun',
"Aucune personne n'est concernée par les animateurs, ou aucun animateur n'est désigné : la vue n'a rien à montrer.",
],
['qualite.animateurs.jumeau.profil', 'Animateurs rencontrés et borne des animateurs, par personne'],
['qualite.animateurs.repartition.valeur', 'Animateurs rencontrés'],
['qualite.animateurs.repartition.effectif', 'Effectif'],
// --- La diversité par animateur (§ 12.13, B22, D38) ------------------------
['qualite.diversite.graphique', 'Diversité rencontrée en animant, sur la soirée'],
@ -335,9 +420,42 @@ export const TABLE = new Map([
"Ni étiquette, ni plus d'une appartenance : la diversité par animateur n'a rien à dessiner. Son tableau jumeau garde les tours animés et les personnes rencontrées en animant.",
],
['qualite.diversite.aucunAnimateur', "Aucun animateur n'est désigné : la vue n'a rien à montrer."],
// Le texte secondaire d'une ligne : ses tours animés et ses personnes
// rencontrées en animant.
[
'qualite.diversite.secondaire',
({ tours, personnes }) =>
`${compte(tours, 'tour animé', 'tours animés')}, ${compte(personnes, 'personne rencontrée', 'personnes rencontrées')} en animant`,
],
// La synthèse du graphique : le moins varié et l'effectif au minimum de
// chaque colonne dessinée, chacune null quand elle ne l'est pas.
[
'qualite.diversite.synthese',
({ appartenances, etiquettes }) =>
[
appartenances === null
? null
: `Le moins varié rencontre ${compte(appartenances.moins, 'appartenance', 'appartenances')} en animant ; ` +
`${compte(appartenances.auMinimum, 'animateur', 'animateurs')} au minimum.`,
etiquettes === null
? null
: `Le moins varié rencontre ${compte(etiquettes.moins, 'étiquette', 'étiquettes')} en animant ; ` +
`${compte(etiquettes.auMinimum, 'animateur', 'animateurs')} au minimum.`,
]
.filter((phrase) => phrase !== null)
.join(' '),
],
['qualite.diversite.colonne.animateur', 'Animateur'],
['qualite.diversite.colonne.portee', 'Soirée ou tour animé'],
['qualite.diversite.jumeau', 'Diversité rencontrée en animant, la soirée puis chaque tour animé'],
// --- La performance du calcul (§ 12.14, B23, D40, D49) ---------------------
['qualite.performance.mouvements', ({ evalues, arret }) => `${evalues} mouvements évalués sur ${arret}`],
// La ligne de tête : chaque morceau est un élément de liste, sans
// ponctuation qui les joigne ; les nombres arrivent écrits.
[
'qualite.performance.mouvements',
({ evalues, arret }) => `${compte(evalues, 'mouvement évalué', 'mouvements évalués')} sur ${arret}`,
],
['qualite.performance.duree', ({ duree }) => `durée observée ${duree}`],
['qualite.performance.dureeInconnue', 'durée inconnue'],
['qualite.performance.cadence', ({ cadence }) => `cadence ${cadence} mouvements par seconde`],
@ -345,14 +463,23 @@ export const TABLE = new Map([
['qualite.performance.propositions', ({ nombre }) => `génération de ${compte(nombre, 'proposition', 'propositions')}`],
[
'qualite.performance.rienAReprendre',
({ evalues, arret }) => `rien à reprendre après ${evalues} mouvements sur ${arret}`,
({ evalues, arret }) => `rien à reprendre après ${compte(evalues, 'mouvement', 'mouvements')} sur ${arret}`,
],
['qualite.performance.planInitial', "le plan initial n'avait rien à reprendre"],
[
'qualite.performance.dernierChangement',
({ de, a }) => `aucune valeur n'a changé entre ${de} et ${a} mouvements ; une autre graine est l'autre façon de chercher`,
({ de, a }) => `Aucune valeur n'a changé entre ${de} et ${a} mouvements ; une autre graine est l'autre façon de chercher.`,
],
['qualite.performance.changeAuDernier', ({ a }) => `une valeur a changé au dernier point de relevé, à ${a} mouvements`],
['qualite.performance.changeAuDernier', ({ a }) => `Une valeur a changé au dernier point de relevé, à ${a} mouvements.`],
[
'qualite.performance.unPoint',
"Un seul point de relevé, le plan de la proposition : rien à relier. Ses valeurs sont dans le tableau.",
],
[
'qualite.performance.syntheseIndicateur',
({ indicateur, premier, dernier }) => `Au premier point de relevé, ${indicateur} : ${premier} ; au dernier : ${dernier}.`,
],
['qualite.performance.jumeau', 'Valeurs à chaque point de relevé'],
['qualite.performance.graphique', ({ indicateur }) => `${indicateur}, aux points de relevé`],
['qualite.performance.axe', 'Mouvements évalués, échelle logarithmique'],
['qualite.performance.minimumBrut', 'minimum brut'],
@ -374,4 +501,9 @@ export const TABLE = new Map([
'qualite.performance.retenuSansCourse',
"La vue mesure une proposition : le placement retenu a été ajusté, ou sa proposition effacée, et n'a pas de course.",
],
[
'qualite.performance.retenuCourseNonGardee',
({ proposition }) =>
`La vue mesure une proposition : le placement retenu vient de la proposition n° ${proposition}, dont la course n'est pas gardée.`,
],
]);

View file

@ -0,0 +1,313 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La rubrique « Rencontres » de la fiche dans un vrai moteur de rendu
// (§ 12.5, B8, B14, D33) : la ligne de la personne dans le placement que le
// plan montre, sous la ligne de source, les plus revues d'abord, égale à sa
// ligne de la matrice de la page de qualité ; une phrase pour une source qui
// ne se mesure pas et pour une personne exclue ; absente sans placement,
// sans phrase ; vivante en lecture et sur un plan bloqué. Chaque épreuve
// monte App par monterApp, au cadre de 1 366 × 700, ouvre la section des
// participants et la fiche, et lit le rendu par le texte et les attributs
// data-…, jamais par une classe ; la dernière monte la fiche seule, sur
// une séance en lecture simulée, et change la proposition demandée au
// plan. Les noms d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { writable } from 'svelte/store';
import { appliquerCommande } from '../../application/commandes.js';
import { rencontresDePersonne } from '../../application/evaluation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { creerEtatUi } from '../etat_ui.js';
import { TABLES_INTERFACE } from '../libelles.js';
import Fiche from './Fiche.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const V1 = VERSION.affichee;
const BASE = 'soiree';
// Douze personnes inventées, quatre tables de trois numérotées 11 à 14, six
// tours ; les deux premières réservées ensemble à la table 11 à chaque tour.
const NOMS = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Tourteau', 'Basile'],
['Quenouille', 'Maëlle'],
['Varech', 'Sidonie'],
['Bruyère', 'Aurèle'],
['Embrun', 'Clovis'],
['Sablière', 'Noé'],
['Goémon', 'Perrine'],
['Ajonc', 'Tancrède'],
['Fougeraie', 'Ysaline'],
];
function chargeDouze() {
const charge = creerCharge({ id: 'evt-fiche', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 6 });
charge.participants = NOMS.map(([nom, prenom], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: rang % 2 === 0 ? 'Coopérative Aubier' : 'Atelier Ressac',
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: 10 + id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
return charge;
}
function generee(charge) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 5, arret: 60, nombre: 2 });
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
let montes = [];
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// La charge posée en fichier d'état, ouverte — en écriture sauf mode
// 'lecture' —, la proposition montrer demandée au plan, la section des
// participants et la fiche de la personne ouvertes.
async function ouvrirFiche(charge, personne, { mode = 'ecriture', montrer = null } = {}) {
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
montes.push(m);
await m.seance.ouvrir(BASE);
if (mode === 'ecriture') await m.seance.passerEnEcriture();
if (montrer !== null) m.ui.montrerProposition(montrer);
m.ui.allerA('participants');
m.ui.ouvrirFiche(personne);
flushSync();
const fiche = await attendre(() => {
const trouvee = m.racine.querySelector(`[data-fiche="${personne}"]`);
expect(trouvee).not.toBeNull();
return trouvee;
});
return { m, fiche };
}
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const rubrique = (fiche) => fiche.querySelector('[data-rubrique-rencontres]');
// Les lignes de la rubrique : [nom, fois, tours], le nom lu dans le premier
// élément de l'intitulé de ligne, le reste dans le texte de chaque cellule.
const lignesDe = (section) =>
[...section.querySelectorAll('tbody tr')].map((ligne) => {
const [intitule, ...cellules] = ligne.querySelectorAll('th, td');
return [texteDe(intitule.firstElementChild), ...cellules.map(texteDe)];
});
// Ce que l'intitulé d'une ligne écrit après le nom, un texte par élément :
// l'identifiant d'un homonyme, puis l'appartenance.
const secondairesDe = (ligne) => [...ligne.querySelector('th').children].slice(1).map(texteDe);
describe('la rubrique « Rencontres » de la fiche (§ 12.5, D33)', () => {
const lue = generee(chargeDouze());
const id = lue.propositions[0].id;
test('sous la ligne de source, les personnes rencontrées dans la proposition montrée, les plus revues d’abord, leurs tours et tables écrits', async () => {
const { fiche } = await ouvrirFiche(lue, 1, { montrer: id });
const section = rubrique(fiche);
expect(section).not.toBeNull();
expect(texteDe(section.querySelector('h4'))).toBe(t('participants.fiche.rencontres.titre'));
expect(texteDe(section)).toContain(
`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`,
);
const attendues = rencontresDePersonne(lue, id, 1).lignes.map(({ nom, fois, tours }) => [
nom,
String(fois),
tours.map(({ tour, table }) => t('participants.fiche.rencontres.tour', { tour: tour + 1, table: 10 + table })).join(' ; '),
]);
expect(lignesDe(section)).toEqual(attendues);
expect(lignesDe(section)[0].slice(0, 2)).toEqual(['Théo Pervenche', '6']);
});
test('la ligne de la fiche égale celle de la matrice de la page de qualité, pour chaque personne', async () => {
const { m } = await ouvrirFiche(lue, 1, { montrer: id });
m.ui.allerA('qualite');
const grille = await attendre(() => {
const trouvee = m.racine.querySelector('[data-vue-qualite="paires"] [data-graphique="matrice"]');
expect(trouvee?.querySelectorAll('[data-case]')).toHaveLength(144);
return trouvee;
});
// Les intitulés de ligne, « rang. nom », dans l'ordre de la matrice.
const ordre = [...grille.querySelectorAll('text')].map((texte) => texte.textContent).filter((texte) => /^\d+\. /.test(texte));
const nomDe = (intitule) => intitule.replace(/^\d+\. /, '');
const matrice = ordre.map((_, i) =>
ordre
.map((intitule, j) => [nomDe(intitule), texteDe(grille.querySelector(`[data-case="${i}:${j}"]`))])
.filter(([, fois]) => fois !== '' && fois !== '0'),
);
for (const [i, intitule] of ordre.entries()) {
const personne = lue.participants.find(({ nom, prenom }) => `${prenom} ${nom}` === nomDe(intitule)).id;
m.ui.allerA('participants');
m.ui.ouvrirFiche(personne);
const section = await attendre(() => {
const trouvee = rubrique(m.racine.querySelector(`[data-fiche="${personne}"]`));
expect(trouvee).not.toBeNull();
return trouvee;
});
const deLaFiche = lignesDe(section).map(([nom, fois]) => [nom, fois]);
const parNom = (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
expect([...deLaFiche].sort(parNom), intitule).toEqual([...matrice[i]].sort(parNom));
}
});
test('sans proposition montrée, le retenu ; vivante en lecture et sur un plan bloqué, sans écriture', async () => {
const retenue = executer(lue, 'retenirProposition', { id: lue.propositions[1].id });
for (const charge of [retenue, executer(retenue, 'changerEtat', { etat: 'bloque' })]) {
const { m, fiche } = await ouvrirFiche(charge, 3, { mode: 'lecture' });
const ecritures = m.trace.length;
const section = rubrique(fiche);
expect(section).not.toBeNull();
expect(texteDe(section)).toContain(`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.retenu')}`);
expect(lignesDe(section).length).toBe(rencontresDePersonne(charge, 'retenu', 3).lignes.length);
expect(section.querySelector('button, [data-refus]')).toBeNull();
expect(m.trace.length).toBe(ecritures);
}
});
test('une proposition demandée au plan l’emporte sur le retenu : la fiche mesure la même source que la page de qualité', async () => {
const retenue = executer(lue, 'retenirProposition', { id: lue.propositions[1].id });
expect(id).not.toBe(lue.propositions[1].id);
const { fiche } = await ouvrirFiche(retenue, 3, { montrer: id });
const section = rubrique(fiche);
expect(texteDe(section)).toContain(
`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`,
);
expect(texteDe(section)).not.toContain(t('participants.fiche.rencontres.retenu'));
const attendues = rencontresDePersonne(retenue, id, 3).lignes.map(({ nom, fois }) => [nom, String(fois)]);
expect(lignesDe(section).map(([nom, fois]) => [nom, fois])).toEqual(attendues);
});
test('une personne exclue : la phrase qui le dit, sans ligne ; une source en dérive : la phrase du placement non mesurable', async () => {
const exclue = structuredClone(lue);
exclue.participants[2].exclu = true;
const { fiche } = await ouvrirFiche(exclue, 3, { montrer: id });
expect(texteDe(rubrique(fiche))).toContain(t('participants.fiche.rencontres.exclue'));
expect(rubrique(fiche).querySelector('table')).toBeNull();
const { fiche: autre } = await ouvrirFiche(exclue, 4, { montrer: id });
expect(texteDe(rubrique(autre))).toContain(t('participants.fiche.rencontres.nonMesurable'));
expect(rubrique(autre).querySelector('table')).toBeNull();
});
test('deux homonymes rencontrés : chaque ligne écrit, après le nom, l’identifiant puis l’appartenance, et les deux lignes diffèrent', async () => {
// La personne 2, réservée avec la personne 1, prend son nom : qui
// rencontre l'une rencontre l'autre.
const homonymes = structuredClone(lue);
Object.assign(homonymes.participants[1], { nom: 'Ombrelle', prenom: 'Iris' });
const temoin = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((personne) =>
rencontresDePersonne(homonymes, id, personne).lignes.some((ligne) => ligne.id === 1),
);
const { fiche } = await ouvrirFiche(homonymes, temoin, { montrer: id });
const rangees = [...rubrique(fiche).querySelectorAll('tbody tr')];
const deLaPersonne = (personne) =>
rangees[rencontresDePersonne(homonymes, id, temoin).lignes.findIndex((ligne) => ligne.id === personne)];
const [une, autre] = [1, 2].map(deLaPersonne);
expect(texteDe(une.querySelector('th').firstElementChild)).toBe('Iris Ombrelle');
expect(texteDe(autre.querySelector('th').firstElementChild)).toBe('Iris Ombrelle');
expect(secondairesDe(une)).toEqual([t('qualite.paires.homonyme', { id: 1 }), 'Coopérative Aubier']);
expect(secondairesDe(autre)).toEqual([t('qualite.paires.homonyme', { id: 2 }), 'Atelier Ressac']);
expect(texteDe(une)).not.toBe(texteDe(autre));
// Les autres : le nom, puis l'appartenance seule.
for (const rangee of rangees.filter((r) => r !== une && r !== autre)) {
expect(secondairesDe(rangee)).toHaveLength(1);
expect(secondairesDe(rangee)[0]).not.toMatch(/n°/);
}
});
test('sans placement — ni proposition ni retenu —, la rubrique est absente, sans phrase', async () => {
const { fiche } = await ouvrirFiche(chargeDouze(), 1);
expect(rubrique(fiche)).toBeNull();
for (const cle of ['titre', 'nonMesurable', 'exclue', 'aucune']) {
expect(texteDe(fiche)).not.toContain(t(`participants.fiche.rencontres.${cle}`));
}
});
});
describe('la rubrique suit le placement que le plan montre (§ 12.5, D25)', () => {
test('la fiche montée seule : une autre proposition demandée au plan change la source et les lignes ; aucune, et sans retenu, la rubrique disparaît', async () => {
const lue = generee(chargeDouze());
const [premiere, seconde] = lue.propositions.map(({ id }) => id);
const etat = { mode: 'lecture', evenement: { etat: 'brouillon', revision: 1 }, charge: lue };
const magasin = writable(etat);
const seance = {
subscribe: magasin.subscribe,
etat: () => etat,
surAnnulation: () => () => {},
debuterGeste() {},
finirGeste() {},
async executer() {
throw new Error('la rubrique n’écrit rien');
},
};
const ui = creerEtatUi();
ui.montrerProposition(premiere);
const cible = document.createElement('div');
document.body.prepend(cible);
const composant = mount(Fiche, {
target: cible,
props: { participant: lue.participants[4], charge: lue },
context: new Map([
['t', t],
['ui', ui],
['seance', seance],
]),
});
try {
flushSync();
const lignesAttendues = (id) => rencontresDePersonne(lue, id, 5).lignes.map(({ nom, fois }) => [nom, String(fois)]);
const source = (id) => `${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`;
expect(texteDe(rubrique(cible))).toContain(source(premiere));
expect(lignesDe(rubrique(cible)).map(([nom, fois]) => [nom, fois])).toEqual(lignesAttendues(premiere));
ui.montrerProposition(seconde);
await tick();
flushSync();
expect(texteDe(rubrique(cible))).toContain(source(seconde));
expect(lignesDe(rubrique(cible)).map(([nom, fois]) => [nom, fois])).toEqual(lignesAttendues(seconde));
ui.montrerProposition(null);
await tick();
flushSync();
expect(rubrique(cible)).toBeNull();
} finally {
unmount(composant);
cible.remove();
}
});
});

View file

@ -49,6 +49,19 @@
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
// grille.
//
// La rubrique « Rencontres » (§ 12.5, D33, data-rubrique-rencontres) porte
// la ligne de la personne dans le placement que le plan montre — celui
// que la page de qualité mesure —, par rubriqueRencontres
// (qualite/paires.js) : la ligne de source qui le nomme, puis chaque
// personne rencontrée, le nombre de fois, et chaque tour avec sa table,
// les plus revues d'abord. Comme au tableau des paires, une personne
// s'écrit par son nom, puis en encre secondaire l'identifiant d'un
// homonyme et son appartenance : deux homonymes n'y font pas deux lignes
// pareilles. Une source qui ne se mesure pas, une personne
// exclue : une phrase le dit. Sans placement, la rubrique est absente,
// sans phrase. Elle n'a aucune commande : elle se lit en lecture et sur un
// plan bloqué comme en écriture.
import { getContext, onDestroy, tick, untrack } from 'svelte';
import { nomAffiche } from '../../application/personnes.js';
import { etatPlaces, statuts } from '../../application/places.js';
@ -58,6 +71,7 @@
import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import { creerGeste } from '../primitives/geste.js';
import { rubriqueRencontres } from '../qualite/paires.js';
import { ajoutEtiquettes, etiquettesDeLaFiche, retraitEtiquette, saisieApresAjout, signauxOrthographe } from './etiquettes.js';
import { cleDeColonne, cleDeStatut, itineraire, ligneVoisine, reservationsDe } from './grille.js';
@ -80,6 +94,7 @@
});
const reservations = $derived(reservationsDe(charge, participant.id));
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
const rencontres = $derived(rubriqueRencontres(charge, $ui.proposition, participant.id));
// La fiche et son champ d'ajout, montés : le défilement vise la fiche,
// le focus d'après un retrait ses commandes de retrait ou le champ.
@ -314,6 +329,50 @@
</tbody>
</table>
{/if}
{#if rencontres.statut !== 'sansPlacement'}
<section class="rencontres" data-rubrique-rencontres aria-labelledby={`${uid}-titre-rencontres`}>
<h4 id={`${uid}-titre-rencontres`}>{t('participants.fiche.rencontres.titre')}</h4>
<p class="source">
<span>{t('participants.fiche.rencontres.intitule')}</span>
<span class="placement">{t(rencontres.source.cle, rencontres.source.details)}</span>
</p>
{#if rencontres.statut === 'exclue'}
<p>{t('participants.fiche.rencontres.exclue')}</p>
{:else if rencontres.statut === 'nonMesurable'}
<p>{t('participants.fiche.rencontres.nonMesurable')}</p>
{:else if rencontres.lignes.length === 0}
<p>{t('participants.fiche.rencontres.aucune')}</p>
{:else}
<table>
<thead>
<tr>
<th scope="col">{t('participants.fiche.rencontres.personne')}</th>
<th scope="col" class="fois">{t('participants.fiche.rencontres.fois')}</th>
<th scope="col">{t('participants.fiche.rencontres.tours')}</th>
</tr>
</thead>
<tbody>
{#each rencontres.lignes as rencontre (rencontre.id)}
<tr>
<th scope="row">
<span class="nom">{rencontre.nom}</span>
{#if rencontre.homonyme}
<span class="secondaire">{t('qualite.paires.homonyme', { id: rencontre.id })}</span>
{/if}
{#if rencontre.appartenance !== null}
<span class="secondaire">{rencontre.appartenance}</span>
{/if}
</th>
<td class="fois">{rencontre.fois}</td>
<td>{rencontre.tours.map((etape) => t('participants.fiche.rencontres.tour', etape)).join(' ; ')}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{/if}
</aside>
<style>
@ -490,6 +549,44 @@
min-width: 0;
}
/* La rubrique des rencontres : l'intitulé en petites capitales, la ligne
de source, le placement en gras, puis le tableau ; l'intitulé de ligne
sur le fond de la carte, le nombre de fois aligné à droite. */
.rencontres {
margin: var(--espace-3) 0;
}
.rencontres .source {
display: flex;
flex-wrap: wrap;
gap: 0 var(--espace-1);
}
.placement {
color: var(--couleur-page-texte);
font-weight: bold;
}
.rencontres tbody th {
background-color: var(--couleur-carte-fond);
border-bottom-color: var(--couleur-ligne-filet);
font-weight: normal;
}
/* Une personne rencontrée : son nom, puis l'identifiant d'un homonyme et
son appartenance, chacun à la ligne, en petit corps d'encre secondaire. */
.rencontres tbody th .secondaire {
display: block;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.fois {
width: 4rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* L'orthographe retenue : un avis, son signe, un texte de l'encre de la
page. */
.signal {

View file

@ -203,16 +203,35 @@ function certificat(ligne) {
return { colonne: 'certificat', valeur, cle, decimales: 0 };
}
/**
* La cellule d'une colonne de mesure pour une ligne comparable de la table
* d'évaluation : { colonne, valeur, cle, decimales }, celle que le tableau
* écrit sous cette colonne. La mesure se lit dans la ligne par le nom de sa
* colonne, que le tableau porte la colonne ou non : une tuile de la page de
* qualité lit ainsi la même mesure qu'une colonne absente — critère décoché,
* valeur que le mode réglé ne compare pas — porterait (§ 12.2, D48). L'a
* priori est inconnu quand troisChiffres ne porte pas d'écart d'itinéraire
* du tout : ses deux colonnes s'écrivent alors « inconnu ». Lève TypeError
* pour un nom qui n'est pas celui d'une colonne de mesure.
*
* @param {import('../../application/evaluation.js').LigneEvaluation} ligne comparable
* @param {string} nom le nom d'une colonne de mesure de COLONNES
* @returns {{colonne: string, valeur: number|boolean|null, cle: string|null, decimales: number}}
*/
export function lireColonne(ligne, nom) {
const lire = LECTURES.get(nom);
if (lire === undefined) throw new TypeError(`colonne de mesure inconnue : ${nom}`);
if (nom === 'certificat') return certificat(ligne);
if (ligne.troisChiffres.ecartItineraireMax === null && SELON_A_PRIORI.has(nom)) {
return { colonne: nom, valeur: null, cle: INCONNU, decimales: 0 };
}
return nombre(nom, lire(ligne));
}
// Les cellules de mesure d'une ligne comparable, une par colonne de mesure
// portée, dans l'ordre de LECTURES. L'a priori est inconnu quand
// troisChiffres ne porte pas d'écart d'itinéraire du tout.
// portée, dans l'ordre de LECTURES.
function cellules(ligne, portees) {
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
return [...LECTURES].filter(([colonne]) => portees.has(colonne)).map(([colonne, lire]) => {
if (colonne === 'certificat') return certificat(ligne);
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
return nombre(colonne, lire(ligne));
});
return [...LECTURES.keys()].filter((colonne) => portees.has(colonne)).map((colonne) => lireColonne(ligne, colonne));
}
// Ce que les réservations imposent, énoncé à part (§ 5.4) : les retours et

View file

@ -30,6 +30,7 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../.
import { planDepuisFichier } from '../../stockage/placements.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import * as MODELE from './modele.js';
import {
COLONNES,
GROUPES_COLONNES,
@ -39,6 +40,7 @@ import {
demandeConfirmation,
evaluation,
explicationDeColonne,
lireColonne,
nomDe,
nombreDePages,
pageDe,
@ -1566,3 +1568,60 @@ describe('pagination : le texte de l’étendue (libelles/propositions.js)', ()
assert.equal(texteEtendue(pagine(vueDe(237), 2), { t, format }), 'Propositions 101 à 200 sur 237');
});
});
describe('lireColonne : la mesure d’une ligne par le nom de sa colonne (§ 12.2, D48)', () => {
// Les colonnes de mesure, dans l'ordre de COLONNES : celles qu'un groupe
// d'en-têtes couvre, puis le certificat.
const MESURES = [...GROUPES_COLONNES.filter(({ cle }) => cle !== null).flatMap(({ colonnes }) => colonnes), 'certificat'];
test('chaque colonne que le tableau porte : la cellule même du tableau, valeur, clé et décimales ; le retenu aussi', () => {
let charge = generee(petiteAObjectifs(), { graine: 3, nombre: 2, arret: 200 });
charge = executer(charge, 'retenirProposition', { id: 2 });
const table = evaluer(charge);
const vue = tableau(table);
const lignes = [...vue.lignes.map((ligne, rang) => [ligne, table.propositions[rang]]), [vue.retenu, table.retenu]];
let lues = 0;
for (const [ligne, source] of lignes) {
for (const attendue of ligne.cellules) {
assert.deepEqual(lireColonne(source, attendue.colonne), attendue, attendue.colonne);
lues += 1;
}
}
assert.equal(lues, 3 * (COLONNES_PAR_DEFAUT.length - 4 + 2));
});
test('une colonne que le tableau ne porte pas, critère décoché : la même mesure, lue dans la ligne', () => {
const table = evaluer(generee(petiteAObjectifs({ respecterObjectifs: false, modeObjectifs: 'chacun' }), { graine: 3, nombre: 2, arret: 200 }));
const [source] = table.propositions;
assert.ok(!tableau(table).colonnes.some(({ nom }) => SIXIEME.includes(nom)));
const { objectifs } = source.mesures;
assert.deepEqual(lireColonne(source, 'jamaisServies'), { colonne: 'jamaisServies', valeur: objectifs.jamaisServies, cle: null, decimales: 0 });
assert.equal(lireColonne(source, 'horsObjectifMax').valeur, objectifs.horsObjectifMax);
assert.equal(lireColonne(source, 'toursHorsObjectif').valeur, objectifs.horsObjectif);
assert.equal(lireColonne(source, 'toursSelonObjectif').valeur, objectifs.agregat.tous.min);
// Sans personne concernée par les animateurs, la mesure manque : « — ».
assert.deepEqual(lireColonne(source, 'animateursRencontres'), { colonne: 'animateursRencontres', valeur: null, cle: 'format.vide', decimales: 0 });
});
test('a priori inconnu : « inconnu » sous les deux colonnes de l’a priori, comme au tableau ; le taux de diversité au centième', () => {
const charge = generee(chargeDemo('petite'), { graine: 2, nombre: 1, arret: 300 });
const table = evaluer(charge, { plafondsAPriori: null });
const [ligne] = tableau(table).lignes;
const [source] = table.propositions;
for (const nom of ['ecartItineraireMax', 'ecartAuPlafondAPriori']) {
assert.deepEqual(lireColonne(source, nom), cellule(ligne, nom), nom);
assert.equal(lireColonne(source, nom).cle, 'propositions.valeur.inconnu', nom);
}
assert.equal(lireColonne(source, 'diversite').decimales, 2);
assert.equal(texte(lireColonne(source, 'certificat')), 'atteignabilité inconnue');
});
test('chaque colonne de mesure se lit ; une colonne qui n’est pas une mesure, ou inconnue, lève ; la table des lectures reste privée', () => {
const [source] = evaluer(generee(chargeSix(), { graine: 1, nombre: 1, arret: 100 })).propositions;
for (const nom of MESURES) assert.equal(lireColonne(source, nom).colonne, nom);
for (const nom of ['rang', 'proposition', 'imposes', 'commandes', 'inconnue']) {
assert.throws(() => lireColonne(source, nom), TypeError, nom);
}
assert.equal(Object.hasOwn(MODELE, 'LECTURES'), false);
});
});

View file

@ -0,0 +1,286 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les deux vues des animateurs dans un vrai moteur de rendu (§ 12.13, B22,
// D37, D38) : le profil des animateurs rencontrés, une série et la borne en
// repère pointillé, sans manque ni pourcentage ; la diversité par animateur,
// la soirée en deux colonnes juxtaposées, chacune son axe, le moins varié en
// tête, les trois nombres de chaque colonne, et le jumeau qui porte chaque
// tour animé. Chaque vue se monte seule, par mount, avec les contextes que
// pose App — 't' et 'format' —, sur le détail d'une génération faite dans
// le navigateur. Le rendu se lit par ses attributs data-… et son texte,
// jamais par une classe. Les noms d'épreuve sont inventés.
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { appliquerCommande } from '../../application/commandes.js';
import { configurationDe } from '../../application/configuration.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { TABLES_INTERFACE } from '../libelles.js';
import VueAnimateurs from './VueAnimateurs.svelte';
import VueDiversite from './VueDiversite.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const CONTEXTE = new Map([
['t', t],
['format', format],
]);
const V1 = VERSION.affichee;
// Neuf personnes inventées, leurs appartenances et leurs étiquettes.
const PERSONNES = [
['Ombrelle', 'Iris', 'Coopérative Aubier', []],
['Pervenche', 'Théo', 'Atelier Ressac', []],
['Lacasse', 'Ondine', 'Atelier Ressac', ['finance']],
['Tourteau', 'Basile', 'Coopérative Aubier', ['bénévole']],
['Quenouille', 'Maëlle', 'Atelier Ressac', ['finance', 'Montréal']],
['Rocaille', 'Anselme', null, ['bénévole']],
['Brindille', 'Pélagie', 'Coopérative Aubier', []],
['Cassonade', 'Firmin', null, ['Montréal']],
['Grésil', 'Ysaline', 'Atelier Ressac', []],
];
// Trois tables de trois places, trois tours ; un titre au siège 1 des tables
// 1 et 2. Iris Ombrelle, réservée à la table 1 pour la soirée, y anime à
// chaque tour ; Théo Pervenche, réservé à la table 2 au seul tour 2, y anime
// ce tour-là, avec Ondine Lacasse et Ysaline Grésil, réservées à sa table à
// ce tour : une appartenance et une étiquette, moins qu'Iris Ombrelle sur sa
// soirée, de sorte que le moins varié et le plus varié diffèrent dans chaque
// colonne. appartenances : false pour une seule appartenance ; etiquettes :
// false pour aucune ; titres : false pour aucun animateur.
function chargeAnimateurs({ appartenances = true, etiquettes = true, titres = true } = {}) {
const charge = creerCharge({ id: 'evt-animateurs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
charge.participants = PERSONNES.map(([nom, prenom, appartenance, liste], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: appartenances ? appartenance : 'Coopérative Aubier',
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(etiquettes && liste.length > 0 ? { etiquettes: liste } : {}),
}));
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id + 10, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
if (titres) {
charge.titres = [1, 2].map((table) => ({ table, siege: 1, libelle: 'Animation' }));
charge.reservations = [
{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null },
{ participant: 2, table: 2, siege: null, portee: 'tour', tour: 2 },
{ participant: 3, table: 2, siege: null, portee: 'tour', tour: 2 },
{ participant: 9, table: 2, siege: null, portee: 'tour', tour: 2 },
];
}
charge.reglages.rencontrerAnimateurs = true;
charge.prochainsIds = { participant: 10, table: 4, proposition: 1 };
return charge;
}
// Le détail de la première proposition d'une génération.
function detailDe(options) {
const charge = chargeAnimateurs(options);
const configuration = configurationDe(charge);
const propositions = rechercher(configuration, { graine: 11, nombre: 1, arret: 200 });
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
return detaillerQualite(generee, generee.propositions[0].id);
}
const montes = [];
beforeAll(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
});
// Monte une vue sur un détail, dans un conteneur de 900 px au début du
// corps, puis laisse son graphique mesurer sa largeur et son texte ; rend
// l'élément [data-vue-qualite] de la vue.
async function monter(Vue, detail) {
const cible = document.createElement('div');
cible.style.width = '900px';
document.body.prepend(cible);
const composant = mount(Vue, { target: cible, props: { detail, ligne: null, table: null }, context: CONTEXTE });
montes.push({ composant, cible });
for (let i = 0; i < 3; i += 1) {
flushSync();
await tick();
}
return cible.querySelector('[data-vue-qualite]');
}
// Ouvre le jumeau de la vue par sa commande ; rend ses tableaux.
async function ouvrirJumeau(vue) {
vue.querySelector('[data-commande="ouvrirJumeau"]').click();
flushSync();
await tick();
return [...vue.querySelectorAll('[data-jumeau]')];
}
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const nombre = (texte) => Number(texte.replace(/\D/g, ''));
// Les cellules d'une rangée de tableau, par la clé de leur colonne.
const cellules = (rangee) => Object.fromEntries([...rangee.children].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)]));
describe('animateurs rencontrés : le profil (§ 12.13, D37)', () => {
test('une seule série et la borne des animateurs en repère pointillé ; la synthèse au minimum ; le jumeau, une ligne par personne concernée', async () => {
const detail = detailDe();
const vue = await monter(VueAnimateurs, detail);
const svg = vue.querySelector('svg[data-graphique="animateurs"]');
expect(svg.querySelectorAll('path[data-serie]').length).toBe(1);
expect(svg.querySelector('path[data-serie]').dataset.serie).toBe('serie1');
expect([...svg.querySelectorAll('path[data-repere]')].map((trace) => trace.dataset.repere)).toEqual(['borne']);
const [premiere] = detail.animateurs.repartition;
expect(texteDe(vue)).toContain(t('qualite.animateurs.synthese', { minimum: premiere.valeur, effectif: premiere.effectif }));
const [profil, repartition] = await ouvrirJumeau(vue);
expect(profil.querySelectorAll('tbody tr').length).toBe(detail.animateurs.profil.length);
expect(repartition.querySelectorAll('tbody tr').length).toBe(detail.animateurs.repartition.length);
expect(
[...profil.querySelectorAll('thead th')].map((th) => th.dataset.colonne),
).toEqual(['nom', 'rencontres', 'borne', 'statut']);
});
test('rouge : ni pourcentage de la borne, ni manque, ni « plafond », le jumeau ouvert compris', async () => {
const vue = await monter(VueAnimateurs, detailDe());
await ouvrirJumeau(vue);
expect(vue.querySelector('[data-pourcentage]')).toBeNull();
expect(texteDe(vue)).not.toMatch(/%|plafond|manque/i);
});
test('sans animateur : la phrase, aucun graphique', async () => {
const vue = await monter(VueAnimateurs, detailDe({ titres: false }));
expect(texteDe(vue.querySelector('[data-phrase-qualite="animateurs.aucun"]'))).toBe(t('qualite.animateurs.aucun'));
expect(vue.querySelector('svg')).toBeNull();
});
});
describe('diversité par animateur (§ 12.13, D38)', () => {
test('rouge : deux colonnes juxtaposées, chacune son axe, jamais empilées ; la soirée, le moins varié en tête', async () => {
const detail = detailDe();
const { lignes } = detail.animateurs.diversite;
const vue = await monter(VueDiversite, detail);
const svg = vue.querySelector('svg[data-graphique="diversite"]');
expect(svg.querySelectorAll('line[data-marque="graphiqueAxe"]').length).toBe(2);
const barres = [...svg.querySelectorAll('[data-ligne-graphique]')];
expect(barres.map((ligne) => Number(ligne.dataset.ligneGraphique))).toEqual(lignes.map(({ id }) => id));
expect(lignes[0].appartenances).toBeLessThan(lignes[1].appartenances);
const fins = [...svg.querySelectorAll('rect[data-segment="serie1"]')].map((rect) => rect.x.baseVal.value + rect.width.baseVal.value);
const debuts = [...svg.querySelectorAll('rect[data-segment="serie2"]')].map((rect) => rect.x.baseVal.value);
expect(fins.length).toBeGreaterThan(0);
expect(debuts.length).toBeGreaterThan(0);
expect(Math.min(...debuts)).toBeGreaterThan(Math.max(...fins));
barres.forEach((ligne, rang) => {
const { toursAnimes, personnes } = lignes[rang];
expect(texteDe(ligne)).toContain(t('qualite.diversite.secondaire', { tours: toursAnimes, personnes }));
});
});
test('au-dessus de chaque colonne, ses trois nombres : le moins varié, le plus varié, au minimum', async () => {
const detail = detailDe();
const vue = await monter(VueDiversite, detail);
const { equite } = detail.animateurs.diversite;
for (const genre of ['appartenances', 'etiquettes']) {
// Les deux animateurs diffèrent : chaque nombre se lit à sa place.
expect(equite[genre].moins, genre).toBeLessThan(equite[genre].plus);
const bloc = vue.querySelector(`[data-equite="${genre}"]`);
expect([...bloc.querySelectorAll('dt')].map(texteDe)).toEqual([
t('qualite.diversite.moins'),
t('qualite.diversite.plus'),
t('qualite.diversite.auMinimum'),
]);
expect([...bloc.querySelectorAll('dd')].map(texteDe)).toEqual(
[equite[genre].moins, equite[genre].plus, equite[genre].auMinimum].map((valeur) => format.nombre(valeur)),
);
}
// Au-dessus : chaque bloc précède le graphique dans le document. Dans
// l'ordre des colonnes : appartenances, la série 1 à gauche du dessin,
// puis étiquettes, la série 2 à sa droite, à l'écran comme au document.
const svg = vue.querySelector('svg[data-graphique="diversite"]');
const blocs = [...vue.querySelectorAll('[data-equite]')];
expect(blocs.map((bloc) => bloc.dataset.equite)).toEqual(['appartenances', 'etiquettes']);
for (const bloc of blocs) {
expect(bloc.compareDocumentPosition(svg) & Node.DOCUMENT_POSITION_FOLLOWING, bloc.dataset.equite).toBeTruthy();
expect(bloc.getBoundingClientRect().bottom, bloc.dataset.equite).toBeLessThanOrEqual(svg.getBoundingClientRect().top);
}
const [gauche, droite] = blocs.map((bloc) => bloc.getBoundingClientRect().left);
expect(gauche).toBeLessThan(droite);
});
test('rouge : le jumeau porte la soirée de chaque animateur puis une ligne par tour animé, chacune au plus la soirée', async () => {
const detail = detailDe();
const { lignes } = detail.animateurs.diversite;
const vue = await monter(VueDiversite, detail);
const [jumeau] = await ouvrirJumeau(vue);
const rangees = [...jumeau.querySelectorAll('tbody tr')].map(cellules);
expect(rangees.length).toBe(lignes.reduce((somme, ligne) => somme + 1 + ligne.toursAnimes, 0));
let rang = 0;
for (const ligne of lignes) {
const soiree = rangees[rang];
expect(soiree.portee).toBe(t('qualite.diversite.soiree'));
expect(nombre(soiree.toursAnimes)).toBe(ligne.toursAnimes);
for (let k = 1; k <= ligne.toursAnimes; k += 1) {
const tour = rangees[rang + k];
expect(tour.animateur).toBe(soiree.animateur);
expect(tour.portee).not.toBe(t('qualite.diversite.soiree'));
for (const cle of ['personnes', 'appartenances', 'etiquettes']) {
expect(nombre(tour[cle]), `${tour.animateur}, ${tour.portee}, ${cle}`).toBeLessThanOrEqual(nombre(soiree[cle]));
}
}
rang += 1 + ligne.toursAnimes;
}
expect(texteDe(vue)).not.toMatch(/équité|plafond|%/i);
});
test('une seule appartenance et des étiquettes : la colonne des étiquettes seule, et la phrase', async () => {
const detail = detailDe({ appartenances: false });
const vue = await monter(VueDiversite, detail);
expect(vue.querySelector('[data-phrase-qualite="diversite.sansAppartenance"]')).not.toBeNull();
expect(vue.querySelector('[data-equite="appartenances"]')).toBeNull();
expect(vue.querySelector('[data-equite="etiquettes"]')).not.toBeNull();
const svg = vue.querySelector('svg[data-graphique="diversite"]');
expect(svg.querySelectorAll('line[data-marque="graphiqueAxe"]').length).toBe(1);
expect(svg.querySelectorAll('rect[data-segment="serie1"]').length).toBe(0);
expect(svg.querySelectorAll('rect[data-segment="serie2"]').length).toBeGreaterThan(0);
});
test('aucune étiquette : la colonne des appartenances seule, et la phrase', async () => {
const vue = await monter(VueDiversite, detailDe({ etiquettes: false }));
expect(vue.querySelector('[data-phrase-qualite="diversite.sansEtiquette"]')).not.toBeNull();
expect(vue.querySelector('[data-equite="etiquettes"]')).toBeNull();
expect(vue.querySelectorAll('svg rect[data-segment="serie2"]').length).toBe(0);
});
test('ni étiquette ni plus d’une appartenance : la phrase, aucun graphique, le jumeau garde tours animés et personnes', async () => {
const vue = await monter(VueDiversite, detailDe({ appartenances: false, etiquettes: false }));
expect(vue.querySelector('[data-phrase-qualite="diversite.rien"]')).not.toBeNull();
expect(vue.querySelector('svg')).toBeNull();
expect(vue.querySelector('[data-equite]')).toBeNull();
const [jumeau] = await ouvrirJumeau(vue);
expect([...jumeau.querySelectorAll('thead th')].map((th) => th.dataset.colonne)).toEqual([
'animateur',
'portee',
'toursAnimes',
'personnes',
]);
});
test('sans animateur : la phrase seule', async () => {
const vue = await monter(VueDiversite, detailDe({ titres: false }));
expect(vue.querySelector('[data-phrase-qualite="diversite.aucunAnimateur"]')).not.toBeNull();
expect(vue.querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
});
});

View file

@ -0,0 +1,683 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les vues des collisions d'appartenance (§ 12.4, B13, D31) et des
// appartenances à chaque table (§ 12.11, B20, D35, D54) dans un vrai moteur
// de rendu. Collisions : le segment « prouvé inévitable » égale le plancher
// que le diagnostic affiche ; les barres suivent l'excédent, non leur
// longueur ; chaque segment est peint de son jeton de graphique, jamais
// d'une teinte d'appartenance ou de conflit ; hors du domaine des
// planchers, un seul segment et la phrase. Tables : sous chacune des trois
// positions, la pire table en tête et l'ordre de la position ; chaque case
// écrit le compte que sa couleur porte, sans signe « % » ; une table de
// bénévoles sans appartenance écrit « — » et se range en dernier ; la
// position se choisit au clavier, survit à un aller-retour vers le plan,
// repart de « sièges » à la réouverture, et la choisir en lecture ou sur un
// plan bloqué n'écrit rien ; quarante tours au cadre de 1 366 px. Chaque
// épreuve monte App par monterApp, sur le système de fichiers simulé, et lit
// le rendu par les attributs data-…, le texte et les couleurs calculées —
// jamais une classe. Les comptes attendus sont calculés à la main ; les noms
// d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { niveau } from '../../geometrie/graphique.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, touche, pointer } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { teintesAppartenances } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { CASE_MAX_TEXTES, sansCollision } from './appartenances.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const V1 = VERSION.affichee;
const BASE = 'soiree';
const AUBIER = 'Coopérative Aubier';
const RESSAC = 'Atelier Ressac';
const POSITIONS = ['sieges', 'assis', 'doublons'];
// --- Les charges d'épreuve -------------------------------------------------------
// Une charge de personnes inventées, de tables de capacités données, et une
// proposition, n° 1, écrite à la main : à chaque tour, les listes de chaque
// table dans l'ordre des tables ; reserves, les personnes en réserve de
// chaque tour ; premier, le numéro de la première table, les suivantes
// numérotées à la suite.
function chargeDe({ personnes, capacites, tours, reserves = tours.map(() => []), premier = 1 }) {
const charge = creerCharge({ id: 'evt-appartenances', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: tours.length });
charge.participants = personnes.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = capacites.map((sieges, rang) => ({ id: rang + 1, numero: premier + rang, sieges, forme: 'ronde', position: { x: 300 * rang, y: 0 } }));
charge.propositions = [
{
id: 1,
graine: 1,
arret: 40,
historique: 1000,
produitVersion: V1,
siegesAttribues: false,
tables: charge.tables.map(({ id }) => id),
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: tours.length,
participants: charge.participants.map(({ id }) => id),
placement: tours.map((listes, r) => ({ sieges: listes, reserve: reserves[r] })),
},
];
charge.prochainsIds = { participant: personnes.length + 1, table: capacites.length + 1, proposition: 2 };
charge.evenement.etat = 'propose';
return charge;
}
// Treize personnes, quatre tables de 5, 4, 4 et 3 places, le même plan aux
// deux tours. Pour chaque case (c, o, α, δ) : table 1 (5, 2, 2, 2), table 2
// (4, 4, 4, 2), table 3 (4, 4, 2, 1), table 4, trois bénévoles sans
// appartenance. Comptes sièges, assis, doublons : table 1 (3, 0, 0), table
// 2 (2, 2, 2), table 3 (3, 3, 1). Ordres : sièges 1, 3, 2, 4 ; assis 3, 2,
// 1, 4 ; doublons 2, 3, 1, 4.
const PERSONNES_MELANGE = [
['Ombrelle', 'Iris', AUBIER],
['Lacasse', 'Ondine', RESSAC],
['Pervenche', 'Théo', AUBIER],
['Quenouille', 'Maëlle', AUBIER],
['Tourteau', 'Basile', RESSAC],
['Brindille', 'Anselme', RESSAC],
['Saule', 'Clothilde', AUBIER],
['Galet', 'Eudes', AUBIER],
['Mousse', 'Félicie', null],
['Ruisseau', 'Gaspard', null],
['Lichen', 'Hortense', null],
['Chardon', 'Jules', null],
['Ronce', 'Léonie', null],
];
const PLAN_MELANGE = [[1, 2], [3, 4, 5, 6], [7, 8, 9, 10], [11, 12, 13]];
const chargeMelange = () => chargeDe({ personnes: PERSONNES_MELANGE, capacites: [5, 4, 4, 3], tours: [PLAN_MELANGE, PLAN_MELANGE] });
const ORDRES = { sieges: [1, 3, 2, 4], assis: [3, 2, 1, 4], doublons: [2, 3, 1, 4] };
// Les comptes des deux tours de chaque table, par numéro, sous chaque
// position ; null pour « — ».
const COMPTES = {
sieges: { 1: 3, 2: 2, 3: 3, 4: null },
assis: { 1: 0, 2: 2, 3: 3, 4: null },
doublons: { 1: 0, 2: 2, 3: 1, 4: null },
};
// Six personnes, deux appartenances de deux, deux tables de trois : aucun
// membre ne retrouve l'autre membre de son appartenance, sur le nombre de
// tours donné.
const PERSONNES_SIX = [
['Ombrelle', 'Iris', AUBIER],
['Pervenche', 'Théo', AUBIER],
['Lacasse', 'Ondine', RESSAC],
['Tourteau', 'Basile', RESSAC],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
];
const chargeSix = ({ personnes = PERSONNES_SIX, tours = 2 } = {}) =>
chargeDe({
personnes,
capacites: [3, 3],
tours: Array.from({ length: tours }, (_, r) =>
r % 2 === 0
? [
[1, 3, 5],
[2, 4, 6],
]
: [
[1, 4, 6],
[2, 3, 5],
],
),
});
// Douze personnes, quatre tables de trois, trois tours. Ressac (1 et 2) se
// retrouve aux trois tours : 3 collisions cumulées sur une paire, excédent
// 2. Aubier (3 à 6) réunit quatre paires distinctes, une fois chacune : 4
// collisions cumulées, excédent 0. La barre la plus longue vient en second.
const chargeExcedent = () =>
chargeDe({
personnes: [
['Ombrelle', 'Iris', RESSAC],
['Pervenche', 'Théo', RESSAC],
['Lacasse', 'Ondine', AUBIER],
['Tourteau', 'Basile', AUBIER],
['Quenouille', 'Maëlle', AUBIER],
['Brindille', 'Anselme', AUBIER],
['Saule', 'Clothilde', null],
['Galet', 'Eudes', null],
['Mousse', 'Félicie', null],
['Ruisseau', 'Gaspard', null],
['Lichen', 'Hortense', null],
['Chardon', 'Jules', null],
],
capacites: [3, 3, 3, 3],
tours: [
[[1, 2, 7], [3, 4, 8], [5, 6, 9], [10, 11, 12]],
[[1, 2, 10], [3, 5, 11], [4, 6, 12], [7, 8, 9]],
[[1, 2, 3], [4, 7, 8], [5, 9, 10], [6, 11, 12]],
],
});
// Six personnes dont deux seules ont une appartenance, la même, et se
// retrouvent aux deux tours : une appartenance unique, 2 collisions
// cumulées sur une paire.
const chargeUneAppartenance = () =>
chargeDe({
personnes: [
['Ombrelle', 'Iris', AUBIER],
['Pervenche', 'Théo', AUBIER],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
['Mousse', 'Félicie', null],
['Ruisseau', 'Gaspard', null],
],
capacites: [3, 3],
tours: [
[
[1, 2, 3],
[4, 5, 6],
],
[
[1, 2, 4],
[3, 5, 6],
],
],
});
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
// La charge, une génération du moteur de plus, enregistrée par la commande.
function generee(charge, reglages) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
// --- Le montage --------------------------------------------------------------------
let montes = [];
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// La charge posée en fichier d'état, ouverte par la séance — en écriture
// sauf mode 'lecture' —, la proposition demandée au plan, la page de la
// qualité ouverte.
async function ouvrir(charge, { mode = 'ecriture', proposition = charge.propositions[0]?.id ?? null } = {}) {
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
montes.push(m);
await m.seance.ouvrir(BASE);
if (mode === 'ecriture') await m.seance.passerEnEcriture();
if (proposition !== null) m.ui.montrerProposition(proposition);
m.ui.allerA('qualite');
flushSync();
return m;
}
// --- Les lectures du rendu ---------------------------------------------------------
const vue = (m, nom) => m.racine.querySelector(`[data-section="qualite"] [data-vue-qualite="${nom}"]`);
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const radio = (m, position) => vue(m, 'tables').querySelector(`[data-position-tables="${position}"]`);
const positionCochee = (m) => [...vue(m, 'tables').querySelectorAll('[data-position-tables]')].find((r) => r.checked)?.dataset.positionTables;
// Les intitulés des lignes de la grille, dans l'ordre rendu : les textes du
// dessin qui nomment une table.
const intitulesGrille = (svg) =>
[...svg.querySelectorAll('text')].map((x) => x.textContent).filter((x) => /^Table \d+$/.test(x));
// Les cases de la grille : par ligne et colonne, le texte écrit et le
// remplissage du niveau, null sans remplissage.
function casesDe(svg) {
return [...svg.querySelectorAll('[data-case]')].map((g) => {
const [ligne, colonne] = g.dataset.case.split(':').map(Number);
const fond = [...g.querySelectorAll('rect')].find((r) => r.style.fill !== '') ?? null;
return {
ligne,
colonne,
texte: g.querySelector('text').textContent,
marque: fond?.getAttribute('data-marque') ?? null,
fond: fond === null ? null : getComputedStyle(fond).fill,
cote: Number(g.querySelector('[data-marque="graphiqueBordureCase"]').getAttribute('width')) + 1,
};
});
}
// La couleur calculée d'un jeton, lue sur un élément témoin.
function couleurDuJeton(jeton) {
const temoin = document.createElement('span');
temoin.style.color = `var(${jeton})`;
document.body.append(temoin);
const couleur = getComputedStyle(temoin).color;
temoin.remove();
return couleur;
}
// Les lignes d'un jumeau ouvert : par ligne, chaque cellule sous sa colonne.
function lignesJumeau(tableau) {
return [...tableau.querySelectorAll('tbody tr')].map((tr) =>
Object.fromEntries([...tr.children].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)])),
);
}
async function ouvrirJumeau(racine) {
racine.querySelector('[data-commande="ouvrirJumeau"]').click();
return attendre(() => {
const tableau = racine.querySelector('[data-jumeau="0"]');
expect(tableau).not.toBeNull();
return tableau;
});
}
// Choisit une position par un clic sur son bouton radio.
async function choisir(m, position) {
radio(m, position).click();
await attendre(() => expect(positionCochee(m)).toBe(position));
}
// --- Les collisions ----------------------------------------------------------------
describe('collisions d’appartenance (§ 12.4, B13, D31)', () => {
test('petite démonstration au conflit inévitable : le segment prouvé vaut 4 et égale le plancher que le diagnostic affiche', async () => {
const charge = generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 1, arret: 300 });
const plancher = diagnostiquerCharge(charge).diagnostic.collisions;
expect(plancher).toHaveLength(1);
expect(plancher[0].plancherCumulees).toBe(4);
const m = await ouvrir(charge);
const collisions = await attendre(() => {
const v = vue(m, 'collisions');
expect(v.querySelector('svg[data-graphique="collisions"]')).not.toBeNull();
return v;
});
const ligne = collisions.querySelector(`[data-ligne-graphique="${plancher[0].groupe}"]`);
expect(ligne.querySelector('[data-segment="prouve"]')).not.toBeNull();
const tableau = await ouvrirJumeau(collisions);
const lignes = lignesJumeau(tableau);
const sienne = lignes.find((l) => l.appartenance === plancher[0].groupe);
expect(sienne.prouve).toBe('4');
expect(Number(sienne.prouve) + Number(sienne.reste)).toBe(Number(sienne.cumulees));
// Le plancher que la section du diagnostic écrit, au même chiffre.
m.ui.allerA('diagnostic');
const enonce = await attendre(() => {
const e = m.racine.querySelector('[data-section="diagnostic"] [data-enonce="diagnostic.collisions"]');
expect(e).not.toBeNull();
return e;
});
expect(texteDe(enonce).match(/au moins (\d+) collisions? cumulées?/)[1]).toBe(sienne.prouve);
});
test('les barres suivent l’excédent, non leur longueur ; chaque segment est peint de son jeton de graphique, aucun d’une teinte d’appartenance ou de conflit ; aucun « évitable »', async () => {
const charge = chargeExcedent();
const detail = detaillerQualite(charge, 1);
expect(detail.collisions.lignes.map(({ groupe, cumulees, excedent }) => [groupe, cumulees, excedent])).toEqual([
[RESSAC, 3, 2],
[AUBIER, 4, 0],
]);
const m = await ouvrir(charge);
const collisions = await attendre(() => {
const v = vue(m, 'collisions');
expect(v.querySelectorAll('[data-ligne-graphique]')).toHaveLength(2);
return v;
});
expect([...collisions.querySelectorAll('[data-ligne-graphique]')].map((g) => g.dataset.ligneGraphique)).toEqual([RESSAC, AUBIER]);
const interdites = new Set(
[
'--couleur-conflit-appartenance',
'--couleur-conflit-voisins',
'--couleur-conflit-table',
...[1, 2, 3, 4, 5, 6].flatMap((k) => [`--couleur-appartenance-${k}-fond`, `--couleur-appartenance-${k}-marque`]),
].map(couleurDuJeton),
);
const segments = [...collisions.querySelectorAll('[data-segment]')];
expect(segments.length).toBeGreaterThan(0);
for (const segment of segments) {
const genre = segment.dataset.segment;
expect(['prouve', 'reste']).toContain(genre);
const peint = getComputedStyle(segment).fill;
expect(peint).toBe(couleurDuJeton(`--couleur-graphique-${genre}`));
expect(interdites.has(peint), `${genre} peint ${peint}`).toBe(false);
}
// La pastille de chaque intitulé : le fond et le point de la teinte que
// teintesAppartenances donne à l'appartenance sur les participants de
// l'événement — deux teintes distinctes, aucune neutre.
const teintes = teintesAppartenances(charge.participants);
const pastilles = [...collisions.querySelectorAll('[data-ligne-graphique]')].map((g) => {
const rang = teintes.get(g.dataset.ligneGraphique);
expect(rang, g.dataset.ligneGraphique).toBeGreaterThanOrEqual(1);
const fond = [...g.querySelectorAll('rect')].find((r) => !r.hasAttribute('data-segment'));
const point = g.querySelector('circle');
expect(getComputedStyle(fond).fill, g.dataset.ligneGraphique).toBe(couleurDuJeton(`--couleur-appartenance-${rang}-fond`));
expect(getComputedStyle(point).fill, g.dataset.ligneGraphique).toBe(couleurDuJeton(`--couleur-appartenance-${rang}-marque`));
return getComputedStyle(point).fill;
});
expect(new Set(pastilles).size).toBe(2);
expect(pastilles).not.toContain(couleurDuJeton('--couleur-neutre-marque'));
// Les chiffres de chaque ligne, en encre secondaire, après sa barre.
expect(texteDe(collisions)).toContain(t('qualite.collisions.secondaire', { effectif: 2, paires: 1, excedent: 2 }));
expect(texteDe(collisions)).not.toMatch(/(?<!in)évitable/i);
const nom = collisions.querySelector('svg[data-graphique="collisions"]').getAttribute('aria-labelledby');
expect(texteDe(document.getElementById(nom))).toBe(t('qualite.collisions.graphique'));
});
test('un plan qui laisse quelqu’un en réserve : un seul segment par barre, les collisions cumulées, et la phrase qui dit pourquoi', async () => {
const reduit = [[1, 2], [3, 4, 5, 6], [7, 8, 9, 10], [11, 12]];
const charge = chargeDe({ personnes: PERSONNES_MELANGE, capacites: [5, 4, 4, 3], tours: [PLAN_MELANGE, reduit], reserves: [[], [13]] });
const m = await ouvrir(charge);
const collisions = await attendre(() => {
const v = vue(m, 'collisions');
expect(v.querySelector('[data-phrase-qualite="collisionsHorsDomaine"]')).not.toBeNull();
return v;
});
expect(texteDe(collisions.querySelector('[data-phrase-qualite="collisionsHorsDomaine"]'))).toBe(t('qualite.collisions.horsDomaine'));
for (const ligne of collisions.querySelectorAll('[data-ligne-graphique]')) {
expect([...ligne.querySelectorAll('[data-segment]')].map((s) => s.dataset.segment)).toEqual(['serie1']);
}
expect(collisions.querySelector('[data-segment="prouve"]')).toBeNull();
});
test('une seule table, aucune appartenance, aucune collision : la phrase du cas, sans graphique', async () => {
const cas = [
[chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] }), 'collisionsUneTable'],
[chargeSix({ personnes: PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]) }), 'collisionsAucuneAppartenance'],
[chargeSix(), 'collisionsAucune'],
];
for (const [charge, code] of cas) {
const m = await ouvrir(charge);
const collisions = await attendre(() => {
const v = vue(m, 'collisions');
expect(v.querySelector(`[data-phrase-qualite="${code}"]`)).not.toBeNull();
return v;
});
expect(collisions.querySelector('svg[data-graphique]')).toBeNull();
expect(collisions.querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
}
});
});
// --- Les appartenances à chaque table ----------------------------------------------
describe('appartenances à chaque table (§ 12.11, B20, D35)', () => {
test('sous chacune des trois positions : la pire table en tête, l’ordre de la position, chaque case écrit le compte que son niveau colore, sans « % » ; la table de bénévoles écrit « — » et vient en dernier', async () => {
const charge = chargeMelange();
const detail = detaillerQualite(charge, 1);
const m = await ouvrir(charge);
await attendre(() => expect(positionCochee(m)).toBe('sieges'));
for (const position of POSITIONS) {
await choisir(m, position);
const svg = await attendre(() => {
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
expect(s).not.toBeNull();
expect(intitulesGrille(s)).toEqual(ORDRES[position].map((numero) => `Table ${numero}`));
return s;
});
expect(detail.tables.ordres[position].map((i) => detail.numerosTables[i])).toEqual(ORDRES[position]);
const cases = casesDe(svg);
expect(cases).toHaveLength(8);
// Deux tours ne font pas de cases géantes : la case ne dépasse pas
// CASE_MAX_TEXTES hauteurs de ligne du compte qu'elle écrit.
const ligneTexte = svg.querySelector('[data-case] text').getBBox().height;
expect(Math.max(...cases.map((c) => c.cote))).toBeLessThanOrEqual(CASE_MAX_TEXTES * ligneTexte + 1);
for (const c of cases) {
const attendu = COMPTES[position][ORDRES[position][c.ligne]];
expect(c.texte).not.toMatch(/%/);
if (attendu === null) {
expect(c.texte, position).toBe(t('format.vide'));
expect(c.fond).toBeNull();
continue;
}
expect(c.texte, `${position} ${c.ligne}:${c.colonne}`).toBe(String(attendu));
const n = niveau(Number(c.texte));
expect(c.fond).toBe(couleurDuJeton(`--couleur-graphique-niveau-${n}`));
expect(c.marque).toBe(n === 0 ? null : `graphiqueNiveau${n}`);
}
// La table de bénévoles, sans affilié, en dernière ligne.
expect(intitulesGrille(svg).at(-1)).toBe('Table 4');
}
});
test('l’infobulle d’une case : le pourcentage de la position puis ses deux comptes, occupants, affiliés, capacité', async () => {
const m = await ouvrir(chargeMelange());
const svg = await attendre(() => {
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
expect(s).not.toBeNull();
return s;
});
// Sous « sièges », la case 0:0 est la table 1 au tour 1 : δ = 2 pour 5 sièges.
const case00 = svg.querySelector('[data-case="0:0"]');
case00.scrollIntoView({ block: 'center' });
const boite = case00.getBoundingClientRect();
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
const bulle = await attendre(() => {
const b = vue(m, 'tables').querySelector('[role="tooltip"]');
expect(b.hidden).toBe(false);
return b;
});
expect(bulle.querySelector('[data-pourcentage]').textContent).toBe('40\u{202F}%');
expect(texteDe(bulle.querySelector('[data-compte-pire]'))).toBe('2 appartenances pour 5 sièges');
for (const fragment of ['Table 1', 'Tour 1', t('qualite.tables.colonne.occupants'), t('qualite.tables.colonne.capacite')]) {
expect(texteDe(bulle)).toContain(fragment);
}
await pointer('bouger', 2, 2);
});
test('le jumeau : une ligne par table et tour dans l’ordre de la position, les trois comptes, chaque pourcentage suivi du compte de sa ligne', async () => {
const m = await ouvrir(chargeMelange());
await choisir(m, 'doublons');
const tableau = await ouvrirJumeau(vue(m, 'tables'));
const lignes = lignesJumeau(tableau);
expect(lignes.map((l) => l.table)).toEqual(['Table 2', 'Table 2', 'Table 3', 'Table 3', 'Table 1', 'Table 1', 'Table 4', 'Table 4']);
expect(lignes[2]).toMatchObject({ tour: 'Tour 1', sieges: '3', assis: '3', doublons: '1', occupants: '4', affilies: '2', capacite: '4' });
const cellule = tableau.querySelectorAll('tbody tr')[2].querySelector('[data-colonne="pourcentDoublons"]');
expect(cellule.querySelector('[data-pourcentage]').textContent).toBe('50\u{202F}%');
expect(texteDe(cellule.querySelector('[data-compte-pire]'))).toBe('1 appartenance pour 2 affiliés');
expect(lignes[7].doublons).toBe(t('format.vide'));
for (const pourcentage of tableau.querySelectorAll('[data-pourcentage]')) {
expect(pourcentage.nextElementSibling?.hasAttribute('data-compte-pire')).toBe(true);
}
expect(tableau.querySelectorAll('[data-pourcentage]')).toHaveLength(8 * 3);
});
test('le sélecteur au clavier : la flèche passe à la position suivante ; les deux graphiques de la page ont des noms distincts', async () => {
const m = await ouvrir(chargeMelange());
await attendre(() => expect(radio(m, 'sieges')).not.toBeNull());
radio(m, 'sieges').focus();
await touche('ArrowRight');
await attendre(() => expect(positionCochee(m)).toBe('assis'));
expect(m.ui.etat().melangeTables).toBe('assis');
expect(document.activeElement).toBe(radio(m, 'assis'));
await touche('ArrowRight');
await attendre(() => expect(m.ui.etat().melangeTables).toBe('doublons'));
const noms = ['collisions', 'tables'].map((nom) => {
const svg = vue(m, nom).querySelector(`svg[data-graphique="${nom}"]`);
return texteDe(document.getElementById(svg.getAttribute('aria-labelledby')));
});
expect(noms[0]).not.toBe(noms[1]);
expect(noms.every((nom) => nom.length > 0)).toBe(true);
});
test('la position survit à un aller-retour vers le plan et repart de « sièges » à la réouverture de l’événement', async () => {
const m = await ouvrir(chargeMelange());
await choisir(m, 'doublons');
m.ui.allerA('plan');
flushSync();
m.ui.allerA('qualite');
await attendre(() => expect(positionCochee(m)).toBe('doublons'));
await m.seance.fermer();
await m.seance.ouvrir(BASE);
m.ui.montrerProposition(1);
m.ui.allerA('qualite');
await attendre(() => expect(positionCochee(m)).toBe('sieges'));
});
for (const [cas, fabriquer] of [
['en lecture', () => chargeMelange()],
[
'sur un plan bloqué',
() => executer(executer(chargeMelange(), 'retenirProposition', { id: 1 }), 'changerEtat', { etat: 'bloque' }),
],
]) {
test(`${cas} : chaque position se choisit sans refus ni écriture`, async () => {
const charge = fabriquer();
const m = await ouvrir(charge, { mode: 'lecture', proposition: cas === 'en lecture' ? 1 : null });
await attendre(() => expect(radio(m, 'sieges')).not.toBeNull());
const ecritures = m.trace.length;
for (const position of ['assis', 'doublons', 'sieges']) {
await choisir(m, position);
expect(m.ui.etat().melangeTables).toBe(position);
}
expect(m.racine.querySelector('[data-refus]')).toBeNull();
expect(m.trace.length).toBe(ecritures);
});
}
test('sans collision : sous « doublons », la phrase des collisions tient lieu de grille, le sélecteur reste ; sous « sièges », la grille', async () => {
const charge = chargeSix();
expect(sansCollision(detaillerQualite(charge, 1))).toBe(true);
const m = await ouvrir(charge);
const tables = await attendre(() => {
const v = vue(m, 'tables');
expect(v.querySelector('svg[data-graphique="tables"]')).not.toBeNull();
return v;
});
await choisir(m, 'doublons');
await attendre(() => expect(tables.querySelector('[data-phrase-qualite="tablesSansCollision"]')).not.toBeNull());
expect(texteDe(tables.querySelector('[data-phrase-qualite="tablesSansCollision"]'))).toBe(t('qualite.collisions.aucune'));
expect(tables.querySelector('svg[data-graphique="tables"]')).toBeNull();
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);
});
test('une seule appartenance : les barres, la grille et le sélecteur, comme pour plusieurs (B15)', async () => {
const m = await ouvrir(chargeUneAppartenance());
const tables = await attendre(() => {
const v = vue(m, 'tables');
expect(v.querySelector('svg[data-graphique="tables"]')).not.toBeNull();
return v;
});
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);
expect(tables.querySelector('[data-phrase-qualite]')).toBeNull();
const collisions = vue(m, 'collisions');
expect(collisions.querySelector('svg[data-graphique="collisions"]')).not.toBeNull();
expect([...collisions.querySelectorAll('[data-ligne-graphique]')].map((g) => g.dataset.ligneGraphique)).toEqual([AUBIER]);
expect(collisions.querySelector('[data-phrase-qualite]')).toBeNull();
for (const position of ['assis', 'doublons']) {
await choisir(m, position);
await attendre(() => expect(tables.querySelector('svg[data-graphique="tables"]')).not.toBeNull());
}
});
test('la vue ne borne pas la largeur de la grille : à un tour, des intitulés longs, le dessin prend la largeur de la carte', async () => {
const charge = chargeDe({ personnes: PERSONNES_SIX, capacites: [3, 3], tours: [[[1, 2, 5], [3, 4, 6]]], premier: 1000 });
const m = await ouvrir(charge);
const svg = await attendre(() => {
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
expect(s).not.toBeNull();
expect(intitulesGrille(s)).toEqual(['Table 1000', 'Table 1001']);
return s;
});
const carte = vue(m, 'tables').getBoundingClientRect().width;
expect(svg.getBoundingClientRect().width).toBeGreaterThanOrEqual(0.8 * carte);
});
test('une seule table, aucune appartenance : la phrase, sans sélecteur ni grille', async () => {
const cas = [
[chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] }), 'tablesUneTable'],
[chargeSix({ personnes: PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]) }), 'tablesAucuneAppartenance'],
];
for (const [charge, code] of cas) {
const m = await ouvrir(charge);
const tables = await attendre(() => {
const v = vue(m, 'tables');
expect(v.querySelector(`[data-phrase-qualite="${code}"]`)).not.toBeNull();
return v;
});
expect(tables.querySelector('[data-position-tables]')).toBeNull();
expect(tables.querySelector('svg[data-graphique]')).toBeNull();
}
});
test('quarante tours au cadre de 1 366 px : chaque case tient 24 px dans la carte ; cent tours : la phrase et le jumeau ouvert, le sélecteur restant', async () => {
const m = await ouvrir(chargeSix({ tours: 40 }));
const svg = await attendre(() => {
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
expect(s).not.toBeNull();
expect(s.querySelectorAll('[data-case]')).toHaveLength(80);
return s;
});
const cases = casesDe(svg);
expect(Math.min(...cases.map((c) => c.cote))).toBeGreaterThanOrEqual(24);
const carte = vue(m, 'tables').getBoundingClientRect();
for (const g of svg.querySelectorAll('[data-case]')) {
const boite = g.getBoundingClientRect();
expect(boite.right).toBeLessThanOrEqual(carte.right);
expect(boite.left).toBeGreaterThanOrEqual(carte.left);
}
// Cent tours ne tiennent pas : la grille cède la place à sa phrase et à
// son jumeau, ouvert ; le sélecteur reste.
const large = await ouvrir(chargeSix({ tours: 100 }));
const tables = await attendre(() => {
const v = vue(large, 'tables');
expect(v.querySelector('[data-jumeau="0"]')).not.toBeNull();
return v;
});
expect(tables.querySelector('svg[data-graphique="tables"]')).toBeNull();
expect(texteDe(tables)).toContain(t('qualite.tables.tropLarge'));
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);
expect(tables.querySelector('[data-commande="fermerJumeau"]')).not.toBeNull();
expect(tables.querySelectorAll('[data-jumeau="0"] tbody tr')).toHaveLength(200);
});
});
describe('la grande démonstration : le poids des deux vues', () => {
test('les nœuds des vues des collisions et des tables, relevés sur la grande', async () => {
const charge = generee(chargeDemo('grande'), { graine: 2, nombre: 1, arret: 50 });
const m = await ouvrir(charge);
const noeuds = await attendre(() => {
const comptes = ['collisions', 'tables'].map((nom) => {
const v = vue(m, nom);
expect(v.querySelector('svg[data-graphique]')).not.toBeNull();
return v.querySelectorAll('*').length + 1;
});
return comptes;
});
console.info(`nœuds : collisions ${noeuds[0]}, tables ${noeuds[1]}`);
expect(noeuds[0] + noeuds[1]).toBeLessThan(1500);
});
});

View file

@ -0,0 +1,379 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La vue des objectifs des tables dans un vrai moteur de rendu (§ 12.12,
// B21, D34, D36, D52) : les personnes jamais servies, la soirée en texte,
// une barre par tour — le tour au plus grand reste en tête, aucune pour la
// soirée, aucune couleur propre à un tour —, chaque pourcentage suivi de
// son compte, les assis sans table possible de chaque ligne, le jumeau au
// clavier. La vue se monte seule, par mount, avec les contextes que pose App
// — 't' et 'format' —, sur le détail d'une génération faite dans le
// navigateur ; une épreuve monte App entière, pour recouper la vue avec le
// tableau des propositions et la section Diagnostic. Le rendu se lit par
// ses attributs data-… et son texte, jamais par une classe. Les noms
// d'épreuve sont inventés.
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { appliquerCommande } from '../../application/commandes.js';
import { configurationDe } from '../../application/configuration.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import VueObjectifs from './VueObjectifs.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const CONTEXTE = new Map([
['t', t],
['format', creerFormat(t)],
]);
const V1 = VERSION.affichee;
// Neuf personnes inventées : les sept premières portent « finance ».
const PERSONNES = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', 'Coopérative Aubier'],
['Rocaille', 'Anselme', 'Atelier Ressac'],
['Brindille', 'Pélagie', null],
['Cassonade', 'Firmin', null],
['Grésil', 'Ysaline', 'Atelier Ressac'],
];
// Trois tables de trois places, tours tours ; la table 1 a l'objectif
// « finance » aux tours toursObjectif — un tour hors de la liste n'a aucun
// couple possible. Sept personnes pour trois places à objectif : à chaque
// tour, quatre au moins restent hors objectif, une au moins n'est jamais
// servie.
function chargeObjectifs({ tours = 3, toursObjectif = [1, 2], respecter = true, mode = null } = {}) {
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours });
charge.participants = PERSONNES.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(rang < 7 ? { etiquettes: ['finance'] } : {}),
}));
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.tables[0].objectifs = toursObjectif.map((tour) => ({ portee: 'tour', tour, regle: null, etiquettes: ['finance'] }));
charge.prochainsIds = { participant: 10, table: 4, proposition: 1 };
if (!respecter) charge.reglages.respecterObjectifs = false;
if (mode !== null) charge.reglages.modeObjectifs = mode;
return charge;
}
// La charge, une génération enregistrée de plus.
function generee(charge) {
const configuration = configurationDe(charge);
const propositions = rechercher(configuration, { graine: 7, nombre: 2, arret: 200 });
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
// Le détail de la première proposition d'une génération.
function detailDe(options) {
const charge = generee(chargeObjectifs(options));
return detaillerQualite(charge, charge.propositions[0].id);
}
// Le détail de la première proposition, sous une réservation ajoutée après
// la génération que son plan ne tient pas : la personne 8, sans étiquette,
// réservée au tour 1 à une table où le plan ne l'assoit pas. Hors du
// domaine des planchers (§ 12.4).
function detailHorsDomaine() {
const charge = generee(chargeObjectifs());
const [proposition] = charge.propositions;
const rang = proposition.placement[0].sieges.findIndex((sieges) => !sieges.includes(8));
const apres = structuredClone(charge);
apres.reservations.push({ participant: 8, table: proposition.tables[rang], siege: null, portee: 'tour', tour: 1 });
return detaillerQualite(apres, proposition.id);
}
const montes = [];
beforeAll(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
for (const monte of montes.splice(0)) monte.defaire();
});
// Monte la vue sur un détail, dans un conteneur de 900 px au début du
// corps, puis laisse ses graphiques mesurer leur largeur et leur texte.
async function monter(detail) {
const cible = document.createElement('div');
cible.style.width = '900px';
document.body.prepend(cible);
const composant = mount(VueObjectifs, { target: cible, props: { detail, ligne: null, table: null }, context: CONTEXTE });
montes.push({
defaire: () => {
unmount(composant);
cible.remove();
},
});
for (let i = 0; i < 3; i += 1) {
flushSync();
await tick();
}
return cible.querySelector('[data-vue-qualite="objectifs"]');
}
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const lignesDuGraphique = (vue) => [...vue.querySelectorAll('svg[data-graphique="objectifs"] [data-ligne-graphique]')];
// Le compte qui suit chaque [data-pourcentage] de la racine, son voisin
// immédiat, et le pourcentage, chacun en son texte exact, espace fine
// comprise ; lève quand un pourcentage n'en a pas.
function comptesQuiSuivent(racine) {
return [...racine.querySelectorAll('[data-pourcentage]')].map((pourcentage) => {
const compte = pourcentage.nextElementSibling;
expect(compte?.hasAttribute('data-compte-pire'), texteDe(pourcentage)).toBe(true);
expect(texteDe(compte)).not.toBe('');
return { pourcentage: pourcentage.textContent.trim(), compte: compte.textContent.trim() };
});
}
// Le pourcentage du § D52, recalculé ici pour l'épreuve.
function pourcentAttendu(n, d) {
if (d === 0) return null;
const brut = Math.floor((100 * n) / d);
return n > 0 && n < d ? Math.min(99, Math.max(1, brut)) : brut;
}
describe('objectifs : la vue rendue (§ 12.12, B21, D36)', () => {
test('les jamais servies d’abord, nommées avec leurs tours possibles ; la soirée en texte, son pourcentage suivi des jamais servies', async () => {
const detail = detailDe();
const vue = await monter(detail);
const { jamaisServies, parTour } = detail.objectifs;
const liste = vue.querySelector('[data-jamais-servies]');
const nomDe = (id) => detail.personnes.find((personne) => personne.id === id).nom;
expect([...liste.querySelectorAll('li')].map(texteDe)).toEqual(
jamaisServies.map(({ id, possibles }) => `${nomDe(id)}${t('qualite.objectifs.toursPossibles', { possibles })}`),
);
const servis = parTour.reduce((somme, tour) => somme + (tour.servis ?? 0), 0);
const possibles = parTour.reduce((somme, tour) => somme + tour.possibles, 0);
const soiree = vue.querySelector('[data-soiree-objectifs]');
expect(comptesQuiSuivent(soiree)).toEqual([
{
pourcentage: t('qualite.pourcentage.servis', { pourcent: pourcentAttendu(servis, possibles) }),
compte: t('qualite.objectifs.pire', { personnes: jamaisServies.length }),
},
]);
expect(texteDe(soiree)).toContain(t('qualite.objectifs.comptes', { servis, possibles }));
// Ses tours hors objectif, Σ possibles − Σ servis, et leur plancher, Σ des planchers des tours.
const plancher = parTour.reduce((somme, tour) => somme + (tour.plancher ?? 0), 0);
expect(texteDe(soiree)).toContain(t('qualite.objectifs.horsObjectif', { hors: possibles - servis, plancher }));
// La liste des jamais servies vient avant la soirée, la soirée avant le graphique.
const graphique = vue.querySelector('svg[data-graphique="objectifs"]');
expect(liste.compareDocumentPosition(soiree) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(soiree.compareDocumentPosition(graphique) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test('rouge : une barre par tour, aucune pour la soirée, la première au plus grand reste ; aucune couleur propre à un tour', async () => {
const detail = structuredClone(detailDe());
detail.objectifs.pireTour = 1;
const vue = await monter(detail);
const lignes = lignesDuGraphique(vue);
expect(lignes.map((ligne) => ligne.dataset.ligneGraphique)).toEqual(['2', '1', '3']);
expect(texteDe(lignes[0])).toContain(t('qualite.objectifs.tour', { tour: 2 }));
for (const genre of ['prouve', 'reste']) {
const remplissages = new Set(
[...vue.querySelectorAll(`svg[data-graphique="objectifs"] rect[data-segment="${genre}"]`)].map((rect) => getComputedStyle(rect).fill),
);
expect(remplissages.size, genre).toBe(1);
}
for (const ligne of lignes) expect(ligne.querySelectorAll('rect:not([data-segment]), circle').length).toBe(0);
});
test('chaque ligne écrit ses assis sans table possible ; un tour sans couple écrit « — », sans barre ni pourcentage', async () => {
// L'objectif aux tours 2 et 3 : le tour 2 est en tête, et l'ordre des
// barres, [2, 1, 3], n'est pas celui des tours.
const detail = detailDe({ toursObjectif: [2, 3] });
expect(detail.objectifs.pireTour).toBe(1);
const vue = await monter(detail);
expect(lignesDuGraphique(vue).map((ligne) => ligne.dataset.ligneGraphique)).toEqual(['2', '1', '3']);
for (const ligne of lignesDuGraphique(vue)) {
const tour = Number(ligne.dataset.ligneGraphique);
const { possibles, sansCouple } = detail.objectifs.parTour[tour - 1];
expect(texteDe(ligne)).toContain(t('qualite.objectifs.assisSansTable', { assis: sansCouple }));
if (possibles === 0) {
expect(texteDe(ligne)).toContain(t('format.vide'));
expect(ligne.querySelectorAll('rect[data-segment]').length).toBe(0);
} else {
expect(ligne.querySelectorAll('rect[data-segment]').length).toBeGreaterThan(0);
}
}
const parTour = [...vue.querySelectorAll('ol li')];
expect(parTour.map((li) => texteDe(li.firstElementChild))).toEqual([2, 1, 3].map((tour) => t('qualite.objectifs.tour', { tour })));
const sansCouple = parTour.find((li) => texteDe(li.firstElementChild) === t('qualite.objectifs.tour', { tour: 1 }));
expect(comptesQuiSuivent(sansCouple)).toEqual([{ pourcentage: t('format.vide'), compte: t('qualite.objectifs.sansCouple') }]);
expect(texteDe(sansCouple)).not.toMatch(/%/);
});
test('rouge : chaque pourcentage de tour sur ses seuls possibles, suivi de ses servis sur ses possibles', async () => {
const detail = detailDe();
const vue = await monter(detail);
const lis = [...vue.querySelectorAll('ol li')];
expect(lis.length).toBe(detail.R);
for (const li of lis) {
const tour = Number(texteDe(li.firstElementChild).replace(/\D/g, ''));
const { possibles, servis } = detail.objectifs.parTour[tour - 1];
if (possibles === 0) continue;
expect(comptesQuiSuivent(li)).toEqual([
{
pourcentage: t('qualite.pourcentage.valeur', { pourcent: pourcentAttendu(servis, possibles) }),
compte: t('qualite.objectifs.comptes', { servis, possibles }),
},
]);
}
});
test('aucune personne jamais servie : la phrase prend la place de la liste, sans aucun nom', async () => {
const detail = structuredClone(detailDe());
detail.objectifs.jamaisServies = [];
const vue = await monter(detail);
const jamais = vue.querySelectorAll('[data-jamais-servies]');
expect(jamais.length).toBe(1);
expect(texteDe(jamais[0])).toBe(t('qualite.objectifs.aucuneJamaisServie'));
expect(jamais[0].tagName).toBe('P');
expect(jamais[0].querySelectorAll('li').length).toBe(0);
});
test('R = 1 : aucune ligne de soirée', async () => {
const vue = await monter(detailDe({ tours: 1, toursObjectif: [1] }));
expect(vue.querySelector('[data-soiree-objectifs]')).toBeNull();
expect(lignesDuGraphique(vue).length).toBe(1);
});
test('critère décoché : la phrase dit qu’il ne classe pas, les planchers se dessinent quand même', async () => {
const vue = await monter(detailDe({ respecter: false }));
expect(vue.querySelector('[data-phrase-qualite="objectifs.critereDecoche"]')).not.toBeNull();
expect(vue.querySelectorAll('rect[data-segment="prouve"]').length).toBeGreaterThan(0);
});
test('hors du domaine des planchers : la phrase, un seul segment par barre, les tours hors objectif, et aucun prouvé inévitable', async () => {
const detail = detailHorsDomaine();
expect(detail.collisions.planchersValides).toBe(false);
const vue = await monter(detail);
expect(texteDe(vue.querySelector('[data-phrase-qualite="objectifs.horsDomaine"]'))).toBe(t('qualite.objectifs.horsDomaine'));
const genres = new Set([...vue.querySelectorAll('svg[data-graphique="objectifs"] rect[data-segment]')].map((rect) => rect.dataset.segment));
expect([...genres]).toEqual(['serie1']);
expect(vue.querySelector('rect[data-segment="prouve"]')).toBeNull();
expect(texteDe(vue.querySelector('[data-soiree-objectifs]'))).toContain(t('qualite.objectifs.hors', { hors: detail.objectifs.soiree.hors }));
});
test('sans personne concernée : la phrase, aucun graphique', async () => {
const vue = await monter(detailDe({ toursObjectif: [] }));
expect(texteDe(vue.querySelector('[data-phrase-qualite="objectifs.aucunConcerne"]'))).toBe(t('qualite.objectifs.aucunConcerne'));
expect(vue.querySelector('svg')).toBeNull();
expect(vue.querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
});
test('le jumeau s’ouvre au clavier : chaque tour, la soirée, les jamais servies, la répartition ; chaque pourcentage suivi du compte de sa ligne', async () => {
const detail = detailDe();
const vue = await monter(detail);
const commande = vue.querySelector('[data-commande="ouvrirJumeau"]');
commande.focus();
await touche('Enter');
await attendre(() => expect(vue.querySelectorAll('[data-jumeau]').length).toBe(3));
const [tours, jamais, repartition] = vue.querySelectorAll('[data-jumeau]');
expect(tours.querySelectorAll('tbody tr').length).toBe(detail.R + 1);
expect(jamais.querySelectorAll('tbody tr').length).toBe(detail.objectifs.jamaisServies.length);
expect(repartition.querySelectorAll('tbody tr').length).toBe(detail.objectifs.repartition.length);
const { soiree } = detail.objectifs;
expect(comptesQuiSuivent(tours).at(-1)).toEqual({
pourcentage: t('qualite.pourcentage.valeur', { pourcent: soiree.pourcent }),
compte: t('qualite.objectifs.comptes', soiree),
});
const noms = [...vue.querySelectorAll('[data-jumeau]')].map((table) => table.getAttribute('aria-label'));
expect(new Set([...noms, t('qualite.objectifs.graphique')]).size).toBe(4);
const fermer = vue.querySelector('[data-commande="fermerJumeau"]');
expect(document.activeElement).toBe(fermer);
await touche('Enter');
await attendre(() => expect(vue.querySelector('[data-jumeau]')).toBeNull());
expect(document.activeElement).toBe(vue.querySelector('[data-commande="ouvrirJumeau"]'));
});
});
describe('objectifs : recoupements dans l’application (§ 12.12, § 5.6)', () => {
test('jamais servies = colonne du tableau des propositions ; possibles − servis de la soirée = tours hors objectif ; Σ planchers des tours = plancher de la section Diagnostic', async () => {
const lue = generee(chargeObjectifs({ mode: 'chacun' }));
const texte = serialiser(lue, { revision: 1, produitVersion: V1 });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `soiree${SUFFIXES.etat}`, texte }] });
montes.push({ defaire: () => m.demonter() });
await m.seance.ouvrir('soiree');
const id = lue.propositions[0].id;
m.ui.montrerProposition(id);
m.ui.allerA('qualite');
const vue = await attendre(() => {
const trouvee = m.racine.querySelector('[data-vue-qualite="objectifs"]');
expect(trouvee?.querySelector('[data-soiree-objectifs]')).not.toBeNull();
return trouvee;
});
const jamais = vue.querySelectorAll('[data-jamais-servies] li').length;
vue.querySelector('[data-commande="ouvrirJumeau"]').click();
const tours = await attendre(() => {
const table = vue.querySelector('[data-jumeau="0"]');
expect(table).not.toBeNull();
return table;
});
const nombre = (cellule) => Number(texteDe(cellule).replace(/\D/g, ''));
const rangees = [...tours.querySelectorAll('tbody tr')];
const soiree = rangees.at(-1);
const horsDeLaSoiree =
nombre(soiree.querySelector('[data-colonne="possibles"]')) - nombre(soiree.querySelector('[data-colonne="servis"]'));
const planchers = rangees
.slice(0, -1)
.map((rangee) => texteDe(rangee.querySelector('[data-colonne="plancher"]')))
.filter((texte) => texte !== t('format.vide'))
.map(Number);
const somme = planchers.reduce((total, plancher) => total + plancher, 0);
expect(planchers.length).toBe(2);
m.ui.allerA('propositions');
const ligne = await attendre(() => {
const trouvee = m.racine.querySelector(`[data-proposition="${id}"]`);
expect(trouvee).not.toBeNull();
return trouvee;
});
expect(nombre(ligne.querySelector('[data-colonne="jamaisServies"]'))).toBe(jamais);
expect(nombre(ligne.querySelector('[data-colonne="toursHorsObjectif"]'))).toBe(horsDeLaSoiree);
m.ui.allerA('diagnostic');
const enonce = await attendre(() => {
const trouve = m.racine.querySelector('[data-plancher-objectifs="toursHorsObjectif"]');
expect(trouve).not.toBeNull();
return trouve;
});
expect(texteDe(enonce)).toContain(t('diagnostic.objectifs.toursHorsObjectif', { plancher: somme }));
expect(m.erreurs()).toEqual([]);
});
});

View file

@ -0,0 +1,496 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La vue des paires répétées dans un vrai moteur de rendu (§ 12.5, B14,
// D32, D33, D50) : sur 12 / 4 × 3 / 6, le tableau des paires choisies puis,
// sous leur intitulé, des paires imposées ; la première commande d'une
// ligne mène au plan au premier tour où la paire se revoit, la première
// personne mise en évidence, et chaque tour à son tour ; la ligne n'est pas
// une cible ; la matrice dans l'ordre du profil, et sa phrase quand elle
// est trop étroite ou au-delà du seuil ; les pages de cent ; les cas d'une
// table et d'un tour. Les épreuves de la page montent App (monterApp) au
// cadre de 1 366 × 700 ; celles des pages montent la vue seule, sur un
// détail forgé. Le rendu se lit par le texte et les attributs data-…,
// jamais par une classe. Les noms d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import '../jetons.css';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { creerEtatUi } from '../etat_ui.js';
import { TABLES_INTERFACE } from '../libelles.js';
import VuePaires from './VuePaires.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const V1 = VERSION.affichee;
const BASE = 'soiree';
// --- Les charges d'épreuve -------------------------------------------------------
// Des noms inventés, assez pour quarante personnes : prénom et nom se
// combinent.
const PRENOMS = ['Iris', 'Théo', 'Ondine', 'Basile', 'Maëlle', 'Sidonie', 'Aurèle', 'Clovis'];
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Tourteau', 'Quenouille'];
const APPARTENANCES = ['Coopérative Aubier', 'Atelier Ressac', null];
// n personnes inventées, t tables de s places numérotées 10 + identifiant,
// r tours ; avec reserver, les deux premières sont réservées ensemble à la
// table 1 à chaque tour : leur paire est imposée.
function chargeDe({ n, tables, sieges, tours, reserver = false }) {
const charge = creerCharge({ id: 'evt-paires', nom: 'Atelier Ressac', siegesParDefaut: sieges, tours });
charge.participants = Array.from({ length: n }, (_, i) => ({
id: i + 1,
nom: NOMS[Math.floor(i / PRENOMS.length) % NOMS.length],
prenom: PRENOMS[i % PRENOMS.length],
appartenance: APPARTENANCES[i % APPARTENANCES.length],
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = Array.from({ length: tables }, (_, i) => ({
id: i + 1,
numero: 11 + i,
sieges: null,
forme: 'ronde',
position: { x: 300 * (i + 1), y: 0 },
}));
if (reserver) charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
charge.prochainsIds = { participant: n + 1, table: tables + 1, proposition: 1 };
return charge;
}
// 12 personnes, 4 tables de 3, 6 tours (§ 12.5) : une répétition au moins
// pour chacun ; la paire 1:2 est imposée.
const chargeDouze = () => chargeDe({ n: 12, tables: 4, sieges: 3, tours: 6, reserver: true });
// La charge, une génération de plus, enregistrée par la commande.
function generee(charge, reglages = { graine: 5, arret: 60, nombre: 2 }) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
// --- Le montage --------------------------------------------------------------------
let montes = [];
let vues = [];
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(async () => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
for (const { composant, cible } of vues.splice(0)) {
unmount(composant);
cible.remove();
}
window.scrollTo(0, 0);
await fixerCadre(1366, 700);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// La charge posée en fichier d'état, ouverte en écriture par la séance, sa
// première proposition montrée au plan, la page de qualité ouverte.
async function ouvrir(charge, { montrer = charge.propositions[0]?.id ?? null } = {}) {
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
montes.push(m);
await m.seance.ouvrir(BASE);
await m.seance.passerEnEcriture();
if (montrer !== null) m.ui.montrerProposition(montrer);
m.ui.allerA('qualite');
flushSync();
return m;
}
const vueDe = (m) => m.racine.querySelector('[data-vue-qualite="paires"]');
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const commandes = (racine, nom) => [...racine.querySelectorAll(`[data-commande="${nom}"]`)];
const phrases = (racine) => [...racine.querySelectorAll('[data-phrase-qualite]')].map((p) => p.dataset.phraseQualite);
// La vue des paires, rendue, sous la page ouverte.
const vueRendue = (m) =>
attendre(() => {
const vue = vueDe(m);
expect(vue).not.toBeNull();
expect(vue.querySelector('[data-paire], [data-phrase-qualite]')).not.toBeNull();
return vue;
});
// La vue seule, montée sur un détail forgé, dans une boîte de la largeur
// donnée. Le détail se lit dans une SvelteMap, sous la clé 'detail' : la
// changer change la propriété de la vue, comme une autre proposition
// montrée.
async function monterVue(detail, largeur = 1100) {
const cible = document.createElement('div');
cible.style.width = `${largeur}px`;
document.body.prepend(cible);
const ui = creerEtatUi();
const contexte = new Map([
['t', t],
['format', format],
['ui', ui],
]);
const proprietes = new SvelteMap([['detail', detail]]);
const props = {
get detail() {
return proprietes.get('detail');
},
ligne: null,
table: null,
};
const composant = mount(VuePaires, { target: cible, props, context: contexte });
vues.push({ composant, cible });
flushSync();
await tick();
flushSync();
return { cible, ui, proprietes };
}
// Un détail forgé : n personnes, une table, quatre tours, les paires
// choisies et imposées données, sans matrice.
function detailForge({ n, choisies, imposees }) {
return {
id: 1,
statut: 'comparable',
raisons: [],
population: { mesuree: n, exclue: 0 },
T: 4,
R: 4,
G: 1,
idsTables: [1, 2, 3, 4],
numerosTables: [1, 2, 3, 4],
personnes: Array.from({ length: n }, (_, i) => ({
id: i + 1,
nom: `${PRENOMS[i % PRENOMS.length]} ${NOMS[Math.floor(i / PRENOMS.length) % NOMS.length]}`,
appartenance: null,
statut: 'mobile',
})),
paires: { choisies, imposees },
matrice: null,
};
}
// --- Les épreuves -----------------------------------------------------------------
describe('le tableau des paires sur 12 / 4 × 3 / 6 (§ 12.5, D32)', () => {
const lue = generee(chargeDouze());
const id = lue.propositions[0].id;
const detail = detaillerQualite(lue, id);
const nomDe = (personne) => detail.personnes.find((candidate) => candidate.id === personne).nom;
test('les paires choisies, puis les imposées sous leur intitulé : chaque ligne nomme la paire, son nombre de fois et ses tours', async () => {
const m = await ouvrir(lue);
const vue = await vueRendue(m);
const tableaux = [...vue.querySelectorAll('table')].filter((tableau) => tableau.querySelector('[data-paire]') !== null);
expect(tableaux.map((tableau) => texteDe(tableau.querySelector('caption')))).toEqual([
t('qualite.paires.choisies'),
t('qualite.paires.imposees'),
]);
const [choisies, imposees] = tableaux.map((tableau) => [...tableau.querySelectorAll('[data-paire]')].map((ligne) => ligne.dataset.paire));
expect(choisies.length).toBeGreaterThan(0);
expect(choisies).toEqual(detail.paires.choisies.map(({ a, b }) => `${a}:${b}`));
expect(imposees).toEqual(['1:2']);
expect(choisies).not.toContain('1:2');
const imposee = vue.querySelector('[data-paire="1:2"]');
expect(texteDe(imposee)).toContain('Iris Ombrelle');
expect(texteDe(imposee)).toContain('Théo Ombrelle');
expect(texteDe(imposee)).toContain('Coopérative Aubier');
expect(texteDe(imposee)).toContain(t('qualite.paires.fois', { fois: 6 }));
const tours = commandes(imposee, 'allerAuTour').map(texteDe);
expect(tours).toEqual([
t('qualite.paires.premierTour', { tour: 2 }),
...[1, 2, 3, 4, 5, 6].map((tour) => t('qualite.paires.tour', { tour, table: 11 })),
]);
expect(texteDe(vue)).toContain(t('qualite.paires.pages.etendue', { premier: 1, dernier: choisies.length + 1, total: choisies.length + 1 }));
});
test('la première commande de chaque ligne mène au plan au premier tour où la paire se revoit, la première personne mise en évidence', async () => {
const m = await ouvrir(lue);
const paires = [...detail.paires.choisies, ...detail.paires.imposees];
for (const { a, b, tours } of paires) {
m.ui.allerA('qualite');
const vue = await vueRendue(m);
const premiere = commandes(vue.querySelector(`[data-paire="${a}:${b}"]`), 'allerAuTour')[0];
expect(texteDe(premiere)).toBe(t('qualite.paires.premierTour', { tour: tours[1].tour + 1 }));
premiere.click();
flushSync();
const etat = m.ui.etat();
expect([etat.section, etat.tour, etat.miseEnEvidence, etat.proposition], `${a}:${b}`).toEqual(['plan', tours[1].tour + 1, a, id]);
expect(tours[1].tour).not.toBe(tours[0].tour);
}
});
test('chaque tour d’une ligne est une commande sœur qui mène au plan à ce tour', async () => {
const m = await ouvrir(lue);
const { a, b, tours } = detail.paires.choisies[0];
for (const [rang, { tour, table }] of tours.entries()) {
m.ui.allerA('qualite');
const vue = await vueRendue(m);
const ligne = vue.querySelector(`[data-paire="${a}:${b}"]`);
const bouton = commandes(ligne, 'allerAuTour')[rang + 1];
const libelle = t('qualite.paires.tour', { tour: tour + 1, table: 10 + table });
expect(texteDe(bouton)).toBe(libelle);
expect(bouton.getAttribute('aria-label')).toBe(t('qualite.paires.tourNom', { tour: tour + 1, table: 10 + table, a: nomDe(a), b: nomDe(b) }));
bouton.click();
flushSync();
expect([m.ui.etat().section, m.ui.etat().tour, m.ui.etat().miseEnEvidence]).toEqual(['plan', tour + 1, a]);
}
});
test('une ligne n’est pas une cible : un clic sur la paire ou son nombre de fois ne quitte pas la page, et rien d’autre que ses commandes ne prend le focus', async () => {
const m = await ouvrir(lue);
const vue = await vueRendue(m);
for (const ligne of vue.querySelectorAll('[data-paire]')) {
expect(ligne.hasAttribute('tabindex')).toBe(false);
expect(ligne.getAttribute('role')).toBeNull();
for (const cellule of ligne.querySelectorAll('th, td:not(:last-child)')) {
cellule.click();
flushSync();
expect(m.ui.etat().section).toBe('qualite');
}
const focalisables = [...ligne.querySelectorAll('button, a, [tabindex]')];
expect(focalisables.every((element) => element.dataset.commande === 'allerAuTour')).toBe(true);
}
expect(m.ui.etat().miseEnEvidence).toBe(null);
});
test('les commandes portent des noms accessibles distincts, chacun contenant son libellé', async () => {
const m = await ouvrir(lue);
const vue = await vueRendue(m);
const noms = commandes(vue, 'allerAuTour').map((bouton) => [texteDe(bouton), bouton.getAttribute('aria-label')]);
expect(noms.every(([libelle, nom]) => nom !== null && nom.startsWith(libelle))).toBe(true);
expect(new Set(noms.map(([, nom]) => nom)).size).toBe(noms.length);
});
});
describe('la matrice « qui a rencontré qui » (§ 12.5, D33)', () => {
const lue = generee(chargeDouze());
const id = lue.propositions[0].id;
const detail = detaillerQualite(lue, id);
test('sous le seuil, à 1 366 px : la grille 12 × 12 dans l’ordre du profil, les comptes du détail écrits, la diagonale vide, aucune case cible', async () => {
const m = await ouvrir(lue);
const vue = await vueRendue(m);
const grille = await attendre(() => {
const trouvee = vue.querySelector('[data-graphique="matrice"]');
expect(trouvee).not.toBeNull();
expect(trouvee.querySelectorAll('[data-case]')).toHaveLength(144);
return trouvee;
});
const ordre = detail.profil.map(({ id: personne }) => personne);
expect(detail.matrice.ordre).toEqual(ordre);
const parId = new Map(detail.personnes.map(({ id: personne, nom }) => [personne, nom]));
const intitules = [...grille.querySelectorAll('text')].map((texte) => texte.textContent).filter((texte) => /^\d+\. /.test(texte));
expect(intitules).toEqual(ordre.map((personne, i) => `${i + 1}. ${parId.get(personne)}`));
const n = ordre.length;
let k = 0;
for (let i = 0; i < n; i += 1) {
expect(texteDe(grille.querySelector(`[data-case="${i}:${i}"]`))).toBe('');
for (let j = i + 1; j < n; j += 1) {
const attendu = String(detail.matrice.comptes[k]);
expect(texteDe(grille.querySelector(`[data-case="${i}:${j}"]`)), `${i}:${j}`).toBe(attendu);
expect(texteDe(grille.querySelector(`[data-case="${j}:${i}"]`)), `${j}:${i}`).toBe(attendu);
k += 1;
}
}
expect(grille.querySelector('[data-case] [tabindex], [data-case] button, [data-case][tabindex]')).toBeNull();
expect(phrases(vue)).not.toContain('matriceEtroite');
// Le jumeau porte les mêmes comptes, une ligne par personne.
commandes(vue, 'ouvrirJumeau')[0].click();
flushSync();
const jumeau = vue.querySelector('[data-jumeau]');
expect(jumeau.querySelectorAll('tbody tr')).toHaveLength(n);
const i = ordre.indexOf(1);
const j = ordre.indexOf(2);
expect(texteDe(jumeau.querySelectorAll('tbody tr')[i].querySelector(`[data-colonne="c${j}"]`))).toBe('6');
});
test('sous le seuil mais trop étroite à 1 024 px : la phrase de la matrice non dessinée, qui renvoie à la fiche', async () => {
const n = constante('SEUIL_MATRICE_PARTICIPANTS');
const lue40 = generee(chargeDe({ n, tables: n / 4, sieges: 4, tours: 3 }), { graine: 2, arret: 20, nombre: 1 });
await fixerCadre(1024, 700);
const m = await ouvrir(lue40);
const vue = await vueRendue(m);
await attendre(() => expect(phrases(vue)).toContain('matriceEtroite'));
expect(vue.querySelector('[data-graphique="matrice"]')).toBeNull();
expect(texteDe(vue)).toContain(t('qualite.paires.matrice.etroite'));
expect(commandes(vue, 'ouvrirJumeau')).toHaveLength(0);
});
// Un arrêt court laisse beaucoup de paires revues : la page de cent est
// pleine, la vue à son plus lourd. Le budget de 1 500 nœuds vaut pour la
// page entière et se mesure avec elle ; cette épreuve borne la part de la
// vue seule, PART_DES_PAIRES, sa page pleine et une marge de quelques
// lignes.
const PART_DES_PAIRES = 1400;
test('la grande : aucune matrice, la phrase du seuil ; cent lignes au plus, la vue dans sa part du budget de la page', async () => {
const lueGrande = generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50 });
const m = await ouvrir(lueGrande);
const vue = await vueRendue(m);
expect(phrases(vue)).toContain('matriceSeuil');
expect(vue.querySelector('[data-graphique]')).toBeNull();
const mesuree = detaillerQualite(lueGrande, lueGrande.propositions[0].id).population.mesuree;
expect(texteDe(vue)).toContain(
t('qualite.paires.matrice.seuil', { participants: format.nombre(mesuree), seuil: format.nombre(constante('SEUIL_MATRICE_PARTICIPANTS')) }),
);
expect(vue.querySelectorAll('[data-paire]').length).toBeLessThanOrEqual(100);
expect(vue.querySelectorAll('*').length).toBeLessThanOrEqual(PART_DES_PAIRES);
});
});
describe('les pages de cent (§ 5.7, D50)', () => {
// 250 paires forgées sur 30 personnes : 180 choisies, puis 70 imposées.
const paires = [];
for (let a = 1; a <= 30 && paires.length < 250; a += 1) {
for (let b = a + 1; b <= 30 && paires.length < 250; b += 1) {
paires.push({ a, b, fois: 2, tours: [{ tour: 0, table: 1 }, { tour: 2, table: 2 }] });
}
}
const detail = detailForge({ n: 30, choisies: paires.slice(0, 180), imposees: paires.slice(180) });
test('250 paires font trois pages : l’étendue et le total écrits, les commandes de page, les imposées à la suite des choisies', async () => {
const { cible } = await monterVue(detail);
const lignes = () => [...cible.querySelectorAll('[data-paire]')].map((ligne) => ligne.dataset.paire);
const etendue = (premier, dernier) => t('qualite.paires.pages.etendue', { premier, dernier, total: 250 });
expect(lignes()).toHaveLength(100);
expect(texteDe(cible)).toContain(etendue(1, 100));
expect(commandes(cible, 'precedentes')).toHaveLength(0);
commandes(cible, 'suivantes')[0].click();
await tick();
flushSync();
expect(lignes()).toHaveLength(100);
expect(lignes()[0]).toBe(`${paires[100].a}:${paires[100].b}`);
expect(lignes()[80]).toBe(`${paires[180].a}:${paires[180].b}`);
expect(texteDe(cible)).toContain(etendue(101, 200));
expect([...cible.querySelectorAll('caption')].map(texteDe)).toEqual([t('qualite.paires.choisies'), t('qualite.paires.imposees')]);
commandes(cible, 'suivantes')[0].click();
await tick();
flushSync();
expect(lignes()).toHaveLength(50);
expect(texteDe(cible)).toContain(etendue(201, 250));
expect([...cible.querySelectorAll('caption')].map(texteDe)).toEqual([t('qualite.paires.imposees')]);
expect(commandes(cible, 'suivantes')).toHaveLength(0);
await attendre(() => expect(texteDe(document.activeElement)).toBe(etendue(201, 250)));
commandes(cible, 'precedentes')[0].click();
await tick();
flushSync();
expect(texteDe(cible)).toContain(etendue(101, 200));
});
test('une autre proposition montrée s’ouvre à sa première page ; la même, remesurée, garde la sienne', async () => {
const { cible, proprietes } = await monterVue(detail);
const etendue = (premier, dernier) => t('qualite.paires.pages.etendue', { premier, dernier, total: 250 });
commandes(cible, 'suivantes')[0].click();
await tick();
flushSync();
expect(texteDe(cible)).toContain(etendue(101, 200));
// Le même détail, remesuré : un nouvel objet, le même identifiant.
proprietes.set('detail', structuredClone(detail));
flushSync();
expect(texteDe(cible)).toContain(etendue(101, 200));
// Une autre proposition, autant de paires.
proprietes.set('detail', { ...structuredClone(detail), id: detail.id + 1 });
flushSync();
expect(texteDe(cible)).toContain(etendue(1, 100));
expect(texteDe(cible)).not.toContain(etendue(101, 200));
expect(cible.querySelector('[data-paire]').dataset.paire).toBe(`${paires[0].a}:${paires[0].b}`);
});
});
describe('les homonymes (§ 12.6)', () => {
// Deux personnes mesurées du même nom et de la même appartenance, chacune
// revue par la troisième au tour 3 : seul l'identifiant les distingue.
const detail = {
...detailForge({ n: 3, choisies: [], imposees: [] }),
personnes: [
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', statut: 'mobile' },
{ id: 2, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', statut: 'mobile' },
{ id: 3, nom: 'Ondine Lacasse', appartenance: 'Atelier Ressac', statut: 'mobile' },
],
paires: {
choisies: [
{ a: 1, b: 3, fois: 2, tours: [{ tour: 0, table: 1 }, { tour: 2, table: 1 }] },
{ a: 2, b: 3, fois: 2, tours: [{ tour: 1, table: 2 }, { tour: 2, table: 1 }] },
],
imposees: [],
},
};
test('chaque ligne écrit l’identifiant de l’homonyme, et les noms accessibles de ses commandes le portent : aucun ne se répète', async () => {
const { cible } = await monterVue(detail);
for (const id of [1, 2]) {
const ligne = cible.querySelector(`[data-paire="${id}:3"]`);
expect(texteDe(ligne)).toContain(t('qualite.paires.homonyme', { id }));
expect(texteDe(ligne)).not.toContain(t('qualite.paires.homonyme', { id: 3 - id }));
for (const bouton of commandes(ligne, 'allerAuTour')) {
expect(bouton.getAttribute('aria-label')).toContain(t('qualite.paires.homonyme', { id }));
}
}
const noms = commandes(cible, 'allerAuTour').map((bouton) => bouton.getAttribute('aria-label'));
expect(noms).toHaveLength(6);
expect(new Set(noms).size).toBe(noms.length);
expect(noms.every((nom) => !nom.includes(t('qualite.paires.homonyme', { id: 3 })))).toBe(true);
});
});
describe('les cas de la vue (§ 12.6)', () => {
test('une seule table : la phrase, ni tableau ni matrice', async () => {
const lue = generee(chargeDe({ n: 4, tables: 1, sieges: 4, tours: 2 }), { graine: 1, arret: 10, nombre: 1 });
const m = await ouvrir(lue);
const vue = await vueRendue(m);
expect(phrases(vue)).toEqual(['pairesUneTable']);
expect(vue.querySelector('[data-paire], [data-graphique]')).toBeNull();
expect(texteDe(vue)).toContain(t('qualite.paires.uneTable'));
});
test('un seul tour : la phrase au lieu du tableau, jamais un tableau vide ; la matrice reste', async () => {
const lue = generee(chargeDe({ n: 6, tables: 2, sieges: 3, tours: 1 }), { graine: 1, arret: 10, nombre: 1 });
const m = await ouvrir(lue);
const vue = await vueRendue(m);
expect(phrases(vue)).toEqual(['pairesUnTour']);
expect(vue.querySelector('[data-paire], table')).toBeNull();
await attendre(() => expect(vue.querySelector('[data-graphique="matrice"]')).not.toBeNull());
});
test('aucune paire revue : la phrase, sans tableau', async () => {
const lue = generee(chargeDe({ n: 9, tables: 3, sieges: 3, tours: 2 }), { graine: 1, arret: 200, nombre: 1 });
const detail = detaillerQualite(lue, lue.propositions[0].id);
expect(detail.paires.choisies.length + detail.paires.imposees.length).toBe(0);
const m = await ouvrir(lue);
const vue = await vueRendue(m);
expect(phrases(vue)).toEqual(['pairesAucune']);
expect(vue.querySelector('[data-paire]')).toBeNull();
});
});

View file

@ -0,0 +1,457 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La vue « Performance du calcul » dans un vrai moteur de rendu (§ 12.14,
// B23, D40, D49) : une génération de la séance sous l'horloge pilotée,
// avancée d'une durée écrite pendant le calcul, donne la durée et la cadence
// exactes ; sans avance, « durée inconnue » et aucune cadence ; les petits
// multiples marquent les seuls points de relevé, sur une abscisse
// logarithmique, chacun son seul axe vertical ; le dernier point du jumeau
// égale la ligne de la proposition au tableau des propositions, avant et
// après un changement de l'ordre des priorités ou d'un objectif ; rouverte
// par une autre séance, la proposition montre les mêmes points, durée
// inconnue ; compte 1, zéro mouvement, retenu non ajusté puis ajusté,
// proposition sans course. Chaque épreuve monte App par monterApp, sur le
// système de fichiers simulé où un fichier d'état d'épreuve est posé puis
// ouvert, au cadre de 1 366 × 700, et lit le rendu : le texte, les
// attributs data-…, l'élément focalisé — jamais une classe. Les noms
// d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js';
import { creerFormat } from '../../application/format.js';
import { creerExecuteurDirect } from '../../application/generation.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { colonneDeValeur } from '../propositions/modele.js';
import { cleIndicateur } from './performance.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const V1 = VERSION.affichee;
const BASE = 'soiree';
// --- Les charges d'épreuve -------------------------------------------------
// Des personnes inventées, de deux appartenances ou sans — aucune quand
// appartenances est faux —, réparties sur des tables numérotées de 1 à
// tables, sans proposition ni retenu.
const PERSONNES = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
['Aubépin', 'Lucien', 'Coopérative Aubier'],
['Bruyère', 'Aglaé', 'Atelier Ressac'],
['Cormier', 'Félix', null],
['Dorine', 'Ysé', 'Coopérative Aubier'],
['Escourgeon', 'Paulin', 'Atelier Ressac'],
['Fenouil', 'Rosalie', null],
];
function chargeDe({ personnes = 6, tables = 2, sieges = 3, tours = 2, appartenances = true } = {}) {
const charge = creerCharge({ id: 'evt-performance', nom: 'Atelier Ressac', siegesParDefaut: sieges, tours });
charge.participants = PERSONNES.slice(0, personnes).map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: appartenances ? appartenance : null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = Array.from({ length: tables }, (_, rang) => ({
id: rang + 1,
numero: rang + 1,
sieges: null,
forme: 'ronde',
position: { x: 300 * (rang + 1), y: 0 },
}));
charge.prochainsIds = { participant: personnes + 1, table: tables + 1, proposition: 1 };
return charge;
}
// Douze personnes, trois tables de quatre, trois tours ; les six premières
// portent l'étiquette « finance », que la table 1 a pour objectif.
function chargeAObjectifs() {
const charge = chargeDe({ personnes: 12, tables: 3, sieges: 4, tours: 3 });
for (const personne of charge.participants) if (personne.id <= 6) personne.etiquettes = ['finance'];
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
return charge;
}
// --- Le montage ------------------------------------------------------------
let montes = [];
beforeEach(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
const erreurs = montes.flatMap((m) => m.erreurs());
for (const m of montes.reverse()) m.demonter();
montes = [];
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// L'exécuteur direct, dont chaque calcul avance l'horloge pilotée de
// dureeMs avant de rendre ses propositions, et qui dit employer coeurs
// cœurs : la durée que le contrôleur prend autour du calcul est dureeMs.
function executeurQuiDure(horloge, dureeMs, coeurs) {
const direct = creerExecuteurDirect({ coeursDisponibles: coeurs });
return {
coeursDisponibles: direct.coeursDisponibles,
lancer(configuration, reglages, options) {
const execution = direct.lancer(configuration, reglages, options);
const resultat = execution.resultat.then((propositions) => {
horloge.avancer(dureeMs);
return propositions;
});
return { ...execution, resultat, coeurs };
},
};
}
// La charge posée en fichier d'état, ouverte par la séance — en écriture
// sauf mode 'lecture' — ; options passe à monterApp.
async function ouvrir(charge, { mode = 'ecriture', ...options } = {}) {
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }], ...options });
montes.push(m);
await m.seance.ouvrir(BASE);
if (mode === 'ecriture') await m.seance.passerEnEcriture();
return m;
}
// La page de qualité ouverte sur la proposition id, ou sur le retenu pour
// null.
function montrer(m, id) {
m.ui.montrerProposition(id);
m.ui.allerA('qualite');
flushSync();
}
// --- Les lectures du rendu -------------------------------------------------
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const vue = (m) => m.racine.querySelector('[data-vue-qualite="performance"]');
const tete = (m) => [...vue(m).querySelectorAll('[data-course] li')].map(texteDe);
const phrases = (m) => [...vue(m).querySelectorAll('[data-phrase-qualite]')].map((p) => [p.dataset.phraseQualite, texteDe(p)]);
const graphiques = (m) => [...vue(m).querySelectorAll('[data-graphique]')];
// Le jumeau de la vue ouvert : ses colonnes, puis chaque ligne, une Map
// colonne → texte de sa cellule.
async function lireJumeau(m) {
const ouvrirJumeau = vue(m).querySelector('[data-commande="ouvrirJumeau"]');
if (ouvrirJumeau !== null) ouvrirJumeau.click();
const table = await attendre(() => {
const trouvee = vue(m).querySelector('[data-jumeau="0"]');
expect(trouvee).not.toBeNull();
return trouvee;
});
const colonnes = [...table.querySelectorAll('thead th')].map((th) => th.dataset.colonne);
const lignes = [...table.querySelectorAll('tbody tr')].map(
(tr) => new Map([...tr.querySelectorAll('[data-colonne]')].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)])),
);
return { colonnes, lignes };
}
// La ligne de la proposition id au tableau des propositions : une Map
// colonne → texte de sa cellule.
async function ligneAuTableau(m, id) {
m.ui.allerA('propositions');
const ligne = await attendre(() => {
const trouvee = m.racine.querySelector(`[data-proposition="${id}"]`);
expect(trouvee).not.toBeNull();
return trouvee;
});
return new Map([...ligne.querySelectorAll('td[data-colonne]')].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)]));
}
// La colonne du tableau des propositions qui porte une valeur de point de
// relevé : celle du minimum des rencontres pour le minimum brut.
const colonneAuTableau = (nom) => (nom === 'minimumBrut' ? 'rencontresTous' : colonneDeValeur(nom));
// Le jumeau lu sur la page de qualité restée ouverte sur la proposition
// id, puis la ligne de cette proposition au tableau des propositions : le
// dernier point égale la ligne, valeur par valeur. La page est rouverte
// sur la proposition id avant de rendre le jumeau lu, et le reste pendant
// le changement qui suit : la vue reçoit le détail de la charge changée
// sans être montée de nouveau.
async function dernierPointEgaleLaLigne(m, id, quoi) {
const jumeau = await lireJumeau(m);
const auTableau = await ligneAuTableau(m, id);
const dernier = jumeau.lignes.at(-1);
for (const nom of jumeau.colonnes.slice(1)) {
expect(dernier.get(nom), `${quoi} : ${nom}`).toBe(auTableau.get(colonneAuTableau(nom)));
}
montrer(m, id);
return jumeau;
}
// --- Les épreuves ----------------------------------------------------------
describe('la ligne de tête : la génération de la séance (§ 12.14, § 5.10, D40)', () => {
const REGLAGES = { graine: 3, nombre: 2, arret: 400, historique: 1000 };
test('rouge : horloge avancée de 2 500 ms pendant le calcul, sur deux cœurs : durée, cœurs, cadence et nombre exacts', async () => {
const horloge = creerHorlogePilotee();
const charge = chargeDe();
const m = await ouvrir(charge, { horloge, executeur: executeurQuiDure(horloge, 2500, 2) });
await m.app.generation().generer(REGLAGES);
montrer(m, 1);
const propositions = rechercher(configurationDepuisCharge(charge), REGLAGES);
const evalues = propositions[0].evalues + propositions[1].evalues;
await attendre(() =>
expect(tete(m)).toEqual([
t('qualite.performance.mouvements', { evalues: format.nombre(propositions[0].evalues), arret: format.nombre(400) }),
'durée observée 2,5 s',
'2 cœurs de calcul',
`cadence ${format.nombre(Math.round((evalues * 1000) / 2500))} mouvements par seconde`,
'génération de 2 propositions',
]),
);
expect(evalues).toBe(800);
expect(tete(m)[3]).toBe('cadence 320 mouvements par seconde');
});
test('rouge : sans avance de l’horloge, durée nulle : « durée inconnue », aucune cadence', async () => {
const m = await ouvrir(chargeDe());
await m.app.generation().generer(REGLAGES);
montrer(m, 1);
await attendre(() => expect(tete(m)).toContain(t('qualite.performance.dureeInconnue')));
expect(tete(m).filter((morceau) => morceau.includes('cadence'))).toEqual([]);
expect(tete(m).filter((morceau) => morceau.startsWith('durée observée'))).toEqual([]);
});
});
describe('les petits multiples et le jumeau (§ 12.14, D49)', () => {
test('rouge : des points aux seuls points de relevé, reliés tout droit, sur une abscisse logarithmique ; un seul axe vertical par graphique', async () => {
const m = await ouvrir(chargeDe());
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 400, historique: 1000 });
montrer(m, 1);
const svgs = await attendre(() => {
const trouves = graphiques(m);
expect(trouves.length).toBeGreaterThan(1);
expect(trouves[0].querySelectorAll('[data-point-releve]').length).toBe(4);
return trouves;
});
expect(svgs[0].dataset.graphique).toBe('performance-minimumBrut');
const noms = svgs.map((svg) => document.getElementById(svg.getAttribute('aria-labelledby')).textContent);
expect(new Set(noms).size).toBe(noms.length);
for (const svg of svgs) {
const points = [...svg.querySelectorAll('[data-point-releve]')];
expect(points.map((p) => p.dataset.pointReleve)).toEqual(['1', '10', '100', '400']);
const x = points.map((p) => Number(p.getAttribute('cx')));
// Une décade a la même largeur : 1 → 10 comme 10 → 100.
expect(Math.abs(x[1] - x[0] - (x[2] - x[1]))).toBeLessThan(0.5);
expect(x[3] - x[2]).toBeLessThan(x[2] - x[1]);
// Le trait passe d'un point au suivant, sans palier entre eux.
const d = svg.querySelector('path[data-serie="serie1"]').getAttribute('d');
expect(d.replace(/[^A-Za-z]/g, '')).toBe('MLLL');
const sommets = [...d.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map(([, px, py]) => [Number(px), Number(py)]);
points.forEach((point, i) => {
expect(sommets[i][0]).toBeCloseTo(Number(point.getAttribute('cx')), 1);
expect(sommets[i][1]).toBeCloseTo(Number(point.getAttribute('cy')), 1);
});
expect(svg.querySelectorAll('[data-serie]:not(text)')).toHaveLength(1);
const verticaux = [...svg.querySelectorAll('[data-marque="graphiqueAxe"]')].filter(
(axe) => axe.getAttribute('x1') === axe.getAttribute('x2'),
);
expect(verticaux).toHaveLength(1);
}
// Les petits multiples alignent leurs abscisses, point contre point.
const abscisses = svgs.map((svg) => [...svg.querySelectorAll('[data-point-releve]')].map((p) => p.getAttribute('cx')));
const colonnes = svgs.map((svg) => Math.round(svg.getBoundingClientRect().left));
svgs.forEach((svg, i) => {
if (colonnes[i] === colonnes[0]) expect(abscisses[i]).toEqual(abscisses[0]);
});
expect(texteDe(vue(m).querySelector('[data-dernier-changement]')).length).toBeGreaterThan(0);
});
test('rouge : le jumeau, au clavier : une ligne par point, ses mouvements puis une colonne par valeur, aucune durée ; refermé, le focus reste sur la commande', async () => {
const m = await ouvrir(chargeDe());
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 400, historique: 1000 });
montrer(m, 1);
const ouvrirJumeau = await attendre(() => {
const trouvee = vue(m).querySelector('[data-commande="ouvrirJumeau"]');
expect(trouvee).not.toBeNull();
return trouvee;
});
ouvrirJumeau.focus();
await touche('Enter');
const table = await attendre(() => {
const trouvee = vue(m).querySelector('[data-jumeau="0"]');
expect(trouvee).not.toBeNull();
return trouvee;
});
expect(table.getAttribute('aria-label')).toBe(t('qualite.performance.jumeau'));
const colonnes = [...table.querySelectorAll('thead th')].map((th) => th.dataset.colonne);
expect(colonnes[0]).toBe('mouvements');
expect(colonnes.slice(1)).toEqual(graphiques(m).map((svg) => svg.dataset.graphique.replace('performance-', '')));
expect(colonnes.filter((colonne) => /duree|temps|cadence/i.test(colonne))).toEqual([]);
expect([...table.querySelectorAll('thead th')].map(texteDe)).toEqual([
t('qualite.performance.colonne.mouvements'),
...colonnes.slice(1).map((nom) => t(cleIndicateur(nom))),
]);
expect([...table.querySelectorAll('tbody tr')].map((tr) => texteDe(tr.querySelector('[data-colonne="mouvements"]')))).toEqual([
'1',
'10',
'100',
'400',
]);
const fermer = vue(m).querySelector('[data-commande="fermerJumeau"]');
fermer.focus();
await touche('Enter');
await attendre(() => expect(vue(m).querySelector('[data-jumeau]')).toBeNull());
expect(document.activeElement?.dataset.commande).toBe('ouvrirJumeau');
});
});
describe('le dernier point égale la ligne du tableau, sous les réglages courants (§ 12.14, D40)', () => {
test('rouge : après un changement de l’ordre des priorités puis d’un objectif, les points remesurés, le dernier toujours égal à la nouvelle ligne', async () => {
const m = await ouvrir(chargeAObjectifs());
await m.app.generation().generer({ graine: 4, nombre: 1, arret: 400, historique: 1000 });
montrer(m, 1);
const avant = await dernierPointEgaleLaLigne(m, 1, 'à la génération');
expect(avant.colonnes).toContain('toursHorsObjectif');
const paliers = [
[{ critere: 'ecartAuPlafondAPrioriMax', poids: 1 }],
[{ critere: 'redondance', poids: 1 }],
[{ critere: 'excedentCollisions', poids: 1 }],
[{ critere: 'collisionsCumulees', poids: 1 }],
[{ critere: 'toursHorsObjectif', poids: 1 }],
[{ critere: 'rencontresRepetees', poids: 1 }],
[{ critere: 'animateursNonRencontres', poids: 1 }],
];
await m.seance.executer('changerPriorites', { paliers });
const reglee = await dernierPointEgaleLaLigne(m, 1, 'priorités');
expect(reglee.colonnes).not.toEqual(avant.colonnes);
expect(reglee.colonnes.slice(0, 4)).toEqual(['mouvements', 'minimumBrut', 'ecartAuPlafondAPrioriMax', 'redondance']);
await m.seance.executer('poserObjectif', { table: 2, tour: null, etiquettes: 'finance', regle: null });
const objectif = await dernierPointEgaleLaLigne(m, 1, 'objectif');
const hors = (jumeau) => jumeau.lignes.map((ligne) => ligne.get('toursHorsObjectif'));
expect(hors(objectif)).not.toEqual(hors(reglee));
expect(objectif.lignes.map((ligne) => ligne.get('mouvements'))).toEqual(reglee.lignes.map((ligne) => ligne.get('mouvements')));
});
});
describe('une proposition rouverte, sa course lue du compagnon (§ 8.6, B23, D53)', () => {
test('rouge : une autre séance rouvre l’événement : les mêmes points et les mêmes valeurs, la course montrée, « durée inconnue », aucune cadence', async () => {
const horloge = creerHorlogePilotee();
const premiere = await ouvrir(chargeDe(), { horloge, executeur: executeurQuiDure(horloge, 2500, 2) });
await premiere.app.generation().generer({ graine: 3, nombre: 2, arret: 400, historique: 1000 });
montrer(premiere, 2);
await attendre(() => expect(tete(premiere)).toContain('durée observée 2,5 s'));
const ecrit = await lireJumeau(premiere);
const seconde = await monterApp({ systeme: { disque: premiere.disque } });
montes.push(seconde);
await seconde.seance.ouvrir(BASE);
montrer(seconde, 2);
const relu = await lireJumeau(seconde);
expect(relu.colonnes).toEqual(ecrit.colonnes);
expect(relu.lignes.map((ligne) => [...ligne])).toEqual(ecrit.lignes.map((ligne) => [...ligne]));
expect(phrases(seconde)).toEqual([]);
expect(graphiques(seconde).length).toBeGreaterThan(0);
expect(tete(seconde)).toContain(t('qualite.performance.dureeInconnue'));
expect(tete(seconde).filter((morceau) => /cadence|durée observée|cœur/.test(morceau))).toEqual([]);
});
});
describe('les cas d’un seul point, du retenu et de la course absente (§ 12.6, B15)', () => {
test('compte 1 : un seul point, sa phrase, aucun graphique ; le jumeau garde sa ligne', async () => {
const m = await ouvrir(chargeDe());
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 1, historique: 1000 });
montrer(m, 1);
await attendre(() => expect(phrases(m)).toEqual([['unPoint', t('qualite.performance.unPoint')]]));
expect(tete(m)[0]).toBe('1 mouvement évalué sur 1');
expect(graphiques(m)).toEqual([]);
expect(vue(m).querySelector('[data-dernier-changement]')).toBeNull();
expect((await lireJumeau(m)).lignes.map((ligne) => ligne.get('mouvements'))).toEqual(['1']);
});
test('zéro mouvement : « le plan initial n’avait rien à reprendre », aucun graphique', async () => {
const m = await ouvrir(chargeDe({ personnes: 4, tables: 1, sieges: 4, tours: 1, appartenances: false }));
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 50, historique: 1000 });
montrer(m, 1);
await attendre(() => expect(tete(m)[0]).toBe(t('qualite.performance.planInitial')));
expect(graphiques(m)).toEqual([]);
expect(phrases(m)).toEqual([]);
expect((await lireJumeau(m)).lignes.map((ligne) => ligne.get('mouvements'))).toEqual(['0']);
});
test('un retenu non ajusté nomme sa proposition et en montre la course ; ajusté, sa phrase seule', async () => {
const m = await ouvrir(chargeDe());
await m.app.generation().generer({ graine: 3, nombre: 2, arret: 400, historique: 1000 });
await m.seance.executer('retenirProposition', { id: 2 });
montrer(m, null);
await attendre(() => expect(phrases(m)).toEqual([['retenu', t('qualite.performance.retenu', { proposition: 2 })]]));
expect(graphiques(m).length).toBeGreaterThan(0);
expect(tete(m).length).toBeGreaterThan(0);
const [premierTour] = m.seance.etat().charge.retenu.placement;
const [a] = premierTour.sieges[0];
const [b] = premierTour.sieges[1];
await m.seance.executer('echangerDansRetenu', { tour: 1, a, b });
montrer(m, null);
await attendre(() => expect(phrases(m)).toEqual([['retenuSansCourse', t('qualite.performance.retenuSansCourse')]]));
expect(graphiques(m)).toEqual([]);
expect(vue(m).querySelector('[data-course]')).toBeNull();
expect(vue(m).querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
});
test('une proposition écrite sans compagnon des courses : « Sa course n’est pas gardée. », rien d’autre', async () => {
const charge = chargeDe();
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 3, nombre: 1, arret: 400 });
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
const m = await ouvrir(generee, { mode: 'lecture' });
montrer(m, 1);
await attendre(() => expect(phrases(m)).toEqual([['sansCourse', "Sa course n'est pas gardée."]]));
expect(graphiques(m)).toEqual([]);
expect(vue(m).querySelector('[data-course]')).toBeNull();
expect(vue(m).querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
});
});
describe('le poids de la vue sur la grande démonstration (§ 12.14, E2s)', () => {
test('une course de sept points : la vue tient sous le budget de nœuds de la page entière', async () => {
const charge = analyser(texteLivre('grande')).charge;
const m = await ouvrir(charge);
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 200000, historique: 1000 }, { confirmee: true });
const id = m.seance.etat().charge.propositions.at(-1).id;
montrer(m, id);
await attendre(() => expect(graphiques(m)[0]?.querySelectorAll('[data-point-releve]').length).toBe(7));
const noeuds = vue(m).querySelectorAll('*').length + 1;
expect(noeuds).toBeLessThan(1500);
}, 120_000);
});

View file

@ -0,0 +1,432 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'en-tête chiffré et le profil trié de la page de qualité dans un vrai
// moteur de rendu (§ 12.2, § 12.3, B11, B12, B15, D29, D30, D34, D41, D48,
// D51) : chaque tuile qui a sa colonne écrit la cellule du tableau des
// propositions ; trois tuiles seulement portent un pourcentage, chacun
// suivi de son compte du pire ; les deux graphiques du profil, au même
// ordre de rangs, désignent le même rang pointé ; chaque trait a son motif
// — la borne de diversité en pointillés, le plafond et la règle à 100 % en
// trait mixte — ; le jumeau s'ouvre et se referme au clavier. Les vues se
// montent par mount sur le détail d'une génération, avec les contextes que
// pose App — 't' et 'format' — ; une épreuve monte App entier et lit les
// deux sections. Le texte attendu vient de la même traduction que celle de
// l'application ; les noms d'épreuve sont inventés.
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite, evaluer } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { tableau, texteCellule } from '../propositions/modele.js';
import VueEntete from './VueEntete.svelte';
import VueProfil from './VueProfil.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const CONTEXTE = new Map([
['t', t],
['format', format],
]);
const V1 = VERSION.affichee;
// --- Les charges d'épreuve -------------------------------------------------------
// Six personnes inventées, deux tables de trois places, trois tours ;
// appartenances : deux (les quatre premières), ou aucune ; la première
// table attend « finance », que portent les trois premières, quand
// objectifs est vrai ; reservation ancre la première à la première table.
function chargeSix({ appartenances = true, objectifs = false, reservation = false, reglages = {} } = {}) {
const charge = creerCharge({ id: 'evt-profil', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
const noms = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
];
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: appartenances ? appartenance : null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(objectifs && rang < 3 ? { etiquettes: ['finance'] } : {}),
}));
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
if (objectifs) charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
if (reservation) charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
Object.assign(charge.reglages, reglages);
return charge;
}
// La charge, une génération de plus, enregistrée par la commande.
function generee(charge, reglages = { graine: 3, nombre: 2, arret: 200 }) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
// Le contrat de propriétés des vues pour une proposition de la charge.
function vueDe(charge, id = charge.propositions[0].id) {
const table = evaluer(charge);
return { detail: detaillerQualite(charge, id), ligne: table.propositions.find((ligne) => ligne.id === id), table };
}
// --- Le montage --------------------------------------------------------------------
const montes = [];
const apps = [];
beforeAll(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
const erreurs = apps.flatMap((m) => m.erreurs());
for (const m of apps.splice(0).reverse()) m.demonter();
window.scrollTo(0, 0);
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
// Monte une vue dans un conteneur neuf de 1 000 px, au début du corps, puis
// laisse ses graphiques mesurer leur largeur et leur texte.
async function monter(Vue, props) {
const cible = document.createElement('div');
cible.style.width = '1000px';
document.body.prepend(cible);
const composant = mount(Vue, { target: cible, props, context: CONTEXTE });
montes.push({ composant, cible });
for (let tour = 0; tour < 3; tour += 1) {
flushSync();
await tick();
}
flushSync();
return cible;
}
// Attend que l'assertion passe, le rendu de Svelte à jour.
async function attendre(assertion) {
for (let essai = 0; ; essai += 1) {
flushSync();
try {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
}
// --- Les lectures du rendu ---------------------------------------------------------
// Le texte d'un élément, ses blancs ordinaires réduits : l'espace fine
// insécable du pourcentage reste.
const texteDe = (element) => element.textContent.replace(/[\t\n\r ]+/g, ' ').trim();
const tuileDe = (racine, nom) => racine.querySelector(`[data-tuile="${nom}"]`);
const valeurDe = (tuile) => texteDe(tuile.querySelector('.valeur'));
const svgDe = (racine, nom) => racine.querySelector(`svg[data-graphique="${nom}"]`);
const surfaceDe = (svg) => svg.querySelector(':scope > rect[pointer-events="all"]');
const motif = (element) => getComputedStyle(element).strokeDasharray;
// Chaque [data-pourcentage] de la racine, suivi de son [data-compte-pire]
// non vide, son voisin immédiat.
function exigerComptes(racine) {
const pourcentages = [...racine.querySelectorAll('[data-pourcentage]')];
for (const valeur of pourcentages) {
const compte = valeur.nextElementSibling;
expect(compte?.hasAttribute('data-compte-pire'), texteDe(valeur)).toBe(true);
expect(texteDe(compte)).not.toBe('');
}
return pourcentages;
}
// --- L'en-tête chiffré -------------------------------------------------------------
describe('l’en-tête chiffré (§ 12.2, B11, D48)', () => {
test('chaque tuile qui a sa colonne écrit la cellule du tableau, sous les groupes du tableau ; la population et le certificat', async () => {
const vue = vueDe(generee(chargeSix({ objectifs: true })));
const cible = await monter(VueEntete, vue);
const ligneDuTableau = tableau(vue.table).lignes.find(({ id }) => id === vue.ligne.id);
let comparees = 0;
for (const cellule of ligneDuTableau.cellules) {
if (cellule.colonne === 'certificat') continue;
const tuile = tuileDe(cible, cellule.colonne);
expect(tuile, cellule.colonne).not.toBeNull();
expect(valeurDe(tuile), cellule.colonne).toBe(texteCellule(cellule, { t, format }));
expect(texteDe(tuile)).not.toContain(t('qualite.tuile.neClassePas'));
comparees += 1;
}
expect(comparees).toBeGreaterThan(15);
const vueQualite = cible.querySelector('[data-vue-qualite="entete"]');
expect(texteDe(vueQualite)).toContain(t('qualite.tuile.population', { mesuree: 6, exclue: 0 }));
expect([...vueQualite.querySelectorAll('h4')].map(texteDe)).toEqual(
['rencontres', 'collisions', 'repetitions', 'diversite', 'manque', 'objectifs'].map((nom) => t(`propositions.groupe.${nom}`)),
);
});
test('un critère décoché : sa tuile lit la même mesure et dit « ne classe pas »', async () => {
const vue = vueDe(generee(chargeSix({ objectifs: true, reglages: { respecterObjectifs: false } })));
const cible = await monter(VueEntete, vue);
const tuile = tuileDe(cible, 'toursHorsObjectif');
expect(texteDe(tuile)).toContain(t('qualite.tuile.toursHorsObjectif'));
expect(texteDe(tuile)).toContain(t('qualite.tuile.neClassePas'));
expect(valeurDe(tuile)).toBe(format.nombre(vue.ligne.mesures.objectifs.horsObjectif));
});
test('le minimum brut nomme la personne au rang 1, son plafond réalisé, son plafond a priori et son pourcentage, le compte du pire dessous', async () => {
const vue = vueDe(generee(chargeSix()));
const cible = await monter(VueEntete, vue);
const { rang1 } = vue.detail.bornes;
const nom = vue.detail.personnes.find(({ id }) => id === rang1.id).nom;
const tuile = tuileDe(cible, 'rencontresTous');
expect(texteDe(tuile)).toContain(t('qualite.tuile.personne', { nom }));
expect(texteDe(tuile)).toContain(t('qualite.tuile.plafondRealise', { plafond: rang1.plafondRealise }));
expect(texteDe(tuile)).toContain(t('qualite.tuile.plafondAPriori', { plafond: rang1.plafondAPriori }));
expect(texteDe(tuile.querySelector('[data-pourcentage]'))).toBe(t('qualite.pourcentage.valeur', { pourcent: rang1.pourcent }));
expect(texteDe(tuile.querySelector('[data-compte-pire]'))).toBe(`minimum ${rang1.rencontres} sur ${rang1.plafondRealise}`);
});
test('trois pourcentages, chacun suivi de son compte du pire ; aucun signe du pourcentage ailleurs dans l’en-tête', async () => {
const cible = await monter(VueEntete, vueDe(generee(chargeSix({ objectifs: true }))));
const pourcentages = exigerComptes(cible);
expect(pourcentages.map((valeur) => valeur.closest('[data-tuile]').dataset.tuile)).toEqual([
'rencontresTous',
'toursHorsObjectif',
'toursSelonObjectif',
]);
// Le texte de l'en-tête, les pourcentages retirés, ne porte aucun « % ».
const copie = cible.cloneNode(true);
for (const valeur of copie.querySelectorAll('[data-pourcentage]')) valeur.remove();
expect(copie.textContent).not.toContain('%');
});
test('le libellé d’une tuile s’explique au focus du clavier : l’infobulle porte l’explication de la colonne', async () => {
const cible = await monter(VueEntete, vueDe(generee(chargeSix())));
const terme = tuileDe(cible, 'manqueMax').querySelector('[data-terme]');
terme.focus();
flushSync();
const bulle = cible.querySelector('[role="tooltip"]');
await attendre(() => expect(bulle.hidden).toBe(false));
expect(texteDe(bulle)).toBe(t('propositions.explication.manqueMax').replace(/\s+/g, ' ').trim());
expect(terme.getAttribute('aria-describedby')).toBe(bulle.id);
});
});
// --- Le profil trié -------------------------------------------------------------------
describe('le profil trié, deux graphiques au même ordre (§ 12.3, B12, D30, D41, D51)', () => {
test('le premier : deux séries et deux repères, la borne de diversité en pointillés, distincte des tirets ; le plafond en trait mixte, distinct du plein', async () => {
const cible = await monter(VueProfil, vueDe(generee(chargeSix({ reservation: true }))));
const svg = svgDe(cible, 'profilComptes');
const trace = (attribut, genre) => svg.querySelector(`path[${attribut}="${genre}"]`);
const borne = trace('data-repere', 'borne');
const plafond = trace('data-repere', 'plafond');
const plein = trace('data-serie', 'serie1');
const tirets = trace('data-serie', 'serie2');
for (const element of [borne, plafond, plein, tirets]) expect(element).not.toBeNull();
expect(motif(plein)).toBe('none');
expect(motif(tirets)).not.toBe('none');
expect(motif(borne)).not.toBe(motif(tirets));
expect(motif(borne)).not.toBe(motif(plafond));
expect(motif(plafond)).not.toBe(motif(plein));
expect(motif(plafond)).not.toBe(motif(tirets));
// Chaque repère est nommé sur le tracé, la borne jamais « plafond ».
expect(texteDe(svg.querySelector('text[data-repere="borne"]'))).toBe('borne de diversité');
expect(texteDe(svg.querySelector('text[data-repere="plafond"]'))).toBe(t('qualite.profil.repere.plafond'));
// Le rang ancré porte son signe ; la phrase de synthèse, dessous.
expect(svg.querySelectorAll('[data-marque="graphiqueMarque"]').length).toBeGreaterThan(0);
const { detail } = vueDe(generee(chargeSix({ reservation: true })));
expect(texteDe(cible)).toContain(t('qualite.profil.synthese', detail.synthese));
});
test('le second : une série en pourcentages, la règle à 100 % du motif du plafond, libellée « 100 % », l’axe de 0 à 100 %', async () => {
const vue = vueDe(generee(chargeSix()));
const cible = await monter(VueProfil, vue);
const svg = svgDe(cible, 'profilPourcentages');
expect(svg.querySelectorAll('path[data-serie]')).toHaveLength(1);
const cent = svg.querySelector('path[data-repere="cent"]');
expect(motif(cent)).toBe(motif(svgDe(cible, 'profilComptes').querySelector('path[data-repere="plafond"]')));
expect(texteDe(svg.querySelector('text[data-repere="cent"]'))).toBe(t('qualite.pourcentage.cent'));
const graduations = [...svg.querySelectorAll('text')].map(texteDe);
expect(graduations).toContain(t('qualite.pourcentage.axe', { valeur: '0' }));
expect(graduations).toContain(t('qualite.pourcentage.axe', { valeur: '100' }));
// Deux noms distincts, une phrase de synthèse qui porte le pire en compte.
expect(svg.getAttribute('aria-labelledby')).not.toBe(svgDe(cible, 'profilComptes').getAttribute('aria-labelledby'));
const nomDe = (graphique) => texteDe(document.getElementById(graphique.getAttribute('aria-labelledby')));
expect(nomDe(svg)).toBe(t('qualite.profil.graphique.pourcentages'));
expect(nomDe(svgDe(cible, 'profilComptes'))).toBe(t('qualite.profil.graphique.comptes'));
const synthese = document.getElementById(svg.getAttribute('aria-describedby'));
const { rang1 } = vue.detail.bornes;
expect(texteDe(synthese.querySelector('[data-compte-pire]'))).toBe(`minimum ${rang1.rencontres} sur ${rang1.plafondRealise}`);
exigerComptes(cible);
});
test('le rang pointé est le même dans les deux graphiques : le rang 1, la personne au minimum ; l’infobulle porte sa ligne, le pourcentage suivi de « r sur p »', async () => {
const vue = vueDe(generee(chargeSix()));
const cible = await monter(VueProfil, vue);
const comptes = svgDe(cible, 'profilComptes');
// Le document a le focus, comme quand l'opérateur survole puis tape :
// une touche n'atteint que le document focalisé. Le focus fait défiler
// la page : la surface se mesure après lui.
const focalisable = document.createElement('button');
cible.append(focalisable);
focalisable.focus({ preventScroll: true });
const boite = surfaceDe(comptes).getBoundingClientRect();
await pointer('bouger', boite.left + 2, boite.top + boite.height / 2);
flushSync();
expect(comptes.querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('1');
expect(svgDe(cible, 'profilPourcentages').querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('1');
// Les abscisses s'alignent rang contre rang : la même zone de tracé.
const zones = ['profilComptes', 'profilPourcentages'].map((nom) => ['x', 'width'].map((a) => surfaceDe(svgDe(cible, nom)).getAttribute(a)));
expect(zones[1]).toEqual(zones[0]);
const [premier] = vue.detail.profil;
expect(premier.rencontres).toBe(vue.ligne.mesures.aggRencontres.tous.min);
const nom = vue.detail.personnes.find(({ id }) => id === premier.id).nom;
const bulles = [...cible.querySelectorAll('[role="tooltip"]')].filter((bulle) => !bulle.hidden);
expect(bulles).toHaveLength(1);
expect(texteDe(bulles[0])).toContain(nom);
expect(texteDe(bulles[0].querySelector('[data-compte-pire]'))).toBe(`${premier.rencontres} sur ${premier.plafondRealise}`);
await touche('Escape');
flushSync();
expect([...cible.querySelectorAll('[role="tooltip"]')].every((bulle) => bulle.hidden)).toBe(true);
});
test('le jumeau s’ouvre au clavier : une ligne par personne, puis la répartition ; refermé, le focus reste sur sa commande', async () => {
const vue = vueDe(generee(chargeSix()));
const cible = await monter(VueProfil, vue);
const commande = cible.querySelector('[data-commande="ouvrirJumeau"]');
commande.focus();
await touche('Enter');
await attendre(() => expect(cible.querySelector('[data-jumeau="0"]')).not.toBeNull());
const lignes = cible.querySelectorAll('[data-jumeau="0"] tbody tr');
expect(lignes).toHaveLength(6);
expect(cible.querySelectorAll('[data-jumeau="1"] tbody tr')).toHaveLength(vue.detail.repartitionRencontres.length);
const nomDuPremier = vue.detail.personnes.find(({ id }) => id === vue.detail.profil[0].id).nom;
expect(texteDe(lignes[0].querySelector('[data-colonne="nom"]'))).toBe(nomDuPremier);
expect(lignes[0].querySelector('[data-colonne="pourcent"] [data-pourcentage] + [data-compte-pire]')).not.toBeNull();
exigerComptes(cible);
const fermer = cible.querySelector('[data-commande="fermerJumeau"]');
fermer.focus();
await touche('Enter');
await attendre(() => expect(cible.querySelector('[data-jumeau]')).toBeNull());
expect(document.activeElement?.dataset.commande).toBe('ouvrirJumeau');
});
test('un rang au plafond réalisé nul : son signe dans les deux graphiques, au même rang ; aucun point au second, où le tracé s’interrompt', async () => {
const vue = vueDe(generee(chargeSix()));
const detail = structuredClone(vue.detail);
Object.assign(detail.profil[3], { rencontres: 0, plafondRealise: 0, pourcent: null, marque: 'plafondNul' });
const cible = await monter(VueProfil, { ...vue, detail });
const marques = detail.profil.filter(({ marque }) => marque !== null).length;
// L'abscisse du centre de chaque signe, au pixel : les deux graphiques
// alignent leurs rangs, un même rang y a la même abscisse.
const abscisses = (svg) =>
[...svg.querySelectorAll('[data-marque="graphiqueMarque"]')].map((signe) => {
const boite = signe.getBoundingClientRect();
return Math.round(boite.left + boite.width / 2);
});
const comptes = svgDe(cible, 'profilComptes');
const pourcentages = svgDe(cible, 'profilPourcentages');
expect(abscisses(comptes)).toHaveLength(marques);
expect(abscisses(pourcentages)).toEqual(abscisses(comptes));
const serie = pourcentages.querySelector('path[data-serie="serie1"]');
// Deux morceaux : le tracé s'interrompt au rang sans valeur.
expect(serie.getAttribute('d').match(/M/g)).toHaveLength(2);
});
test('la personne au rang 1 au plafond réalisé nul : la synthèse du second graphique, une phrase entière, sans pourcentage ni compte', async () => {
const vue = vueDe(generee(chargeSix()));
const detail = structuredClone(vue.detail);
Object.assign(detail.profil[0], { rencontres: 0, plafondRealise: 0, pourcent: null, marque: 'plafondNul' });
Object.assign(detail.bornes.rang1, { id: detail.profil[0].id, rencontres: 0, plafondRealise: 0, pourcent: null });
const cible = await monter(VueProfil, { ...vue, detail });
const svg = svgDe(cible, 'profilPourcentages');
const synthese = document.getElementById(svg.getAttribute('aria-describedby'));
const nom = detail.personnes.find(({ id }) => id === detail.profil[0].id).nom;
expect(texteDe(synthese)).toBe(t('qualite.profil.plafondNul', { nom }));
expect(synthese.querySelector('[data-pourcentage]')).toBeNull();
expect(synthese.querySelector('[data-compte-pire]')).toBeNull();
});
test('aucune appartenance : une seule série, ni borne de diversité, et la phrase ; une seule table : la phrase seule', async () => {
const cible = await monter(VueProfil, vueDe(generee(chargeSix({ appartenances: false }))));
const svg = svgDe(cible, 'profilComptes');
expect(svg.querySelectorAll('path[data-serie]')).toHaveLength(1);
expect(svg.querySelector('[data-repere="borne"]')).toBeNull();
expect(texteDe(cible.querySelector('[data-phrase-qualite="aucuneAppartenance"]'))).toBe(t('qualite.profil.aucuneAppartenance'));
const seule = chargeSix();
seule.tables = [{ id: 1, numero: 1, sieges: 6, forme: 'ronde', position: { x: 300, y: 0 } }];
const uneTable = await monter(VueProfil, vueDe(generee(seule)));
expect(uneTable.querySelector('[data-phrase-qualite="uneTable"]')).not.toBeNull();
expect(uneTable.querySelector('svg[data-graphique]')).toBeNull();
});
});
// --- La grande démonstration, et l'application entière ------------------------------
describe('l’en-tête et le profil dans la page (§ 12, § 19.4)', () => {
test('sur la grande : le nombre de nœuds des deux vues, jumeau fermé, sous le budget de la page', async () => {
const charge = generee(analyser(texteLivre('grande')).charge, { graine: 1, nombre: 1, arret: 300 });
const vue = vueDe(charge);
const entete = await monter(VueEntete, vue);
const profil = await monter(VueProfil, vue);
const noeuds = entete.querySelectorAll('*').length + profil.querySelectorAll('*').length;
// Le relevé que le rapport de la tâche cite.
console.info(`noeuds en-tête et profil, grande : ${entete.querySelectorAll('*').length} + ${profil.querySelectorAll('*').length} = ${noeuds}`);
expect(noeuds).toBeLessThan(1500);
expect(svgDe(profil, 'profilComptes').querySelector('path[data-serie="serie1"]')).not.toBeNull();
});
test('dans l’application : la page de qualité de la proposition demandée écrit, en tuiles, les cellules de sa ligne au tableau des propositions', async () => {
const charge = generee(chargeSix({ objectifs: true }));
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `soiree${SUFFIXES.etat}`, texte }] });
apps.push(m);
await m.seance.ouvrir('soiree');
const id = charge.propositions[1].id;
m.ui.allerA('propositions');
const cellules = await attendre(() => {
const ligne = m.racine.querySelector(`[data-proposition="${id}"]`);
expect(ligne).not.toBeNull();
return new Map([...ligne.querySelectorAll('td[data-colonne]')].map((td) => [td.dataset.colonne, texteDe(td)]));
});
m.ui.montrerProposition(id);
m.ui.allerA('qualite');
const page = await attendre(() => {
const section = m.racine.querySelector('[data-section="qualite"]');
expect(section.querySelector('[data-tuile]')).not.toBeNull();
expect(svgDe(section, 'profilComptes')).not.toBeNull();
return section;
});
let lues = 0;
for (const tuile of page.querySelectorAll('[data-tuile]')) {
if (!cellules.has(tuile.dataset.tuile)) continue;
expect(valeurDe(tuile), tuile.dataset.tuile).toBe(cellules.get(tuile.dataset.tuile));
lues += 1;
}
expect(lues).toBeGreaterThan(15);
});
});

View file

@ -2,21 +2,66 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Animateurs rencontrés » (§ 12.13) de la page de qualité, en
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// La vue « Animateurs rencontrés » (§ 12.13, B22, D37) de la page de
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne,
// sa ligne d'évaluation ; table, la table d'évaluation de la charge — et
// ne lit que detail, mis en forme par modeleAnimateurs (animateurs.js) :
// la vue ne calcule rien.
//
// Un profil trié de m(p), sur les personnes concernées, de la moins servie
// à la mieux servie : une seule série, et la borne des animateurs B(p) en
// repère pointillé, sous ce nom. Ni manque ni pourcentage : la borne ne se
// garantit pas (§ 4.5). Puis le jumeau, le profil et la répartition de
// m(p). Sans animateur ou sans personne concernée, la vue cède la place à
// sa phrase.
//
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte, que
// la carte ne transmet pas ; data-phrase-qualite sur la phrase, par son
// cas.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import { modeleAnimateurs } from './animateurs.js';
import Escalier from './graphiques/Escalier.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const modele = $derived(modeleAnimateurs(detail, t));
</script>
<div data-vue-qualite="animateurs">
<Carte titre={t('qualite.vue.animateurs.titre')} sousTitre={t('qualite.vue.animateurs.question')} niveau={3} />
<Carte titre={t('qualite.vue.animateurs.titre')} sousTitre={t('qualite.vue.animateurs.question')} niveau={3}>
{#if modele !== null}
{#if modele.cas === 'aucun'}
<p data-phrase-qualite="animateurs.aucun">{t('qualite.animateurs.aucun')}</p>
{:else}
<div class="vue">
<Escalier
graphique="animateurs"
nom={modele.escalier.nom}
synthese={modele.escalier.synthese}
series={modele.escalier.series}
reperes={modele.escalier.reperes}
infobulle={modele.infobulle}
/>
<Jumeau tableaux={modele.jumeau} />
</div>
{/if}
{/if}
</Carte>
</div>
<style>
.vue {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
</style>

View file

@ -2,21 +2,75 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Collisions d'appartenance » (§ 12.4) de la page de qualité, en
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
// La vue « Collisions d'appartenance » (§ 12.4, B13, D31) de la page de
// qualité : des barres horizontales, une par appartenance, la pire en
// haut — l'excédent décroissant, dans l'ordre du détail —, chacune en deux
// segments qui partent de zéro, « prouvé inévitable » et « reste », sur un
// axe commun, puis en encre secondaire son effectif, ses paires distinctes
// et son excédent ; l'intitulé porte la pastille de l'appartenance, de la
// teinte que teintesAppartenances donne sur tous les participants de
// l'événement, exclus compris, et le tracé n'en prend aucune. Hors du
// domaine des planchers, chaque barre n'a qu'un segment, les collisions
// cumulées, et une phrase dit pourquoi. Une seule table, aucune
// appartenance, aucune collision : la phrase du cas en tient lieu
// (§ 12.6, B15). Sous le graphique, son jumeau.
//
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// placement mesuré, null tant qu'il n'est pas calculé ; ligne et table, que
// cette vue ne lit pas — et met en forme le détail par modeleCollisions
// (appartenances.js), sans rien calculer. Son <div> porte data-vue-qualite,
// que la carte ne transmet pas : il enveloppe la carte.
import { getContext } from 'svelte';
import { teintesAppartenances } from '../appartenances.js';
import Carte from '../primitives/Carte.svelte';
import { modeleCollisions } from './appartenances.js';
import Barres from './graphiques/Barres.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const seance = getContext('seance');
const teintes = $derived(teintesAppartenances($seance.charge?.participants ?? []));
const modele = $derived(detail === null || detail.collisions === null ? null : modeleCollisions(detail, { t, teintes }));
let pointe = $state(null);
</script>
<div data-vue-qualite="collisions">
<Carte titre={t('qualite.vue.collisions.titre')} sousTitre={t('qualite.vue.collisions.question')} niveau={3} />
<Carte titre={t('qualite.vue.collisions.titre')} sousTitre={t('qualite.vue.collisions.question')} niveau={3}>
{#if modele !== null}
<div class="corps">
{#if modele.phrase !== null}
<p data-phrase-qualite={modele.code}>{modele.phrase}</p>
{/if}
{#if modele.graphique !== null}
{@const g = modele.graphique}
<Barres
graphique="collisions"
nom={g.nom}
synthese={g.synthese}
lignes={g.lignes}
segments={g.segments}
infobulle={g.infobulle}
bind:pointe
/>
<Jumeau tableaux={modele.tableaux} />
{/if}
</div>
{/if}
</Carte>
</div>
<style>
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
</style>

View file

@ -2,21 +2,131 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Diversité par animateur » (§ 12.13) de la page de qualité, en
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// La vue « Diversité par animateur » (§ 12.13, B22, D38) de la page de
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne,
// sa ligne d'évaluation ; table, la table d'évaluation de la charge — et
// ne lit que detail, mis en forme par modeleDiversite (animateurs.js) : la
// vue ne calcule rien.
//
// Le graphique dessine la soirée, le moins varié en tête : les
// appartenances et les étiquettes rencontrées en animant, en deux colonnes
// de barres juxtaposées, chacune son axe — une seule quand le détail n'a
// que celle-là, et la phrase qui le dit. Au-dessus, pour chaque colonne
// dessinée, ses trois nombres : le moins varié, le plus varié, au minimum.
// Chaque ligne porte en encre secondaire ses tours animés et ses personnes
// rencontrées en animant. Le jumeau porte la soirée de chaque animateur,
// puis une ligne par tour animé ; sans colonne à dessiner, il reste seul
// sous la phrase. Sans animateur, la vue cède la place à sa phrase.
//
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte, que
// la carte ne transmet pas ; data-equite sur les trois nombres d'une
// colonne, par sa grandeur ; data-phrase-qualite sur chaque phrase, par son
// cas.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import { modeleDiversite } from './animateurs.js';
import Barres from './graphiques/Barres.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const format = getContext('format');
const modele = $derived(modeleDiversite(detail, t));
</script>
<!-- Les trois nombres d'une colonne, sous son titre. -->
{#snippet troisNombres(colonne)}
<div><dt>{t('qualite.diversite.moins')}</dt><dd>{format.nombre(colonne.moins)}</dd></div>
<div><dt>{t('qualite.diversite.plus')}</dt><dd>{format.nombre(colonne.plus)}</dd></div>
<div><dt>{t('qualite.diversite.auMinimum')}</dt><dd>{format.nombre(colonne.auMinimum)}</dd></div>
{/snippet}
<div data-vue-qualite="diversite">
<Carte titre={t('qualite.vue.diversite.titre')} sousTitre={t('qualite.vue.diversite.question')} niveau={3} />
<Carte titre={t('qualite.vue.diversite.titre')} sousTitre={t('qualite.vue.diversite.question')} niveau={3}>
{#if modele !== null}
{#if modele.cas === 'aucunAnimateur'}
<p data-phrase-qualite="diversite.aucunAnimateur">{t('qualite.diversite.aucunAnimateur')}</p>
{:else}
<div class="vue">
{#if modele.phrase !== null}
<p data-phrase-qualite="diversite.{modele.phrase}">{t(`qualite.diversite.${modele.phrase}`)}</p>
{/if}
{#if modele.equite.length > 0}
<div class="equite">
{#each modele.equite as colonne (colonne.genre)}
<div class="colonne">
<p class="titre">{colonne.titre}</p>
{#if colonne.genre === 'appartenances'}
<dl data-equite="appartenances">{@render troisNombres(colonne)}</dl>
{:else}
<dl data-equite="etiquettes">{@render troisNombres(colonne)}</dl>
{/if}
</div>
{/each}
</div>
{/if}
{#if modele.barres !== null}
<Barres
graphique="diversite"
nom={modele.barres.nom}
synthese={modele.barres.synthese}
lignes={modele.barres.lignes}
colonnes={modele.barres.colonnes}
segments={modele.barres.segments}
infobulle={modele.infobulle}
/>
{/if}
<Jumeau tableaux={modele.jumeau} />
</div>
{/if}
{/if}
</Carte>
</div>
<style>
.vue {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
/* Les trois nombres de chaque colonne, côte à côte, dans l'ordre des
colonnes du graphique. */
.equite {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--espace-2) var(--espace-5);
}
.titre {
font-weight: bold;
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-4);
margin: var(--espace-1) 0 0;
}
dl div {
display: flex;
gap: var(--espace-1);
}
dt {
color: var(--couleur-page-texte-secondaire);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -2,20 +2,185 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « En-tête chiffré » (§ 12.2) de la page de qualité, en attente de
// son contenu : la carte titrée de sa question, et rien d'autre. Elle reçoit
// le contrat de propriétés des vues — detail, le détail du placement mesuré,
// null tant qu'il n'est pas calculé ; ligne, sa ligne d'évaluation ; table,
// la table d'évaluation de la charge — et n'en lit rien. Son <div> porte
// data-vue-qualite, que la carte ne transmet pas : il enveloppe la carte.
// La vue « En-tête chiffré » (§ 12.2, B11, D29, D34, D48) de la page de
// qualité : la population mesurée, puis une tuile par colonne de mesure
// du tableau des propositions, sous les groupes de ses en-têtes, puis la
// mention du certificat. Elle reçoit le contrat de propriétés des vues —
// detail, le détail du placement mesuré, null tant qu'il n'est pas
// calculé ; ligne, sa ligne d'évaluation ; table, la table d'évaluation de
// la charge — et rend ce que enteteDe (entete.js) en tire : rien ne se
// calcule ici.
//
// Une tuile porte son libellé, sa valeur écrite comme au tableau, « ne
// classe pas » quand le tableau ne porte pas sa colonne, son pourcentage
// et le compte du pire (Pourcentage) sur trois tuiles seulement, puis les
// lignes de sa borne, chacune d'une personne nommée. Le libellé est un
// terme : le survol et le focus du clavier ouvrent l'infobulle de son
// explication, celle de la colonne au tableau (Infobulle, primitive).
//
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte,
// que la carte ne transmet pas ; data-tuile sur chaque tuile, par le nom
// de sa colonne ; data-terme sur son libellé.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
import Pourcentage from './graphiques/Pourcentage.svelte';
import { enteteDe } from './entete.js';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const format = getContext('format');
const uid = $props.id();
const idExplication = `${uid}-explication`;
const entete = $derived(enteteDe({ detail, ligne, table }, { t, format }));
// Le terme expliqué : {ancre, terme, cle, details}, null sans infobulle.
// Ouvrir sur un terme remplace l'explication d'un autre ; quitter un terme
// ne ferme que la sienne.
let expliquee = $state(null);
function expliquer(evenement, tuile) {
expliquee = { ancre: evenement.currentTarget, terme: tuile.nom, cle: tuile.explication, details: {} };
}
function taire(tuile) {
if (expliquee?.terme === tuile.nom) expliquee = null;
}
const fermer = () => (expliquee = null);
const decrit = (tuile) => (expliquee?.terme === tuile.nom ? idExplication : undefined);
</script>
<div data-vue-qualite="entete">
<Carte titre={t('qualite.vue.entete.titre')} sousTitre={t('qualite.vue.entete.question')} niveau={3} />
<Carte titre={t('qualite.vue.entete.titre')} sousTitre={t('qualite.vue.entete.question')} niveau={3}>
{#if entete !== null}
<div class="entete">
<p class="population">{entete.population}</p>
{#each entete.groupes as groupe (groupe.cle)}
<section class="groupe" aria-labelledby="{uid}-{groupe.cle}">
<h4 id="{uid}-{groupe.cle}">{groupe.libelle}</h4>
<ul class="tuiles">
{#each groupe.tuiles as tuile (tuile.nom)}
<li class="tuile" data-tuile={tuile.nom}>
<!-- Le libellé est un terme, non une commande : le focus ne
sert qu'à montrer au clavier l'explication que le
pointeur montre au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<span
class="libelle terme"
data-terme="tuile:{tuile.nom}"
tabindex="0"
aria-describedby={decrit(tuile)}
onmouseenter={(evenement) => expliquer(evenement, tuile)}
onmouseleave={() => taire(tuile)}
onfocus={(evenement) => expliquer(evenement, tuile)}
onblur={() => taire(tuile)}>{tuile.libelle}</span
>
<span class="valeur">{tuile.texte}</span>
{#if tuile.neClassePas !== null}
<span class="ne-classe-pas">{tuile.neClassePas}</span>
{/if}
{#if tuile.pourcentage !== null}
<Pourcentage texte={tuile.pourcentage.texte} compte={tuile.pourcentage.compte} />
{/if}
{#each tuile.bornes as borne, rang (rang)}
<span class="borne">{borne}</span>
{/each}
</li>
{/each}
</ul>
</section>
{/each}
<p class="certificat">{entete.certificat}</p>
</div>
{/if}
</Carte>
<Infobulle id={idExplication} ouverte={expliquee} {fermer} />
</div>
<style>
.entete {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
.population,
.certificat {
color: var(--couleur-page-texte-secondaire);
}
.groupe {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
/* L'intitulé d'un groupe : un intertitre en petit corps, comme la rangée
des groupes du tableau. */
h4 {
margin: 0;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
font-weight: bold;
}
/* Les tuiles d'un groupe, côte à côte, qui passent à la ligne ; chacune
sur le fond sourd de la carte, sous un filet. */
.tuiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: var(--espace-2);
margin: 0;
padding: 0;
list-style: none;
}
.tuile {
display: flex;
flex-direction: column;
gap: var(--espace-1);
min-width: 0;
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond-sourd);
}
.libelle {
align-self: flex-start;
font-size: var(--taille-texte-petit);
}
/* Un libellé qui s'explique se signale par un soulignement pointillé ;
focalisé, par le contour du focus. */
.terme {
text-decoration: underline dotted;
text-underline-offset: 0.2em;
}
.terme:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.valeur {
font-size: var(--taille-titre);
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.ne-classe-pas,
.borne {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.ne-classe-pas {
font-style: italic;
}
</style>

View file

@ -2,21 +2,172 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Objectifs des tables » (§ 12.12) de la page de qualité, en
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// La vue « Objectifs des tables » (§ 12.12, B21, D34, D36) de la page de
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne,
// sa ligne d'évaluation ; table, la table d'évaluation de la charge — et
// ne lit que detail, mis en forme par modeleObjectifs (objectifs.js) : la
// vue ne calcule rien.
//
// Dans l'ordre : les phrases du critère décoché — la vue ne classe pas,
// ses planchers se dessinent quand même — et du domaine des planchers ;
// les personnes jamais servies, à place fixe, chacune avec ses tours
// possibles ; la soirée en texte, absente à un seul tour — la part des
// tours possibles servis, suivie des personnes jamais servies, puis ses
// deux comptes, ses tours hors objectif et leur plancher ; les barres
// par tour, le tour au plus grand reste en tête, chaque ligne suivie de
// ses assis sans table possible ; dessous, dans l'ordre des barres et en
// encre secondaire, le pourcentage de chaque tour et ses deux comptes —
// un pourcentage s'écrit hors du dessin, par Pourcentage, son compte juste
// après lui ; enfin le jumeau. Sans personne concernée, la vue cède la
// place à sa phrase.
//
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte, que
// la carte ne transmet pas ; data-jamais-servies sur la liste des
// personnes jamais servies, data-soiree-objectifs sur la ligne de la
// soirée, data-phrase-qualite sur chaque phrase, par son cas.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Barres from './graphiques/Barres.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
import Pourcentage from './graphiques/Pourcentage.svelte';
import { modeleObjectifs } from './objectifs.js';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const uid = $props.id();
const idJamais = `${uid}-jamais`;
const modele = $derived(modeleObjectifs(detail, t));
</script>
<div data-vue-qualite="objectifs">
<Carte titre={t('qualite.vue.objectifs.titre')} sousTitre={t('qualite.vue.objectifs.question')} niveau={3} />
<Carte titre={t('qualite.vue.objectifs.titre')} sousTitre={t('qualite.vue.objectifs.question')} niveau={3}>
{#if modele !== null}
{#if modele.cas === 'aucunConcerne'}
<p data-phrase-qualite="objectifs.aucunConcerne">{t('qualite.objectifs.aucunConcerne')}</p>
{:else}
<div class="vue">
{#if modele.neClassePas}
<p data-phrase-qualite="objectifs.critereDecoche">{t('qualite.objectifs.critereDecoche')}</p>
{/if}
{#if !modele.planchersValides}
<p data-phrase-qualite="objectifs.horsDomaine">{t('qualite.objectifs.horsDomaine')}</p>
{/if}
<section class="jamais" aria-labelledby={idJamais}>
<h4 id={idJamais}>{t('qualite.objectifs.jamaisServies')}</h4>
{#if modele.jamaisServies.length === 0}
<p class="secondaire" data-jamais-servies>{t('qualite.objectifs.aucuneJamaisServie')}</p>
{:else}
<ul data-jamais-servies>
{#each modele.jamaisServies as personne (personne.id)}
<li><span class="nom">{personne.nom}</span><span class="secondaire">{personne.possibles}</span></li>
{/each}
</ul>
{/if}
</section>
{#if modele.soiree !== null}
<div class="soiree" data-soiree-objectifs>
<Pourcentage texte={modele.soiree.pourcentage.texte} compte={modele.soiree.pourcentage.compte} />
<p>{modele.soiree.comptes}</p>
<p>{modele.soiree.hors}</p>
</div>
{/if}
<Barres
graphique="objectifs"
nom={modele.barres.nom}
synthese={modele.barres.synthese}
lignes={modele.barres.lignes}
segments={modele.barres.segments}
infobulle={modele.infobulle}
/>
<ol class="tours" aria-label={t('qualite.objectifs.parTour')}>
{#each modele.tours as tour (tour.tour)}
<li>
<span class="tour">{tour.nom}</span>
<Pourcentage texte={tour.pourcentage.texte} compte={tour.pourcentage.compte} />
</li>
{/each}
</ol>
<Jumeau tableaux={modele.jumeau} />
</div>
{/if}
{/if}
</Carte>
</div>
<style>
.vue {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
h4 {
margin: 0 0 var(--espace-1);
font-size: var(--taille-texte);
}
/* Les personnes jamais servies : le nom, puis ses tours possibles en
encre secondaire, une par ligne. */
ul {
display: flex;
flex-direction: column;
gap: var(--espace-1);
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
flex-wrap: wrap;
gap: 0 var(--espace-2);
}
.nom {
font-weight: bold;
}
.secondaire {
color: var(--couleur-page-texte-secondaire);
}
/* La soirée, en texte : la part servie en gras, son compte dessous, puis
ses comptes. */
.soiree {
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
.soiree :global([data-pourcentage]) {
font-weight: bold;
}
/* Le pourcentage de chaque tour, dans l'ordre des barres, en encre
secondaire : côte à côte, chacun son compte dessous. */
.tours {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2) var(--espace-5);
margin: 0;
padding: 0;
list-style: none;
color: var(--couleur-page-texte-secondaire);
}
.tours li {
flex-direction: column;
gap: 0;
}
.tour {
font-size: var(--taille-texte-petit);
}
</style>

View file

@ -2,20 +2,355 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Paires répétées » (§ 12.5) de la page de qualité, en attente de
// son contenu : la carte titrée de sa question, et rien d'autre. Elle reçoit
// le contrat de propriétés des vues — detail, le détail du placement mesuré,
// null tant qu'il n'est pas calculé ; ligne, sa ligne d'évaluation ; table,
// la table d'évaluation de la charge — et n'en lit rien. Son <div> porte
// La vue « Paires répétées » (§ 12.5, B14, D32, D33, D50) de la page de
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne et
// table, qu'elle ne lit pas — et met en forme ce que le détail porte, par
// paires.js : elle ne trie, ne compte ni ne mesure rien. Son <div> porte
// data-vue-qualite, que la carte ne transmet pas : il enveloppe la carte.
import { getContext } from 'svelte';
//
// Le tableau des paires : les paires choisies, puis, sous leur intitulé,
// les paires que les réservations imposent, chacune dans l'ordre du
// détail — chaque groupe est un tableau titré de sa légende. Une ligne
// (data-paire, « a:b ») nomme les deux personnes, leur appartenance et,
// pour un homonyme, son identifiant ; puis le nombre de fois ; puis ses
// commandes. Une ligne n'est pas une cible : seule sa première commande
// mène au plan au premier tour où la paire se revoit, la première
// personne mise en évidence, et chaque tour de la paire, avec sa table,
// est une commande sœur vers le plan à ce tour, la même personne mise en
// évidence. Chaque commande se nomme par son libellé suivi de la paire,
// l'identifiant d'un homonyme compris.
// Les commandes de ligne sont des boutons simples, sans refus possible :
// une ligne coûte une quinzaine de nœuds, cent lignes par page.
// Le tableau se pagine comme celui des propositions, par pages de cent,
// l'étendue et le total écrits ; changer de page porte le focus sur
// l'étendue. Une seule table, un seul tour, aucune paire revue : la
// phrase du cas (data-phrase-qualite) remplace le tableau.
//
// La matrice « qui a rencontré qui », sauf sur une seule table : quand le
// détail la porte — sous SEUIL_MATRICE_PARTICIPANTS —, la grille n × n
// dans l'ordre du profil, et son jumeau ; quand ses cases ne tiennent pas
// dans la largeur de la carte (Grille), la phrase de la matrice non
// dessinée remplace la grille et le jumeau. Au-delà du seuil, la phrase
// du seuil. L'une et l'autre renvoient à la fiche de chaque personne.
//
// Regarder n'écrit rien : la vue vit en lecture et sur un plan bloqué.
import { getContext, tick } from 'svelte';
import { constante } from '../../geometrie/constantes.js';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import Grille from './graphiques/Grille.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
import { casDesPaires, lignesDesPaires, matriceDesPaires, nomDistinct, pageDesPaires, syntheseMatrice } from './paires.js';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const format = getContext('format');
const ui = getContext('ui');
const cas = $derived(detail === null ? null : casDesPaires(detail));
const lignes = $derived(cas === 'tableau' ? lignesDesPaires(detail) : null);
// La page montrée, à partir de 1, retenue avec l'identifiant du détail où
// elle a été choisie : un autre détail — une autre proposition montrée —
// s'ouvre à sa première page ; le même détail remesuré garde la sienne,
// que pageDesPaires borne aux pages existantes.
let choix = $state({ detail: null, page: 1 });
const page = $derived(choix.detail === (detail?.id ?? null) ? choix.page : 1);
const pageMontree = $derived(lignes === null ? null : pageDesPaires(lignes, page));
const matrice = $derived(detail === null || cas === 'uneTable' ? null : matriceDesPaires(detail, { t, format }));
const synthese = $derived(detail === null ? null : syntheseMatrice(detail));
let dessinable = $state(true);
// L'étendue de la page, où le focus revient après un changement de page.
let etendue = $state(null);
function tourner(numero) {
choix = { detail: detail?.id ?? null, page: numero };
tick().then(() => etendue?.focus());
}
// Le nom d'une personne dans le nom accessible d'une commande.
const nomAccessible = (p) => nomDistinct(p, t);
// Montre la paire au plan au tour donné, à partir de 1, sa première
// personne mise en évidence.
function voir(paire, tour) {
ui.montrerTour(tour);
ui.mettreEnEvidence(paire.a.id);
ui.allerA('plan');
}
</script>
{#snippet personne(p)}
<span class="personne">
<span class="nom">{p.nom}</span>
{#if p.homonyme}
<span class="secondaire">{t('qualite.paires.homonyme', { id: p.id })}</span>
{/if}
{#if p.appartenance !== null}
<span class="secondaire">{p.appartenance}</span>
{/if}
</span>
{/snippet}
{#snippet groupe(titre, paires)}
<table>
<caption>{titre}</caption>
<thead>
<tr>
<th scope="col">{t('qualite.paires.colonne.paire')}</th>
<th scope="col" class="nombre">{t('qualite.paires.colonne.fois')}</th>
<th scope="col">{t('qualite.paires.colonne.tours')}</th>
</tr>
</thead>
<tbody>
{#each paires as paire (paire.cle)}
{@const noms = { a: nomAccessible(paire.a), b: nomAccessible(paire.b) }}
<tr data-paire={paire.cle}>
<th scope="row">{@render personne(paire.a)}{@render personne(paire.b)}</th>
<td class="nombre">{t('qualite.paires.fois', { fois: format.nombre(paire.fois) })}</td>
<td class="tours">
<button
type="button"
class="premiere"
data-commande="allerAuTour"
aria-label={t('qualite.paires.premierTourNom', { tour: paire.repetition, ...noms })}
onclick={() => voir(paire, paire.repetition)}>{t('qualite.paires.premierTour', { tour: paire.repetition })}</button
>
{#each paire.tours as { tour, table: numero } (tour)}
<button
type="button"
data-commande="allerAuTour"
aria-label={t('qualite.paires.tourNom', { tour, table: numero, ...noms })}
onclick={() => voir(paire, tour)}>{t('qualite.paires.tour', { tour, table: numero })}</button
>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
{/snippet}
{#snippet etroite()}
<span data-phrase-qualite="matriceEtroite">{t('qualite.paires.matrice.etroite')}</span>
{/snippet}
<div data-vue-qualite="paires">
<Carte titre={t('qualite.vue.paires.titre')} sousTitre={t('qualite.vue.paires.question')} niveau={3} />
<Carte titre={t('qualite.vue.paires.titre')} sousTitre={t('qualite.vue.paires.question')} niveau={3}>
{#if detail !== null}
<div class="corps">
{#if cas === 'uneTable'}
<p data-phrase-qualite="pairesUneTable">{t('qualite.paires.uneTable')}</p>
{:else if cas === 'unTour'}
<p data-phrase-qualite="pairesUnTour">{t('qualite.paires.unTour')}</p>
{:else if cas === 'aucune'}
<p data-phrase-qualite="pairesAucune">{t('qualite.paires.aucune')}</p>
{:else if pageMontree !== null}
<nav class="pages" aria-label={t('qualite.paires.pages.nom')}>
{#if pageMontree.page > 1}
<Commande cle="qualite.paires.pages.precedentes" nom="precedentes" executer={() => tourner(pageMontree.page - 1)} />
{/if}
<p class="etendue" bind:this={etendue} role="status" tabindex="-1">
{t('qualite.paires.pages.etendue', {
premier: format.nombre(pageMontree.premier),
dernier: format.nombre(pageMontree.dernier),
total: format.nombre(pageMontree.total),
})}
</p>
{#if pageMontree.page < pageMontree.pages}
<Commande cle="qualite.paires.pages.suivantes" nom="suivantes" executer={() => tourner(pageMontree.page + 1)} />
{/if}
</nav>
<div class="defilement">
{#if pageMontree.choisies.length > 0}
{@render groupe(t('qualite.paires.choisies'), pageMontree.choisies)}
{/if}
{#if pageMontree.imposees.length > 0}
{@render groupe(t('qualite.paires.imposees'), pageMontree.imposees)}
{/if}
</div>
{/if}
{#if cas !== 'uneTable'}
<section class="matrice">
<h4>{t('qualite.paires.matrice.titre')}</h4>
{#if matrice === null}
<p data-phrase-qualite="matriceSeuil">
{t('qualite.paires.matrice.seuil', {
participants: format.nombre(detail.population.mesuree),
seuil: format.nombre(constante('SEUIL_MATRICE_PARTICIPANTS')),
})}
</p>
{:else}
<Grille
graphique="matrice"
nom={t('qualite.paires.matrice.titre')}
synthese={t(synthese.cle, synthese.details)}
lignes={matrice.lignes}
colonnes={matrice.colonnes}
valeurs={matrice.valeurs}
diagonale
phrase={etroite}
infobulle={matrice.infobulle}
bind:dessinable
/>
{#if dessinable}
<Jumeau tableaux={[matrice.jumeau]} />
{/if}
{/if}
</section>
{/if}
</div>
{/if}
</Carte>
</div>
<style>
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
/* La nav des pages : précédentes, l'étendue, suivantes, sur une ligne
quand elle tient. */
.pages {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2) var(--espace-3);
}
.etendue {
font-variant-numeric: tabular-nums;
}
.etendue:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.defilement {
display: flex;
flex-direction: column;
gap: var(--espace-4);
max-width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--taille-texte);
}
caption {
padding-bottom: var(--espace-2);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: bold;
letter-spacing: var(--interlettre-capitales);
text-align: start;
text-transform: uppercase;
}
/* Un filet léger entre deux lignes, aucun filet vertical. */
th,
td {
padding: var(--espace-2) var(--espace-3);
border-bottom: 1px solid var(--couleur-ligne-filet);
vertical-align: top;
text-align: start;
}
thead th {
background-color: var(--couleur-carte-fond-sourd);
border-bottom-color: var(--couleur-carte-filet);
font-size: var(--taille-texte-petit);
font-weight: bold;
vertical-align: bottom;
}
tbody th {
font-weight: normal;
}
.nombre {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Les deux personnes d'une paire, l'une sous l'autre : le nom en gras,
l'identifiant d'un homonyme et l'appartenance en encre secondaire. */
.personne {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 var(--espace-2);
overflow-wrap: anywhere;
}
.nom {
font-weight: bold;
}
.secondaire {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
/* Les commandes d'une ligne : la première, à filet, puis chaque tour,
sans fond ni filet au repos, en rangée qui se replie. */
.tours {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1) var(--espace-2);
}
button {
min-height: var(--cible-minimale);
padding: var(--espace-1) var(--espace-2);
border: 1px solid transparent;
border-radius: var(--rayon-2);
background-color: transparent;
color: var(--couleur-action-encre);
font: inherit;
cursor: pointer;
}
button.premiere {
border-color: var(--couleur-page-filet);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
}
button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.matrice {
display: flex;
flex-direction: column;
gap: var(--espace-2);
min-width: 0;
}
h4 {
margin: 0;
font-size: var(--taille-titre-carte);
}
</style>

View file

@ -2,21 +2,154 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Performance du calcul » (§ 12.14) de la page de qualité, en
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
// La vue « Performance du calcul » (§ 12.14, B23, D40, D49) de la page de
// qualité : relancer plus longtemps aurait-il donné mieux ? Elle rend ce
// que vuePerformance (performance.js) tire de la course du détail, sans
// rien calculer (contrainte 41) :
//
// - la phrase de la source, sous [data-phrase-qualite] : sur le retenu,
// la proposition qu'elle mesure ; sans course gardée, que sa course ne
// l'est pas ; d'un seul point de relevé, qu'il n'y a rien à relier ;
// - la ligne de tête [data-course], un morceau par élément de liste :
// mouvements évalués sur le compte, puis durée, cœurs, cadence et
// nombre de propositions de la génération, ou « durée inconnue » ;
// - les petits multiples : un Escalier en forme points par valeur, sur
// l'abscisse logarithmique des mouvements, graduée aux points de
// relevé ; ils partagent leurs marges — l'abscisse alignée, point
// contre point — et le point pointé, désigné dans tous à la fois ;
// chaque graphique a son seul axe vertical, de zéro à sa plus grande
// valeur, et son infobulle porte la ligne du jumeau ;
// - la phrase du dernier changement [data-dernier-changement] ;
// - le jumeau, une ligne par point de relevé, le chemin du clavier et du
// lecteur d'écran vers chaque valeur.
//
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// d'évaluation ; table, la table d'évaluation de la charge — et ne lit que
// detail : le dernier point est le plan de la proposition, mesuré sous la
// même charge que sa ligne. Son <div> porte data-vue-qualite, que la carte
// ne transmet pas : il enveloppe la carte.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Escalier from './graphiques/Escalier.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
import { margesCommunes } from './graphiques/pointage.js';
import { vuePerformance } from './performance.js';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const format = getContext('format');
const vue = $derived(vuePerformance(detail, { t, format }));
const remplie = $derived(vue.phrases.length > 0 || vue.tete !== null);
// L'infobulle de chaque petit multiple : la ligne du jumeau du point
// pointé.
const infobulle = $derived(vue.jumeau === null ? null : { colonnes: vue.jumeau.colonnes, lignes: vue.jumeau.lignes });
// Les marges que chaque petit multiple demande pour son texte, par rang,
// absentes tant qu'il ne les a pas mesurées — la liaison lit alors null ;
// tous reçoivent les plus grandes, et leurs abscisses s'alignent.
let marges = $state([]);
const alignees = $derived(margesCommunes(marges));
// Le point de relevé pointé, commun aux petits multiples.
let pointe = $state(null);
let ouvert = $state(false);
</script>
<div data-vue-qualite="performance">
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3} />
{#if remplie}
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3}>
<div class="performance">
{#each vue.phrases as phrase (phrase.code)}
<p data-phrase-qualite={phrase.code}>{phrase.texte}</p>
{/each}
{#if vue.tete !== null}
<ul class="tete" data-course>
{#each vue.tete as morceau, rang (rang)}
<li>{morceau}</li>
{/each}
</ul>
{/if}
{#if vue.multiples.length > 0}
<p class="synthese">{vue.synthese}</p>
<div class="multiples">
{#each vue.multiples as multiple, rang (multiple.nom)}
<div class="multiple">
<Escalier
graphique={multiple.graphique}
nom={multiple.titre}
synthese={multiple.synthese}
series={[{ libelle: multiple.libelle, valeurs: multiple.valeurs }]}
forme="points"
abscisses={vue.abscisses}
hauteur={150}
titreX={t('qualite.performance.axe')}
{infobulle}
alignerSur={alignees}
bind:marges={() => marges[rang] ?? null, (requises) => (marges[rang] = requises)}
bind:pointe
/>
</div>
{/each}
</div>
{/if}
{#if vue.dernierChangement !== null}
<p data-dernier-changement>{vue.dernierChangement}</p>
{/if}
{#if vue.jumeau !== null}
<Jumeau tableaux={[vue.jumeau]} bind:ouvert />
{/if}
</div>
</Carte>
{:else}
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3} />
{/if}
</div>
<style>
.performance {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
/* La ligne de tête : ses morceaux côte à côte, séparés par un filet,
repliés quand ils ne tiennent pas. */
.tete {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-3);
margin: 0;
padding: 0;
list-style: none;
font-weight: bold;
}
.tete li + li {
padding-left: var(--espace-3);
border-left: 1px solid var(--couleur-carte-filet);
}
.synthese {
color: var(--couleur-page-texte-secondaire);
}
/* Les petits multiples : deux par ligne quand la carte le permet, un
seul sinon. */
.multiples {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
gap: var(--espace-4);
min-width: 0;
}
.multiple {
min-width: 0;
}
</style>

View file

@ -2,21 +2,115 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Profil trié des rencontres » (§ 12.3) de la page de qualité, en
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
// La vue « Profil trié des rencontres » (§ 12.3, B12, B15, D28, D30, D41,
// D51) de la page de qualité : deux graphiques empilés au même ordre de
// rangs, puis leur jumeau. Le premier, en comptes de personnes : les
// rencontres en plein, les appartenances rencontrées en tirets, le plafond
// réalisé en trait mixte, la borne de diversité en pointillés, les rangs
// marqués de leur signe, et sa phrase de synthèse dessous. Le second, en
// pourcentages : le pourcentage de chaque rang, interrompu aux rangs sans
// plafond, la règle à 100 %, les mêmes signes aux mêmes rangs — celui
// d'un rang sans point au pied de l'axe —, et sa synthèse, la personne au
// rang 1, son pourcentage et le compte du pire. Une appartenance au plus :
// une seule série, sans borne, et la phrase du cas ; une seule table : la
// phrase seule.
//
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// placement mesuré, null tant qu'il n'est pas calculé ; ligne et table,
// qu'elle ne lit pas — et rend ce que profilDe (profil.js) en tire : rien
// ne se trie ni ne se calcule ici. Les deux graphiques partagent le rang
// pointé (pointe) — l'infobulle porte la ligne entière du rang, dans le
// seul graphique survolé — et leurs marges (margesCommunes), qui alignent
// leurs abscisses rang contre rang.
//
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte ;
// data-graphique profilComptes et profilPourcentages sur les deux <svg> ;
// data-phrase-qualite sur la phrase d'un cas limite, par son code.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Escalier from './graphiques/Escalier.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
import Pourcentage from './graphiques/Pourcentage.svelte';
import { margesCommunes } from './graphiques/pointage.js';
import { graduerPourcent, profilDe } from './profil.js';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const format = getContext('format');
const profil = $derived(profilDe(detail, { t, format }));
const graduer = graduerPourcent({ t, format });
const titreX = t('qualite.profil.axe.rangs');
let pointe = $state(null);
let margesComptes = $state(null);
let margesPourcentages = $state(null);
const communes = $derived(margesCommunes([margesComptes, margesPourcentages]));
</script>
{#snippet synthesePourcentages()}
<span class="synthese">{profil.pourcentages.synthese.texte}</span>
{#if profil.pourcentages.synthese.pourcentage !== null}
<Pourcentage texte={profil.pourcentages.synthese.pourcentage.texte} compte={profil.pourcentages.synthese.pourcentage.compte} />
{/if}
{/snippet}
<div data-vue-qualite="profil">
<Carte titre={t('qualite.vue.profil.titre')} sousTitre={t('qualite.vue.profil.question')} niveau={3} />
<Carte titre={t('qualite.vue.profil.titre')} sousTitre={t('qualite.vue.profil.question')} niveau={3}>
{#if profil !== null}
<div class="profil">
{#if profil.phrase !== null}
<p data-phrase-qualite={profil.phrase}>{t(`qualite.profil.${profil.phrase}`)}</p>
{/if}
{#if profil.cas === 'profil'}
<Escalier
graphique="profilComptes"
nom={profil.comptes.nom}
synthese={profil.comptes.synthese}
series={profil.comptes.series}
reperes={profil.comptes.reperes}
marques={profil.comptes.marques}
infobulle={profil.infobulle}
{titreX}
alignerSur={communes}
bind:marges={margesComptes}
bind:pointe
/>
<Escalier
graphique="profilPourcentages"
nom={profil.pourcentages.nom}
synthese={synthesePourcentages}
series={profil.pourcentages.series}
reperes={profil.pourcentages.reperes}
marques={profil.pourcentages.marques}
infobulle={profil.infobulle}
{graduer}
{titreX}
alignerSur={communes}
bind:marges={margesPourcentages}
bind:pointe
/>
<Jumeau tableaux={profil.tableaux} />
{/if}
</div>
{/if}
</Carte>
</div>
<style>
.profil {
display: flex;
flex-direction: column;
gap: var(--espace-4);
min-width: 0;
}
p {
margin: 0;
}
.synthese {
display: block;
}
</style>

View file

@ -2,21 +2,178 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La vue « Appartenances à chaque table » (§ 12.11) de la page de qualité,
// en attente de son contenu : la carte titrée de sa question, et rien
// d'autre. Elle reçoit le contrat de propriétés des vues — detail, le détail
// du placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
// enveloppe la carte.
// La vue « Appartenances à chaque table » (§ 12.11, B20, D35, D54) de la
// page de qualité. Un sélecteur à trois positions — un groupe de boutons
// radio, au clavier — choisit ce que la grille compte : les sièges, les
// assis ou les doublons parmi les affiliés. La position est un état de la
// séance (etat_ui.js, melangeTables) : elle survit au changement de
// section, repart de « sièges » à l'ouverture d'un événement, n'écrit rien
// et ne fait aucune entrée d'historique ; elle vit en lecture et sur un
// plan bloqué.
//
// Puis la grille, une ligne par table dans l'ordre de la position — la
// pire en tête, une table sans affilié en dernier —, une colonne par tour ;
// chaque case écrit et colore le compte de la position, « — » sans
// couleur sans affilié ; l'infobulle d'une case porte son pourcentage et
// ses deux comptes. Une grille dont les cases ne tiennent pas dans la
// largeur de la carte cède la place à sa phrase, et son jumeau s'ouvre ;
// le sélecteur reste. Sous « doublons », sans collision, la phrase de la
// vue des collisions tient lieu de grille. Une seule table, aucune
// appartenance : la phrase du cas, sans sélecteur (§ 12.6, B15). Sous la
// grille, son jumeau : les trois comptes de chaque table à chaque tour.
//
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
// placement mesuré, null tant qu'il n'est pas calculé ; ligne et table, que
// cette vue ne lit pas — et met en forme le détail par modeleTables
// (appartenances.js), sans rien calculer. Son <div> porte data-vue-qualite,
// que la carte ne transmet pas : il enveloppe la carte.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import { CASE_MAX_TEXTES, modeleTables } from './appartenances.js';
import Grille from './graphiques/Grille.svelte';
import Jumeau from './graphiques/Jumeau.svelte';
let { detail = null, ligne = null, table = null } = $props();
const t = getContext('t');
const ui = getContext('ui');
const uid = $props.id();
const nomGroupe = `${uid}-position`;
const position = $derived($ui.melangeTables);
const modele = $derived(detail === null || detail.tables === null ? null : modeleTables(detail, position, { t }));
let pointe = $state(null);
let dessinable = $state(true);
let ouvert = $state(false);
// Une grille qui ne tient pas cède la place à son jumeau, ouvert.
$effect(() => {
if (!dessinable) ouvert = true;
});
</script>
<div data-vue-qualite="tables">
<Carte titre={t('qualite.vue.tables.titre')} sousTitre={t('qualite.vue.tables.question')} niveau={3} />
<Carte titre={t('qualite.vue.tables.titre')} sousTitre={t('qualite.vue.tables.question')} niveau={3}>
{#if modele !== null}
<div class="corps">
{#if modele.selecteur}
<fieldset class="selecteur">
<legend>{t('qualite.tables.selecteur')}</legend>
<label class="option">
<input
type="radio"
name={nomGroupe}
value="sieges"
data-position-tables="sieges"
checked={position === 'sieges'}
onchange={() => ui.reglerMelangeTables('sieges')}
/>
<span>{t('qualite.tables.position.sieges')}</span>
</label>
<label class="option">
<input
type="radio"
name={nomGroupe}
value="assis"
data-position-tables="assis"
checked={position === 'assis'}
onchange={() => ui.reglerMelangeTables('assis')}
/>
<span>{t('qualite.tables.position.assis')}</span>
</label>
<label class="option">
<input
type="radio"
name={nomGroupe}
value="doublons"
data-position-tables="doublons"
checked={position === 'doublons'}
onchange={() => ui.reglerMelangeTables('doublons')}
/>
<span>{t('qualite.tables.position.doublons')}</span>
</label>
</fieldset>
{/if}
{#if modele.phrase !== null}
<p data-phrase-qualite={modele.code}>{modele.phrase}</p>
{/if}
{#if modele.grille !== null}
{@const g = modele.grille}
<div class="grille">
<Grille
graphique="tables"
nom={g.nom}
synthese={g.synthese}
lignes={g.lignes}
colonnes={g.colonnes}
valeurs={g.valeurs}
phrase={g.phrase}
coteMaxTextes={CASE_MAX_TEXTES}
infobulle={g.infobulle}
bind:dessinable
bind:pointe
/>
</div>
{/if}
{#if modele.tableaux.length > 0}
<Jumeau tableaux={modele.tableaux} bind:ouvert />
{/if}
</div>
{/if}
</Carte>
</div>
<style>
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
p {
margin: 0;
}
/* La grille reçoit toute la largeur du corps de la carte et la mesure ;
min-width: 0 la laisse rétrécir avec la carte. */
.grille {
min-width: 0;
}
/* Le sélecteur : sa légende comme le libellé d'un champ, puis les trois
boutons côte à côte, qui passent à la ligne. */
.selecteur {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1) var(--espace-4);
margin: 0;
padding: 0;
border: none;
}
legend {
float: left;
margin-right: var(--espace-2);
padding: 0;
color: var(--couleur-page-texte-secondaire);
}
.option {
display: inline-flex;
align-items: center;
gap: var(--espace-1);
min-height: var(--cible-minimale);
cursor: pointer;
}
.option input {
margin: 0;
}
.option input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,179 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les modèles des deux vues des animateurs (§ 12.13, B22, D37, D38), purs :
// VueAnimateurs.svelte et VueDiversite.svelte les rendent. Ils mettent en
// forme les animateurs du détail d'un placement (detaillerQualite), dans
// l'ordre où le détail les porte, sans rien trier ni recompter.
//
// Les animateurs rencontrés : une série, m(p), et la borne des animateurs
// B(p) en repère pointillé, sous ce nom. Ni manque ni pourcentage : la
// borne n'est pas toujours atteignable (§ 4.5), et rien n'en mesure la part.
//
// La diversité par animateur : le graphique dessine la soirée, une ligne
// par animateur, le moins varié en tête, dans l'ordre du détail ; les
// appartenances et les étiquettes en deux colonnes juxtaposées, chacune son
// axe, jamais empilées. Une colonne seule quand le détail n'a que celle-là,
// sous le jeton de sa série ; aucune quand il n'en a ni l'une ni l'autre. Le
// jumeau porte la soirée de chaque animateur, puis une ligne par tour animé,
// dans l'ordre des tours : il détaille au-delà du graphique (§ 12.7).
// Les colonnes de la diversité, dans l'ordre : chacune sous le nom de son
// drapeau dans detail.animateurs.diversite.colonnes, de son compte dans une
// ligne et de son libellé, avec le genre de sa série.
const COLONNES_DIVERSITE = [
['appartenances', 'serie1'],
['etiquettes', 'serie2'],
];
/**
* Le modèle de la vue des animateurs rencontrés, ou ce qui la remplace.
*
* - null sans détail mesuré ;
* - {cas: 'aucun'} sans animateur ou sans personne concernée : la vue cède
* la place à sa phrase ;
* - sinon {cas: 'animateurs', escalier, infobulle, jumeau} : escalier,
* {nom, synthese, series, reperes} pour Escalier, une ligne par rang du
* profil des animateurs ; la synthèse lit le minimum et son effectif à la
* première ligne de la répartition, croissante ; infobulle, {colonnes,
* lignes}, la ligne du jumeau de chaque rang ; jumeau, le profil — nom,
* m(p), B(p), statut —, puis la répartition de m(p).
*
* @param {Object|null} detail le détail du placement (detaillerQualite)
* @param {(cle: string, details?: Object) => string} t
*/
export function modeleAnimateurs(detail, t) {
if (detail === null) return null;
const { animateurs, personnes } = detail;
if (animateurs === null || animateurs.profil === null) return { cas: 'aucun' };
const { profil, repartition } = animateurs;
const nomDe = new Map(personnes.map(({ id, nom }) => [id, nom]));
const colonnes = [
{ cle: 'nom', libelle: t('qualite.animateurs.colonne.nom'), genre: 'texte' },
{ cle: 'rencontres', libelle: t('qualite.animateurs.colonne.rencontres'), genre: 'nombre' },
{ cle: 'borne', libelle: t('qualite.animateurs.colonne.borne'), genre: 'nombre' },
{ cle: 'statut', libelle: t('qualite.animateurs.colonne.statut'), genre: 'texte' },
];
const lignes = profil.map(({ id, rencontres, borne, statut }) => ({
nom: nomDe.get(id),
rencontres,
borne,
statut: t(`participants.statut.${statut}`),
}));
const [minimum] = repartition;
return {
cas: 'animateurs',
escalier: {
nom: t('qualite.animateurs.graphique'),
synthese: t('qualite.animateurs.synthese', { minimum: minimum.valeur, effectif: minimum.effectif }),
series: [{ libelle: t('qualite.animateurs.serie'), valeurs: profil.map(({ rencontres }) => rencontres) }],
reperes: [{ genre: 'borne', libelle: t('qualite.animateurs.repere.borne'), valeurs: profil.map(({ borne }) => borne) }],
},
infobulle: { colonnes, lignes },
jumeau: [
{ nom: t('qualite.animateurs.jumeau.profil'), colonnes, lignes },
{
nom: t('qualite.animateurs.repartition.titre'),
colonnes: [
{ cle: 'valeur', libelle: t('qualite.animateurs.repartition.valeur'), genre: 'nombre' },
{ cle: 'effectif', libelle: t('qualite.animateurs.repartition.effectif'), genre: 'nombre' },
],
lignes: repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
},
],
};
}
/**
* Le modèle de la vue de la diversité par animateur, ou ce qui la remplace.
*
* - null sans détail mesuré ;
* - {cas: 'aucunAnimateur'} sans animateur : la vue cède la place à sa
* phrase ;
* - sinon {cas: 'diversite', phrase, equite, barres, infobulle, jumeau} :
* phrase, null quand les deux colonnes se dessinent, sinon le cas du
* § 12.6 — 'sansEtiquette', 'sansAppartenance' ou 'rien' ;
* equite, une entrée {genre, titre, moins, plus, auMinimum} par colonne
* dessinée, ses trois nombres ;
* barres, null sans colonne, sinon {nom, synthese, lignes, colonnes,
* segments} pour Barres — deux colonnes juxtaposées, ou un segment seul
* (colonnes null) —, une ligne {id, nom, valeurs, secondaire} par
* animateur, id son identifiant, valeurs la soirée, secondaire ses tours
* animés et ses personnes rencontrées en animant ;
* infobulle, null sans barres, sinon la ligne de la soirée du jumeau de
* chaque animateur ;
* jumeau, un tableau : la soirée de chaque animateur, puis chacun de ses
* tours animés — tours animés, personnes, et les colonnes dessinées.
*
* @param {Object|null} detail le détail du placement (detaillerQualite)
* @param {(cle: string, details?: Object) => string} t
*/
export function modeleDiversite(detail, t) {
if (detail === null) return null;
const { animateurs, personnes, idsTables, numerosTables } = detail;
if (animateurs === null || animateurs.diversite.lignes.length === 0) return { cas: 'aucunAnimateur' };
const { colonnes: drapeaux, lignes, equite } = animateurs.diversite;
const nomDe = new Map(personnes.map(({ id, nom }) => [id, nom]));
const numeroDe = new Map(idsTables.map((id, rang) => [id, numerosTables[rang]]));
const dessinees = COLONNES_DIVERSITE.filter(([cle]) => drapeaux[cle]);
let phrase = null;
if (dessinees.length === 0) phrase = 'rien';
else if (!drapeaux.etiquettes) phrase = 'sansEtiquette';
else if (!drapeaux.appartenances) phrase = 'sansAppartenance';
const parts = dessinees.map(([cle, genre]) => ({ genre, libelle: t(`qualite.diversite.colonne.${cle}`) }));
const colonnes = [
{ cle: 'animateur', libelle: t('qualite.diversite.colonne.animateur'), genre: 'texte' },
{ cle: 'portee', libelle: t('qualite.diversite.colonne.portee'), genre: 'texte' },
{ cle: 'toursAnimes', libelle: t('qualite.diversite.colonne.toursAnimes'), genre: 'nombre' },
{ cle: 'personnes', libelle: t('qualite.diversite.colonne.personnes'), genre: 'nombre' },
...dessinees.map(([cle]) => ({ cle, libelle: t(`qualite.diversite.colonne.${cle}`), genre: 'nombre' })),
];
// Une ligne du jumeau : l'animateur, sa portée, et les comptes des
// colonnes dessinées.
const rangee = (animateur, portee, toursAnimes, comptes) => {
const ligne = { animateur, portee, toursAnimes, personnes: comptes.personnes };
for (const [cle] of dessinees) ligne[cle] = comptes[cle];
return ligne;
};
const soirees = [];
const rangees = [];
for (const ligne of lignes) {
const nom = nomDe.get(ligne.id);
const soiree = rangee(nom, t('qualite.diversite.soiree'), ligne.toursAnimes, ligne);
soirees.push(soiree);
rangees.push(soiree);
for (const tour of ligne.parTour) {
const portee = t('qualite.diversite.tour', { tour: tour.tour + 1, table: numeroDe.get(tour.table) });
rangees.push(rangee(nom, portee, null, tour));
}
}
const barres =
dessinees.length === 0
? null
: {
nom: t('qualite.diversite.graphique'),
synthese: t('qualite.diversite.synthese', equite),
colonnes: dessinees.length === 2 ? parts : null,
segments: dessinees.length === 2 ? null : parts,
lignes: lignes.map((ligne) => ({
id: ligne.id,
nom: nomDe.get(ligne.id),
valeurs: dessinees.map(([cle]) => ligne[cle]),
secondaire: t('qualite.diversite.secondaire', { tours: ligne.toursAnimes, personnes: ligne.personnes }),
})),
};
return {
cas: 'diversite',
phrase,
equite: dessinees.map(([cle]) => ({ genre: cle, titre: t(`qualite.diversite.colonne.${cle}`), ...equite[cle] })),
barres,
infobulle: barres === null ? null : { colonnes, lignes: soirees },
jumeau: [{ nom: t('qualite.diversite.jumeau'), colonnes, lignes: rangees }],
};
}

View file

@ -0,0 +1,312 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des modèles des deux vues des animateurs (§ 12.13, B22, D37,
// D38) : le profil trié des animateurs rencontrés, sa borne en repère, sans
// manque ni pourcentage ; la diversité par animateur sur la soirée, en deux
// colonnes juxtaposées, le moins varié en tête, et son jumeau, la soirée
// puis chaque tour animé. Chaque nombre vient du détail du placement
// (detaillerQualite). Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { configurationDe } from '../../application/configuration.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { modeleAnimateurs, modeleDiversite } from './animateurs.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const V1 = VERSION.affichee;
// Neuf personnes inventées, leurs appartenances et leurs étiquettes.
const PERSONNES = [
['Ombrelle', 'Iris', 'Coopérative Aubier', []],
['Pervenche', 'Théo', 'Atelier Ressac', []],
['Lacasse', 'Ondine', 'Atelier Ressac', ['finance']],
['Tourteau', 'Basile', 'Coopérative Aubier', ['bénévole']],
['Quenouille', 'Maëlle', 'Atelier Ressac', ['finance', 'Montréal']],
['Rocaille', 'Anselme', null, ['bénévole']],
['Brindille', 'Pélagie', 'Coopérative Aubier', []],
['Cassonade', 'Firmin', null, ['Montréal']],
['Grésil', 'Ysaline', 'Atelier Ressac', []],
];
// Trois tables de trois places, trois tours ; un titre au siège 1 des tables
// 1 et 2. Iris Ombrelle, réservée à la table 1 pour la soirée, y anime à
// chaque tour ; Théo Pervenche, réservé à la table 2 au seul tour 2, y anime
// ce tour-là. appartenances : false pour une seule appartenance ;
// etiquettes : false pour aucune ; titres : false pour aucun animateur.
function chargeAnimateurs({ appartenances = true, etiquettes = true, titres = true } = {}) {
const charge = creerCharge({ id: 'evt-animateurs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
charge.participants = PERSONNES.map(([nom, prenom, appartenance, liste], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: appartenances ? appartenance : 'Coopérative Aubier',
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(etiquettes && liste.length > 0 ? { etiquettes: liste } : {}),
}));
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id + 10, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
if (titres) {
charge.titres = [1, 2].map((table) => ({ table, siege: 1, libelle: 'Animation' }));
charge.reservations = [
{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null },
{ participant: 2, table: 2, siege: null, portee: 'tour', tour: 2 },
];
}
charge.reglages.rencontrerAnimateurs = true;
charge.prochainsIds = { participant: 10, table: 4, proposition: 1 };
return charge;
}
// Le détail de la première proposition d'une génération.
function detailDe(options) {
const charge = chargeAnimateurs(options);
const configuration = configurationDe(charge);
const propositions = rechercher(configuration, { graine: 11, nombre: 1, arret: 200 });
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
return detaillerQualite(generee, generee.propositions[0].id);
}
// Chaque texte qu'atteint une valeur, chaînes des objets et des listes
// comprises.
function textes(valeur, rendus = []) {
if (typeof valeur === 'string') rendus.push(valeur);
else if (valeur !== null && typeof valeur === 'object') for (const element of Object.values(valeur)) textes(element, rendus);
return rendus;
}
describe('animateurs rencontrés : le profil trié (§ 12.13, B22, D37)', () => {
test('sans animateur, ou sans personne concernée, la vue cède la place à sa phrase', () => {
const detail = detailDe({ titres: false });
assert.equal(detail.animateurs, null);
assert.deepEqual(modeleAnimateurs(detail, t), { cas: 'aucun' });
const forge = structuredClone(detailDe());
forge.animateurs.profil = null;
forge.animateurs.repartition = null;
assert.deepEqual(modeleAnimateurs(forge, t), { cas: 'aucun' });
assert.equal(modeleAnimateurs(null, t), null);
});
test('une seule série, m(p) dans l’ordre du profil, et la borne des animateurs en repère pointillé, sous ce nom', () => {
const detail = detailDe();
const { profil } = detail.animateurs;
assert.ok(profil.length >= 2);
const modele = modeleAnimateurs(detail, t);
assert.equal(modele.cas, 'animateurs');
assert.deepEqual(modele.escalier.series, [
{ libelle: t('qualite.animateurs.serie'), valeurs: profil.map(({ rencontres }) => rencontres) },
]);
assert.deepEqual(modele.escalier.reperes, [
{ genre: 'borne', libelle: t('qualite.animateurs.repere.borne'), valeurs: profil.map(({ borne }) => borne) },
]);
assert.equal(modele.escalier.nom, t('qualite.animateurs.graphique'));
});
test('la synthèse : le minimum et son effectif, lus à la répartition', () => {
const detail = detailDe();
const [premiere] = detail.animateurs.repartition;
assert.equal(premiere.valeur, detail.animateurs.profil[0].rencontres);
assert.equal(
modeleAnimateurs(detail, t).escalier.synthese,
t('qualite.animateurs.synthese', { minimum: premiere.valeur, effectif: premiere.effectif }),
);
});
test('rouge : ni manque ni pourcentage de la borne, ni « plafond » — le jumeau porte m(p), B(p) et le statut', () => {
const detail = detailDe();
const modele = modeleAnimateurs(detail, t);
const [profil, repartition] = modele.jumeau;
assert.deepEqual(
profil.colonnes.map(({ cle, genre }) => [cle, genre]),
[
['nom', 'texte'],
['rencontres', 'nombre'],
['borne', 'nombre'],
['statut', 'texte'],
],
);
const nomDe = new Map(detail.personnes.map(({ id, nom }) => [id, nom]));
assert.deepEqual(
profil.lignes,
detail.animateurs.profil.map(({ id, rencontres, borne, statut }) => ({
nom: nomDe.get(id),
rencontres,
borne,
statut: t(`participants.statut.${statut}`),
})),
);
assert.deepEqual(
repartition.lignes,
detail.animateurs.repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
);
assert.deepEqual(modele.infobulle, { colonnes: profil.colonnes, lignes: profil.lignes });
for (const texte of textes(modele)) {
assert.doesNotMatch(texte, /%/, texte);
assert.doesNotMatch(texte, /plafond|manque/i, texte);
}
assert.equal(
textes(modele).some((texte) => /pourcent/i.test(texte)),
false,
);
const noms = [modele.escalier.nom, ...modele.jumeau.map(({ nom }) => nom)];
assert.equal(new Set(noms).size, noms.length, 'noms accessibles distincts');
});
});
describe('diversité par animateur : la soirée en graphique, chaque tour au jumeau (§ 12.13, B22, D38)', () => {
test('sans animateur, la vue cède la place à sa phrase', () => {
assert.deepEqual(modeleDiversite(detailDe({ titres: false }), t), { cas: 'aucunAnimateur' });
assert.equal(modeleDiversite(null, t), null);
});
test('deux colonnes juxtaposées, appartenances puis étiquettes, chacune son axe ; les valeurs de la soirée ; le moins varié en tête', () => {
const detail = detailDe();
const { colonnes, lignes } = detail.animateurs.diversite;
assert.deepEqual(colonnes, { appartenances: true, etiquettes: true });
const modele = modeleDiversite(detail, t);
assert.equal(modele.phrase, null);
assert.equal(modele.barres.segments, null);
assert.deepEqual(modele.barres.colonnes, [
{ genre: 'serie1', libelle: t('qualite.diversite.colonne.appartenances') },
{ genre: 'serie2', libelle: t('qualite.diversite.colonne.etiquettes') },
]);
assert.deepEqual(
modele.barres.lignes.map(({ id, valeurs }) => [id, valeurs]),
lignes.map(({ id, appartenances, etiquettes }) => [id, [appartenances, etiquettes]]),
);
const premiere = modele.barres.lignes[0];
const minimum = Math.min(...lignes.map(({ appartenances }) => appartenances));
assert.equal(premiere.valeurs[0], minimum, 'le moins varié en tête');
for (const ligne of modele.barres.lignes) {
const source = lignes.find(({ id }) => id === ligne.id);
assert.equal(ligne.secondaire, t('qualite.diversite.secondaire', { tours: source.toursAnimes, personnes: source.personnes }));
assert.equal(Object.hasOwn(ligne, 'appartenance'), false);
}
});
test('rouge : le graphique dessine la soirée, une ligne par animateur, jamais une ligne par tour', () => {
const detail = detailDe();
const { lignes } = detail.animateurs.diversite;
assert.ok(lignes.some(({ toursAnimes }) => toursAnimes > 1));
const modele = modeleDiversite(detail, t);
assert.equal(modele.barres.lignes.length, lignes.length);
assert.equal(new Set(modele.barres.lignes.map(({ id }) => id)).size, lignes.length);
});
test('au-dessus de chaque colonne, ses trois nombres : le moins varié, le plus varié, au minimum', () => {
const detail = detailDe();
const { equite } = detail.animateurs.diversite;
assert.deepEqual(modeleDiversite(detail, t).equite, [
{ genre: 'appartenances', titre: t('qualite.diversite.colonne.appartenances'), ...equite.appartenances },
{ genre: 'etiquettes', titre: t('qualite.diversite.colonne.etiquettes'), ...equite.etiquettes },
]);
});
test('rouge : le jumeau porte la soirée de chaque animateur, puis une ligne par tour animé, chacune au plus la soirée', () => {
const detail = detailDe();
const { lignes } = detail.animateurs.diversite;
const modele = modeleDiversite(detail, t);
const [jumeau] = modele.jumeau;
assert.deepEqual(
jumeau.colonnes.map(({ cle }) => cle),
['animateur', 'portee', 'toursAnimes', 'personnes', 'appartenances', 'etiquettes'],
);
const nomDe = new Map(detail.personnes.map(({ id, nom }) => [id, nom]));
const numeroDe = new Map(detail.idsTables.map((id, rang) => [id, detail.numerosTables[rang]]));
const attendues = lignes.flatMap((ligne) => [
{
animateur: nomDe.get(ligne.id),
portee: t('qualite.diversite.soiree'),
toursAnimes: ligne.toursAnimes,
personnes: ligne.personnes,
appartenances: ligne.appartenances,
etiquettes: ligne.etiquettes,
},
...ligne.parTour.map((tour) => ({
animateur: nomDe.get(ligne.id),
portee: t('qualite.diversite.tour', { tour: tour.tour + 1, table: numeroDe.get(tour.table) }),
toursAnimes: null,
personnes: tour.personnes,
appartenances: tour.appartenances,
etiquettes: tour.etiquettes,
})),
]);
assert.deepEqual(jumeau.lignes, attendues);
assert.equal(
jumeau.lignes.length,
lignes.reduce((somme, ligne) => somme + 1 + ligne.toursAnimes, 0),
);
for (const ligne of lignes) {
assert.equal(ligne.parTour.length, ligne.toursAnimes);
for (const tour of ligne.parTour) {
for (const cle of ['personnes', 'appartenances', 'etiquettes']) assert.ok(tour[cle] <= ligne[cle], `${ligne.id}, ${cle}`);
}
}
assert.ok(attendues.some(({ portee }) => portee === t('qualite.diversite.tour', { tour: 2, table: 12 })));
assert.deepEqual(modele.infobulle, {
colonnes: jumeau.colonnes,
lignes: jumeau.lignes.filter(({ portee }) => portee === t('qualite.diversite.soiree')),
});
const noms = [modele.barres.nom, ...modele.jumeau.map(({ nom }) => nom)];
assert.equal(new Set(noms).size, noms.length, 'noms accessibles distincts');
});
test('aucune étiquette : la colonne des appartenances seule, et la phrase', () => {
const detail = detailDe({ etiquettes: false });
assert.deepEqual(detail.animateurs.diversite.colonnes, { appartenances: true, etiquettes: false });
const modele = modeleDiversite(detail, t);
assert.equal(modele.phrase, 'sansEtiquette');
assert.equal(modele.barres.colonnes, null);
assert.deepEqual(modele.barres.segments, [{ genre: 'serie1', libelle: t('qualite.diversite.colonne.appartenances') }]);
assert.deepEqual(
modele.barres.lignes.map(({ valeurs }) => valeurs),
detail.animateurs.diversite.lignes.map(({ appartenances }) => [appartenances]),
);
assert.deepEqual(modele.equite.map(({ genre }) => genre), ['appartenances']);
assert.equal(modele.jumeau[0].colonnes.some(({ cle }) => cle === 'etiquettes'), false);
});
test('une seule appartenance et des étiquettes : la colonne des étiquettes seule, triée sur E_f, et la phrase', () => {
const detail = detailDe({ appartenances: false });
assert.deepEqual(detail.animateurs.diversite.colonnes, { appartenances: false, etiquettes: true });
const modele = modeleDiversite(detail, t);
assert.equal(modele.phrase, 'sansAppartenance');
assert.deepEqual(modele.barres.segments, [{ genre: 'serie2', libelle: t('qualite.diversite.colonne.etiquettes') }]);
assert.deepEqual(
modele.barres.lignes.map(({ id, valeurs }) => [id, valeurs]),
detail.animateurs.diversite.lignes.map(({ id, etiquettes }) => [id, [etiquettes]]),
);
assert.deepEqual(modele.equite.map(({ genre }) => genre), ['etiquettes']);
assert.equal(modele.jumeau[0].colonnes.some(({ cle }) => cle === 'appartenances'), false);
});
test('ni étiquette ni plus d’une appartenance : la phrase, aucun graphique, le jumeau garde tours animés et personnes', () => {
const detail = detailDe({ appartenances: false, etiquettes: false });
const modele = modeleDiversite(detail, t);
assert.equal(modele.phrase, 'rien');
assert.equal(modele.barres, null);
assert.equal(modele.infobulle, null);
assert.deepEqual(modele.equite, []);
assert.deepEqual(
modele.jumeau[0].colonnes.map(({ cle }) => cle),
['animateur', 'portee', 'toursAnimes', 'personnes'],
);
});
test('aucun « équité », aucun « plafond », aucun pourcentage', () => {
for (const options of [{}, { etiquettes: false }, { appartenances: false }]) {
for (const texte of textes(modeleDiversite(detailDe(options), t))) {
assert.doesNotMatch(texte, /équité|equite|plafond|%/i, texte);
}
}
});
});

View file

@ -0,0 +1,254 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle pur des vues des collisions d'appartenance (§ 12.4, B13, D31)
// et des appartenances à chaque table (§ 12.11, B20, D35) :
// VueCollisions.svelte et VueTables.svelte le câblent. Il met en forme ce
// que le détail du placement porte, dans l'ordre où il le porte, et ne
// trie, ne réduit ni ne recompte rien (contrainte 41) : chaque nombre rendu
// est un champ du détail, chaque pourcentage un entier qu'il porte déjà,
// écrit par la seule clé du signe, qualite.pourcentage.valeur, et suivi du
// compte de sa ligne.
//
// Chaque modèle rend son cas — le dessin, ou la phrase qui en tient lieu —,
// le code du cas, que la vue pose sur sa phrase (data-phrase-qualite), et
// les données de ses composants de graphique : Barres et Grille reçoivent
// leurs lignes, leurs valeurs et leur infobulle, Jumeau ses tableaux.
import { POSITIONS_MELANGE } from '../etat_ui.js';
/**
* Vrai quand aucune appartenance ne compte de collision : chaque ligne des
* collisions a zéro collision cumulée (§ 12.6, B15).
* @param {{collisions: {lignes: Array<{cumulees: number}>}}} detail
* @returns {boolean}
*/
export function sansCollision(detail) {
return detail.collisions.lignes.every(({ cumulees }) => cumulees === 0);
}
// Un cas sans dessin : sa phrase, ni graphique ni jumeau.
const phraseSeule = (cas, code, phrase) => ({ cas, code, phrase, graphique: null, tableaux: [] });
/**
* La vue des collisions d'appartenance (§ 12.4, B13, D31). Une seule
* table, aucune appartenance, aucune collision : la phrase de son cas,
* dans cet ordre. Sinon des barres, une ligne par appartenance dans l'ordre
* du détail — l'excédent décroissant —, chacune intitulée de l'appartenance,
* sa pastille de la teinte que teintes lui donne, ses deux segments,
* prouvé inévitable et reste, puis en encre secondaire son effectif, ses
* paires distinctes et son excédent. Hors du domaine des planchers, chaque
* barre n'a qu'un segment, les collisions cumulées, et la phrase dit
* pourquoi. Le jumeau, et l'infobulle de chaque barre, portent la ligne de
* l'appartenance : tous ses chiffres, prouvé et reste sur le domaine
* seulement.
*
* @param {Object} detail le détail du placement (detaillerQualite), comparable
* @param {{t: Function, teintes: Map<string, number|null>}} contexte
* t, la traduction ; teintes, teintesAppartenances des participants
* de l'événement, exclus compris
* @returns {{cas: 'uneTable'|'aucuneAppartenance'|'aucune'|'barres',
* code: string, phrase: string|null,
* graphique: {nom, synthese, segments, lignes, infobulle}|null,
* tableaux: Array<{nom, colonnes, lignes}>}}
*/
export function modeleCollisions(detail, { t, teintes }) {
if (detail.T === 1) return phraseSeule('uneTable', 'collisionsUneTable', t('qualite.collisions.uneTable'));
if (detail.G === 0) {
return phraseSeule('aucuneAppartenance', 'collisionsAucuneAppartenance', t('qualite.collisions.aucuneAppartenance'));
}
if (sansCollision(detail)) return phraseSeule('aucune', 'collisionsAucune', t('qualite.collisions.aucune'));
const { planchersValides, lignes } = detail.collisions;
const colonne = (cle, genre = 'nombre') => ({ cle, libelle: t(`qualite.collisions.colonne.${cle}`), genre });
const colonnes = [
colonne('appartenance', 'texte'),
colonne('effectif'),
colonne('cumulees'),
...(planchersValides ? [colonne('prouve'), colonne('reste')] : []),
colonne('pairesDistinctes'),
colonne('excedent'),
];
const lignesJumeau = lignes.map(({ groupe, effectif, cumulees, prouve, reste, pairesDistinctes, excedent }) => ({
appartenance: groupe,
effectif,
cumulees,
...(planchersValides ? { prouve, reste } : {}),
pairesDistinctes,
excedent,
}));
const segments = planchersValides
? [
{ genre: 'prouve', libelle: t('qualite.collisions.segment.prouve') },
{ genre: 'reste', libelle: t('qualite.collisions.segment.reste') },
]
: [{ genre: 'serie1', libelle: t('qualite.collisions.axe') }];
const pire = lignes[0];
return {
cas: 'barres',
code: planchersValides ? 'collisions' : 'collisionsHorsDomaine',
phrase: planchersValides ? null : t('qualite.collisions.horsDomaine'),
graphique: {
nom: t('qualite.collisions.graphique'),
synthese: t('qualite.collisions.synthese', { appartenance: pire.groupe, excedent: pire.excedent }),
segments,
lignes: lignes.map(({ groupe, effectif, cumulees, prouve, reste, pairesDistinctes, excedent }) => ({
id: groupe,
nom: groupe,
appartenance: teintes.get(groupe) ?? null,
valeurs: planchersValides ? [prouve, reste] : [cumulees],
secondaire: t('qualite.collisions.secondaire', { effectif, paires: pairesDistinctes, excedent }),
})),
infobulle: { colonnes, lignes: lignesJumeau },
},
tableaux: [{ nom: t('qualite.collisions.graphique'), colonnes, lignes: lignesJumeau }],
};
}
/** La plus grande case de la grille des tables, en hauteurs de ligne du
* compte qu'elle écrit (coteMaxTextes de Grille) : la case suit la taille
* du texte mesurée, et deux tours ne remplissent pas la carte de cases de
* plusieurs centaines de pixels. */
export const CASE_MAX_TEXTES = 2;
// Le nom de la colonne du pourcentage de chaque position, au jumeau.
const COLONNE_POURCENT = Object.freeze({ sieges: 'pourcentSieges', assis: 'pourcentAssis', doublons: 'pourcentDoublons' });
// Le pourcentage d'une case sous une position, pour Pourcentage : l'entier
// du détail suivi du signe, ou null pour « — », et le compte de sa ligne —
// δ appartenances pour c sièges, o assis ou α affiliés. Une case sans
// affilié n'a ni pourcentage ni compte : elle le dit.
function pourcentDe(t, cellule, position, capacite) {
if (cellule === null) return { texte: null, compte: t('qualite.tables.sansAffilie') };
const pourcent = cellule.pourcents[position];
return {
texte: pourcent === null ? null : t('qualite.pourcentage.valeur', { pourcent }),
compte: t(`qualite.tables.ligne.${position}`, {
appartenances: cellule.appartenances,
sieges: capacite,
assis: cellule.occupants,
affilies: cellule.affilies,
}),
};
}
// Les lignes du tableau des tables dans l'ordre de la position : pour
// chacune, son index dans le détail, sa ligne et son intitulé.
function rangees(detail, position, t) {
return detail.tables.ordres[position].map((i) => ({
ligne: detail.tables.lignes[i],
libelle: t('qualite.tables.table', { numero: detail.numerosTables[i] }),
}));
}
// Le jumeau de la grille : une ligne par table et par tour, dans l'ordre
// de la position, les trois comptes et leurs trois pourcentages, chacun
// suivi du compte de sa ligne, puis occupants, affiliés et capacité.
function jumeauTables(detail, position, t) {
const colonne = (cle, genre = 'nombre') => ({ cle, libelle: t(`qualite.tables.colonne.${cle}`), genre });
const colonnes = [colonne('table', 'texte'), colonne('tour', 'texte')];
for (const p of POSITIONS_MELANGE) colonnes.push(colonne(p), colonne(COLONNE_POURCENT[p], 'pourcentage'));
colonnes.push(colonne('occupants'), colonne('affilies'), colonne('capacite'));
const lignes = [];
for (const { ligne, libelle } of rangees(detail, position, t)) {
ligne.tours.forEach((cellule, r) => {
const valeurs = { table: libelle, tour: t('qualite.tables.tour', { tour: r + 1 }) };
for (const p of POSITIONS_MELANGE) {
valeurs[p] = cellule === null ? null : cellule.comptes[p];
valeurs[COLONNE_POURCENT[p]] = pourcentDe(t, cellule, p, ligne.capacite);
}
lignes.push({
...valeurs,
occupants: cellule?.occupants ?? null,
affilies: cellule?.affilies ?? null,
capacite: ligne.capacite,
});
});
}
return [{ nom: t('qualite.tables.graphique'), colonnes, lignes }];
}
/**
* La vue des appartenances à chaque table sous une position du sélecteur
* (§ 12.11, B20, D35). Une seule table, aucune appartenance : la phrase de
* son cas sous les trois positions, sans sélecteur. Sous « doublons », sans
* collision : la phrase de la vue des collisions, le sélecteur et le
* jumeau restant. Sinon la grille, une ligne par table dans l'ordre de la
* position — la pire en tête, une table sans affilié en dernier —, une
* colonne par tour, chaque case le compte de la position, null pour une
* case sans affilié ; son infobulle, une ligne par case dans l'ordre de la
* grille : la table, le tour, le compte, son pourcentage suivi de ses deux
* comptes, les occupants, les affiliés, la capacité. Le jumeau porte les
* trois comptes de chaque table à chaque tour, dans l'ordre de la position.
* La synthèse nomme la pire table et sa case la plus haute sous la
* position.
*
* Lève RangeError sur une position hors de POSITIONS_MELANGE.
*
* @param {Object} detail le détail du placement (detaillerQualite), comparable
* @param {'sieges'|'assis'|'doublons'} position
* @param {{t: Function}} contexte
* @returns {{cas: 'uneTable'|'aucuneAppartenance'|'sansCollision'|'grille',
* code: string, phrase: string|null, selecteur: boolean,
* grille: {nom, synthese, phrase, lignes, colonnes, valeurs, infobulle}|null,
* tableaux: Array<{nom, colonnes, lignes}>}}
*/
export function modeleTables(detail, position, { t }) {
if (!POSITIONS_MELANGE.includes(position)) {
throw new RangeError(`mélange des tables : position hors de ${POSITIONS_MELANGE.join(', ')}, reçu ${String(position)}`);
}
const sansSelecteur = (cas, code, cle) => ({ cas, code, phrase: t(cle), selecteur: false, grille: null, tableaux: [] });
if (detail.T === 1) return sansSelecteur('uneTable', 'tablesUneTable', 'qualite.tables.uneTable');
if (detail.G === 0) return sansSelecteur('aucuneAppartenance', 'tablesAucuneAppartenance', 'qualite.tables.aucuneAppartenance');
const tableaux = jumeauTables(detail, position, t);
if (position === 'doublons' && sansCollision(detail)) {
const phrase = t('qualite.collisions.aucune');
return { cas: 'sansCollision', code: 'tablesSansCollision', phrase, selecteur: true, grille: null, tableaux };
}
const lignes = rangees(detail, position, t);
const colonneInfobulle = (cle, cleLibelle, genre = 'nombre') => ({ cle, libelle: t(cleLibelle), genre });
const infobulle = {
colonnes: [
colonneInfobulle('table', 'qualite.tables.colonne.table', 'texte'),
colonneInfobulle('tour', 'qualite.tables.colonne.tour', 'texte'),
colonneInfobulle('compte', `qualite.tables.colonne.${position}`),
colonneInfobulle('pourcent', `qualite.tables.colonne.${COLONNE_POURCENT[position]}`, 'pourcentage'),
colonneInfobulle('occupants', 'qualite.tables.colonne.occupants'),
colonneInfobulle('affilies', 'qualite.tables.colonne.affilies'),
colonneInfobulle('capacite', 'qualite.tables.colonne.capacite'),
],
lignes: lignes.flatMap(({ ligne, libelle }) =>
ligne.tours.map((cellule, r) => ({
table: libelle,
tour: t('qualite.tables.tour', { tour: r + 1 }),
compte: cellule === null ? null : cellule.comptes[position],
pourcent: pourcentDe(t, cellule, position, ligne.capacite),
occupants: cellule?.occupants ?? null,
affilies: cellule?.affilies ?? null,
capacite: ligne.capacite,
})),
),
};
const [tete] = lignes;
const tetes = detail.tables.ordres[position];
return {
cas: 'grille',
code: 'tables',
phrase: null,
selecteur: true,
grille: {
nom: t('qualite.tables.graphique'),
synthese:
tete.ligne.pire === null
? t('qualite.tables.aucuneAppartenance')
: t('qualite.tables.synthese', { numero: detail.numerosTables[tetes[0]], valeur: tete.ligne.pire[position] }),
phrase: t('qualite.tables.tropLarge'),
lignes: lignes.map(({ libelle }) => ({ libelle })),
colonnes: Array.from({ length: detail.R }, (_, r) => ({ libelle: t('qualite.tables.tour', { tour: r + 1 }) })),
valeurs: lignes.flatMap(({ ligne }) => ligne.tours.map((cellule) => (cellule === null ? null : cellule.comptes[position]))),
infobulle,
},
tableaux,
};
}

View file

@ -0,0 +1,524 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle des vues des collisions d'appartenance (§ 12.4, B13,
// D31) et des appartenances à chaque table (§ 12.11, B20, D35) : chaque
// nombre vient du détail du placement, dans son ordre ; les barres, leur
// pastille, leurs segments et leur jumeau ; la grille de la position, ses
// cases, son infobulle et son jumeau des trois comptes ; les cas qui cèdent
// la place à une phrase. Les détails se mesurent par detaillerQualite sur
// des plans écrits à la main, dont les comptes attendus sont calculés ici à
// la main. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerTraduction } from '../../application/traduction.js';
import { capacite, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { teintesAppartenances } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { modeleCollisions, modeleTables, sansCollision } from './appartenances.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const AUBIER = 'Coopérative Aubier';
const RESSAC = 'Atelier Ressac';
// Une charge de personnes inventées, de tables de capacités données, et une
// proposition, n° 1, écrite à la main : à chaque tour, les listes de chaque
// table dans l'ordre des tables, sièges non attribués ; reserves, les
// personnes en réserve de chaque tour.
function chargeDe({ personnes, capacites, tours, reserves = tours.map(() => []) }) {
const charge = creerCharge({ id: 'evt-appartenances', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: tours.length });
charge.participants = personnes.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = capacites.map((sieges, rang) => ({
id: rang + 1,
numero: rang + 1,
sieges,
forme: 'ronde',
position: { x: 300 * rang, y: 0 },
}));
charge.propositions = [
{
id: 1,
graine: 1,
arret: 40,
historique: 1000,
produitVersion: VERSION.affichee,
siegesAttribues: false,
tables: charge.tables.map(({ id }) => id),
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: tours.length,
participants: charge.participants.map(({ id }) => id),
placement: tours.map((listes, r) => ({ sieges: listes, reserve: reserves[r] })),
},
];
charge.prochainsIds = { participant: personnes.length + 1, table: capacites.length + 1, proposition: 2 };
charge.evenement.etat = 'propose';
return charge;
}
// Treize personnes, quatre tables de 5, 4, 4 et 3 places, le même plan aux
// deux tours. Table 1 : un membre de chaque appartenance, trois places
// vides. Table 2 : deux membres de chacune. Table 3 : deux membres d'Aubier
// et deux personnes sans appartenance. Table 4 : trois bénévoles sans
// appartenance. Pour chaque case (c, o, α, δ) : table 1 (5, 2, 2, 2),
// table 2 (4, 4, 4, 2), table 3 (4, 4, 2, 1), table 4 sans affilié. Les
// comptes sièges, assis, doublons : table 1 (3, 0, 0), table 2 (2, 2, 2),
// table 3 (3, 3, 1). Les trois ordres diffèrent : sièges 1, 3, 2, 4 (1 et 3
// à égalité, départagées par l'ordre des tables) ; assis 3, 2, 1, 4 ;
// doublons 2, 3, 1, 4. Aubier : 5 membres, deux paires réunies à chaque
// tour, 4 collisions cumulées sur 2 paires distinctes ; Ressac : 3
// membres, une paire, 2 collisions sur 1 paire.
const PERSONNES_MELANGE = [
['Ombrelle', 'Iris', AUBIER],
['Lacasse', 'Ondine', RESSAC],
['Pervenche', 'Théo', AUBIER],
['Quenouille', 'Maëlle', AUBIER],
['Tourteau', 'Basile', RESSAC],
['Brindille', 'Anselme', RESSAC],
['Saule', 'Clothilde', AUBIER],
['Galet', 'Eudes', AUBIER],
['Mousse', 'Félicie', null],
['Ruisseau', 'Gaspard', null],
['Lichen', 'Hortense', null],
['Chardon', 'Jules', null],
['Ronce', 'Léonie', null],
];
const PLAN_MELANGE = [[1, 2], [3, 4, 5, 6], [7, 8, 9, 10], [11, 12, 13]];
const chargeMelange = () =>
chargeDe({ personnes: PERSONNES_MELANGE, capacites: [5, 4, 4, 3], tours: [PLAN_MELANGE, PLAN_MELANGE] });
// Six personnes, deux appartenances de deux, deux tables de trois, deux
// tours : aucun membre ne retrouve l'autre membre de son appartenance.
const PERSONNES_SIX = [
['Ombrelle', 'Iris', AUBIER],
['Pervenche', 'Théo', AUBIER],
['Lacasse', 'Ondine', RESSAC],
['Tourteau', 'Basile', RESSAC],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
];
const chargeSix = (personnes = PERSONNES_SIX) =>
chargeDe({
personnes,
capacites: [3, 3],
tours: [
[[1, 3, 5], [2, 4, 6]],
[[1, 4, 6], [2, 3, 5]],
],
});
// Sept personnes, deux tables de 5 et 4 places, deux tours. Au tour 1, la
// table 1 réunit deux membres d'Aubier, un de Ressac et une personne sans
// appartenance (c, o, α, δ) = (5, 4, 3, 2), comptes (3, 2, 1) ; la table 2,
// trois personnes sans appartenance, sans affilié. Au tour 2, la table 1
// réunit les deux membres d'Aubier et deux personnes sans appartenance
// (5, 4, 2, 1), comptes (4, 3, 1) ; la table 2, le membre de Ressac et deux
// personnes sans appartenance (4, 3, 1, 1), comptes (3, 2, 0). La table 1
// est en tête sous les trois positions, et ses cases les plus hautes
// diffèrent d'une position à l'autre : 4, 3 et 1, chacune hors du tour 1
// sous « sièges » et « assis ».
const chargeSynthese = () =>
chargeDe({
personnes: [
['Ombrelle', 'Iris', AUBIER],
['Pervenche', 'Théo', AUBIER],
['Lacasse', 'Ondine', RESSAC],
['Mousse', 'Félicie', null],
['Ruisseau', 'Gaspard', null],
['Lichen', 'Hortense', null],
['Chardon', 'Jules', null],
],
capacites: [5, 4],
tours: [
[[1, 2, 3, 4], [5, 6, 7]],
[[1, 2, 4, 5], [3, 6, 7]],
],
});
// Six personnes dont deux seules ont une appartenance, la même : une
// appartenance unique, dont les deux membres se retrouvent aux deux tours,
// 2 collisions cumulées sur une paire.
const chargeUneAppartenance = () =>
chargeDe({
personnes: [
['Ombrelle', 'Iris', AUBIER],
['Pervenche', 'Théo', AUBIER],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
['Mousse', 'Félicie', null],
['Ruisseau', 'Gaspard', null],
],
capacites: [3, 3],
tours: [
[[1, 2, 3], [4, 5, 6]],
[[1, 2, 4], [3, 5, 6]],
],
});
const detailDe = (charge) => detaillerQualite(charge, 1);
const teintesDe = (charge) => teintesAppartenances(charge.participants);
describe('modèle des collisions (§ 12.4, B13, D31)', () => {
test('une barre par appartenance dans l’ordre du détail, sa pastille, ses deux segments et ses trois autres chiffres en encre secondaire', () => {
const charge = chargeMelange();
const detail = detailDe(charge);
assert.equal(detail.statut, 'comparable');
const modele = modeleCollisions(detail, { t, teintes: teintesDe(charge) });
assert.equal(modele.cas, 'barres');
assert.equal(modele.phrase, null);
assert.deepEqual(
modele.graphique.segments.map(({ genre }) => genre),
['prouve', 'reste'],
);
assert.deepEqual(
modele.graphique.segments.map(({ libelle }) => libelle),
[t('qualite.collisions.segment.prouve'), t('qualite.collisions.segment.reste')],
);
// Les comptes calculés à la main ; le plancher, lu au détail.
assert.deepEqual(
detail.collisions.lignes.map(({ groupe, effectif, cumulees, pairesDistinctes }) => [groupe, effectif, cumulees, pairesDistinctes]),
[
[AUBIER, 5, 4, 2],
[RESSAC, 3, 2, 1],
],
);
const teintes = teintesDe(charge);
assert.deepEqual(
modele.graphique.lignes,
detail.collisions.lignes.map((l) => ({
id: l.groupe,
nom: l.groupe,
appartenance: teintes.get(l.groupe),
valeurs: [l.prouve, l.reste],
secondaire: t('qualite.collisions.secondaire', { effectif: l.effectif, paires: l.pairesDistinctes, excedent: l.excedent }),
})),
);
assert.deepEqual(
modele.graphique.lignes.map(({ appartenance }) => appartenance),
[2, 1],
'Ressac avant Aubier au comparateur de noms',
);
assert.match(modele.graphique.lignes[0].secondaire, /5/);
assert.equal(modele.graphique.synthese, t('qualite.collisions.synthese', { appartenance: AUBIER, excedent: detail.collisions.lignes[0].excedent }));
});
test('le jumeau et l’infobulle portent la même ligne par appartenance, tous ses chiffres, dans l’ordre des barres', () => {
const charge = chargeMelange();
const detail = detailDe(charge);
const modele = modeleCollisions(detail, { t, teintes: teintesDe(charge) });
const [tableau] = modele.tableaux;
assert.equal(modele.tableaux.length, 1);
assert.equal(tableau.nom, t('qualite.collisions.graphique'));
assert.deepEqual(
tableau.colonnes.map(({ cle, genre }) => [cle, genre]),
[
['appartenance', 'texte'],
['effectif', 'nombre'],
['cumulees', 'nombre'],
['prouve', 'nombre'],
['reste', 'nombre'],
['pairesDistinctes', 'nombre'],
['excedent', 'nombre'],
],
);
assert.deepEqual(
tableau.lignes,
detail.collisions.lignes.map((l) => ({
appartenance: l.groupe,
effectif: l.effectif,
cumulees: l.cumulees,
prouve: l.prouve,
reste: l.reste,
pairesDistinctes: l.pairesDistinctes,
excedent: l.excedent,
})),
);
assert.deepEqual(modele.graphique.infobulle, { colonnes: tableau.colonnes, lignes: tableau.lignes });
});
test('hors du domaine des planchers : un seul segment, les collisions cumulées, la phrase qui dit pourquoi, ni prouvé ni reste au jumeau', () => {
const charge = chargeMelange();
const detail = detailDe(charge);
const hors = {
...detail,
collisions: {
planchersValides: false,
lignes: detail.collisions.lignes.map((l) => ({ ...l, prouve: null, reste: null })),
},
};
const modele = modeleCollisions(hors, { t, teintes: teintesDe(charge) });
assert.equal(modele.cas, 'barres');
assert.equal(modele.code, 'collisionsHorsDomaine');
assert.equal(modele.phrase, t('qualite.collisions.horsDomaine'));
assert.deepEqual(modele.graphique.segments, [{ genre: 'serie1', libelle: t('qualite.collisions.axe') }]);
assert.deepEqual(
modele.graphique.lignes.map(({ valeurs }) => valeurs),
[[4], [2]],
);
assert.deepEqual(
modele.tableaux[0].colonnes.map(({ cle }) => cle),
['appartenance', 'effectif', 'cumulees', 'pairesDistinctes', 'excedent'],
);
});
test('une seule appartenance : ses barres et son jumeau, comme pour plusieurs (B15)', () => {
const charge = chargeUneAppartenance();
const detail = detailDe(charge);
assert.equal(detail.G, 1);
const modele = modeleCollisions(detail, { t, teintes: teintesDe(charge) });
assert.equal(modele.cas, 'barres');
assert.equal(modele.code, 'collisions');
assert.equal(modele.phrase, null);
assert.deepEqual(
modele.graphique.lignes.map(({ id, valeurs }) => [id, valeurs.reduce((a, b) => a + b, 0)]),
[[AUBIER, 2]],
);
assert.equal(modele.tableaux.length, 1);
});
test('une seule table, aucune appartenance, aucune collision : la phrase de chaque cas, sans graphique ni jumeau', () => {
const uneTable = chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] });
const sans = chargeSix(PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]));
const six = chargeSix();
const cas = [
[uneTable, 'uneTable', 'collisionsUneTable', 'qualite.collisions.uneTable'],
[sans, 'aucuneAppartenance', 'collisionsAucuneAppartenance', 'qualite.collisions.aucuneAppartenance'],
[six, 'aucune', 'collisionsAucune', 'qualite.collisions.aucune'],
];
for (const [charge, nom, code, cle] of cas) {
const modele = modeleCollisions(detailDe(charge), { t, teintes: teintesDe(charge) });
assert.deepEqual(modele, { cas: nom, code, phrase: t(cle), graphique: null, tableaux: [] }, nom);
}
assert.equal(sansCollision(detailDe(six)), true);
assert.equal(sansCollision(detailDe(chargeMelange())), false);
});
});
describe('modèle des appartenances à chaque table (§ 12.11, B20, D35)', () => {
// Le numéro de table de chaque ligne de la grille, dans l'ordre rendu.
const numeros = (modele) => modele.grille.lignes.map(({ libelle }) => libelle);
const tables = (...n) => n.map((numero) => t('qualite.tables.table', { numero }));
test('sous chaque position, les lignes suivent l’ordre de la position, la pire table en tête, la table sans affilié en dernier', () => {
const detail = detailDe(chargeMelange());
const attendus = { sieges: [1, 3, 2, 4], assis: [3, 2, 1, 4], doublons: [2, 3, 1, 4] };
for (const [position, ordre] of Object.entries(attendus)) {
const modele = modeleTables(detail, position, { t });
assert.equal(modele.cas, 'grille', position);
assert.equal(modele.selecteur, true);
assert.deepEqual(numeros(modele), tables(...ordre), position);
assert.deepEqual(
detail.tables.ordres[position].map((i) => detail.numerosTables[i]),
ordre,
position,
);
}
});
test('chaque case écrit le compte de la position, null pour « — » ; une colonne par tour', () => {
const detail = detailDe(chargeMelange());
const valeurs = {
sieges: [3, 3, 3, 3, 2, 2, null, null],
assis: [3, 3, 2, 2, 0, 0, null, null],
doublons: [2, 2, 1, 1, 0, 0, null, null],
};
for (const [position, attendues] of Object.entries(valeurs)) {
const { grille } = modeleTables(detail, position, { t });
assert.deepEqual(grille.valeurs, attendues, position);
assert.deepEqual(
grille.colonnes,
[1, 2].map((tour) => ({ libelle: t('qualite.tables.tour', { tour }) })),
);
assert.equal(grille.nom, t('qualite.tables.graphique'));
assert.equal(grille.phrase, t('qualite.tables.tropLarge'));
}
});
test('l’infobulle d’une case : sa table, son tour, le compte, le pourcentage de la position suivi de ses deux comptes, occupants, affiliés, capacité', () => {
const detail = detailDe(chargeMelange());
const pour = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
const sieges = modeleTables(detail, 'sieges', { t }).grille.infobulle;
assert.deepEqual(
sieges.colonnes.map(({ cle, genre }) => [cle, genre]),
[
['table', 'texte'],
['tour', 'texte'],
['compte', 'nombre'],
['pourcent', 'pourcentage'],
['occupants', 'nombre'],
['affilies', 'nombre'],
['capacite', 'nombre'],
],
);
// Table 1, tour 1 : δ = 2 sur 5 sièges, 40 %.
assert.deepEqual(sieges.lignes[0], {
table: t('qualite.tables.table', { numero: 1 }),
tour: t('qualite.tables.tour', { tour: 1 }),
compte: 3,
pourcent: { texte: pour(40), compte: t('qualite.tables.ligne.sieges', { appartenances: 2, sieges: 5 }) },
occupants: 2,
affilies: 2,
capacite: 5,
});
assert.equal(pour(40), '40\u{202F}%');
assert.equal(sieges.lignes[0].pourcent.compte, '2 appartenances pour 5 sièges');
// Table 3, tour 2, sous « assis » : δ = 1 pour 4 assis, 25 %.
const assis = modeleTables(detail, 'assis', { t }).grille.infobulle;
assert.deepEqual(assis.lignes[1].pourcent, { texte: pour(25), compte: '1 appartenance pour 4 assis' });
// Table 2, sous « doublons » : δ = 2 pour 4 affiliés, 50 %.
const doublons = modeleTables(detail, 'doublons', { t }).grille.infobulle;
assert.deepEqual(doublons.lignes[0].pourcent, { texte: pour(50), compte: '2 appartenances pour 4 affiliés' });
// Le compte de chaque case est celui de la position : la table 1 en
// compte 3 sous « sièges », 0 sous « assis » et « doublons » ; la
// table 3, 3 sous « assis », 1 sous « doublons ».
const comptes = (infobulle) => infobulle.lignes.map(({ table, compte }) => [table, compte]);
const table = (numero) => t('qualite.tables.table', { numero });
const parCase = (liste) => liste.flatMap(([numero, compte]) => [[table(numero), compte], [table(numero), compte]]);
assert.deepEqual(comptes(sieges), parCase([[1, 3], [3, 3], [2, 2], [4, null]]));
assert.deepEqual(comptes(assis), parCase([[3, 3], [2, 2], [1, 0], [4, null]]));
assert.deepEqual(comptes(doublons), parCase([[2, 2], [3, 1], [1, 0], [4, null]]));
// Table 4, sans affilié : « — », sans compte inventé, sous les trois.
for (const infobulle of [sieges, assis, doublons]) {
assert.deepEqual(infobulle.lignes.slice(-2), [1, 2].map((tour) => ({
table: t('qualite.tables.table', { numero: 4 }),
tour: t('qualite.tables.tour', { tour }),
compte: null,
pourcent: { texte: null, compte: t('qualite.tables.sansAffilie') },
occupants: null,
affilies: null,
capacite: 3,
})));
}
});
test('le jumeau : par table et tour, dans l’ordre de la position, les trois comptes et leurs trois pourcentages, chacun suivi du compte de sa ligne', () => {
const detail = detailDe(chargeMelange());
const pour = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
const { tableaux } = modeleTables(detail, 'doublons', { t });
assert.equal(tableaux.length, 1);
const [tableau] = tableaux;
assert.equal(tableau.nom, t('qualite.tables.graphique'));
assert.deepEqual(
tableau.colonnes.map(({ cle, genre }) => [cle, genre]),
[
['table', 'texte'],
['tour', 'texte'],
['sieges', 'nombre'],
['pourcentSieges', 'pourcentage'],
['assis', 'nombre'],
['pourcentAssis', 'pourcentage'],
['doublons', 'nombre'],
['pourcentDoublons', 'pourcentage'],
['occupants', 'nombre'],
['affilies', 'nombre'],
['capacite', 'nombre'],
],
);
assert.deepEqual(
tableau.lignes.map(({ table }) => table),
tables(2, 2, 3, 3, 1, 1, 4, 4),
);
// Table 3, tour 1 : (c, o, α, δ) = (4, 4, 2, 1).
assert.deepEqual(tableau.lignes[2], {
table: t('qualite.tables.table', { numero: 3 }),
tour: t('qualite.tables.tour', { tour: 1 }),
sieges: 3,
pourcentSieges: { texte: pour(25), compte: '1 appartenance pour 4 sièges' },
assis: 3,
pourcentAssis: { texte: pour(25), compte: '1 appartenance pour 4 assis' },
doublons: 1,
pourcentDoublons: { texte: pour(50), compte: '1 appartenance pour 2 affiliés' },
occupants: 4,
affilies: 2,
capacite: 4,
});
assert.deepEqual(tableau.lignes[7].pourcentDoublons, { texte: null, compte: t('qualite.tables.sansAffilie') });
assert.equal(tableau.lignes[7].doublons, null);
});
test('la synthèse nomme la pire table de la position et sa case la plus haute', () => {
const detail = detailDe(chargeMelange());
assert.equal(modeleTables(detail, 'sieges', { t }).grille.synthese, t('qualite.tables.synthese', { numero: 1, valeur: 3 }));
assert.equal(modeleTables(detail, 'assis', { t }).grille.synthese, t('qualite.tables.synthese', { numero: 3, valeur: 3 }));
assert.equal(modeleTables(detail, 'doublons', { t }).grille.synthese, t('qualite.tables.synthese', { numero: 2, valeur: 2 }));
});
test('la synthèse prend la case la plus haute de la pire table sous la position choisie, non sous une autre', () => {
const detail = detailDe(chargeSynthese());
assert.deepEqual(detail.tables.lignes[0].pire, { sieges: 4, assis: 3, doublons: 1 });
for (const [position, valeur] of [['sieges', 4], ['assis', 3], ['doublons', 1]]) {
const modele = modeleTables(detail, position, { t });
assert.deepEqual(numeros(modele), tables(1, 2), position);
assert.equal(modele.grille.synthese, t('qualite.tables.synthese', { numero: 1, valeur }), position);
}
});
test('une seule appartenance : la grille sous les trois positions, le sélecteur et le jumeau (B15)', () => {
const detail = detailDe(chargeUneAppartenance());
assert.equal(detail.G, 1);
for (const position of ['sieges', 'assis', 'doublons']) {
const modele = modeleTables(detail, position, { t });
assert.equal(modele.cas, 'grille', position);
assert.equal(modele.selecteur, true, position);
assert.equal(modele.tableaux.length, 1, position);
assert.deepEqual(modele.grille.valeurs, { sieges: [2, 2, null, null], assis: [2, 2, null, null], doublons: [1, 1, null, null] }[position], position);
}
});
test('sans collision : sous « doublons » seulement, la phrase des collisions, le sélecteur et le jumeau restent ; sous « sièges » et « assis », la grille', () => {
const detail = detailDe(chargeSix());
const doublons = modeleTables(detail, 'doublons', { t });
assert.equal(doublons.cas, 'sansCollision');
assert.equal(doublons.code, 'tablesSansCollision');
assert.equal(doublons.phrase, t('qualite.collisions.aucune'));
assert.equal(doublons.selecteur, true);
assert.equal(doublons.grille, null);
assert.equal(doublons.tableaux.length, 1);
for (const position of ['sieges', 'assis']) {
const modele = modeleTables(detail, position, { t });
assert.equal(modele.cas, 'grille', position);
assert.deepEqual(modele.grille.valeurs, [1, 1, 1, 1], position);
}
});
test('une seule table, aucune appartenance : la phrase sous les trois positions, sans sélecteur, grille ni jumeau', () => {
const uneTable = chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] });
const sans = chargeSix(PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]));
for (const position of ['sieges', 'assis', 'doublons']) {
assert.deepEqual(modeleTables(detailDe(uneTable), position, { t }), {
cas: 'uneTable',
code: 'tablesUneTable',
phrase: t('qualite.tables.uneTable'),
selecteur: false,
grille: null,
tableaux: [],
});
assert.deepEqual(modeleTables(detailDe(sans), position, { t }), {
cas: 'aucuneAppartenance',
code: 'tablesAucuneAppartenance',
phrase: t('qualite.tables.aucuneAppartenance'),
selecteur: false,
grille: null,
tableaux: [],
});
}
});
test('une position hors des trois lève RangeError', () => {
const detail = detailDe(chargeMelange());
for (const faute of ['Sieges', 'tous', null, undefined]) {
assert.throws(() => modeleTables(detail, faute, { t }), RangeError, String(faute));
}
});
});

View file

@ -0,0 +1,169 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de l'en-tête chiffré de la page de qualité (§ 12.2, B11, D29,
// D34, D48, D52), pur : VueEntete.svelte le câble.
//
// Une tuile par colonne de mesure du tableau des propositions, dans l'ordre
// de COLONNES et sous ses groupes (GROUPES_COLONNES) : la tuile lit sa
// mesure dans la ligne d'évaluation de la source par lireColonne, la
// cellule même que le tableau écrit, mise en forme par texteCellule. Une
// colonne que le tableau porte donne à sa tuile son libellé et son
// explication ; une colonne qu'il ne porte pas — critère décoché, valeur
// que le mode réglé ne compare pas — donne la sienne sous le libellé
// qualite.tuile.<nom>, qui dit qu'elle ne classe pas. Un critère sans objet
// n'a pas de tuile : sa mesure est absente de la ligne (null), seul cas où
// une colonne conditionnelle n'a pas de valeur.
//
// Les bornes des tuiles viennent du détail du placement (detail.bornes),
// chacune celle d'une personne nommée, que la tuile nomme : rien ne s'y
// recompte, rien ne s'y moyenne. Les tours hors objectif portent le
// plancher de la soirée (detail.objectifs) quand le diagnostic en écrit
// un — non nul, sous des planchers valides — ; les rencontres répétées et
// les retours choisis énoncent à part les imposés, lus dans la ligne
// d'évaluation et dans la table. Trois tuiles portent un pourcentage, un
// entier que le détail porte déjà, suivi du compte du pire : le minimum
// brut (la personne au rang 1 du profil), les tours hors objectif (la
// soirée), le minimum de s(p) (la personne à ce minimum). Aucune autre.
import { COLONNES, GROUPES_COLONNES, explicationDeColonne, lireColonne, tableau, texteCellule } from '../propositions/modele.js';
/** Les tuiles qui portent un pourcentage (B11, D34), dans l'ordre de
* COLONNES : aucune autre n'en porte. */
export const TUILES_A_POURCENTAGE = Object.freeze(['rencontresTous', 'toursHorsObjectif', 'toursSelonObjectif']);
// La clé de l'en-tête de chaque colonne, celle que le tableau écrit.
const EN_TETES = new Map(COLONNES.map(({ nom, cle }) => [nom, cle]));
/**
* @typedef {Object} Tuile
* @property {string} nom le nom de sa colonne
* @property {string} libelle traduit : celui de la colonne, ou qualite.tuile.<nom>
* @property {string} explication la clé de l'explication de la colonne
* @property {string} texte la valeur, écrite comme au tableau
* @property {boolean} classe vrai quand le tableau porte la colonne
* @property {string|null} neClassePas la mention, null quand elle classe
* @property {string[]} bornes les lignes de la borne, traduites
* @property {{texte: string|null, compte: string}|null} pourcentage
* le pourcentage écrit, null quand son dénominateur est nul, et
* le compte du pire ; null hors des trois tuiles
*/
// Les lignes de borne et le pourcentage d'une tuile, lus dans le détail.
// nommer(id) rend la ligne qui nomme la personne id ; lire(nom) la valeur
// d'une colonne de la ligne d'évaluation ; ligne et table, la ligne
// d'évaluation et la table, qui portent les imposés.
function bornesDe(nom, { detail, ligne, table, t, nommer, lire }) {
const { bornes } = detail;
const pourcent = (valeur) => (valeur === null ? null : t('qualite.pourcentage.valeur', { pourcent: valeur }));
const rendu = (lignes, pourcentage = null) => ({ bornes: lignes, pourcentage });
switch (nom) {
case 'rencontresTous': {
const { rang1 } = bornes;
if (rang1 === null) return rendu([]);
const lignes = [nommer(rang1.id), t('qualite.tuile.plafondRealise', { plafond: rang1.plafondRealise })];
if (rang1.plafondAPriori !== null) lignes.push(t('qualite.tuile.plafondAPriori', { plafond: rang1.plafondAPriori }));
return rendu(lignes, {
texte: pourcent(rang1.pourcent),
compte: t('qualite.tuile.pireRencontres', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }),
});
}
case 'manqueMax': {
const { manque } = bornes;
if (manque === null) return rendu([]);
const effectif = lire('effectifManque');
return rendu([
nommer(manque.id),
...(effectif === null ? [] : [t('qualite.tuile.effectifManque', { effectif })]),
t('qualite.tuile.plafondRealise', { plafond: manque.plafondRealise }),
]);
}
case 'appartenancesVues': {
const { diversite } = bornes;
if (diversite === null) return rendu([t('qualite.tuile.sansAppartenance')]);
return rendu([nommer(diversite.id), t('qualite.tuile.borneDiversite', { borne: diversite.aMax })]);
}
case 'toursHorsObjectif': {
const { soiree } = bornes;
if (soiree === null) return rendu([]);
const plancher = detail.objectifs?.soiree.plancher ?? null;
const ecrit = detail.collisions.planchersValides && plancher !== null && plancher > 0;
return rendu(ecrit ? [t('qualite.tuile.plancher', { plancher })] : [], {
texte: soiree.pourcent === null ? null : t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
compte: t('qualite.tuile.pireJamaisServies', { personnes: soiree.jamaisServies }),
});
}
case 'toursSelonObjectif': {
const { objectifs } = bornes;
if (objectifs === null) return rendu([]);
return rendu([nommer(objectifs.id), t('qualite.tuile.toursPossibles', { possibles: objectifs.possibles })], {
texte: pourcent(objectifs.pourcent),
compte: t('qualite.tuile.pireServis', { servis: objectifs.servis, possibles: objectifs.possibles }),
});
}
case 'rencontresRepetees':
return rendu([t('qualite.tuile.imposees', { imposees: ligne.mesures.rencontresRepetees.imposees })]);
case 'retoursChoisis':
return rendu([t('qualite.tuile.imposes', { imposes: table.imposes.retours })]);
case 'animateursRencontres': {
const { animateurs } = bornes;
if (animateurs === null) return rendu([]);
return rendu([nommer(animateurs.id), t('qualite.tuile.borneAnimateurs', { borne: animateurs.borne })]);
}
default:
return rendu([]);
}
}
/**
* L'en-tête chiffré d'une source mesurée, sous le contrat de propriétés des
* vues : null sans détail comparable, sans ligne ou sans table.
*
* @param {{detail: Object|null, ligne: Object|null, table: Object|null}} vue
* le détail du placement (detaillerQualite), sa ligne d'évaluation
* et la table d'évaluation de la charge
* @param {{t: Function, format: {nombre: Function}}} outils
* @returns {null|{population: string, groupes: Array<{cle: string,
* libelle: string, tuiles: Tuile[]}>, certificat: string}}
* population : la mention de la population mesurée ; groupes : ceux de
* GROUPES_COLONNES qui portent une tuile au moins, dans leur ordre ;
* certificat : « minimum atteint » ou « atteignabilité inconnue », qui
* suit les tuiles
*/
export function enteteDe({ detail, ligne, table }, { t, format }) {
if (detail === null || detail.statut !== 'comparable' || ligne === null || table === null) return null;
const portees = new Set(tableau(table).colonnes.map(({ nom }) => nom));
const noms = new Map(detail.personnes.map(({ id, nom }) => [id, nom]));
const contexte = {
detail,
ligne,
table,
t,
nommer: (id) => t('qualite.tuile.personne', { nom: noms.get(id) }),
lire: (nom) => lireColonne(ligne, nom).valeur,
};
const groupes = [];
for (const { cle, colonnes } of GROUPES_COLONNES) {
if (cle === null) continue;
const deGroupe = [];
for (const nom of colonnes) {
const cellule = lireColonne(ligne, nom);
const classe = portees.has(nom);
if (!classe && cellule.valeur === null) continue;
deGroupe.push({
nom,
libelle: t(classe ? EN_TETES.get(nom) : `qualite.tuile.${nom}`),
explication: explicationDeColonne(nom),
texte: texteCellule(cellule, { t, format }),
classe,
neClassePas: classe ? null : t('qualite.tuile.neClassePas'),
...bornesDe(nom, contexte),
});
}
if (deGroupe.length > 0) groupes.push({ cle, libelle: t(cle), tuiles: deGroupe });
}
return {
population: t('qualite.tuile.population', detail.population),
groupes,
certificat: texteCellule(lireColonne(ligne, 'certificat'), { t, format }),
};
}

View file

@ -0,0 +1,329 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de l'en-tête chiffré de la page de qualité (§ 12.2,
// B11, D29, D34, D48, D52) : une tuile par colonne de mesure du tableau des
// propositions, dans son ordre et sous ses groupes, la cellule même du
// tableau quand la colonne y est ; une tuile sans colonne lit la même
// mesure et dit qu'elle ne classe pas ; un critère sans objet n'a pas de
// tuile ; chaque borne est celle d'une personne nommée, lue dans le détail ;
// trois tuiles seulement portent un pourcentage, suivi du compte du pire.
// Les charges s'écrivent ici ou partent des démonstrations livrées ; leurs
// propositions s'enregistrent par la commande. Les noms d'épreuve sont
// inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite, evaluer } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { rechercher } from '../../moteur/recherche.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { COLONNES, GROUPES_COLONNES, explicationDeColonne, lireColonne, tableau, texteCellule } from '../propositions/modele.js';
import { TUILES_A_POURCENTAGE, enteteDe } from './entete.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const outils = { t, format };
const V1 = VERSION.affichee;
// Six personnes inventées, deux appartenances, deux tables de trois places,
// trois tours ; les finance premières portent « finance », que la première
// table attend pour la soirée quand objectifs est vrai ; reservees : les
// deux dernières réservées ensemble à la seconde table pour la soirée.
function chargeSix({ objectifs = false, appartenances = true, reglages = {}, finance = 3, reservees = false } = {}) {
const charge = creerCharge({ id: 'evt-entete', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
const noms = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
];
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: appartenances ? appartenance : null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(objectifs && rang < finance ? { etiquettes: ['finance'] } : {}),
}));
if (reservees) charge.reservations = [5, 6].map((participant) => ({ participant, table: 2, siege: null, portee: 'tous', tour: null }));
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
if (objectifs) charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
Object.assign(charge.reglages, reglages);
return charge;
}
// La charge, une génération de plus, enregistrée par la commande.
function generee(charge, reglages = { graine: 3, nombre: 2, arret: 200 }) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
// La vue d'une proposition de la charge, comme la section la passe : son
// détail, sa ligne d'évaluation et la table d'évaluation.
function vueDe(charge, id = charge.propositions[0].id) {
const table = evaluer(charge);
const ligne = table.propositions.find((candidate) => candidate.id === id);
return { detail: detaillerQualite(charge, id), ligne, table };
}
const tuiles = (entete) => entete.groupes.flatMap(({ tuiles: deGroupe }) => deGroupe);
const tuile = (entete, nom) => {
const trouvee = tuiles(entete).find((candidate) => candidate.nom === nom);
assert.ok(trouvee !== undefined, `tuile ${nom} absente`);
return trouvee;
};
const nomDe = (detail, id) => detail.personnes.find((personne) => personne.id === id).nom;
const valeur = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
// Les tuiles des objectifs et des animateurs.
const SIXIEME = ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres'];
describe('enteteDe : une tuile par colonne de mesure (§ 12.2, D48)', () => {
test('chaque tuile qui a sa colonne porte la cellule du tableau, son libellé et son explication, dans l’ordre et sous les groupes de COLONNES', () => {
const charge = generee(chargeSix({ objectifs: true }));
const vue = vueDe(charge);
const entete = enteteDe(vue, outils);
const lignes = tableau(vue.table).lignes;
const ligneDuTableau = lignes.find(({ id }) => id === vue.ligne.id);
const portees = ligneDuTableau.cellules.filter(({ colonne }) => colonne !== 'certificat');
// Les tuiles des colonnes portées, dans l'ordre du tableau.
const classees = tuiles(entete).filter(({ classe }) => classe);
assert.deepEqual(classees.map(({ nom }) => nom), portees.map(({ colonne }) => colonne));
for (const cellule of portees) {
const tuileDe = tuile(entete, cellule.colonne);
assert.equal(tuileDe.texte, texteCellule(cellule, outils), cellule.colonne);
assert.equal(tuileDe.libelle, t(COLONNES.find(({ nom }) => nom === cellule.colonne).cle), cellule.colonne);
assert.equal(tuileDe.explication, explicationDeColonne(cellule.colonne), cellule.colonne);
}
// Les groupes : ceux de COLONNES, dans leur ordre, chacun de ses tuiles.
const attendus = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
assert.deepEqual(entete.groupes.map(({ cle }) => cle), attendus.map(({ cle }) => cle));
for (const groupe of entete.groupes) {
assert.equal(groupe.libelle, t(groupe.cle));
const colonnes = attendus.find(({ cle }) => cle === groupe.cle).colonnes;
assert.ok(groupe.tuiles.every(({ nom }) => colonnes.includes(nom)), groupe.cle);
}
assert.equal(entete.certificat, texteCellule(lireColonne(vue.ligne, 'certificat'), outils));
assert.equal(entete.population, t('qualite.tuile.population', { mesuree: 6, exclue: 0 }));
});
test('un critère décoché : ses tuiles lisent la même mesure sous leur propre libellé, et ne classent pas ; sans objet, aucune tuile', () => {
const decoche = vueDe(generee(chargeSix({ objectifs: true, reglages: { respecterObjectifs: false, modeObjectifs: 'chacun' } })));
assert.ok(!tableau(decoche.table).colonnes.some(({ nom }) => SIXIEME.includes(nom)));
const entete = enteteDe(decoche, outils);
const objectifs = ['jamaisServies', 'toursHorsObjectif', 'toursSelonObjectif', 'horsObjectifMax'];
for (const nom of objectifs) {
const tuileDe = tuile(entete, nom);
assert.equal(tuileDe.classe, false, nom);
assert.equal(tuileDe.libelle, t(`qualite.tuile.${nom}`), nom);
assert.equal(tuileDe.texte, texteCellule(lireColonne(decoche.ligne, nom), outils), nom);
}
assert.equal(tuile(entete, 'jamaisServies').texte, format.nombre(decoche.ligne.mesures.objectifs.jamaisServies));
// Les tuiles du groupe suivent l'ordre de COLONNES.
const groupe = entete.groupes.find(({ cle }) => cle === 'propositions.groupe.objectifs');
assert.deepEqual(groupe.tuiles.map(({ nom }) => nom), ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif']);
assert.ok(tuiles(entete).filter(({ classe }) => !classe).every(({ nom }) => objectifs.includes(nom)));
// Sans objet : ni objectif ni animateur, aucune tuile du sixième groupe.
const sansObjet = enteteDe(vueDe(generee(chargeSix())), outils);
assert.ok(!tuiles(sansObjet).some(({ nom }) => SIXIEME.includes(nom)));
assert.ok(!sansObjet.groupes.some(({ cle }) => cle === 'propositions.groupe.objectifs'));
});
test('sous « le plus de places servies », les personnes jamais servies et le plus de tours hors objectif d’une personne ne classent pas ; les deux autres classent', () => {
const entete = enteteDe(vueDe(generee(chargeSix({ objectifs: true }))), outils);
assert.deepEqual(
['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif'].map((nom) => [nom, tuile(entete, nom).classe]),
[['jamaisServies', false], ['horsObjectifMax', false], ['toursHorsObjectif', true], ['toursSelonObjectif', true]],
);
assert.equal(tuile(entete, 'jamaisServies').neClassePas, t('qualite.tuile.neClassePas'));
assert.equal(tuile(entete, 'toursHorsObjectif').neClassePas, null);
});
test('le taux de diversité au centième, comme au tableau ; une population vide et un a priori inconnu s’écrivent comme au tableau', () => {
const charge = generee(chargeSix());
const entete = enteteDe(vueDe(charge), outils);
const { ligne } = vueDe(charge);
assert.equal(tuile(entete, 'diversite').texte, format.nombre(ligne.mesures.aggDiversite.tous.min, { decimales: 2 }));
// Aucun ancré : « — ».
assert.equal(tuile(entete, 'rencontresAncres').texte, '\u{2014}');
// Sans a priori, les deux écarts qui en dépendent écrivent « inconnu »,
// jamais « — » ni un nombre, comme leurs colonnes au tableau.
const sansAPriori = { ...vueDe(charge), table: evaluer(charge, { plafondsAPriori: null }) };
sansAPriori.ligne = sansAPriori.table.propositions.find(({ id }) => id === charge.propositions[0].id);
assert.equal(sansAPriori.ligne.troisChiffres.ecartItineraireMax, null);
const enteteSansAPriori = enteteDe(sansAPriori, outils);
for (const nom of ['ecartItineraireMax', 'ecartAuPlafondAPriori']) {
assert.equal(tuile(enteteSansAPriori, nom).texte, t('propositions.valeur.inconnu'), nom);
assert.notEqual(tuile(entete, nom).texte, t('propositions.valeur.inconnu'), `${nom}, a priori connu`);
}
});
test('aucune vue sans détail comparable, sans ligne ou sans table', () => {
const vue = vueDe(generee(chargeSix()));
assert.equal(enteteDe({ ...vue, detail: null }, outils), null);
assert.equal(enteteDe({ ...vue, ligne: null }, outils), null);
assert.equal(enteteDe({ ...vue, table: null }, outils), null);
assert.equal(enteteDe({ ...vue, detail: { ...vue.detail, statut: 'derive' } }, outils), null);
});
});
describe('enteteDe : les bornes, chacune d’une personne nommée (§ 12.2, B11, D29)', () => {
test('le minimum brut : la personne au rang 1 du profil, au minimum, son plafond réalisé, son plafond a priori et son pourcentage, le compte du pire dessous', () => {
const vue = vueDe(generee(chargeSix()));
const { rang1 } = vue.detail.bornes;
assert.equal(rang1.id, vue.detail.profil[0].id);
assert.equal(rang1.rencontres, vue.ligne.mesures.aggRencontres.tous.min);
const minimum = tuile(enteteDe(vue, outils), 'rencontresTous');
assert.deepEqual(minimum.bornes, [
t('qualite.tuile.personne', { nom: nomDe(vue.detail, rang1.id) }),
t('qualite.tuile.plafondRealise', { plafond: rang1.plafondRealise }),
t('qualite.tuile.plafondAPriori', { plafond: rang1.plafondAPriori }),
]);
assert.deepEqual(minimum.pourcentage, {
texte: valeur(rang1.pourcent),
compte: t('qualite.tuile.pireRencontres', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }),
});
assert.match(minimum.pourcentage.compte, /^minimum \d+ sur \d+$/);
});
test('les bornes et les pourcentages se lisent dans le détail, jamais recomptés : un détail forgé les fait suivre', () => {
const vue = vueDe(generee(chargeSix()));
const detail = structuredClone(vue.detail);
const [, autre] = detail.personnes;
detail.bornes.rang1 = { ...detail.bornes.rang1, id: autre.id, pourcent: 37, rencontres: 3, plafondRealise: 8, plafondAPriori: null };
detail.bornes.manque = { id: autre.id, plafondRealise: 41 };
detail.bornes.diversite = { id: autre.id, aMax: 17 };
const entete = enteteDe({ ...vue, detail }, outils);
const minimum = tuile(entete, 'rencontresTous');
// Le pourcentage de la personne nommée, jamais une moyenne de la
// population ; sans plafond a priori, aucune ligne pour lui.
assert.deepEqual(minimum.pourcentage, { texte: valeur(37), compte: 'minimum 3 sur 8' });
assert.deepEqual(minimum.bornes, [t('qualite.tuile.personne', { nom: autre.nom }), t('qualite.tuile.plafondRealise', { plafond: 8 })]);
// Le manque maximal : le plafond réalisé de la tête du profil de manque,
// jamais le plus grand de la population, et aucun pourcentage.
const manque = tuile(entete, 'manqueMax');
assert.ok(manque.bornes.includes(t('qualite.tuile.plafondRealise', { plafond: 41 })), manque.bornes.join(' | '));
assert.ok(manque.bornes.includes(t('qualite.tuile.personne', { nom: autre.nom })));
assert.equal(manque.pourcentage, null);
// A(p) : la borne de diversité de la personne nommée, sans pourcentage.
const diversite = tuile(entete, 'appartenancesVues');
assert.deepEqual(diversite.bornes, [t('qualite.tuile.personne', { nom: autre.nom }), t('qualite.tuile.borneDiversite', { borne: 17 })]);
assert.equal(diversite.pourcentage, null);
assert.ok(!diversite.bornes.some((ligne) => /plafond|%/.test(ligne)), diversite.bornes.join(' | '));
// Un pourcentage de dénominateur nul : « — », le compte reste écrit.
detail.bornes.rang1 = { ...detail.bornes.rang1, pourcent: null, rencontres: 0, plafondRealise: 0 };
assert.deepEqual(tuile(enteteDe({ ...vue, detail }, outils), 'rencontresTous').pourcentage, { texte: null, compte: 'minimum 0 sur 0' });
});
test('le manque maximal porte l’effectif au manque maximal et le plafond réalisé de la tête du profil de manque', () => {
const vue = vueDe(generee(chargeSix()));
const { manque } = vue.detail.bornes;
assert.deepEqual(tuile(enteteDe(vue, outils), 'manqueMax').bornes, [
t('qualite.tuile.personne', { nom: nomDe(vue.detail, manque.id) }),
t('qualite.tuile.effectifManque', { effectif: vue.ligne.troisChiffres.effectifManque.tous }),
t('qualite.tuile.plafondRealise', { plafond: manque.plafondRealise }),
]);
});
test('sans appartenance : la tuile de A(p) le dit, sans borne de diversité', () => {
const vue = vueDe(generee(chargeSix({ appartenances: false })));
assert.equal(vue.detail.bornes.diversite, null);
assert.deepEqual(tuile(enteteDe(vue, outils), 'appartenancesVues').bornes, [t('qualite.tuile.sansAppartenance')]);
});
test('les objectifs : la soirée sur les tours hors objectif, la personne au minimum de s(p) et ses tours possibles, chacun suivi du compte du pire', () => {
const vue = vueDe(generee(chargeSix({ objectifs: true })));
const { soiree, objectifs } = vue.detail.bornes;
const entete = enteteDe(vue, outils);
assert.deepEqual(tuile(entete, 'toursHorsObjectif').pourcentage, {
texte: soiree.pourcent === null ? null : t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
compte: t('qualite.tuile.pireJamaisServies', { personnes: soiree.jamaisServies }),
});
const selon = tuile(entete, 'toursSelonObjectif');
assert.deepEqual(selon.bornes, [
t('qualite.tuile.personne', { nom: nomDe(vue.detail, objectifs.id) }),
t('qualite.tuile.toursPossibles', { possibles: objectifs.possibles }),
]);
assert.deepEqual(selon.pourcentage, {
texte: valeur(objectifs.pourcent),
compte: t('qualite.tuile.pireServis', { servis: objectifs.servis, possibles: objectifs.possibles }),
});
});
test('les tours hors objectif : le plancher de la soirée quand le diagnostic en écrit un, sous des planchers valides ; aucun sinon', () => {
// Quatre personnes attendues à une table de trois places, deux autres
// réservées à l'autre table : au moins trois tours hors objectif.
const vue = vueDe(generee(chargeSix({ objectifs: true, finance: 4, reservees: true })));
const { soiree } = vue.detail.objectifs;
assert.equal(vue.detail.collisions.planchersValides, true);
assert.ok(soiree.plancher > 0, `plancher ${soiree.plancher}`);
const ligneDuPlancher = t('qualite.tuile.plancher', { plancher: soiree.plancher });
assert.ok(tuile(enteteDe(vue, outils), 'toursHorsObjectif').bornes.includes(ligneDuPlancher));
// Hors du domaine des planchers, le même plancher ne s'écrit pas.
const detail = structuredClone(vue.detail);
detail.collisions.planchersValides = false;
assert.ok(!tuile(enteteDe({ ...vue, detail }, outils), 'toursHorsObjectif').bornes.some((ligne) => ligne.startsWith('plancher')));
// Un plancher nul : le diagnostic se tait, aucune ligne.
detail.collisions.planchersValides = true;
detail.objectifs.soiree = { ...detail.objectifs.soiree, plancher: 0 };
assert.ok(!tuile(enteteDe({ ...vue, detail }, outils), 'toursHorsObjectif').bornes.some((ligne) => ligne.startsWith('plancher')));
});
test('les rencontres répétées et les retours : les choisis en valeur, les imposés énoncés à part', () => {
const vue = vueDe(generee(chargeSix({ reservees: true })));
const { imposees } = vue.ligne.mesures.rencontresRepetees;
assert.ok(imposees > 0, `imposées ${imposees}`);
assert.ok(vue.table.imposes.retours > 0, `retours imposés ${vue.table.imposes.retours}`);
const entete = enteteDe(vue, outils);
assert.deepEqual(tuile(entete, 'rencontresRepetees').bornes, [t('qualite.tuile.imposees', { imposees })]);
assert.deepEqual(tuile(entete, 'retoursChoisis').bornes, [t('qualite.tuile.imposes', { imposes: vue.table.imposes.retours })]);
// Sans réservation, les imposés valent zéro et restent énoncés.
const libre = enteteDe(vueDe(generee(chargeSix())), outils);
assert.deepEqual(tuile(libre, 'rencontresRepetees').bornes, [t('qualite.tuile.imposees', { imposees: 0 })]);
assert.deepEqual(tuile(libre, 'retoursChoisis').bornes, [t('qualite.tuile.imposes', { imposes: 0 })]);
});
test('les animateurs : la personne au minimum de m(p) et sa borne des animateurs, sans pourcentage', () => {
const charge = structuredClone(analyser(texteLivre('grande')).charge);
charge.reglages.rencontrerAnimateurs = true;
const vue = vueDe(generee(charge, { graine: 1, nombre: 1, arret: 300 }));
const { animateurs } = vue.detail.bornes;
assert.notEqual(animateurs, null);
const rencontres = tuile(enteteDe(vue, outils), 'animateursRencontres');
assert.deepEqual(rencontres.bornes, [
t('qualite.tuile.personne', { nom: nomDe(vue.detail, animateurs.id) }),
t('qualite.tuile.borneAnimateurs', { borne: animateurs.borne }),
]);
assert.equal(rencontres.pourcentage, null);
});
test('trois tuiles seulement portent un pourcentage : le minimum brut, les tours hors objectif, le minimum de s(p)', () => {
assert.deepEqual([...TUILES_A_POURCENTAGE], ['rencontresTous', 'toursHorsObjectif', 'toursSelonObjectif']);
for (const charge of [generee(chargeSix({ objectifs: true })), generee(chargeSix({ objectifs: true, reglages: { respecterObjectifs: false } }))]) {
const avec = tuiles(enteteDe(vueDe(charge), outils)).filter(({ pourcentage }) => pourcentage !== null);
assert.deepEqual(avec.map(({ nom }) => nom), [...TUILES_A_POURCENTAGE]);
for (const { pourcentage } of avec) assert.ok(pourcentage.compte.length > 0);
}
// Aucun signe du pourcentage hors des trois pourcentages.
const entete = enteteDe(vueDe(generee(chargeSix({ objectifs: true }))), outils);
for (const tuileDe of tuiles(entete)) {
for (const texte of [tuileDe.libelle, tuileDe.texte, ...tuileDe.bornes, tuileDe.pourcentage?.compte ?? '']) {
assert.ok(!texte.includes('%'), `${tuileDe.nom} : ${texte}`);
}
}
});
});

View file

@ -9,7 +9,11 @@
// légende ; la surface de pointage ; l'infobulle. Escalier, Barres et
// Grille le remplissent : il ne dessine aucune marque et ne calcule rien.
//
// La largeur se mesure sur le conteneur (largeur, liable), et le texte sur
// La largeur se mesure sur une règle de hauteur nulle en tête du
// conteneur (largeur, liable) : sa hauteur ne suit pas le dessin, si bien
// qu'un dessin dont la hauteur suit la largeur — une grille — ne change
// pas, dans l'image même, la taille d'un élément déjà mesuré, ce que
// ResizeObserver signale comme une boucle. Le texte se mesure sur
// une sonde du dessin (mesures, liable : {mesurer, hauteurTexte}, null
// tant que la sonde n'est pas rendue) : le composant qui remplit le cadre
// en tire sa disposition (pointage.js), puis rend ses marques par
@ -161,7 +165,8 @@
</svg>
{/snippet}
<div class="graphique" bind:clientWidth={largeur}>
<div class="graphique">
<div class="mesure" bind:clientWidth={largeur}></div>
<span id={idNom} hidden>{nom}</span>
<svg
bind:this={svg}
@ -229,7 +234,18 @@
</div>
<style>
/* La règle de la largeur : la largeur du conteneur, aucune hauteur ;
posée hors du flux, elle n'ajoute pas d'écart à la colonne. */
.mesure {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 0;
}
.graphique {
position: relative;
display: flex;
flex-direction: column;
gap: var(--espace-2);

View file

@ -21,7 +21,9 @@
// pointe, liable, en porte l'indice, ligne × colonnes + colonne ;
// infobulle, {colonnes, lignes}, une ligne par case dans cet ordre
// (Graphique). ecrire(v) écrit un compte, par défaut le nombre mis en
// forme.
// forme. coteMaxTextes borne la case à ce nombre de hauteurs de ligne du
// compte écrit, mesurée par la sonde ; null, sans borne : la case prend
// alors toute la largeur que la carte donne à sa colonne.
//
// Désignations : data-graphique sur le <svg>, data-case sur chaque case,
// par « ligne:colonne » à partir de 0, data-rang sur la désignation de la
@ -42,6 +44,7 @@
diagonale = false,
phrase = null,
ecrire = null,
coteMaxTextes = null,
infobulle = null,
dessinable = $bindable(true),
pointe = $bindable(null),
@ -65,6 +68,7 @@
mesurer: mesures.mesurer,
hauteurTexte: mesures.hauteurTexte,
ecrire: ecrire ?? ((v) => format.nombre(v)),
coteMax: coteMaxTextes === null ? Number.POSITIVE_INFINITY : coteMaxTextes * mesures.hauteurTexte,
}),
);
@ -85,8 +89,11 @@
</script>
<!-- La largeur se mesure ici aussi : une grille qui a cédé la place se
redessine quand la carte s'élargit. -->
<div bind:clientWidth={largeur}>
redessine quand la carte s'élargit. Elle se lit sur une règle de
hauteur nulle, comme dans Graphique : la hauteur de la grille suit sa
largeur, et le conteneur qui la porte ne se mesure pas. -->
<div>
<div class="mesure" bind:clientWidth={largeur}></div>
{#if disposition !== null && !disposition.dessinable}
<p class="phrase">{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}</p>
{:else}
@ -152,6 +159,11 @@
</div>
<style>
/* La règle de la largeur : la largeur du conteneur, aucune hauteur. */
.mesure {
height: 0;
}
.phrase {
margin: 0;
}

View file

@ -683,13 +683,23 @@ export function cibleLigne(y, disposition) {
* se tournent d'un quart de tour quand l'un d'eux dépasse la case. ecrire(v)
* écrit un compte.
*/
export function dispositionGrille({ largeur, lignes, colonnes, valeurs, diagonale = false, mesurer, hauteurTexte, ecrire }) {
export function dispositionGrille({
largeur,
lignes,
colonnes,
valeurs,
diagonale = false,
mesurer,
hauteurTexte,
ecrire,
coteMax = Number.POSITIVE_INFINITY,
}) {
exiger(Array.isArray(lignes) && Array.isArray(colonnes), 'grille : lignes et colonnes attendues', TypeError);
const nl = lignes.length;
const nc = colonnes.length;
exiger(Array.isArray(valeurs) && valeurs.length === nl * nc, `grille : ${nl * nc} valeurs attendues`);
const largeurIntitules = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte);
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte, coteMax);
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
const enTete = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
const x0 = largeurIntitules;

View file

@ -0,0 +1,201 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de la vue des objectifs des tables (§ 12.12, B21, D34, D36,
// D52), pur : VueObjectifs.svelte le rend. Il met en forme les objectifs du
// détail d'un placement (detaillerQualite) dans l'ordre où le détail les
// porte, sans rien trier, sommer ni recompter : chaque pourcentage est
// l'entier du détail, chaque somme de la soirée est celle du détail.
//
// L'ordre des barres est le seul que la vue décide : le tour de pireTour —
// au plus grand reste, que le moteur désigne — en tête, à place fixe, puis
// les autres dans leur ordre. Aucune barre pour la soirée : elle s'écrit en
// texte, et pas du tout à un seul tour, que sa ligne dit déjà. Aucune
// couleur ne désigne un tour : une ligne ne porte pas de pastille.
//
// Une barre porte les tours hors objectif du tour en deux segments, le
// plancher du tour, prouvé inévitable, et le reste, tous deux lus au détail
// tels que le moteur les rend (B21) — la garde à quatre paramètres refuse un
// reste négatif avant tout dessin. Hors du domaine des planchers — un plan
// qui laisse quelqu'un en réserve —, la barre n'a qu'un segment, les tours
// hors objectif, et ni le plancher ni le reste ne s'écrivent (§ 12.4), pas
// même dans la phrase de synthèse.
// Un tour sans couple possible n'a ni barre ni pourcentage : « — », puis ses
// assis sans table possible, que chaque ligne écrit.
// La clé de traduction d'un segment, par son genre.
const SEGMENTS_DANS_LE_DOMAINE = [
['prouve', 'qualite.objectifs.segment.prouve'],
['reste', 'qualite.objectifs.segment.reste'],
];
const SEGMENT_HORS_DU_DOMAINE = ['serie1', 'qualite.objectifs.segment.hors'];
// Les colonnes du jumeau par tour, dans l'ordre : la première est
// l'en-tête de sa ligne.
const COLONNES_PAR_TOUR = [
['tour', 'texte'],
['possibles', 'nombre'],
['servis', 'nombre'],
['pourcent', 'pourcentage'],
['hors', 'nombre'],
['plancher', 'nombre'],
['assis', 'nombre'],
];
// Les colonnes {cle, libelle, genre} d'un jumeau, chacune sous la clé
// qualite.objectifs.colonne.<cle>.
const colonnesDe = (t, liste) => liste.map(([cle, genre]) => ({ cle, libelle: t(`qualite.objectifs.colonne.${cle}`), genre }));
/**
* Le modèle de la vue des objectifs, ou ce qui la remplace.
*
* - null sans détail mesuré ;
* - {cas: 'aucunConcerne'} sans personne concernée par les objectifs : la
* vue cède la place à sa phrase ;
* - sinon {cas: 'objectifs', neClassePas, planchersValides, jamaisServies,
* soiree, barres, tours, infobulle, jumeau} :
* neClassePas, le critère des objectifs décoché ou sans objet ;
* planchersValides, le domaine des planchers du détail ;
* jamaisServies, [{id, nom, possibles}], possibles déjà écrit ;
* soiree, null à un seul tour, sinon {pourcentage: {texte, compte},
* comptes, hors} — la part des tours possibles servis, suivie des
* personnes jamais servies, puis ses servis sur ses possibles, ses tours
* hors objectif et leur plancher ;
* barres, {nom, synthese, segments, lignes} pour Barres, synthese le tour
* de tête, son reste puis ses tours hors objectif — ses tours hors
* objectif seuls hors du domaine —, une ligne {id, nom, valeurs,
* secondaire} par tour, id le numéro du tour, secondaire ses assis sans
* table possible ;
* tours, {tour, nom, pourcentage: {texte, compte}} dans l'ordre des
* barres, le pourcentage du tour suivi de ses deux comptes ;
* infobulle, {colonnes, lignes}, la ligne du jumeau de chaque barre ;
* jumeau, les tableaux de Jumeau : par tour puis la soirée, les personnes
* jamais servies quand il y en a, la répartition des tours hors objectif
* d'une personne.
*
* Tout texte vient de t ; ne modifie pas le détail.
*
* @param {Object|null} detail le détail du placement (detaillerQualite)
* @param {(cle: string, details?: Object) => string} t
*/
export function modeleObjectifs(detail, t) {
if (detail === null) return null;
const { objectifs, personnes, R } = detail;
if (objectifs === null) return { cas: 'aucunConcerne' };
const planchersValides = detail.collisions.planchersValides;
const { critere, parTour, pireTour, soiree, repartition } = objectifs;
const nomDe = new Map(personnes.map(({ id, nom }) => [id, nom]));
const jamaisServies = objectifs.jamaisServies.map(({ id, possibles }) => ({
id,
nom: nomDe.get(id),
possibles: t('qualite.objectifs.toursPossibles', { possibles }),
}));
// Les indices des tours, celui de tête d'abord, les autres dans leur ordre.
const ordre = [pireTour, ...parTour.map((_, r) => r).filter((r) => r !== pireTour)];
const segments = (planchersValides ? SEGMENTS_DANS_LE_DOMAINE : [SEGMENT_HORS_DU_DOMAINE]).map(([genre, cle]) => ({
genre,
libelle: t(cle),
}));
const pourcentageDe = (pourcent) => (pourcent === null ? null : t('qualite.pourcentage.valeur', { pourcent }));
const tours = [];
const lignes = [];
const rangees = [];
for (const r of ordre) {
const { possibles, servis, pourcent, hors, plancher, reste, sansCouple } = parTour[r];
const tour = r + 1;
const nom = t('qualite.objectifs.tour', { tour });
const sansCoupleAuTour = possibles === 0;
let valeurs;
if (sansCoupleAuTour) valeurs = segments.map(() => 0);
else valeurs = planchersValides ? [plancher, reste] : [hors];
lignes.push({
id: tour,
nom,
valeurs,
secondaire: t(sansCoupleAuTour ? 'qualite.objectifs.secondaireSansCouple' : 'qualite.objectifs.assisSansTable', {
assis: sansCouple,
}),
});
const pourcentage = {
texte: pourcentageDe(pourcent),
compte: sansCoupleAuTour ? t('qualite.objectifs.sansCouple') : t('qualite.objectifs.comptes', { servis, possibles }),
};
tours.push({ tour, nom, pourcentage });
rangees.push({ tour: nom, possibles, servis, pourcent: pourcentage, hors, plancher: planchersValides ? plancher : null, assis: sansCouple });
}
const colonnes = colonnesDe(t, COLONNES_PAR_TOUR);
const unTour = R === 1;
const jumeau = [
{
nom: t('qualite.objectifs.jumeau.tours'),
colonnes,
lignes: unTour
? rangees
: [
...rangees,
{
tour: t('qualite.objectifs.soiree'),
possibles: soiree.possibles,
servis: soiree.servis,
pourcent: { texte: pourcentageDe(soiree.pourcent), compte: t('qualite.objectifs.comptes', soiree) },
hors: soiree.hors,
plancher: planchersValides ? soiree.plancher : null,
assis: null,
},
],
},
];
if (jamaisServies.length > 0) {
jumeau.push({
nom: t('qualite.objectifs.jamaisServies'),
colonnes: [
{ cle: 'nom', libelle: t('qualite.objectifs.colonne.personne'), genre: 'texte' },
{ cle: 'possibles', libelle: t('qualite.objectifs.colonne.toursPossibles'), genre: 'nombre' },
],
lignes: objectifs.jamaisServies.map(({ id, possibles }) => ({ nom: nomDe.get(id), possibles })),
});
}
jumeau.push({
nom: t('qualite.objectifs.repartition.titre'),
colonnes: [
{ cle: 'valeur', libelle: t('qualite.objectifs.repartition.valeur'), genre: 'nombre' },
{ cle: 'effectif', libelle: t('qualite.objectifs.repartition.effectif'), genre: 'nombre' },
],
lignes: repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
});
return {
cas: 'objectifs',
neClassePas: !critere.coche || critere.sansObjet,
planchersValides,
jamaisServies,
soiree: unTour
? null
: {
pourcentage: {
texte: t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
compte: t('qualite.objectifs.pire', { personnes: detail.bornes.soiree.jamaisServies }),
},
comptes: t('qualite.objectifs.comptes', soiree),
hors: planchersValides
? t('qualite.objectifs.horsObjectif', { hors: soiree.hors, plancher: soiree.plancher })
: t('qualite.objectifs.hors', { hors: soiree.hors }),
},
barres: {
nom: t('qualite.objectifs.graphique'),
synthese: planchersValides
? t('qualite.objectifs.synthese', { tour: pireTour + 1, reste: parTour[pireTour].reste, hors: parTour[pireTour].hors })
: t('qualite.objectifs.syntheseHorsDomaine', { tour: pireTour + 1, hors: parTour[pireTour].hors }),
segments,
lignes,
},
tours,
infobulle: { colonnes, lignes: rangees },
jumeau,
};
}

View file

@ -0,0 +1,433 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de la vue des objectifs des tables (§ 12.12, B21, D34,
// D36, D52) : les personnes jamais servies, la soirée en texte, une barre
// par tour — le tour au plus grand reste en tête —, le pourcentage de chaque
// tour et ses deux comptes, ses assis sans table possible, et le jumeau.
// Chaque nombre vient du détail du placement (detaillerQualite), recoupé
// contre la ligne d'évaluation de la même proposition et contre le
// diagnostic. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { configurationDe } from '../../application/configuration.js';
import { detaillerQualite, evaluer } from '../../application/evaluation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { rechercher } from '../../moteur/recherche.js';
import { creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { modeleObjectifs } from './objectifs.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const V1 = VERSION.affichee;
// Neuf personnes inventées : les sept premières portent « finance ».
const PERSONNES = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', 'Coopérative Aubier'],
['Rocaille', 'Anselme', 'Atelier Ressac'],
['Brindille', 'Pélagie', null],
['Cassonade', 'Firmin', null],
['Grésil', 'Ysaline', 'Atelier Ressac'],
];
// n personnes, trois tables de trois places, tours tours ; la table 1 a
// l'objectif « finance » aux tours toursObjectif, aucune autre table n'en a
// — un tour hors de la liste n'a aucun couple possible. Sept personnes pour
// trois places à objectif : à chaque tour, quatre au moins restent hors
// objectif, et une au moins n'est jamais servie.
function chargeObjectifs({ n = 9, tours = 3, toursObjectif = [1, 2], respecter = true, mode = null } = {}) {
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours });
charge.participants = PERSONNES.slice(0, n).map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
...(rang < 7 ? { etiquettes: ['finance'] } : {}),
}));
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.tables[0].objectifs = toursObjectif.map((tour) => ({ portee: 'tour', tour, regle: null, etiquettes: ['finance'] }));
charge.prochainsIds = { participant: n + 1, table: 4, proposition: 1 };
if (!respecter) charge.reglages.respecterObjectifs = false;
if (mode !== null) charge.reglages.modeObjectifs = mode;
return charge;
}
// La charge, une génération enregistrée de plus.
function generee(charge, reglages = { graine: 7, nombre: 2, arret: 200 }) {
const configuration = configurationDe(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
// Le détail de la première proposition, et la charge qui le porte.
function detailDe(options) {
const charge = generee(chargeObjectifs(options));
const id = charge.propositions[0].id;
return { charge, id, detail: detaillerQualite(charge, id) };
}
// Le pourcentage du § D52, recalculé ici pour l'épreuve : ⌊100 · n / d⌋,
// borné à 1 et 99 entre les bords, null à dénominateur nul.
function pourcentAttendu(n, d) {
if (d === 0) return null;
const brut = Math.floor((100 * n) / d);
if (n > 0 && n < d) return Math.min(99, Math.max(1, brut));
return brut;
}
// Le détail de la première proposition, sous une réservation ajoutée après
// la génération que son plan ne tient pas : la personne 8, sans étiquette,
// réservée au tour 1 à une table où le plan ne l'assoit pas. Hors du
// domaine des planchers (§ 12.4).
function detailHorsDomaine() {
const charge = generee(chargeObjectifs());
const [proposition] = charge.propositions;
const rang = proposition.placement[0].sieges.findIndex((sieges) => !sieges.includes(8));
const apres = structuredClone(charge);
apres.reservations.push({ participant: 8, table: proposition.tables[rang], siege: null, portee: 'tour', tour: 1 });
return detaillerQualite(apres, proposition.id);
}
// Une copie modifiable d'un détail.
const copie = (detail) => structuredClone(detail);
// Le détail de la première proposition quand l'objectif tient aux tours 2
// et 3 : le tour 1, sans couple possible, n'a pas de reste, et le tour de
// tête est le tour 2. Les lignes vont alors dans l'ordre [2, 1, 3], que
// l'ordre naturel des tours ne donne pas : une épreuve « dans l'ordre des
// barres » qui le lit échoue dès qu'une liste retrie ses tours.
function detailPireAuTour2() {
const { detail } = detailDe({ toursObjectif: [2, 3] });
assert.equal(detail.objectifs.pireTour, 1);
return detail;
}
describe('objectifs : les cas sans vue', () => {
test('sans personne concernée, la vue cède la place à sa phrase', () => {
const { detail } = detailDe({ toursObjectif: [] });
assert.equal(detail.objectifs, null);
assert.deepEqual(modeleObjectifs(detail, t), { cas: 'aucunConcerne' });
});
test('sans détail mesuré, rien', () => {
assert.equal(modeleObjectifs(null, t), null);
});
});
describe('objectifs : les barres par tour (§ 12.12, B21, D36)', () => {
test('une barre par tour, aucune pour la soirée ; le tour au plus grand reste en tête, les autres dans leur ordre', () => {
const { detail } = detailDe();
const modele = modeleObjectifs(detail, t);
assert.equal(modele.cas, 'objectifs');
assert.equal(modele.barres.lignes.length, detail.R);
const restes = detail.objectifs.parTour.map((tour) => (tour.possibles === 0 ? -Infinity : tour.hors - tour.plancher));
const pire = restes.indexOf(Math.max(...restes));
assert.equal(detail.objectifs.pireTour, pire);
const autres = detail.objectifs.parTour.map((_, r) => r + 1).filter((tour) => tour !== pire + 1);
assert.deepEqual(
modele.barres.lignes.map(({ id }) => id),
[pire + 1, ...autres],
);
assert.deepEqual(
modele.barres.lignes.map(({ nom }) => nom),
[pire + 1, ...autres].map((tour) => t('qualite.objectifs.tour', { tour })),
);
});
test('rouge : la première barre suit pireTour du détail, jamais le premier tour par défaut', () => {
const { detail } = detailDe();
const forge = copie(detail);
forge.objectifs.pireTour = 1;
assert.deepEqual(
modeleObjectifs(forge, t).barres.lignes.map(({ id }) => id),
[2, 1, 3],
);
forge.objectifs.pireTour = 2;
forge.objectifs.parTour[2] = { ...forge.objectifs.parTour[0] };
assert.deepEqual(
modeleObjectifs(forge, t).barres.lignes.map(({ id }) => id),
[3, 1, 2],
);
});
test('deux segments, prouvé inévitable puis reste, le plancher du tour et le reste ; aucune couleur propre à un tour', () => {
const { detail } = detailDe();
const modele = modeleObjectifs(detail, t);
assert.deepEqual(modele.barres.segments, [
{ genre: 'prouve', libelle: t('qualite.objectifs.segment.prouve') },
{ genre: 'reste', libelle: t('qualite.objectifs.segment.reste') },
]);
for (const ligne of modele.barres.lignes) {
const tour = detail.objectifs.parTour[ligne.id - 1];
if (tour.possibles === 0) continue;
assert.equal(tour.reste, tour.hors - tour.plancher, `tour ${ligne.id}`);
assert.deepEqual(ligne.valeurs, [tour.plancher, tour.reste], `tour ${ligne.id}`);
assert.equal(Object.hasOwn(ligne, 'appartenance'), false, `tour ${ligne.id} : aucune pastille`);
}
});
test('rouge : le reste d’une barre est celui que le détail porte, lu sans soustraction', () => {
const detail = structuredClone(detailDe().detail);
const tour = detail.objectifs.parTour[0];
tour.reste = tour.hors + 7;
const ligne = modeleObjectifs(detail, t).barres.lignes.find(({ id }) => id === 1);
assert.deepEqual(ligne.valeurs, [tour.plancher, tour.hors + 7]);
});
test('un tour sans couple possible écrit « — », sans barre ni pourcentage, et garde ses assis sans table possible', () => {
const { detail } = detailDe();
const tour3 = detail.objectifs.parTour[2];
assert.equal(tour3.possibles, 0);
assert.equal(tour3.sansCouple, 9);
const modele = modeleObjectifs(detail, t);
const ligne = modele.barres.lignes.find(({ id }) => id === 3);
assert.deepEqual(ligne.valeurs, [0, 0]);
assert.equal(ligne.secondaire, t('qualite.objectifs.secondaireSansCouple', { assis: 9 }));
assert.ok(ligne.secondaire.startsWith(t('format.vide')), ligne.secondaire);
const parTour = modele.tours.find(({ tour }) => tour === 3);
assert.deepEqual(parTour.pourcentage, { texte: null, compte: t('qualite.objectifs.sansCouple') });
});
test('chaque ligne écrit ses assis sans table possible, qui égalent sansCouple', () => {
const { detail } = detailDe();
const modele = modeleObjectifs(detail, t);
for (const ligne of modele.barres.lignes) {
const { possibles, sansCouple } = detail.objectifs.parTour[ligne.id - 1];
const cle = possibles === 0 ? 'qualite.objectifs.secondaireSansCouple' : 'qualite.objectifs.assisSansTable';
assert.equal(ligne.secondaire, t(cle, { assis: sansCouple }), `tour ${ligne.id}`);
}
assert.deepEqual(
detail.objectifs.parTour.map(({ sansCouple }) => sansCouple),
[2, 2, 9],
);
});
test('le pourcentage de chaque tour : ses servis sur ses possibles seuls, suivi de ses deux comptes, dans l’ordre des barres', () => {
const detail = detailPireAuTour2();
const modele = modeleObjectifs(detail, t);
assert.deepEqual(
modele.barres.lignes.map(({ id }) => id),
[2, 1, 3],
);
assert.deepEqual(
modele.tours.map(({ tour }) => tour),
[2, 1, 3],
);
for (const { tour, nom, pourcentage } of modele.tours) {
const { possibles, servis, pourcent } = detail.objectifs.parTour[tour - 1];
assert.equal(nom, t('qualite.objectifs.tour', { tour }));
if (possibles === 0) continue;
assert.equal(pourcent, pourcentAttendu(servis, possibles), `tour ${tour}`);
assert.deepEqual(pourcentage, {
texte: t('qualite.pourcentage.valeur', { pourcent }),
compte: t('qualite.objectifs.comptes', { servis, possibles }),
});
}
});
test('la phrase de synthèse nomme le tour de tête, son reste, puis ses tours hors objectif', () => {
const { detail } = detailDe();
const { pireTour, parTour } = detail.objectifs;
const { reste, hors } = parTour[pireTour];
assert.deepEqual([pireTour, reste, hors], [0, 1, 5]);
const synthese = modeleObjectifs(detail, t).barres.synthese;
assert.equal(synthese, t('qualite.objectifs.synthese', { tour: 1, reste, hors }));
assert.equal(synthese, 'En tête, le tour 1, au plus grand reste : 1 au-delà de son plancher, sur 5 tours hors objectif.');
});
test('hors du domaine des planchers, la phrase de synthèse ne parle plus de reste', () => {
const detail = detailHorsDomaine();
const { pireTour, parTour } = detail.objectifs;
const synthese = modeleObjectifs(detail, t).barres.synthese;
assert.equal(synthese, t('qualite.objectifs.syntheseHorsDomaine', { tour: pireTour + 1, hors: parTour[pireTour].hors }));
assert.doesNotMatch(synthese, /reste|plancher/);
});
});
describe('objectifs : les personnes jamais servies et la soirée', () => {
test('les jamais servies, nommées, chacune avec ses tours possibles, dans l’ordre du détail', () => {
const { detail } = detailDe();
const modele = modeleObjectifs(detail, t);
assert.ok(detail.objectifs.jamaisServies.length >= 1);
assert.deepEqual(
modele.jamaisServies,
detail.objectifs.jamaisServies.map(({ id, possibles }) => ({
id,
nom: detail.personnes.find((personne) => personne.id === id).nom,
possibles: t('qualite.objectifs.toursPossibles', { possibles }),
})),
);
});
test('la soirée : Σ servis sur Σ possibles, suivie des jamais servies, puis ses deux comptes, hors objectif et plancher', () => {
const { detail } = detailDe();
const { parTour, soiree, jamaisServies } = detail.objectifs;
const servis = parTour.reduce((somme, tour) => somme + (tour.servis ?? 0), 0);
const possibles = parTour.reduce((somme, tour) => somme + tour.possibles, 0);
assert.deepEqual([soiree.servis, soiree.possibles], [servis, possibles]);
const modele = modeleObjectifs(detail, t);
assert.deepEqual(modele.soiree, {
pourcentage: {
texte: t('qualite.pourcentage.servis', { pourcent: pourcentAttendu(servis, possibles) }),
compte: t('qualite.objectifs.pire', { personnes: jamaisServies.length }),
},
comptes: t('qualite.objectifs.comptes', { servis, possibles }),
hors: t('qualite.objectifs.horsObjectif', { hors: soiree.hors, plancher: soiree.plancher }),
});
});
test('R = 1 : aucune ligne de soirée, ni au texte ni au jumeau', () => {
const { detail } = detailDe({ tours: 1, toursObjectif: [1] });
assert.equal(detail.R, 1);
const modele = modeleObjectifs(detail, t);
assert.equal(modele.soiree, null);
assert.deepEqual(
modele.jumeau[0].lignes.map(({ tour }) => tour),
[t('qualite.objectifs.tour', { tour: 1 })],
);
});
});
describe('objectifs : recoupements avec le tableau des propositions et le diagnostic', () => {
test('possibles − servis de la soirée = tours hors objectif de la ligne ; jamais servies = sa colonne ; Σ planchers des tours = plancher du diagnostic', () => {
const { charge, id, detail } = detailDe({ mode: 'chacun' });
const ligne = evaluer(charge).propositions.find((element) => element.id === id);
const { soiree, parTour, jamaisServies } = detail.objectifs;
assert.equal(soiree.possibles - soiree.servis, ligne.mesures.objectifs.horsObjectif);
assert.equal(jamaisServies.length, ligne.mesures.objectifs.jamaisServies);
assert.equal(detail.bornes.soiree.jamaisServies, ligne.mesures.objectifs.jamaisServies);
const diagnostic = diagnostiquer(configurationDe(charge));
const somme = parTour.reduce((total, tour) => total + (tour.plancher ?? 0), 0);
assert.equal(somme, diagnostic.objectifsInevitables.horsObjectif);
assert.equal(somme, 8);
const modele = modeleObjectifs(detail, t);
assert.equal(
modele.soiree.hors,
t('qualite.objectifs.horsObjectif', { hors: ligne.mesures.objectifs.horsObjectif, plancher: diagnostic.objectifsInevitables.horsObjectif }),
);
assert.equal(modele.soiree.pourcentage.compte, t('qualite.objectifs.pire', { personnes: ligne.mesures.objectifs.jamaisServies }));
});
});
describe('objectifs : le critère décoché et le domaine des planchers (§ 12.4, B15)', () => {
test('critère décoché : la vue dit qu’il ne classe pas, et les planchers se dessinent quand même', () => {
const { detail } = detailDe({ respecter: false });
assert.deepEqual(detail.objectifs.critere, { coche: false, sansObjet: false });
const modele = modeleObjectifs(detail, t);
assert.equal(modele.neClassePas, true);
assert.deepEqual(
modele.barres.segments.map(({ genre }) => genre),
['prouve', 'reste'],
);
assert.ok(modele.barres.lignes.some(({ valeurs }) => valeurs[0] > 0));
assert.equal(modeleObjectifs(detailDe().detail, t).neClassePas, false);
});
test('hors du domaine des planchers : un seul segment, les tours hors objectif, et ni plancher ni reste', () => {
const detail = detailHorsDomaine();
assert.equal(detail.statut, 'comparable');
assert.equal(detail.collisions.planchersValides, false);
const modele = modeleObjectifs(detail, t);
assert.equal(modele.planchersValides, false);
assert.deepEqual(modele.barres.segments, [{ genre: 'serie1', libelle: t('qualite.objectifs.segment.hors') }]);
for (const ligne of modele.barres.lignes) {
const tour = detail.objectifs.parTour[ligne.id - 1];
assert.deepEqual(ligne.valeurs, [tour.possibles === 0 ? 0 : tour.hors], `tour ${ligne.id}`);
}
assert.equal(modele.soiree.hors, t('qualite.objectifs.hors', { hors: detail.objectifs.soiree.hors }));
for (const rangee of modele.jumeau[0].lignes) assert.equal(rangee.plancher, null);
});
});
describe('objectifs : le jumeau et l’infobulle (§ 12.7, B21)', () => {
test('une ligne par tour dans l’ordre des barres, puis la soirée ; possibles, servis, pourcentage suivi du compte de la ligne, hors, plancher, assis', () => {
const detail = detailPireAuTour2();
const modele = modeleObjectifs(detail, t);
const [tours, jamais, repartition] = modele.jumeau;
assert.deepEqual(
tours.colonnes.map(({ cle, genre }) => [cle, genre]),
[
['tour', 'texte'],
['possibles', 'nombre'],
['servis', 'nombre'],
['pourcent', 'pourcentage'],
['hors', 'nombre'],
['plancher', 'nombre'],
['assis', 'nombre'],
],
);
const ordre = modele.barres.lignes.map(({ id }) => id);
assert.deepEqual(ordre, [2, 1, 3]);
assert.deepEqual(
tours.lignes.map(({ tour }) => tour),
[...ordre.map((tour) => t('qualite.objectifs.tour', { tour })), t('qualite.objectifs.soiree')],
);
ordre.forEach((numero, i) => {
const tour = detail.objectifs.parTour[numero - 1];
const rangee = tours.lignes[i];
assert.deepEqual(
[rangee.possibles, rangee.servis, rangee.hors, rangee.plancher, rangee.assis],
[tour.possibles, tour.servis, tour.hors, tour.plancher, tour.sansCouple],
);
assert.deepEqual(rangee.pourcent, modele.tours[i].pourcentage);
});
const { soiree } = detail.objectifs;
const derniere = tours.lignes.at(-1);
assert.deepEqual(
[derniere.possibles, derniere.servis, derniere.hors, derniere.plancher, derniere.assis],
[soiree.possibles, soiree.servis, soiree.hors, soiree.plancher, null],
);
assert.deepEqual(derniere.pourcent, {
texte: t('qualite.pourcentage.valeur', { pourcent: soiree.pourcent }),
compte: t('qualite.objectifs.comptes', { servis: soiree.servis, possibles: soiree.possibles }),
});
assert.deepEqual(
jamais.lignes.map(({ nom, possibles }) => [nom, possibles]),
modele.jamaisServies.map(({ id }) => [
detail.personnes.find((personne) => personne.id === id).nom,
detail.objectifs.jamaisServies.find((element) => element.id === id).possibles,
]),
);
assert.deepEqual(
repartition.lignes,
detail.objectifs.repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
);
const noms = modele.jumeau.map(({ nom }) => nom);
assert.equal(new Set([...noms, modele.barres.nom]).size, noms.length + 1, 'noms accessibles distincts');
});
test('l’infobulle porte la ligne du jumeau de chaque barre, dans l’ordre des barres', () => {
const detail = detailPireAuTour2();
const modele = modeleObjectifs(detail, t);
assert.deepEqual(modele.infobulle.colonnes, modele.jumeau[0].colonnes);
assert.deepEqual(
modele.infobulle.lignes.map(({ tour }) => tour),
[2, 1, 3].map((tour) => t('qualite.objectifs.tour', { tour })),
);
assert.deepEqual(modele.infobulle.lignes, modele.jumeau[0].lignes.slice(0, detail.R));
});
test('aucune personne jamais servie : le jumeau n’a pas de table vide pour elles', () => {
const { detail } = detailDe();
const forge = copie(detail);
forge.objectifs.jamaisServies = [];
const modele = modeleObjectifs(forge, t);
assert.deepEqual(modele.jamaisServies, []);
assert.deepEqual(
modele.jumeau.map(({ nom }) => nom),
[t('qualite.objectifs.jumeau.tours'), t('qualite.objectifs.repartition.titre')],
);
});
});

View file

@ -0,0 +1,229 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de la vue des paires répétées (§ 12.5, B14, D32, D33, D50) et de
// la rubrique « Rencontres » de la fiche, pur : VuePaires.svelte et
// Fiche.svelte le câblent.
//
// Il met en forme ce que le détail du placement porte, dans l'ordre où il
// le porte : les paires choisies puis les paires imposées, chacune triée par
// le moteur (fois décroissant, puis identifiants) ; la matrice dans l'ordre
// du profil (§ 12.3), jamais par l'alphabet. Il ne trie, ne recompte et ne
// mesure rien : un tour du détail, compté à partir de 0, s'écrit à partir
// de 1, une table, nommée par son identifiant, s'écrit par son numéro, et
// une liste du détail se lit par sa longueur et sa tête, sans s'ajouter à
// une autre ni s'y comparer.
import { rencontresDePersonne } from '../../application/evaluation.js';
import { nomAffiche } from '../../application/personnes.js';
import { pagine } from '../propositions/modele.js';
/** Les cas de la vue des paires, dans l'ordre où elle les éprouve :
* uneTable, une seule table ; unTour, un seul tour ; aucune, aucune paire
* ne se revoit ; tableau, le tableau des paires. */
export const CAS_PAIRES = Object.freeze(['uneTable', 'unTour', 'aucune', 'tableau']);
/**
* Le cas de la vue des paires pour un détail comparable : une seule table
* l'emporte, puis un seul tour ; sans paire choisie ni imposée, aucune ;
* sinon le tableau.
*
* @param {{T: number, R: number, paires: {choisies: Object[], imposees: Object[]}}} detail
* @returns {string} l'un de CAS_PAIRES
*/
export function casDesPaires({ T, R, paires }) {
if (T === 1) return 'uneTable';
if (R === 1) return 'unTour';
if (paires.choisies.length === 0 && paires.imposees.length === 0) return 'aucune';
return 'tableau';
}
// Les personnes données par identifiant, chacune {id, nom, appartenance,
// homonyme} : homonyme vrai quand une autre personne de la liste porte le
// même nom affiché — la vue écrit alors son identifiant à côté du nom.
function personnesParId(personnes) {
const parNom = new Map();
for (const { nom } of personnes) parNom.set(nom, (parNom.get(nom) ?? 0) + 1);
return new Map(
personnes.map(({ id, nom, appartenance }) => [id, { id, nom, appartenance, homonyme: parNom.get(nom) > 1 }]),
);
}
/**
* Le nom d'une personne là où un seul texte la désigne — une infobulle, un
* intitulé de la matrice, un nom accessible : son nom, suivi de son
* identifiant pour un homonyme, comme une ligne l'écrit en deux morceaux.
*
* @param {{id: number, nom: string, homonyme: boolean}} personne
* @param {(cle: string, details?: Object) => string} t
*/
export const nomDistinct = ({ id, nom, homonyme }, t) => (homonyme ? t('qualite.paires.nomHomonyme', { nom, id }) : nom);
// Le numéro de chaque table du détail, par son identifiant.
const numerosParTable = ({ idsTables, numerosTables }) => new Map(idsTables.map((id, t) => [id, numerosTables[t]]));
/**
* Les lignes du tableau des paires (§ 12.5, D32) : {choisies, imposees},
* chacune dans l'ordre du détail. Une ligne : {cle, genre, a, b, fois,
* tours, repetition} — cle, « a:b » par identifiants ; genre, 'choisie' ou
* 'imposee' ; a et b, {id, nom, appartenance, homonyme} ; tours, chaque
* tour où la paire est assise ensemble, {tour, table}, tour à partir de 1,
* table son numéro ; repetition, le premier tour où la paire se revoit —
* son second tour ensemble —, où mène la première commande de la ligne.
*
* @param {Object} detail un détail comparable (detaillerQualite)
* @returns {{choisies: Object[], imposees: Object[]}}
*/
export function lignesDesPaires(detail) {
const personnes = personnesParId(detail.personnes);
const numeros = numerosParTable(detail);
const ligne = (genre) => ({ a, b, fois, tours }) => {
const ecrits = tours.map(({ tour, table }) => ({ tour: tour + 1, table: numeros.get(table) }));
return { cle: `${a}:${b}`, genre, a: personnes.get(a), b: personnes.get(b), fois, tours: ecrits, repetition: ecrits[1].tour };
};
return { choisies: detail.paires.choisies.map(ligne('choisie')), imposees: detail.paires.imposees.map(ligne('imposee')) };
}
/**
* La page p du tableau des paires (§ 12.5, D50) : le découpage du tableau
* des propositions (pagine, TAILLE_PAGE lignes par page), les paires
* choisies d'abord, puis les imposées, comptées à la suite. Rend
* {choisies, imposees, page, pages, premier, dernier, total, paginee} :
* les lignes de chaque groupe que la page porte, puis la forme de pagine.
*
* @param {{choisies: Object[], imposees: Object[]}} lignes lignesDesPaires
* @param {number} page entière, à partir de 1 ; bornée aux pages existantes
*/
export function pageDesPaires({ choisies, imposees }, page) {
const { lignes, horsClassement, ...reste } = pagine({ lignes: choisies, horsClassement: imposees }, page);
return { choisies: lignes, imposees: horsClassement, ...reste };
}
/**
* La matrice « qui a rencontré qui » (§ 12.5, D33) mise en forme pour la
* grille, son infobulle et son jumeau ; null quand le détail ne la porte
* pas (au-delà du seuil). Les personnes viennent dans l'ordre de la
* matrice, celui du profil, chacune sous son rang à partir de 1 : une ligne
* de la grille s'intitule « rang. nom », une colonne par le rang seul. Le
* nom d'une personne, celui de nomDistinct, s'écrit une fois et sert à
* l'intitulé de sa ligne comme à l'infobulle de chacune de ses cases. Les
* valeurs sont le triangle du détail déplié en n × n, ligne après ligne,
* symétrique, null sur la diagonale, que la grille laisse vide.
*
* Rend {personnes, lignes, colonnes, valeurs, infobulle, jumeau} :
* personnes, [{id, nom, rang}], nom celui de nomDistinct ; lignes et
* colonnes, [{libelle}] ;
* infobulle, {colonnes, lignes}, une ligne par case dans l'ordre des
* valeurs — la paire et son compte, la personne seule sur la diagonale ;
* jumeau, {nom, colonnes, lignes}, une ligne par personne, son intitulé
* puis un compte par colonne, vide sur la diagonale.
*
* @param {Object} detail un détail comparable
* @param {{t: Function, format: {nombre: Function}}} outils
*/
export function matriceDesPaires(detail, { t, format }) {
if (detail.matrice === null) return null;
const { ordre, comptes } = detail.matrice;
const parId = personnesParId(detail.personnes);
const n = ordre.length;
const personnes = ordre.map((id, i) => ({ id, nom: nomDistinct(parId.get(id), t), rang: i + 1 }));
// Le rang d'une paire i < j dans le triangle du détail, ligne après ligne.
const rangPaire = (i, j) => (i * (2 * n - i - 1)) / 2 + (j - i - 1);
const compte = (i, j) => (i === j ? null : comptes[i < j ? rangPaire(i, j) : rangPaire(j, i)]);
const lignes = personnes.map(({ nom, rang }) => ({ libelle: t('qualite.paires.matrice.ligne', { rang: format.nombre(rang), nom }) }));
const colonnes = personnes.map(({ rang }) => ({ libelle: format.nombre(rang) }));
const valeurs = [];
const bulles = [];
for (let i = 0; i < n; i += 1) {
for (let j = 0; j < n; j += 1) {
valeurs.push(compte(i, j));
bulles.push({
paire: i === j ? personnes[i].nom : t('qualite.paires.matrice.paire', { a: personnes[i].nom, b: personnes[j].nom }),
fois: compte(i, j),
});
}
}
const colonnesBulle = [
{ cle: 'paire', libelle: t('qualite.paires.colonne.paire'), genre: 'texte' },
{ cle: 'fois', libelle: t('qualite.paires.colonne.fois'), genre: 'nombre' },
];
const colonnesJumeau = [
{ cle: 'personne', libelle: t('qualite.paires.matrice.personne'), genre: 'texte' },
...personnes.map(({ rang }, j) => ({ cle: `c${j}`, libelle: format.nombre(rang), genre: 'nombre' })),
];
const lignesJumeau = personnes.map((personne, i) => {
const ligne = { personne: lignes[i].libelle };
for (let j = 0; j < n; j += 1) ligne[`c${j}`] = i === j ? '' : compte(i, j);
return ligne;
});
return {
personnes,
lignes,
colonnes,
valeurs,
infobulle: { colonnes: colonnesBulle, lignes: bulles },
jumeau: { nom: t('qualite.paires.matrice.titre'), colonnes: colonnesJumeau, lignes: lignesJumeau },
};
}
/**
* La phrase de synthèse de la matrice (§ 12.7) : chaque liste du détail qui
* n'est pas vide, les choisies puis les imposées, lue telle que le moteur la
* rend — son effectif et le fois de sa tête, la plus revue de la liste — ;
* sans paire revue, la phrase qui le dit. Rien ne s'y additionne ni ne s'y
* compare : les deux têtes s'écrivent l'une après l'autre. Rend {cle,
* details}, details {choisies, imposees}, chacune {paires, fois}, null pour
* une liste vide.
*
* @param {{paires: {choisies: Object[], imposees: Object[]}}} detail
*/
export function syntheseMatrice({ paires: { choisies, imposees } }) {
if (choisies.length === 0 && imposees.length === 0) return { cle: 'qualite.paires.matrice.syntheseAucune', details: {} };
const liste = (paires) => (paires.length === 0 ? null : { paires: paires.length, fois: paires[0].fois });
return { cle: 'qualite.paires.matrice.synthese', details: { choisies: liste(choisies), imposees: liste(imposees) } };
}
/**
* La rubrique « Rencontres » de la fiche d'une personne (§ 12.5, B14, D33)
* : la ligne de la personne dans le placement que le plan montre, la
* source de la page de qualité — la proposition demandée au plan quand la
* liste la porte, sinon le retenu, sinon aucun, comme sourceQualite
* (source.js) le choisit, sans en évaluer la table.
*
* Rend {statut, source, lignes} : statut, celui de rencontresDePersonne —
* mesuree, nonMesurable, exclue ou sansPlacement ; source, {cle, details},
* le libellé du placement, null sans placement ; lignes, chaque personne
* rencontrée, {id, nom, appartenance, homonyme, fois, tours: [{tour,
* table}]}, les plus revues d'abord, tour à partir de 1, table son numéro.
* appartenance et homonyme sont ceux du tableau des paires, sur les
* personnes non exclues de la charge, celles que le placement mesure. Ne
* modifie pas la charge.
*
* @param {Object} charge la charge de l'état observable de la séance
* @param {number|null} demandee la proposition demandée au plan (ui.proposition)
* @param {number} personne l'identifiant de la personne de la fiche
*/
export function rubriqueRencontres(charge, demandee, personne) {
let id = null;
if (demandee !== null && charge.propositions.some((proposition) => proposition.id === demandee)) id = demandee;
else if (charge.retenu !== null) id = 'retenu';
const { statut, lignes } = rencontresDePersonne(charge, id, personne);
let source = null;
if (id === 'retenu') source = { cle: 'participants.fiche.rencontres.retenu', details: {} };
else if (id !== null) source = { cle: 'participants.fiche.rencontres.proposition', details: { proposition: id } };
const numeros = new Map(charge.tables.map(({ id: table, numero }) => [table, numero]));
const personnes = personnesParId(
charge.participants
.filter(({ exclu }) => !exclu)
.map((p) => ({ id: p.id, nom: nomAffiche(p), appartenance: (p.appartenance ?? '').trim() === '' ? null : p.appartenance })),
);
return {
statut,
source,
lignes: lignes.map(({ id: autre, fois, tours }) => ({
...personnes.get(autre),
fois,
tours: tours.map(({ tour, table }) => ({ tour: tour + 1, table: numeros.get(table) })),
})),
};
}

View file

@ -0,0 +1,407 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle des paires répétées (§ 12.5, B14, D32, D33, D50) : les
// cas de la vue ; les paires choisies, puis les imposées, dans l'ordre du
// détail, tours écrits à partir de 1 et tables par leur numéro, la ligne
// menant au premier tour où la paire se revoit ; les pages de cent ; la
// matrice dans l'ordre du profil, jamais par l'alphabet, symétrique, vide
// sur la diagonale ; la rubrique « Rencontres » de la fiche, égale à la
// ligne de la matrice. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
CAS_PAIRES,
casDesPaires,
lignesDesPaires,
matriceDesPaires,
nomDistinct,
pageDesPaires,
rubriqueRencontres,
syntheseMatrice,
} from './paires.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const V1 = VERSION.affichee;
// Douze personnes inventées, deux appartenances, la dernière sans ; les deux
// premières réservées ensemble à la table 1 à chaque tour : leur paire est
// imposée.
const NOMS = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', 'Coopérative Aubier'],
['Varech', 'Sidonie', 'Atelier Ressac'],
['Bruyère', 'Aurèle', 'Coopérative Aubier'],
['Embrun', 'Clovis', 'Atelier Ressac'],
['Sablière', 'Noé', 'Coopérative Aubier'],
['Goémon', 'Perrine', 'Atelier Ressac'],
['Ajonc', 'Tancrède', null],
['Fougeraie', 'Ysaline', null],
];
// Douze personnes, quatre tables de trois numérotées 11 à 14, six tours :
// douze places de voisinage pour onze autres, une répétition au moins pour
// chacun (§ 12.5).
function chargeDouze() {
const charge = creerCharge({ id: 'evt-paires', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 6 });
charge.participants = NOMS.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: 10 + id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
return charge;
}
// La charge, une génération de plus, enregistrée par la commande.
function generee(charge, reglages = { graine: 5, arret: 60, nombre: 2 }) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, reglages);
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
}
const CHARGE = generee(chargeDouze());
const PREMIERE = CHARGE.propositions[0].id;
const DETAIL = detaillerQualite(CHARGE, PREMIERE);
// Une paire forgée du détail, a < b, rencontrée aux tours donnés, à partir
// de 0, à la table 1.
const paireForgee = (a, b, tours) => ({ a, b, fois: tours.length, tours: tours.map((tour) => ({ tour, table: 1 })) });
describe('le cas de la vue (§ 12.6)', () => {
const paires = { choisies: [paireForgee(1, 2, [0, 1])], imposees: [] };
const vide = { choisies: [], imposees: [] };
test('une seule table l’emporte, puis un seul tour ; sans paire, aucune ; sinon le tableau', () => {
assert.deepEqual(CAS_PAIRES, ['uneTable', 'unTour', 'aucune', 'tableau']);
assert.equal(casDesPaires({ T: 1, R: 1, paires }), 'uneTable');
assert.equal(casDesPaires({ T: 1, R: 3, paires }), 'uneTable');
assert.equal(casDesPaires({ T: 4, R: 1, paires: vide }), 'unTour');
assert.equal(casDesPaires({ T: 4, R: 6, paires: vide }), 'aucune');
assert.equal(casDesPaires({ T: 4, R: 6, paires }), 'tableau');
assert.equal(casDesPaires({ T: 4, R: 6, paires: { choisies: [], imposees: paires.choisies } }), 'tableau');
});
test('sur 12 / 4 × 3 / 6, le détail est comparable et sa vue est le tableau', () => {
assert.equal(DETAIL.statut, 'comparable');
assert.equal(casDesPaires(DETAIL), 'tableau');
});
});
describe('les lignes du tableau des paires (§ 12.5, D32)', () => {
const { choisies, imposees } = lignesDesPaires(DETAIL);
test('les paires choisies d’abord, dans l’ordre du détail ; la paire réservée ensemble à chaque tour est imposée, et seulement elle', () => {
assert.ok(choisies.length > 0, 'au moins une paire choisie sur 12 / 4 × 3 / 6');
assert.deepEqual(
choisies.map(({ cle }) => cle),
DETAIL.paires.choisies.map(({ a, b }) => `${a}:${b}`),
);
assert.deepEqual(
imposees.map(({ cle, genre, fois }) => [cle, genre, fois]),
[['1:2', 'imposee', 6]],
);
assert.ok(choisies.every(({ genre }) => genre === 'choisie'));
assert.ok(!choisies.some(({ cle }) => cle === '1:2'), 'la paire imposée n’est pas parmi les choisies');
});
test('chaque ligne écrit ses tours à partir de 1 et ses tables par leur numéro, et mène au premier tour où la paire se revoit', () => {
for (const [ligne, brute] of [...choisies, ...imposees].map((l, i) => [l, [...DETAIL.paires.choisies, ...DETAIL.paires.imposees][i]])) {
assert.deepEqual(
ligne.tours,
brute.tours.map(({ tour, table }) => ({ tour: tour + 1, table: 10 + table })),
);
assert.equal(ligne.repetition, brute.tours[1].tour + 1, `${ligne.cle} : le second tour ensemble`);
assert.notEqual(ligne.repetition, ligne.tours[0].tour, `${ligne.cle} : jamais le premier tour de rencontre`);
assert.equal(ligne.fois, ligne.tours.length);
}
assert.deepEqual(imposees[0].tours.map(({ tour, table }) => [tour, table]), [1, 2, 3, 4, 5, 6].map((tour) => [tour, 11]));
assert.equal(imposees[0].repetition, 2);
});
test('chaque personne porte son nom affiché et son appartenance ; un homonyme se signale, pas les autres', () => {
const { a, b } = imposees[0];
assert.deepEqual(a, { id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', homonyme: false });
assert.deepEqual(b, { id: 2, nom: 'Théo Pervenche', appartenance: 'Coopérative Aubier', homonyme: false });
const forge = {
...DETAIL,
personnes: [
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', statut: 'mobile' },
{ id: 2, nom: 'Iris Ombrelle', appartenance: 'Atelier Ressac', statut: 'mobile' },
{ id: 3, nom: 'Ondine Lacasse', appartenance: null, statut: 'mobile' },
],
idsTables: [1],
numerosTables: [7],
paires: { choisies: [paireForgee(1, 2, [0, 2]), paireForgee(1, 3, [1, 2])], imposees: [] },
};
const lignes = lignesDesPaires(forge).choisies;
assert.deepEqual(
lignes.map(({ a, b }) => [a.homonyme, b.homonyme]),
[
[true, true],
[true, false],
],
);
assert.deepEqual(lignes[0].tours, [
{ tour: 1, table: 7 },
{ tour: 3, table: 7 },
]);
assert.equal(lignes[0].repetition, 3);
});
});
describe('les pages du tableau des paires (§ 5.7, D50)', () => {
// 180 paires choisies et 70 imposées, forgées : 250 lignes.
const forgees = (n, genre) => Array.from({ length: n }, (_, i) => ({ cle: `${genre}${i}`, genre }));
const lignes = { choisies: forgees(180, 'choisie'), imposees: forgees(70, 'imposee') };
test('250 paires font trois pages de cent : les choisies d’abord, puis les imposées, comptées à la suite', () => {
const [une, deux, trois] = [1, 2, 3].map((page) => pageDesPaires(lignes, page));
assert.deepEqual(
[une, deux, trois].map(({ page, pages, premier, dernier, total, paginee }) => [page, pages, premier, dernier, total, paginee]),
[
[1, 3, 1, 100, 250, true],
[2, 3, 101, 200, 250, true],
[3, 3, 201, 250, 250, true],
],
);
assert.deepEqual(
[une, deux, trois].map(({ choisies, imposees }) => [choisies.length, imposees.length]),
[
[100, 0],
[80, 20],
[0, 50],
],
);
assert.equal(deux.choisies[0].cle, 'choisie100');
assert.equal(deux.imposees[0].cle, 'imposee0');
assert.equal(trois.imposees[0].cle, 'imposee20');
});
test('une page hors des pages existantes se borne ; une seule page n’est pas paginée', () => {
assert.equal(pageDesPaires(lignes, 9).page, 3);
assert.equal(pageDesPaires(lignes, 0).page, 1);
const seule = pageDesPaires({ choisies: forgees(3, 'choisie'), imposees: forgees(1, 'imposee') }, 1);
assert.deepEqual([seule.pages, seule.premier, seule.dernier, seule.total, seule.paginee], [1, 1, 4, 4, false]);
});
});
describe('la matrice « qui a rencontré qui » (§ 12.5, D33)', () => {
const matrice = matriceDesPaires(DETAIL, { t, format });
const n = DETAIL.personnes.length;
test('les personnes viennent dans l’ordre du profil, jamais par l’alphabet', () => {
const profil = DETAIL.profil.map(({ id }) => id);
assert.deepEqual(DETAIL.matrice.ordre, profil);
assert.deepEqual(
matrice.personnes.map(({ id }) => id),
profil,
);
const parNom = new Map(DETAIL.personnes.map(({ id, nom }) => [id, nom]));
const alphabet = [...profil].sort((a, b) => (parNom.get(a) < parNom.get(b) ? -1 : 1));
assert.notDeepEqual(profil, alphabet, 'l’épreuve exige un profil qui n’est pas l’ordre alphabétique');
assert.deepEqual(
matrice.lignes.map(({ libelle }) => libelle),
profil.map((id, i) => `${i + 1}. ${parNom.get(id)}`),
);
assert.deepEqual(
matrice.colonnes.map(({ libelle }) => libelle),
profil.map((_, i) => String(i + 1)),
);
});
test('n × n valeurs, symétriques, vides sur la diagonale, chacune le compte du triangle du détail', () => {
assert.equal(matrice.valeurs.length, n * n);
let k = 0;
for (let i = 0; i < n; i += 1) {
assert.equal(matrice.valeurs[i * n + i], null);
for (let j = i + 1; j < n; j += 1) {
assert.equal(matrice.valeurs[i * n + j], DETAIL.matrice.comptes[k], `case ${i}:${j}`);
assert.equal(matrice.valeurs[j * n + i], DETAIL.matrice.comptes[k], `case ${j}:${i}`);
k += 1;
}
}
});
test('la paire imposée se lit dans sa case ; l’infobulle porte une ligne par case, le jumeau une ligne par personne', () => {
const i = matrice.personnes.findIndex(({ id }) => id === 1);
const j = matrice.personnes.findIndex(({ id }) => id === 2);
assert.equal(matrice.valeurs[i * n + j], 6);
assert.deepEqual(matrice.infobulle.lignes[i * n + j], { paire: 'Iris Ombrelle et Théo Pervenche', fois: 6 });
assert.deepEqual(matrice.infobulle.lignes[i * n + i], { paire: 'Iris Ombrelle', fois: null });
assert.equal(matrice.infobulle.lignes.length, n * n);
assert.deepEqual(
matrice.infobulle.colonnes.map(({ cle, genre }) => [cle, genre]),
[
['paire', 'texte'],
['fois', 'nombre'],
],
);
const { jumeau } = matrice;
assert.equal(jumeau.nom, 'Qui a rencontré qui');
assert.equal(jumeau.colonnes.length, n + 1);
assert.equal(jumeau.lignes.length, n);
assert.equal(jumeau.lignes[i].personne, matrice.lignes[i].libelle);
assert.equal(jumeau.lignes[i][`c${j}`], 6);
assert.equal(jumeau.lignes[i][`c${i}`], '');
});
test('deux homonymes : l’intitulé de leur ligne et l’infobulle de leurs cases portent leur identifiant, écrit une fois par personne', () => {
// La personne 2 prend le nom affiché de la personne 1.
const forge = {
...DETAIL,
personnes: DETAIL.personnes.map((p) => (p.id === 2 ? { ...p, nom: 'Iris Ombrelle' } : p)),
};
const homonymes = matriceDesPaires(forge, { t, format });
const indice = (id) => homonymes.personnes.findIndex((p) => p.id === id);
const [i1, i2, i3] = [1, 2, 3].map(indice);
const nom1 = t('qualite.paires.nomHomonyme', { nom: 'Iris Ombrelle', id: 1 });
const nom2 = t('qualite.paires.nomHomonyme', { nom: 'Iris Ombrelle', id: 2 });
assert.equal(nom1, 'Iris Ombrelle (n° 1)');
assert.deepEqual([homonymes.personnes[i1].nom, homonymes.personnes[i2].nom], [nom1, nom2]);
assert.equal(homonymes.lignes[i1].libelle, `${i1 + 1}. ${nom1}`);
assert.equal(homonymes.lignes[i2].libelle, `${i2 + 1}. ${nom2}`);
const bulle = (i, j) => homonymes.infobulle.lignes[i * n + j].paire;
assert.equal(bulle(i1, i3), `${nom1} et Ondine Lacasse`);
assert.equal(bulle(i2, i3), `${nom2} et Ondine Lacasse`);
assert.notEqual(bulle(i1, i3), bulle(i2, i3));
assert.equal(bulle(i1, i1), nom1);
// Les autres gardent leur nom seul, dans l'intitulé comme dans
// l'infobulle.
assert.equal(homonymes.personnes[i3].nom, 'Ondine Lacasse');
assert.equal(homonymes.jumeau.lignes[i2].personne, homonymes.lignes[i2].libelle);
assert.equal(nomDistinct({ id: 3, nom: 'Ondine Lacasse', homonyme: false }, t), 'Ondine Lacasse');
});
test('sans matrice au détail, aucune', () => {
assert.equal(matriceDesPaires({ ...DETAIL, matrice: null }, { t, format }), null);
});
test('la synthèse lit chaque liste du détail, sans la recompter : son effectif et le fois de sa tête, les choisies puis les imposées', () => {
const synthese = syntheseMatrice(DETAIL);
assert.deepEqual(synthese, {
cle: 'qualite.paires.matrice.synthese',
details: {
choisies: { paires: DETAIL.paires.choisies.length, fois: DETAIL.paires.choisies[0].fois },
imposees: { paires: 1, fois: 6 },
},
});
// Chaque liste garde sa tête : aucune des deux n'est retenue sur l'autre.
const deux = { paires: { choisies: [paireForgee(1, 2, [0, 1])], imposees: [paireForgee(3, 4, [0, 1, 2]), paireForgee(5, 6, [0, 1])] } };
assert.deepEqual(syntheseMatrice(deux).details, { choisies: { paires: 1, fois: 2 }, imposees: { paires: 2, fois: 3 } });
const seuleChoisie = { paires: { choisies: [paireForgee(1, 2, [0, 1])], imposees: [] } };
assert.deepEqual(syntheseMatrice(seuleChoisie).details, { choisies: { paires: 1, fois: 2 }, imposees: null });
assert.deepEqual(syntheseMatrice({ paires: { choisies: [], imposees: [] } }), { cle: 'qualite.paires.matrice.syntheseAucune', details: {} });
assert.equal(
t(syntheseMatrice(deux).cle, syntheseMatrice(deux).details),
'Chaque case porte le nombre de rencontres de la paire. 1 paire choisie se revoit, 2 fois ; 2 paires imposées se revoient, la plus revue 3 fois.',
);
});
});
describe('la rubrique « Rencontres » de la fiche (§ 12.5, B14, D33)', () => {
test('la ligne de la fiche égale celle de la matrice, pour chaque personne', () => {
const matrice = matriceDesPaires(DETAIL, { t, format });
const n = matrice.personnes.length;
matrice.personnes.forEach(({ id }, i) => {
const rubrique = rubriqueRencontres(CHARGE, PREMIERE, id);
assert.equal(rubrique.statut, 'mesuree');
assert.deepEqual(rubrique.source, { cle: 'participants.fiche.rencontres.proposition', details: { proposition: PREMIERE } });
const deLaFiche = new Map(rubrique.lignes.map(({ id: autre, fois }) => [autre, fois]));
const deLaMatrice = new Map();
matrice.personnes.forEach(({ id: autre }, j) => {
const fois = matrice.valeurs[i * n + j];
if (fois !== null && fois > 0) deLaMatrice.set(autre, fois);
});
assert.deepEqual(deLaFiche, deLaMatrice, `personne ${id}`);
});
});
test('les plus revues d’abord ; tours à partir de 1, tables par leur numéro', () => {
const { lignes } = rubriqueRencontres(CHARGE, PREMIERE, 1);
assert.deepEqual(lignes[0].id, 2);
assert.equal(lignes[0].nom, 'Théo Pervenche');
assert.deepEqual([lignes[0].appartenance, lignes[0].homonyme], ['Coopérative Aubier', false]);
assert.equal(lignes[0].fois, 6);
assert.deepEqual(lignes[0].tours, [1, 2, 3, 4, 5, 6].map((tour) => ({ tour, table: 11 })));
for (let k = 1; k < lignes.length; k += 1) assert.ok(lignes[k - 1].fois >= lignes[k].fois);
});
test('la source est la proposition demandée qu’a la liste, sinon le retenu, sinon aucune : sans placement, rien à écrire', () => {
const autre = CHARGE.propositions[1].id;
assert.deepEqual(rubriqueRencontres(CHARGE, autre, 3).source.details, { proposition: autre });
const retenue = appliquerCommande('retenirProposition', CHARGE, { id: autre }).charge;
const duRetenu = rubriqueRencontres(retenue, null, 3);
assert.deepEqual([duRetenu.statut, duRetenu.source], ['mesuree', { cle: 'participants.fiche.rencontres.retenu', details: {} }]);
assert.deepEqual(rubriqueRencontres(retenue, 999, 3).source.cle, 'participants.fiche.rencontres.retenu');
// Une proposition demandée l'emporte sur le retenu : la fiche mesure ce
// que la page de qualité mesure.
assert.notEqual(PREMIERE, autre);
const demandeeSurRetenu = rubriqueRencontres(retenue, PREMIERE, 3);
assert.deepEqual(demandeeSurRetenu.source, { cle: 'participants.fiche.rencontres.proposition', details: { proposition: PREMIERE } });
assert.deepEqual(demandeeSurRetenu.lignes, rubriqueRencontres(CHARGE, PREMIERE, 3).lignes);
assert.deepEqual(rubriqueRencontres(CHARGE, null, 3), { statut: 'sansPlacement', source: null, lignes: [] });
assert.deepEqual(rubriqueRencontres(chargeDouze(), null, 3), { statut: 'sansPlacement', source: null, lignes: [] });
});
test('deux homonymes rencontrés : chaque ligne porte son identifiant, son appartenance et le signe homonyme, comme au tableau des paires', () => {
// La personne 2, réservée avec la personne 1, prend son nom : qui
// rencontre l'une rencontre l'autre. La 12, sans appartenance.
const charge = structuredClone(CHARGE);
Object.assign(charge.participants[1], { nom: 'Ombrelle', prenom: 'Iris' });
const lignesDe = (personne) => rubriqueRencontres(charge, PREMIERE, personne).lignes;
const temoin = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((personne) => lignesDe(personne).some(({ id }) => id === 1));
assert.notEqual(temoin, undefined);
const lignes = lignesDe(temoin);
const de = (id) => lignes.find((ligne) => ligne.id === id);
assert.deepEqual(
[1, 2].map((id) => {
const { nom, appartenance, homonyme } = de(id);
return { id, nom, appartenance, homonyme };
}),
[
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', homonyme: true },
{ id: 2, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', homonyme: true },
],
);
for (const ligne of lignes.filter(({ id }) => id > 2)) assert.equal(ligne.homonyme, false, `personne ${ligne.id}`);
// Le même signe que celui du tableau des paires, sur les mêmes personnes.
const detail = detaillerQualite(charge, PREMIERE);
const paire = lignesDesPaires(detail).imposees[0];
assert.deepEqual([paire.a.homonyme, paire.b.homonyme], [true, true]);
// Une personne sans appartenance n'en porte aucune.
const rencontreLa12 = [3, 4, 5, 6, 7, 8, 9, 10, 11].find((personne) => lignesDe(personne).some(({ id }) => id === 12));
assert.equal(lignesDe(rencontreLa12).find(({ id }) => id === 12).appartenance, null);
});
test('une personne exclue, une source qui ne se mesure pas : le statut le dit, sans ligne', () => {
const exclue = structuredClone(CHARGE);
exclue.participants[2].exclu = true;
assert.deepEqual(rubriqueRencontres(exclue, PREMIERE, 3).statut, 'exclue');
assert.deepEqual(rubriqueRencontres(exclue, PREMIERE, 3).lignes, []);
// La proposition dérive pour les autres : l'exclue n'y est plus mesurée.
const derive = rubriqueRencontres(exclue, PREMIERE, 4);
assert.deepEqual([derive.statut, derive.lignes], ['nonMesurable', []]);
assert.deepEqual(derive.source, { cle: 'participants.fiche.rencontres.proposition', details: { proposition: PREMIERE } });
});
});

View file

@ -0,0 +1,173 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle pur de la vue « Performance du calcul » (§ 12.14, B23, D40,
// D49) : il met en mots et en séries ce que la course du détail porte
// (detaillerQualite, evaluation.js), dans l'ordre où elle le porte, sans
// rien mesurer, trier ni recompter (contrainte 41). VuePerformance.svelte
// le rend.
//
// La ligne de tête dit les mouvements évalués sur le compte d'arrêt — ou
// « rien à reprendre après … » sous le compte, ou « le plan initial n'avait
// rien à reprendre » à zéro mouvement —, puis la génération de la séance :
// durée observée, cœurs, cadence, nombre de propositions. Une durée
// inconnue — nulle, absente, d'une proposition rouverte — s'écrit « durée
// inconnue », et aucune cadence ne s'écrit sans durée, même si le détail
// en portait une : la cadence ne vaut que divisée par une durée mesurée.
//
// Les petits multiples : un graphique par valeur de la course, dans
// l'ordre de ses indicateurs — le minimum brut, puis chaque valeur que les
// critères appliqués classent —, chacun la valeur de chaque point de
// relevé, sur les mouvements de ces points ; une valeur qu'aucun point ne
// porte (null partout) n'a pas de graphique et garde sa colonne au jumeau.
// Un seul point — compte 1, ou zéro mouvement — n'a rien à relier : aucun
// graphique. Le jumeau porte une ligne par point de relevé, ses mouvements
// puis une colonne par valeur, et aucune durée : la cadence est celle de
// la génération entière, et ne date aucun point (D40).
//
// La phrase du dernier changement s'arrête aux mouvements évalués, le
// dernier point, jamais au compte d'arrêt : elle n'affirme rien des
// mouvements que la recherche n'a pas évalués (D49).
//
// Le texte passe par t et les nombres par format, ceux de la page : le
// modèle les reçoit et rend du texte prêt à poser.
// Le rendu d'un détail absent, ou d'une source qui ne se compare pas.
const RIEN = Object.freeze({
phrases: [],
tete: null,
synthese: null,
dernierChangement: null,
abscisses: null,
multiples: [],
jumeau: null,
});
/**
* La clé du nom d'une valeur de point de relevé : celle de la page pour le
* minimum brut, moteur.valeur.<nom> pour une valeur qui classe — le nom que
* la phrase de l'ordre du tableau des propositions lui donne.
*
* @param {string} nom un nom de course.indicateurs
* @returns {string}
*/
export function cleIndicateur(nom) {
return nom === 'minimumBrut' ? 'qualite.performance.minimumBrut' : `moteur.valeur.${nom}`;
}
// La phrase de tête de la vue : sur le retenu, qu'elle mesure une
// proposition, laquelle, et si elle en montre la course ; sur une
// proposition sans course gardée, que sa course ne l'est pas ; null sinon.
function phraseDeSource({ id, propositionDuRetenu, course }, t) {
if (id === 'retenu') {
if (course !== null) return { code: 'retenu', texte: t('qualite.performance.retenu', { proposition: course.propositionDuRetenu }) };
if (propositionDuRetenu === null) return { code: 'retenuSansCourse', texte: t('qualite.performance.retenuSansCourse') };
return {
code: 'retenuCourseNonGardee',
texte: t('qualite.performance.retenuCourseNonGardee', { proposition: propositionDuRetenu }),
};
}
return course === null ? { code: 'sansCourse', texte: t('qualite.performance.sansCourse') } : null;
}
// Les morceaux de la ligne de tête, chacun un texte.
function teteDe({ evalues, arret, generation }, { t, format }) {
const nombres = { evalues: format.nombre(evalues), arret: format.nombre(arret) };
let mouvements;
if (evalues === 0) mouvements = t('qualite.performance.planInitial');
else if (evalues < arret) mouvements = t('qualite.performance.rienAReprendre', nombres);
else mouvements = t('qualite.performance.mouvements', nombres);
const { dureeMs, coeurs, cadence, nombre } = generation;
const connue = dureeMs !== null;
const morceaux = [
mouvements,
connue ? t('qualite.performance.duree', { duree: format.duree(dureeMs) }) : t('qualite.performance.dureeInconnue'),
];
if (coeurs !== null) morceaux.push(t('qualite.performance.coeurs', { coeurs }));
if (connue && cadence !== null) morceaux.push(t('qualite.performance.cadence', { cadence: format.nombre(cadence) }));
if (nombre !== null) morceaux.push(t('qualite.performance.propositions', { nombre }));
return morceaux;
}
// La phrase du dernier changement : entre le dernier point où une valeur a
// changé et le dernier point, aux mouvements évalués ; au dernier point
// lui-même, qu'une valeur y a changé.
function phraseDuChangement({ dernierChangement, evalues }, { t, format }) {
const a = format.nombre(evalues);
if (dernierChangement === evalues) return t('qualite.performance.changeAuDernier', { a });
return t('qualite.performance.dernierChangement', { de: format.nombre(dernierChangement), a });
}
/**
* Ce que la vue de la performance montre du détail d'un placement.
*
* @param {import('../../application/evaluation.js').DetailQualite|null} detail
* @param {{t: Function, format: {nombre: Function, duree: Function}}} ecrire
* la traduction et l'écriture des nombres de la page
* @returns {{phrases: Array<{code: string, texte: string}>,
* tete: string[]|null, synthese: string|null,
* dernierChangement: string|null, abscisses: number[]|null,
* multiples: Array<{nom: string, graphique: string, titre: string,
* libelle: string, valeurs: Array<number|null>, synthese: string}>,
* jumeau: {nom: string, colonnes: Array<{cle: string, libelle: string,
* genre: 'nombre'}>, lignes: Object[]}|null}}
* phrases : la phrase du retenu, de la course absente ou d'un seul point,
* chacune sous son code ; tete : les morceaux de la ligne de tête ;
* abscisses : les mouvements de chaque point, null sans graphique ;
* multiples : un graphique par valeur, graphique son nom de désignation,
* titre son nom accessible, libelle le nom de sa série ; jumeau : un
* tableau, une ligne par point. Sans course, ni tête, ni graphique, ni
* jumeau.
*/
export function vuePerformance(detail, { t, format }) {
if (detail === null || detail.statut !== 'comparable') return RIEN;
const source = phraseDeSource(detail, t);
const phrases = source === null ? [] : [source];
const { course } = detail;
if (course === null) return { ...RIEN, phrases };
const { indicateurs, releves } = course;
const libelles = indicateurs.map((nom) => t(cleIndicateur(nom)));
const jumeau = {
nom: t('qualite.performance.jumeau'),
colonnes: [
{ cle: 'mouvements', libelle: t('qualite.performance.colonne.mouvements'), genre: 'nombre' },
...indicateurs.map((nom, k) => ({ cle: nom, libelle: libelles[k], genre: 'nombre' })),
],
lignes: releves.map(({ mouvements, valeurs }) => ({ mouvements, ...valeurs })),
};
const tete = teteDe(course, { t, format });
if (releves.length < 2) {
if (course.evalues > 0) phrases.push({ code: 'unPoint', texte: t('qualite.performance.unPoint') });
return { ...RIEN, phrases, tete, jumeau };
}
const premier = releves[0];
const dernier = releves[releves.length - 1];
const multiples = [];
indicateurs.forEach((nom, k) => {
const valeurs = releves.map((point) => point.valeurs[nom]);
if (valeurs.every((valeur) => valeur === null)) return;
multiples.push({
nom,
graphique: `performance-${nom}`,
titre: t('qualite.performance.graphique', { indicateur: libelles[k] }),
libelle: libelles[k],
valeurs,
synthese: t('qualite.performance.syntheseIndicateur', {
indicateur: libelles[k],
premier: format.nombre(premier.valeurs[nom]),
dernier: format.nombre(dernier.valeurs[nom]),
}),
});
});
return {
phrases,
tete,
synthese: t('qualite.performance.synthese'),
dernierChangement: phraseDuChangement(course, { t, format }),
abscisses: releves.map(({ mouvements }) => mouvements),
multiples,
jumeau,
};
}

View file

@ -0,0 +1,299 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de la vue « Performance du calcul » (§ 12.14, B23,
// D40, D49) : la ligne de tête — mouvements évalués sur le compte, durée,
// cœurs, cadence et nombre de la génération, ou « durée inconnue » sans
// cadence ; « rien à reprendre » sous le compte, « le plan initial » à zéro
// mouvement — ; un petit multiple par valeur, le minimum brut d'abord, sur
// l'abscisse des points de relevé ; la phrase du dernier changement, bornée
// aux mouvements évalués ; le jumeau, points × valeurs, sans durée ; les
// phrases du retenu et de la course absente. Les détails sont écrits à la
// main sur le contrat de detaillerQualite (evaluation.js), sauf la dernière
// épreuve, qui le prend d'une génération du moteur.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { avecCourses, courseDe } from '../../stockage/courses.js';
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { cleIndicateur, vuePerformance } from './performance.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const ecrire = { t, format };
// Les valeurs d'un point : le minimum brut, l'excédent de collisions, les
// rencontres répétées.
const valeurs = (minimumBrut, excedentCollisions, rencontresRepeteesChoisies) => ({
minimumBrut,
excedentCollisions,
rencontresRepeteesChoisies,
});
// Un détail comparable d'épreuve, sur le contrat de detaillerQualite : la
// proposition 4, sa course de quatre points, une génération de la séance.
function detailDe({ id = 4, propositionDuRetenu = null, course = {} } = {}) {
const base = {
source: id === 'retenu' ? 'retenu' : 'proposition',
propositionDuRetenu: id === 'retenu' ? propositionDuRetenu : null,
evalues: 200,
arret: 200,
indicateurs: ['minimumBrut', 'excedentCollisions', 'rencontresRepeteesChoisies'],
releves: [
{ mouvements: 1, valeurs: valeurs(1, 6, 9) },
{ mouvements: 10, valeurs: valeurs(2, 4, 7) },
{ mouvements: 100, valeurs: valeurs(3, 2, 5) },
{ mouvements: 200, valeurs: valeurs(3, 2, 5) },
],
dernierChangement: 100,
generation: { dureeMs: 2500, coeurs: 2, cadence: 1600, nombre: 3 },
};
return {
id,
statut: 'comparable',
raisons: [],
propositionDuRetenu,
course: course === null ? null : { ...base, ...course },
};
}
// Les clés des colonnes du jumeau, dans leur ordre.
const clesDuJumeau = (vue) => vue.jumeau.colonnes.map(({ cle }) => cle);
describe('qualite/performance : la ligne de tête (§ 12.14, § 5.10, D40)', () => {
test('une génération de la séance : mouvements sur le compte, durée observée, cœurs, cadence, propositions', () => {
const vue = vuePerformance(detailDe(), ecrire);
assert.deepEqual(vue.tete, [
t('qualite.performance.mouvements', { evalues: '200', arret: '200' }),
t('qualite.performance.duree', { duree: format.duree(2500) }),
t('qualite.performance.coeurs', { coeurs: 2 }),
t('qualite.performance.cadence', { cadence: format.nombre(1600) }),
t('qualite.performance.propositions', { nombre: 3 }),
]);
assert.equal(vue.tete[1], 'durée observée 2,5 s');
assert.equal(vue.tete[3], 'cadence 1\u{202F}600 mouvements par seconde');
assert.deepEqual(vue.phrases, []);
});
test('rouge : une durée nulle ou inconnue écrit « durée inconnue », sans aucune cadence, même portée par le détail', () => {
for (const generation of [
{ dureeMs: null, coeurs: 1, cadence: 950, nombre: 1 },
{ dureeMs: null, coeurs: null, cadence: null, nombre: null },
]) {
const vue = vuePerformance(detailDe({ course: { generation } }), ecrire);
assert.ok(vue.tete.includes(t('qualite.performance.dureeInconnue')), JSON.stringify(generation));
assert.equal(vue.tete.filter((morceau) => morceau.includes('cadence')).length, 0, JSON.stringify(generation));
assert.equal(vue.tete.filter((morceau) => morceau.startsWith('durée observée')).length, 0);
}
const inconnue = vuePerformance(detailDe({ course: { generation: { dureeMs: null, coeurs: null, cadence: null, nombre: null } } }), ecrire);
assert.deepEqual(inconnue.tete, [t('qualite.performance.mouvements', { evalues: '200', arret: '200' }), 'durée inconnue']);
});
test("rouge : sous le compte, « rien à reprendre » ; la phrase du dernier changement s'arrête aux mouvements évalués, jamais au compte", () => {
const releves = [
{ mouvements: 1, valeurs: valeurs(1, 6, 9) },
{ mouvements: 10, valeurs: valeurs(2, 4, 7) },
{ mouvements: 100, valeurs: valeurs(2, 4, 7) },
{ mouvements: 1000, valeurs: valeurs(2, 4, 7) },
{ mouvements: 3412, valeurs: valeurs(2, 4, 7) },
];
const vue = vuePerformance(detailDe({ course: { evalues: 3412, arret: 20000, releves, dernierChangement: 10 } }), ecrire);
assert.equal(vue.tete[0], 'rien à reprendre après 3\u{202F}412 mouvements sur 20\u{202F}000');
assert.equal(vue.dernierChangement, t('qualite.performance.dernierChangement', { de: '10', a: '3\u{202F}412' }));
assert.ok(!vue.dernierChangement.includes('20\u{202F}000'));
assert.deepEqual(vue.abscisses, [1, 10, 100, 1000, 3412]);
});
// Un arrêt avant le compte, sous les réglages de la génération : le
// n-uplet atteint zéro au dernier mouvement évalué, une valeur y passe donc
// à zéro, et le dernier changement tombe sur les mouvements évalués.
test("rouge : un arrêt avant le compte change au dernier point évalué : « rien à reprendre », la phrase du dernier point, jamais celle d'un intervalle", () => {
const releves = [
{ mouvements: 1, valeurs: valeurs(1, 6, 9) },
{ mouvements: 10, valeurs: valeurs(2, 4, 7) },
{ mouvements: 100, valeurs: valeurs(2, 3, 4) },
{ mouvements: 1000, valeurs: valeurs(2, 1, 2) },
{ mouvements: 3412, valeurs: valeurs(2, 0, 0) },
];
const vue = vuePerformance(detailDe({ course: { evalues: 3412, arret: 20000, releves, dernierChangement: 3412 } }), ecrire);
assert.equal(vue.tete[0], 'rien à reprendre après 3\u{202F}412 mouvements sur 20\u{202F}000');
assert.equal(vue.dernierChangement, t('qualite.performance.changeAuDernier', { a: '3\u{202F}412' }));
assert.notEqual(vue.dernierChangement, t('qualite.performance.dernierChangement', { de: '3\u{202F}412', a: '3\u{202F}412' }));
assert.deepEqual(vue.jumeau.lignes.at(-1), { mouvements: 3412, ...valeurs(2, 0, 0) });
});
test("à zéro mouvement : « le plan initial n'avait rien à reprendre », un seul point, aucun graphique ni phrase de changement", () => {
const course = { evalues: 0, releves: [{ mouvements: 0, valeurs: valeurs(2, 4, 7) }], dernierChangement: 0 };
const vue = vuePerformance(detailDe({ course }), ecrire);
assert.equal(vue.tete[0], "le plan initial n'avait rien à reprendre");
assert.deepEqual([vue.multiples, vue.abscisses, vue.dernierChangement, vue.synthese], [[], null, null, null]);
assert.deepEqual(vue.jumeau.lignes, [{ mouvements: 0, ...valeurs(2, 4, 7) }]);
});
test('un seul mouvement évalué, au compte 1 : un point, sa phrase, aucun graphique', () => {
const course = { evalues: 1, arret: 1, releves: [{ mouvements: 1, valeurs: valeurs(2, 4, 7) }], dernierChangement: 1 };
const vue = vuePerformance(detailDe({ course }), ecrire);
assert.equal(vue.tete[0], t('qualite.performance.mouvements', { evalues: '1', arret: '1' }));
assert.equal(vue.tete[0], '1 mouvement évalué sur 1');
assert.deepEqual(vue.phrases, [{ code: 'unPoint', texte: t('qualite.performance.unPoint') }]);
assert.deepEqual([vue.multiples, vue.abscisses, vue.dernierChangement], [[], null, null]);
assert.equal(vue.jumeau.lignes.length, 1);
});
});
describe('qualite/performance : les petits multiples et le jumeau (§ 12.14, D49)', () => {
test('un graphique par valeur, le minimum brut d’abord, dans l’ordre du détail, sur les mouvements de chaque point', () => {
const vue = vuePerformance(detailDe(), ecrire);
assert.deepEqual(vue.abscisses, [1, 10, 100, 200]);
assert.deepEqual(
vue.multiples.map(({ nom, graphique, libelle, valeurs: v }) => [nom, graphique, libelle, v]),
[
['minimumBrut', 'performance-minimumBrut', 'minimum brut', [1, 2, 3, 3]],
['excedentCollisions', 'performance-excedentCollisions', 'excédent de collisions', [6, 4, 2, 2]],
['rencontresRepeteesChoisies', 'performance-rencontresRepeteesChoisies', 'rencontres répétées', [9, 7, 5, 5]],
],
);
const titres = vue.multiples.map(({ titre }) => titre);
assert.equal(new Set(titres).size, titres.length, 'chaque graphique a son nom');
assert.equal(titres[0], t('qualite.performance.graphique', { indicateur: 'minimum brut' }));
assert.equal(vue.multiples[1].synthese, t('qualite.performance.syntheseIndicateur', { indicateur: 'excédent de collisions', premier: '6', dernier: '2' }));
assert.equal(vue.synthese, t('qualite.performance.synthese'));
});
test('rouge : le jumeau porte une ligne par point, ses mouvements puis une colonne par valeur, et aucune durée', () => {
const vue = vuePerformance(detailDe(), ecrire);
assert.deepEqual(clesDuJumeau(vue), ['mouvements', 'minimumBrut', 'excedentCollisions', 'rencontresRepeteesChoisies']);
assert.ok(vue.jumeau.colonnes.every(({ genre }) => genre === 'nombre'));
assert.deepEqual(
vue.jumeau.colonnes.map(({ libelle }) => libelle),
[t('qualite.performance.colonne.mouvements'), 'minimum brut', 'excédent de collisions', 'rencontres répétées'],
);
assert.deepEqual(vue.jumeau.lignes, [
{ mouvements: 1, ...valeurs(1, 6, 9) },
{ mouvements: 10, ...valeurs(2, 4, 7) },
{ mouvements: 100, ...valeurs(3, 2, 5) },
{ mouvements: 200, ...valeurs(3, 2, 5) },
]);
assert.equal(vue.jumeau.nom, t('qualite.performance.jumeau'));
for (const ligne of vue.jumeau.lignes) assert.deepEqual(Object.keys(ligne), clesDuJumeau(vue));
});
test('le dernier changement : avant le dernier point, entre lui et les mouvements évalués ; au dernier, sa phrase', () => {
assert.equal(vuePerformance(detailDe(), ecrire).dernierChangement, t('qualite.performance.dernierChangement', { de: '100', a: '200' }));
const auDernier = vuePerformance(detailDe({ course: { dernierChangement: 200 } }), ecrire);
assert.equal(auDernier.dernierChangement, t('qualite.performance.changeAuDernier', { a: '200' }));
});
test('une valeur sans aucun point mesuré — un a priori inconnu — garde sa colonne, « — », sans graphique', () => {
const indicateurs = ['minimumBrut', 'ecartAuPlafondAPrioriMax'];
const releves = [
{ mouvements: 1, valeurs: { minimumBrut: 1, ecartAuPlafondAPrioriMax: null } },
{ mouvements: 10, valeurs: { minimumBrut: 2, ecartAuPlafondAPrioriMax: null } },
];
const vue = vuePerformance(detailDe({ course: { indicateurs, releves, evalues: 10, arret: 10, dernierChangement: 10 } }), ecrire);
assert.deepEqual(vue.multiples.map(({ nom }) => nom), ['minimumBrut']);
assert.deepEqual(clesDuJumeau(vue), ['mouvements', 'minimumBrut', 'ecartAuPlafondAPrioriMax']);
assert.equal(vue.jumeau.lignes[0].ecartAuPlafondAPrioriMax, null);
});
test('la clé du nom de chaque valeur : celle de la page pour le minimum brut, celle du moteur pour une valeur qui classe', () => {
assert.equal(cleIndicateur('minimumBrut'), 'qualite.performance.minimumBrut');
assert.equal(cleIndicateur('redondance'), 'moteur.valeur.redondance');
});
});
describe('qualite/performance : le retenu et la course absente (§ 12.6, § 12.14, B15)', () => {
test('un retenu non ajusté : la vue dit qu’elle mesure une proposition, la nomme et en montre la course', () => {
const vue = vuePerformance(detailDe({ id: 'retenu', propositionDuRetenu: 2 }), ecrire);
assert.deepEqual(vue.phrases, [{ code: 'retenu', texte: t('qualite.performance.retenu', { proposition: 2 }) }]);
assert.equal(vue.multiples.length, 3);
assert.notEqual(vue.tete, null);
});
test('un retenu ajusté, ou dont la proposition est effacée : sa phrase, rien d’autre', () => {
const vue = vuePerformance(detailDe({ id: 'retenu', propositionDuRetenu: null, course: null }), ecrire);
assert.deepEqual(vue.phrases, [{ code: 'retenuSansCourse', texte: t('qualite.performance.retenuSansCourse') }]);
assert.deepEqual([vue.tete, vue.multiples, vue.jumeau, vue.dernierChangement], [null, [], null, null]);
});
test('un retenu dont la proposition est dans la liste sans course gardée : il la nomme, et dit sa course non gardée', () => {
const vue = vuePerformance(detailDe({ id: 'retenu', propositionDuRetenu: 3, course: null }), ecrire);
assert.deepEqual(vue.phrases, [{ code: 'retenuCourseNonGardee', texte: t('qualite.performance.retenuCourseNonGardee', { proposition: 3 }) }]);
assert.deepEqual([vue.tete, vue.multiples, vue.jumeau], [null, [], null]);
});
test('une proposition sans course gardée : « Sa course n’est pas gardée. », rien d’autre', () => {
const vue = vuePerformance(detailDe({ course: null }), ecrire);
assert.deepEqual(vue.phrases, [{ code: 'sansCourse', texte: "Sa course n'est pas gardée." }]);
assert.deepEqual([vue.tete, vue.multiples, vue.jumeau, vue.synthese], [null, [], null, null]);
});
test('sans détail, ou un détail qui ne se compare pas : rien', () => {
for (const detail of [null, { id: 4, statut: 'derive', raisons: [{ code: 'X' }], course: null, propositionDuRetenu: null }]) {
assert.deepEqual(vuePerformance(detail, ecrire), {
phrases: [],
tete: null,
synthese: null,
dernierChangement: null,
abscisses: null,
multiples: [],
jumeau: null,
});
}
});
});
describe('qualite/performance : sur le détail d’une génération du moteur (§ 12.14, D40)', () => {
// Six personnes inventées, deux appartenances, deux tables de trois
// places, deux tours.
function chargeSix() {
const charge = creerCharge({ id: 'evt-performance', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
const noms = [
['Ombrelle', 'Iris', 'Coopérative Aubier'],
['Pervenche', 'Théo', 'Coopérative Aubier'],
['Lacasse', 'Ondine', 'Atelier Ressac'],
['Tourteau', 'Basile', 'Atelier Ressac'],
['Quenouille', 'Maëlle', null],
['Varech', 'Sidonie', null],
];
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
return charge;
}
test('le jumeau reprend chaque point du détail, le dernier à ses mouvements évalués ; un graphique par valeur', () => {
const charge = chargeSix();
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 5, nombre: 1, arret: 300 });
const rendu = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee });
const courses = avecCourses(new Map(), rendu.courses);
const detail = detaillerQualite(rendu.charge, 1, { courseDe: (id) => courseDe({ charge: rendu.charge, courses }, id) });
const vue = vuePerformance(detail, ecrire);
assert.deepEqual(
vue.jumeau.lignes,
detail.course.releves.map(({ mouvements, valeurs: v }) => ({ mouvements, ...v })),
);
assert.equal(vue.jumeau.lignes.at(-1).mouvements, detail.course.evalues);
assert.deepEqual(clesDuJumeau(vue), ['mouvements', ...detail.course.indicateurs]);
assert.ok(vue.multiples.length >= 1);
assert.equal(vue.multiples[0].nom, 'minimumBrut');
assert.ok(vue.tete.includes('durée inconnue'));
});
});

View file

@ -0,0 +1,162 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle du profil trié des rencontres de la page de qualité (§ 12.3,
// B12, B15, D28, D30, D34, D41, D51), pur : VueProfil.svelte le câble.
//
// Le profil se dessine en deux graphiques empilés, au même ordre de rangs,
// celui que le détail rend (detail.profil, D28) : rien ne s'y trie ni ne
// s'y recompte. Le premier, en comptes de personnes : les rencontres, puis
// les appartenances rencontrées, et deux repères, le plafond réalisé et la
// borne de diversité. Le second, en pourcentages : le pourcentage de chaque
// rang, l'entier que le détail porte, null — sans point — à plafond réalisé
// nul, et la règle à 100 %, l'image du plafond réalisé. Chaque rang marqué
// porte son signe dans les deux graphiques : au second, celui d'un rang
// sans point se pose au pied de l'axe. Une appartenance au plus : une
// seule série, sans borne de diversité tracée, et la phrase du cas (B15) ;
// une seule table : la phrase seule, pour les deux graphiques.
//
// L'infobulle des deux graphiques et la première table du jumeau portent la
// même ligne entière par rang, le pourcentage suivi du compte de la ligne
// (« 24 sur 27 ») ; la seconde table, la répartition des rencontres.
/** Les graduations de l'axe du second graphique : une valeur, écrite par
* format.nombre, suivie du signe sous la clé de l'axe (contrainte 44). */
export const graduerPourcent =
({ t, format }) =>
(valeur) =>
t('qualite.pourcentage.axe', { valeur: format.nombre(valeur) });
// Les colonnes de la ligne d'un rang, dans l'ordre de l'infobulle et du
// jumeau : [clé, genre] ; les deux colonnes des appartenances s'écrivent
// dès qu'une appartenance est portée (B5), même quand le premier graphique
// ne les trace pas ; sans appartenance, elles valent 0 partout et tombent.
const COLONNES_LIGNE = [
['rang', 'nombre'],
['nom', 'texte'],
['appartenance', 'texte'],
['rencontres', 'nombre'],
['plafondRealise', 'nombre'],
['pourcent', 'pourcentage'],
['plafondAPriori', 'nombre'],
['appartenances', 'nombre'],
['borneDiversite', 'nombre'],
['toursNonAssis', 'nombre'],
['marque', 'texte'],
];
const DES_APPARTENANCES = new Set(['appartenances', 'borneDiversite']);
// Les colonnes de la répartition des rencontres : la valeur, l'effectif,
// l'effectif cumulé, les personnes à leur plafond réalisé.
const COLONNES_REPARTITION = ['valeur', 'effectif', 'cumul', 'auPlafond'];
/**
* Le profil d'une source mesurée : null sans détail comparable ou sans
* personne mesurée — aucun axe vide ne se dessine ; une seule
* table, {cas: 'uneTable', phrase: 'uneTable'} ; sinon les deux graphiques.
*
* @param {Object|null} detail le détail du placement (detaillerQualite)
* @param {{t: Function, format: {nombre: Function}}} outils
* @returns {null|{cas: 'uneTable', phrase: 'uneTable'}|{cas: 'profil',
* phrase: null|'uneAppartenance'|'aucuneAppartenance',
* comptes: {nom, series, reperes, marques, synthese},
* pourcentages: {nom, series, reperes, marques,
* synthese: {texte, pourcentage: null|{texte, compte}}},
* infobulle: {colonnes, lignes}, tableaux: Array<{nom, colonnes, lignes}>}}
* phrase : le code du cas limite, sa phrase sous qualite.profil.<code> ;
* series et reperes : ceux d'Escalier, libellés traduits, une valeur par
* rang ; marques : la marque de chaque rang, null sans, la même dans
* les deux graphiques ; synthese : la phrase du premier graphique, et
* celle du second, la personne au rang 1 puis son pourcentage et le
* compte du pire — à plafond réalisé nul, une phrase entière qui le dit,
* et pourcentage null ; infobulle : les colonnes et une ligne par rang ;
* tableaux : les tables du jumeau
*/
export function profilDe(detail, { t }) {
if (detail === null || detail.statut !== 'comparable' || detail.profil.length === 0) return null;
if (detail.idsTables.length === 1) return { cas: 'uneTable', phrase: 'uneTable' };
const { profil, bornes } = detail;
const personnes = new Map(detail.personnes.map((personne) => [personne.id, personne]));
const avecAppartenances = detail.G > 1;
const colonnesAppartenances = detail.G > 0;
let phrase = null;
if (detail.G === 1) phrase = 'uneAppartenance';
else if (detail.G === 0) phrase = 'aucuneAppartenance';
const pourcent = (valeur) => (valeur === null ? null : t('qualite.pourcentage.valeur', { pourcent: valeur }));
const valeursDe = (champ) => profil.map((rang) => rang[champ]);
const rencontres = { libelle: t('qualite.profil.serie.rencontres'), valeurs: valeursDe('rencontres') };
const colonnes = COLONNES_LIGNE.filter(([cle]) => colonnesAppartenances || !DES_APPARTENANCES.has(cle)).map(([cle, genre]) => ({
cle,
libelle: t(`qualite.profil.colonne.${cle}`),
genre,
}));
const lignes = profil.map((rang, indice) => {
const personne = personnes.get(rang.id);
const ligne = {
rang: indice + 1,
nom: personne.nom,
appartenance: personne.appartenance,
rencontres: rang.rencontres,
plafondRealise: rang.plafondRealise,
pourcent: {
texte: pourcent(rang.pourcent),
compte: t('qualite.profil.ligne', { rencontres: rang.rencontres, plafond: rang.plafondRealise }),
},
plafondAPriori: rang.plafondAPriori,
toursNonAssis: rang.toursNonAssis,
marque: rang.marque === null ? null : t(`qualite.profil.marque.${rang.marque}`),
};
if (colonnesAppartenances) {
ligne.appartenances = rang.appartenances;
ligne.borneDiversite = rang.borneDiversite;
}
return ligne;
});
const infobulle = { colonnes, lignes };
const { rang1 } = bornes;
const nomRang1 = personnes.get(rang1.id).nom;
const marques = valeursDe('marque');
return {
cas: 'profil',
phrase,
comptes: {
nom: t('qualite.profil.graphique.comptes'),
series: avecAppartenances
? [rencontres, { libelle: t('qualite.profil.serie.appartenances'), valeurs: valeursDe('appartenances') }]
: [rencontres],
reperes: [
{ genre: 'plafond', libelle: t('qualite.profil.repere.plafond'), valeurs: valeursDe('plafondRealise') },
...(avecAppartenances ? [{ genre: 'borne', libelle: t('qualite.profil.repere.borne'), valeurs: valeursDe('borneDiversite') }] : []),
],
marques,
synthese: t('qualite.profil.synthese', detail.synthese),
},
pourcentages: {
nom: t('qualite.profil.graphique.pourcentages'),
series: [{ libelle: t('qualite.profil.serie.rencontres'), valeurs: valeursDe('pourcent') }],
reperes: [{ genre: 'cent', libelle: t('qualite.pourcentage.cent'), valeurs: profil.map(() => 100) }],
marques,
synthese:
rang1.pourcent === null
? { texte: t('qualite.profil.plafondNul', { nom: nomRang1 }), pourcentage: null }
: {
texte: t('qualite.profil.synthesePourcentage', { nom: nomRang1 }),
pourcentage: {
texte: pourcent(rang1.pourcent),
compte: t('qualite.profil.pire', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }),
},
},
},
infobulle,
tableaux: [
{ nom: t('qualite.vue.profil.titre'), colonnes, lignes },
{
nom: t('qualite.profil.repartition.titre'),
colonnes: COLONNES_REPARTITION.map((cle) => ({ cle, libelle: t(`qualite.profil.repartition.${cle}`), genre: 'nombre' })),
lignes: detail.repartitionRencontres,
},
],
};
}

View file

@ -0,0 +1,321 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle du profil trié de la page de qualité (§ 12.3, B12,
// B15, D28, D30, D41, D51) : deux graphiques au même ordre de rangs, celui
// du détail — le premier en comptes, deux séries, le plafond réalisé et la
// borne de diversité en repères, les rangs marqués ; le second en
// pourcentages, une série interrompue aux rangs sans plafond, la règle à
// 100 % ; l'infobulle et le jumeau portent la ligne entière, le
// pourcentage suivi du compte de la ligne ; une seule appartenance ou
// aucune ne trace qu'une série, une seule table cède la place à sa phrase.
// Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { rechercher } from '../../moteur/recherche.js';
import { capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { exigerColonnes, ligneInfobulle } from './graphiques/pointage.js';
import { graduerPourcent, profilDe } from './profil.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
const outils = { t, format };
const V1 = VERSION.affichee;
// Six personnes inventées, deux tables de trois places, trois tours ;
// appartenances : les deux premières à l'une, les deux suivantes à l'autre
// (deux), toutes à la même (une), aucune. tables : 2, ou 1 de six places.
function chargeSix({ appartenances = 'deux', tables = 2, reservation = false } = {}) {
const charge = creerCharge({ id: 'evt-profil', nom: 'Atelier Ressac', siegesParDefaut: tables === 1 ? 6 : 3, tours: 3 });
const noms = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Tourteau', 'Basile'],
['Quenouille', 'Maëlle'],
['Varech', 'Sidonie'],
];
const appartenanceDe = (rang) => {
if (appartenances === 'une') return 'Coopérative Aubier';
if (appartenances === 'aucune' || rang >= 4) return null;
return rang < 2 ? 'Coopérative Aubier' : 'Atelier Ressac';
};
charge.participants = noms.map(([nom, prenom], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: appartenanceDe(rang),
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = Array.from({ length: tables }, (_, rang) => ({
id: rang + 1,
numero: rang + 1,
sieges: null,
forme: 'ronde',
position: { x: 300 * (rang + 1), y: 0 },
}));
if (reservation) charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
charge.prochainsIds = { participant: 7, table: tables + 1, proposition: 1 };
return charge;
}
// Le détail de la première proposition d'une génération de la charge.
function detailDe(charge) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 3, nombre: 1, arret: 200 });
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
return detaillerQualite(generee, generee.propositions[0].id);
}
// Quatre personnes inventées, une table de trois places et une de deux,
// deux tours, et une proposition écrite à la main qui assied Maëlle
// Quenouille seule à la petite table aux deux tours : son plafond réalisé
// est nul. reservee : une réservation l'y ancre.
function chargeSeule({ reservee = false } = {}) {
const charge = creerCharge({ id: 'evt-seule', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
charge.participants = [
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Quenouille', 'Maëlle'],
].map(([nom, prenom], rang) => ({
id: rang + 1,
nom,
prenom,
appartenance: rang < 2 ? 'Coopérative Aubier' : 'Atelier Ressac',
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = [
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } },
{ id: 2, numero: 2, sieges: 2, forme: 'ronde', position: { x: 600, y: 0 } },
];
if (reservee) charge.reservations = [{ participant: 4, table: 2, siege: null, portee: 'tous', tour: null }];
charge.prochainsIds = { participant: 5, table: 3, proposition: 2 };
charge.propositions = [
{
id: 1,
graine: 1,
arret: 40,
historique: 1000,
produitVersion: V1,
siegesAttribues: false,
tables: [1, 2],
capacites: charge.tables.map((table) => capacite(charge, table)),
tours: 2,
participants: [1, 2, 3, 4],
placement: [0, 1].map(() => ({ sieges: [[1, 2, 3], [4]], reserve: [] })),
},
];
charge.evenement.etat = 'propose';
return charge;
}
const nomDe = (detail, id) => detail.personnes.find((personne) => personne.id === id).nom;
const cles = (colonnes) => colonnes.map(({ cle }) => cle);
describe('profilDe : le premier graphique, en comptes (§ 12.3, B12, D30, D41)', () => {
test('deux séries et deux repères, chacun une valeur par rang dans l’ordre du profil : rencontres, appartenances, plafond réalisé, borne de diversité', () => {
const detail = detailDe(chargeSix());
const { comptes, cas, phrase } = profilDe(detail, outils);
assert.equal(cas, 'profil');
assert.equal(phrase, null);
assert.deepEqual(comptes.series, [
{ libelle: t('qualite.profil.serie.rencontres'), valeurs: detail.profil.map(({ rencontres }) => rencontres) },
{ libelle: t('qualite.profil.serie.appartenances'), valeurs: detail.profil.map(({ appartenances }) => appartenances) },
]);
assert.deepEqual(comptes.reperes, [
{ genre: 'plafond', libelle: t('qualite.profil.repere.plafond'), valeurs: detail.profil.map(({ plafondRealise }) => plafondRealise) },
{ genre: 'borne', libelle: t('qualite.profil.repere.borne'), valeurs: detail.profil.map(({ borneDiversite }) => borneDiversite) },
]);
// La borne se nomme « borne de diversité », jamais « plafond ».
assert.equal(comptes.reperes[1].libelle, 'borne de diversité');
assert.deepEqual(comptes.marques, detail.profil.map(({ marque }) => marque));
assert.equal(comptes.synthese, t('qualite.profil.synthese', detail.synthese));
assert.equal(comptes.nom, t('qualite.profil.graphique.comptes'));
});
test('l’ordre est celui du détail, jamais retrié : un profil forgé dans un autre ordre se trace tel quel, dans les deux graphiques', () => {
const detail = structuredClone(detailDe(chargeSix()));
detail.profil.reverse();
detail.profil[0].pourcent = 99;
detail.profil[1].pourcent = 3;
const { comptes, pourcentages, infobulle } = profilDe(detail, outils);
assert.deepEqual(comptes.series[0].valeurs, detail.profil.map(({ rencontres }) => rencontres));
assert.deepEqual(pourcentages.series[0].valeurs, detail.profil.map(({ pourcent }) => pourcent));
assert.deepEqual(infobulle.lignes.map(({ nom }) => nom), detail.profil.map(({ id }) => nomDe(detail, id)));
});
test('une personne réservée : le rang marqué « ancre », le signe à son rang', () => {
const detail = detailDe(chargeSix({ reservation: true }));
const { comptes } = profilDe(detail, outils);
const rang = detail.profil.findIndex(({ id }) => id === 1);
assert.equal(comptes.marques[rang], 'ancre');
});
});
describe('profilDe : un plafond réalisé nul (§ 12.3, B12, D51)', () => {
test('une personne mobile seule à sa table : la marque plafondNul, sans point au second graphique, son compte écrit ; ancrée, la marque ancre', () => {
const detail = detaillerQualite(chargeSeule(), 1);
assert.equal(detail.statut, 'comparable');
const rang = detail.profil.findIndex(({ id }) => id === 4);
assert.equal(detail.profil[rang].plafondRealise, 0);
const { comptes, pourcentages, infobulle } = profilDe(detail, outils);
assert.equal(comptes.marques[rang], 'plafondNul');
assert.equal(pourcentages.series[0].valeurs[rang], null);
assert.deepEqual(infobulle.lignes[rang].pourcent, { texte: null, compte: t('qualite.profil.ligne', { rencontres: 0, plafond: 0 }) });
assert.equal(infobulle.lignes[rang].marque, t('qualite.profil.marque.plafondNul'));
// Ancrée, la première marque qui s'applique.
const ancree = detaillerQualite(chargeSeule({ reservee: true }), 1);
const sonRang = ancree.profil.findIndex(({ id }) => id === 4);
assert.equal(ancree.profil[sonRang].plafondRealise, 0);
assert.equal(profilDe(ancree, outils).comptes.marques[sonRang], 'ancre');
});
});
describe('profilDe : le second graphique, en pourcentages (§ 12.3, B12, D51)', () => {
test('une seule série, le pourcentage du détail ; la règle à 100 % en repère, du motif du plafond, libellée « 100 % »', () => {
const detail = detailDe(chargeSix());
const { pourcentages } = profilDe(detail, outils);
assert.deepEqual(pourcentages.series, [{ libelle: t('qualite.profil.serie.rencontres'), valeurs: detail.profil.map(({ pourcent }) => pourcent) }]);
assert.deepEqual(pourcentages.reperes, [{ genre: 'cent', libelle: t('qualite.pourcentage.cent'), valeurs: detail.profil.map(() => 100) }]);
assert.equal(pourcentages.nom, t('qualite.profil.graphique.pourcentages'));
assert.notEqual(pourcentages.nom, profilDe(detail, outils).comptes.nom);
// Sa synthèse : la personne au rang 1, son pourcentage, puis le pire en compte.
const { rang1 } = detail.bornes;
assert.deepEqual(pourcentages.synthese, {
texte: t('qualite.profil.synthesePourcentage', { nom: nomDe(detail, rang1.id) }),
pourcentage: { texte: t('qualite.pourcentage.valeur', { pourcent: rang1.pourcent }), compte: t('qualite.profil.pire', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }) },
});
assert.match(pourcentages.synthese.pourcentage.compte, /^minimum \d+ sur \d+$/);
});
test('les graduations de l’axe s’écrivent en pourcentages', () => {
assert.equal(graduerPourcent(outils)(100), t('qualite.pourcentage.axe', { valeur: format.nombre(100) }));
assert.equal(graduerPourcent(outils)(40), `40\u{202F}%`);
});
test('un rang au plafond réalisé nul n’a pas de point : sa valeur est null, et le compte de sa ligne reste écrit', () => {
const detail = structuredClone(detailDe(chargeSix()));
const ligne = detail.profil[2];
Object.assign(ligne, { plafondRealise: 0, rencontres: 0, pourcent: null, marque: 'plafondNul' });
const { pourcentages, comptes, infobulle } = profilDe(detail, outils);
assert.equal(pourcentages.series[0].valeurs[2], null);
assert.equal(comptes.marques[2], 'plafondNul');
assert.deepEqual(infobulle.lignes[2].pourcent, { texte: null, compte: t('qualite.profil.ligne', { rencontres: 0, plafond: 0 }) });
});
test('la personne au rang 1 au plafond réalisé nul : la synthèse du second graphique le dit en entier, sans pourcentage', () => {
const detail = detaillerQualite(chargeSeule(), 1);
const { rang1 } = detail.bornes;
assert.deepEqual([rang1.id, rang1.plafondRealise, rang1.pourcent], [4, 0, null]);
const { synthese } = profilDe(detail, outils).pourcentages;
assert.deepEqual(synthese, { texte: t('qualite.profil.plafondNul', { nom: nomDe(detail, 4) }), pourcentage: null });
assert.equal(
synthese.texte,
'La personne au rang 1, Maëlle Quenouille, a un plafond réalisé nul : elle ne rencontre personne, et son rang n’a pas de point en pourcentage.',
);
});
});
describe('profilDe : l’infobulle et le jumeau portent la ligne entière (§ 12.3, B12, D34)', () => {
test('une ligne par rang : rang, nom, appartenance, rencontres, plafond réalisé, pourcentage et son compte, a priori, A(p), borne de diversité, tours non assis, marque', () => {
const detail = detailDe(chargeSix({ reservation: true }));
const { infobulle, tableaux } = profilDe(detail, outils);
exigerColonnes(infobulle.colonnes);
assert.deepEqual(cles(infobulle.colonnes), [
'rang',
'nom',
'appartenance',
'rencontres',
'plafondRealise',
'pourcent',
'plafondAPriori',
'appartenances',
'borneDiversite',
'toursNonAssis',
'marque',
]);
assert.equal(infobulle.colonnes.find(({ cle }) => cle === 'pourcent').genre, 'pourcentage');
detail.profil.forEach((rang, i) => {
const personne = detail.personnes.find(({ id }) => id === rang.id);
assert.deepEqual(infobulle.lignes[i], {
rang: i + 1,
nom: personne.nom,
appartenance: personne.appartenance,
rencontres: rang.rencontres,
plafondRealise: rang.plafondRealise,
pourcent: {
texte: rang.pourcent === null ? null : t('qualite.pourcentage.valeur', { pourcent: rang.pourcent }),
compte: t('qualite.profil.ligne', { rencontres: rang.rencontres, plafond: rang.plafondRealise }),
},
plafondAPriori: rang.plafondAPriori,
appartenances: rang.appartenances,
borneDiversite: rang.borneDiversite,
toursNonAssis: rang.toursNonAssis,
marque: rang.marque === null ? null : t(`qualite.profil.marque.${rang.marque}`),
});
});
// Chaque ligne s'écrit, son pourcentage suivi du compte « 24 sur 27 ».
const ecrite = ligneInfobulle(infobulle.colonnes, infobulle.lignes[0], format.nombre);
assert.match(ecrite.find(({ cle }) => cle === 'pourcent').compte, /^\d+ sur \d+$/);
// Le jumeau : les mêmes lignes, puis la répartition des rencontres.
assert.equal(tableaux.length, 2);
assert.deepEqual(tableaux[0], { nom: t('qualite.vue.profil.titre'), colonnes: infobulle.colonnes, lignes: infobulle.lignes });
assert.equal(tableaux[1].nom, t('qualite.profil.repartition.titre'));
assert.deepEqual(cles(tableaux[1].colonnes), ['valeur', 'effectif', 'cumul', 'auPlafond']);
assert.deepEqual(tableaux[1].lignes, detail.repartitionRencontres);
});
});
describe('profilDe : les cas limites (§ 12.6, B15)', () => {
// Une seule appartenance : A(p) et la borne de diversité valent 1 et
// restent écrites dans l'infobulle et le jumeau (B5) ; aucune : elles
// valent 0 partout et leurs colonnes tombent avec le tracé.
for (const [appartenances, code, colonnesEcrites] of [
['une', 'uneAppartenance', true],
['aucune', 'aucuneAppartenance', false],
]) {
test(`${code} : une seule série, ni borne de diversité tracée ; la phrase le dit ; les colonnes des appartenances ${colonnesEcrites ? 'restent écrites' : 'tombent'}`, () => {
const detail = detailDe(chargeSix({ appartenances }));
const { cas, phrase, comptes, infobulle, tableaux } = profilDe(detail, outils);
assert.equal(cas, 'profil');
assert.equal(phrase, code);
assert.equal(comptes.series.length, 1);
assert.deepEqual(comptes.reperes.map(({ genre }) => genre), ['plafond']);
for (const colonnes of [infobulle.colonnes, tableaux[0].colonnes]) {
assert.equal(cles(colonnes).includes('appartenances'), colonnesEcrites);
assert.equal(cles(colonnes).includes('borneDiversite'), colonnesEcrites);
}
if (colonnesEcrites) {
// La phrase ne nie que le tracé : la colonne écrite ne la contredit
// pas.
assert.match(t(`qualite.profil.${code}`), /pas de borne de diversité tracée/);
assert.deepEqual(infobulle.lignes.map(({ appartenances: a }) => a), detail.profil.map(({ appartenances: a }) => a));
assert.deepEqual(infobulle.lignes.map(({ borneDiversite }) => borneDiversite), detail.profil.map(({ borneDiversite }) => borneDiversite));
}
});
}
test('une seule table : la phrase, pour les deux graphiques', () => {
const detail = detailDe(chargeSix({ tables: 1 }));
assert.deepEqual(profilDe(detail, outils), { cas: 'uneTable', phrase: 'uneTable' });
});
test('aucun profil sans détail comparable', () => {
assert.equal(profilDe(null, outils), null);
assert.equal(profilDe({ statut: 'fautif' }, outils), null);
});
});

View file

@ -771,6 +771,23 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(FONDS_GRAPHIQUE.map((f) => f.nom)).toContain('graphiqueGrille');
});
test('marques de graphique : un repère à motif posé sur sa série se mesure contre le fond ; un repère plein, contre la série', () => {
const trace = (designation, jeton, marque) =>
`<path ${designation} data-marque="${marque}" d="M10 20 H190" style="fill:none;stroke:var(${jeton});stroke-width:2px" />`;
const fragment = (motif) =>
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +
trace('data-serie="serie1"', '--couleur-graphique-serie-1', 'graphiqueSerie1') +
trace(`data-repere="borne"${motif}`, '--couleur-graphique-borne', 'graphiqueBorne') +
'</svg>';
const pointille = avecFragment(fragment(' stroke-dasharray="1 3"'), (c) => releverTheme(c).marques);
expect(pointille.vues).toEqual(['graphiqueSerie1', 'graphiqueBorne']);
expect(pointille.mesures).toBe(2);
expect(pointille.fautes).toEqual([]);
const plein = avecFragment(fragment(''), (c) => releverTheme(c).marques);
expect(plein.fautes).toHaveLength(1);
expect(plein.fautes[0]).toContain('graphiqueBorne');
});
test('marques de graphique : le relevé du parcours photographié (stylesDuTheme) les connaît aussi — une série sur la carte ne porte aucune faute et se range sous son jeton, rôle marque', () => {
const { styles, fautes } = avecFragment(
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +

View file

@ -447,13 +447,14 @@ export function mixTables(instance, tableDe, occupation, parcours = parcourirTou
* Les objectifs des tables tour par tour (§ 12.12, D36, B21), lus dans
* parcourirTours et dans les planchers par tour du diagnostic, sans recompte.
* Par tour : possibles, Π_r ; servis, S_r ; leur pourcentage ; hors, Π_r −
* S_r ; plancher, le plancher du tour ; sansCouple, σ_r, les assis sans table
* S_r ; plancher, le plancher du tour ; reste, hors − plancher, le second
* segment de la barre du tour (B21) ; sansCouple, σ_r, les assis sans table
* possible. Un tour sans couple possible a possibles 0, servis, pourcent,
* hors et plancher null, et garde sansCouple.
* hors, plancher et reste null, et garde sansCouple.
*
* pireTour : l'index du tour au plus grand reste, hors − plancher, le
* premier à égalité. soiree : les sommes des tours, Σ S_r sur Σ Π_r, la seule
* somme d'un pourcentage admise (D52), et la somme des planchers.
* pireTour : l'index du tour au plus grand reste, le premier à égalité.
* soiree : les sommes des tours, Σ S_r sur Σ Π_r, la seule somme d'un
* pourcentage admise (D52), et la somme des planchers.
* jamaisServies : les concernées dont s(p) est nul, avec leurs tours
* possibles, les plus nombreux d'abord, puis par index. repartition :
* P(p) − s(p) sur les concernées, de la plus grande valeur à la plus petite.
@ -478,20 +479,21 @@ export function objectifsParTour(instance, parcours, planchers) {
const possibles = possiblesParTour[r];
const sansCouple = assisSansCoupleParTour[r];
if (possibles === 0) {
parTour.push({ possibles, servis: null, pourcent: null, hors: null, plancher: null, sansCouple });
parTour.push({ possibles, servis: null, pourcent: null, hors: null, plancher: null, reste: null, sansCouple });
continue;
}
const servis = servisParTour[r];
const hors = possibles - servis;
const plancher = planchers[r];
parTour.push({ possibles, servis, pourcent: pourcentage(servis, possibles), hors, plancher, sansCouple });
const reste = hors - plancher;
parTour.push({ possibles, servis, pourcent: pourcentage(servis, possibles), hors, plancher, reste, sansCouple });
soiree.possibles += possibles;
soiree.servis += servis;
soiree.hors += hors;
soiree.plancher += plancher;
if (pireTour === null || hors - plancher > pireReste) {
if (pireTour === null || reste > pireReste) {
pireTour = r;
pireReste = hors - plancher;
pireReste = reste;
}
}
if (soiree.possibles === 0) return null;

View file

@ -336,12 +336,12 @@ function objectifsDe(prepare) {
}
describe('objectifsParTour (§ 12.12, D36, B21)', () => {
test('chaque tour, compté à la main : possibles, servis, pourcentage, hors objectif, plancher, assis sans table possible', () => {
test('chaque tour, compté à la main : possibles, servis, pourcentage, hors objectif, plancher, reste, assis sans table possible', () => {
const { objectifs } = objectifsDe(preparer(OBJECTIFS));
assert.deepEqual(objectifs.parTour, [
{ possibles: 5, servis: 3, pourcent: 60, hors: 2, plancher: 2, sansCouple: 4 },
{ possibles: 6, servis: 2, pourcent: 33, hors: 4, plancher: 3, sansCouple: 2 },
{ possibles: 0, servis: null, pourcent: null, hors: null, plancher: null, sansCouple: 9 },
{ possibles: 5, servis: 3, pourcent: 60, hors: 2, plancher: 2, reste: 0, sansCouple: 4 },
{ possibles: 6, servis: 2, pourcent: 33, hors: 4, plancher: 3, reste: 1, sansCouple: 2 },
{ possibles: 0, servis: null, pourcent: null, hors: null, plancher: null, reste: null, sansCouple: 9 },
]);
assert.equal(objectifs.pireTour, 1);
assert.deepEqual(objectifs.soiree, { possibles: 11, servis: 5, pourcent: 45, hors: 6, plancher: 5 });
@ -390,7 +390,7 @@ describe('objectifsParTour (§ 12.12, D36, B21)', () => {
plan[1] = { 1: [6, 3, 1], 2: [2, 7, 8], 3: [4, 5] };
const prepare = preparer({ ...OBJECTIFS, plan });
const { objectifs } = objectifsDe(prepare);
assert.deepEqual(objectifs.parTour.map(({ hors, plancher }) => [hors, plancher]), [[2, 2], [3, 3], [null, null]]);
assert.deepEqual(objectifs.parTour.map(({ hors, plancher, reste }) => [hors, plancher, reste]), [[2, 2, 0], [3, 3, 0], [null, null, null]]);
assert.equal(objectifs.pireTour, 0);
const { tours } = prepare;
const lu = tourLu(prepare.configuration, 1, tours[0]);

View file

@ -290,10 +290,27 @@ function dessousLePlusFrequent(forme, quoi) {
return [...comptes.values()].reduce((a, b) => (b.n > a.n ? b : a)).couleurs;
}
// Le frère avant lequel se pose la sonde de ce qui est peint sous une forme :
// la forme elle-même, sauf pour un repère de graphique à motif — data-repere
// et un stroke-dasharray —, tracé par-dessus les séries qu'il borne et qui
// recouvre une série sur toute sa longueur quand chaque rang est à sa borne.
// Les vides de son motif laissent alors voir la série : le repère se mesure
// contre ce qui est peint sous la première série tracée avant lui, le fond du
// graphique et sa grille. Un repère plein, sans vide, se mesure contre la
// série qu'il recouvre.
function sondeAvant(forme) {
if (!forme.matches('[data-repere]') || getComputedStyle(forme).strokeDasharray === 'none') return forme;
for (const frere of forme.parentNode.children) {
if (frere === forme) break;
if (frere.matches('[data-serie]')) return frere;
}
return forme;
}
// Les couleurs peintes sous une forme SVG au point donné de son repère : une
// sonde sans peinture, posée juste avant elle chez son parent, se mesure par
// fondPeintSous, qui lit les formes peintes avant elle à ce point, puis ce
// qui est sous le dessin.
// sonde sans peinture, posée chez son parent juste avant elle (sondeAvant),
// se mesure par fondPeintSous, qui lit les formes peintes avant elle à ce
// point, puis ce qui est sous le dessin.
function dessousAuPoint(forme, point) {
const ecran = point.matrixTransform(forme.getScreenCTM());
const parent = forme.parentNode;
@ -307,7 +324,7 @@ function dessousAuPoint(forme, point) {
sonde.setAttribute('height', String(cote));
sonde.style.fill = 'none';
sonde.style.stroke = 'none';
parent.insertBefore(sonde, forme);
parent.insertBefore(sonde, sondeAvant(forme));
try {
return fondPeintSous(sonde);
} finally {

View file

@ -57,10 +57,12 @@ const ENTREES = [
['data-conversion', 'plan : une conversion offerte par une liste de travail', null],
['data-corbeille', 'accueil : la corbeille', ['']],
['data-corbeille-dossier', 'accueil : un dossier de la corbeille', null],
['data-course', 'qualité : la ligne de tête de la performance du calcul', ['']],
['data-critere', 'propositions : un nom de la phrase de l’ordre, par le critère qui classe sa valeur, objectifs et animateurs compris', null],
['data-defaut', 'tables : le réglage du défaut de places', ['']],
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
['data-dernier-changement', 'qualité : la phrase du dernier changement de la performance du calcul', ['']],
['data-devient', 'import : ce que devient une ligne', ['']],
['data-donnee', 'diagnostic : une donnée nommée dans une phrase', ['']],
['data-dossier', 'accueil : le dossier de travail montré', ['']],
@ -75,6 +77,7 @@ const ENTREES = [
['data-enregistre', 'import : le chemin des refus enregistrés', ['']],
['data-entete', 'import : la ligne d’en-tête lue', ['']],
['data-entete-liste', 'aperçu : l’en-tête d’une liste imprimée — titre et têtes de colonnes —, répété sur chaque feuille', ['']],
['data-equite', 'qualité : les trois nombres d’une colonne de la diversité par animateur, par sa grandeur', ['appartenances', 'etiquettes']],
['data-estimation', 'génération : l’estimation de la durée', ['']],
['data-etat', 'événement : un état du plan dans la tête', null],
['data-etat-table', 'tables : l’état d’une table', ['']],
@ -104,6 +107,7 @@ const ENTREES = [
['data-impression', 'aperçu : le document imprimé', ['']],
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
['data-issue', 'génération : l’issue de la dernière génération', ['']],
['data-jamais-servies', 'qualité : la liste des personnes jamais servies, ou sa phrase quand elle est vide', ['']],
['data-jumeau', 'qualité : un tableau jumeau ouvert, par son rang dans le jumeau', null],
['data-legende', 'plan : la légende des conflits', ['']],
['data-legende-etiquettes', 'participants : la légende des étiquettes', ['']],
@ -137,6 +141,7 @@ const ENTREES = [
['data-ordre', 'propositions : la phrase de l’ordre', ['']],
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
['data-pages', 'propositions : une nav des pages du classement, haut ou bas', null],
['data-paire', 'qualité : une ligne du tableau des paires répétées, par « a:b » des identifiants', null],
['data-palier', 'génération : un palier de l’éditeur des priorités, par son index', null],
['data-panneau', 'plan : un panneau', ['place', 'table']],
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
@ -154,6 +159,7 @@ const ENTREES = [
['data-poignee', 'plan : la poignée d’une table', null],
['data-point-releve', 'qualité : un point de relevé d’un petit multiple, par ses mouvements évalués', null],
['data-portee', 'tables : la portée d’un objectif, tous pour la soirée, tour pour un tour désigné', ['tous', 'tour']],
['data-position-tables', 'qualité : un bouton radio du sélecteur du mélange des tables, par sa position', ['sieges', 'assis', 'doublons']],
['data-pourcentage', 'qualité : un pourcentage rendu, que suit son compte', ['']],
['data-priorite', 'génération : un critère de l’éditeur des priorités, par son nom', null],
['data-priorites', 'génération : la liste des paliers de l’éditeur des priorités', ['']],
@ -170,6 +176,7 @@ const ENTREES = [
['data-repere', 'qualité : le tracé d’un repère — plafond, borne ou règle à 100 % (cent) —, par son genre', null],
['data-reserve', 'plan : la réserve d’un tour', null],
['data-retenu', 'propositions : la ligne du retenu', null],
['data-rubrique-rencontres', 'participants : la rubrique « Rencontres » de la fiche', ['']],
['data-secours', 'récupération : une source de secours', null],
['data-segment', 'qualité : un segment de barre, par son genre', null],
['data-section', 'une section de l’événement', ['badges', 'diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'qualite', 'tables']],
@ -179,6 +186,7 @@ const ENTREES = [
['data-sieges-retires', 'tables : les sièges retirés', ['']],
['data-signalement', 'accueil : un signalement d’une ligne', null],
['data-socle', 'plan : le socle d’une pastille', ['']],
['data-soiree-objectifs', 'qualité : la soirée des objectifs, en texte', ['']],
['data-source', 'plan : le placement montré, dans la barre de vue', ['']],
['data-source-qualite', 'qualité : la ligne de source, le placement que la page mesure', ['']],
['data-support-rompu', 'événement : le message du support rompu', ['']],
@ -191,6 +199,7 @@ const ENTREES = [
['data-theme-choix', 'le bandeau : un choix de thème', null],
['data-titre', 'plan : un titre non pourvu d’une liste de travail', null],
['data-tours', 'tables : le réglage du nombre de tours', ['']],
['data-tuile', 'qualité : une tuile de l’en-tête chiffré, par le nom de sa colonne', null],
['data-valides', 'import : le compte des lignes valides', ['']],
['data-verrou', 'événement : le verrou d’une autre séance', ['']],
['data-verrou-depuis', 'événement : l’instant du verrou', ['']],