diff --git a/src/geometrie/graphique.js b/src/geometrie/graphique.js
index 2b98edc..8c95538 100644
--- a/src/geometrie/graphique.js
+++ b/src/geometrie/graphique.js
@@ -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 };
}
diff --git a/src/geometrie/graphique.test.js b/src/geometrie/graphique.test.js
index 3076f41..f392148 100644
--- a/src/geometrie/graphique.test.js
+++ b/src/geometrie/graphique.test.js
@@ -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);
diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js
index c96d4cb..9254429 100644
--- a/src/interface/etat_ui.js
+++ b/src/interface/etat_ui.js
@@ -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' });
diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js
index 114457d..3ee1efa 100644
--- a/src/interface/etat_ui.test.js
+++ b/src/interface/etat_ui.test.js
@@ -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 };
diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js
index 39edc10..bd9cda2 100644
--- a/src/interface/libelles/participants.js
+++ b/src/interface/libelles/participants.js
@@ -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'],
diff --git a/src/interface/libelles/qualite.js b/src/interface/libelles/qualite.js
index a03f4c6..fda424c 100644
--- a/src/interface/libelles/qualite.js
+++ b/src/interface/libelles/qualite.js
@@ -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.`,
+ ],
]);
diff --git a/src/interface/participants/Fiche.navigateur.test.js b/src/interface/participants/Fiche.navigateur.test.js
new file mode 100644
index 0000000..07a62fe
--- /dev/null
+++ b/src/interface/participants/Fiche.navigateur.test.js
@@ -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();
+ }
+ });
+});
diff --git a/src/interface/participants/Fiche.svelte b/src/interface/participants/Fiche.svelte
index ca138bf..6e7e552 100644
--- a/src/interface/participants/Fiche.svelte
+++ b/src/interface/participants/Fiche.svelte
@@ -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 @@
{/if}
+
+ {#if rencontres.statut !== 'sansPlacement'}
+
+ {t('participants.fiche.rencontres.titre')}
+
+ {t('participants.fiche.rencontres.intitule')}
+ {t(rencontres.source.cle, rencontres.source.details)}
+
+ {#if rencontres.statut === 'exclue'}
+ {t('participants.fiche.rencontres.exclue')}
+ {:else if rencontres.statut === 'nonMesurable'}
+ {t('participants.fiche.rencontres.nonMesurable')}
+ {:else if rencontres.lignes.length === 0}
+ {t('participants.fiche.rencontres.aucune')}
+ {:else}
+
+
+
+ | {t('participants.fiche.rencontres.personne')} |
+ {t('participants.fiche.rencontres.fois')} |
+ {t('participants.fiche.rencontres.tours')} |
+
+
+
+ {#each rencontres.lignes as rencontre (rencontre.id)}
+
+ |
+ {rencontre.nom}
+ {#if rencontre.homonyme}
+ {t('qualite.paires.homonyme', { id: rencontre.id })}
+ {/if}
+ {#if rencontre.appartenance !== null}
+ {rencontre.appartenance}
+ {/if}
+ |
+ {rencontre.fois} |
+ {rencontre.tours.map((etape) => t('participants.fiche.rencontres.tour', etape)).join(' ; ')} |
+
+ {/each}
+
+
+ {/if}
+
+ {/if}
diff --git a/src/interface/qualite/VueCollisions.svelte b/src/interface/qualite/VueCollisions.svelte
index 6271108..39e5c34 100644
--- a/src/interface/qualite/VueCollisions.svelte
+++ b/src/interface/qualite/VueCollisions.svelte
@@ -2,21 +2,75 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+
+ {#if modele !== null}
+
+ {#if modele.phrase !== null}
+
{modele.phrase}
+ {/if}
+ {#if modele.graphique !== null}
+ {@const g = modele.graphique}
+
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/src/interface/qualite/VueDiversite.svelte b/src/interface/qualite/VueDiversite.svelte
index 73a625a..2f1cf7a 100644
--- a/src/interface/qualite/VueDiversite.svelte
+++ b/src/interface/qualite/VueDiversite.svelte
@@ -2,21 +2,131 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
+
+{#snippet troisNombres(colonne)}
+ {t('qualite.diversite.moins')}{format.nombre(colonne.moins)}
+ {t('qualite.diversite.plus')}{format.nombre(colonne.plus)}
+ {t('qualite.diversite.auMinimum')}{format.nombre(colonne.auMinimum)}
+{/snippet}
+
-
+
+ {#if modele !== null}
+ {#if modele.cas === 'aucunAnimateur'}
+ {t('qualite.diversite.aucunAnimateur')}
+ {:else}
+
+ {#if modele.phrase !== null}
+
{t(`qualite.diversite.${modele.phrase}`)}
+ {/if}
+ {#if modele.equite.length > 0}
+
+ {#each modele.equite as colonne (colonne.genre)}
+
+
{colonne.titre}
+ {#if colonne.genre === 'appartenances'}
+
{@render troisNombres(colonne)}
+ {:else}
+
{@render troisNombres(colonne)}
+ {/if}
+
+ {/each}
+
+ {/if}
+ {#if modele.barres !== null}
+
+ {/if}
+
+
+ {/if}
+ {/if}
+
+
+
diff --git a/src/interface/qualite/VueEntete.svelte b/src/interface/qualite/VueEntete.svelte
index 3d0b980..ea6c3fa 100644
--- a/src/interface/qualite/VueEntete.svelte
+++ b/src/interface/qualite/VueEntete.svelte
@@ -2,20 +2,185 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+
+ {#if entete !== null}
+
+
{entete.population}
+ {#each entete.groupes as groupe (groupe.cle)}
+
+ {groupe.libelle}
+
+ {#each groupe.tuiles as tuile (tuile.nom)}
+ -
+
+
+ expliquer(evenement, tuile)}
+ onmouseleave={() => taire(tuile)}
+ onfocus={(evenement) => expliquer(evenement, tuile)}
+ onblur={() => taire(tuile)}>{tuile.libelle}
+ {tuile.texte}
+ {#if tuile.neClassePas !== null}
+ {tuile.neClassePas}
+ {/if}
+ {#if tuile.pourcentage !== null}
+
+ {/if}
+ {#each tuile.bornes as borne, rang (rang)}
+ {borne}
+ {/each}
+
+ {/each}
+
+
+ {/each}
+
{entete.certificat}
+
+ {/if}
+
+
+
+
diff --git a/src/interface/qualite/VueObjectifs.svelte b/src/interface/qualite/VueObjectifs.svelte
index cac0df8..e5ed073 100644
--- a/src/interface/qualite/VueObjectifs.svelte
+++ b/src/interface/qualite/VueObjectifs.svelte
@@ -2,21 +2,172 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+
+ {#if modele !== null}
+ {#if modele.cas === 'aucunConcerne'}
+ {t('qualite.objectifs.aucunConcerne')}
+ {:else}
+
+ {#if modele.neClassePas}
+
{t('qualite.objectifs.critereDecoche')}
+ {/if}
+ {#if !modele.planchersValides}
+
{t('qualite.objectifs.horsDomaine')}
+ {/if}
+
+ {t('qualite.objectifs.jamaisServies')}
+ {#if modele.jamaisServies.length === 0}
+ {t('qualite.objectifs.aucuneJamaisServie')}
+ {:else}
+
+ {#each modele.jamaisServies as personne (personne.id)}
+ - {personne.nom}{personne.possibles}
+ {/each}
+
+ {/if}
+
+ {#if modele.soiree !== null}
+
+
+
{modele.soiree.comptes}
+
{modele.soiree.hors}
+
+ {/if}
+
+
+ {#each modele.tours as tour (tour.tour)}
+ -
+ {tour.nom}
+
+
+ {/each}
+
+
+
+ {/if}
+ {/if}
+
+
+
diff --git a/src/interface/qualite/VuePaires.svelte b/src/interface/qualite/VuePaires.svelte
index 9ef1129..7538749 100644
--- a/src/interface/qualite/VuePaires.svelte
+++ b/src/interface/qualite/VuePaires.svelte
@@ -2,20 +2,355 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
+{#snippet personne(p)}
+
+ {p.nom}
+ {#if p.homonyme}
+ {t('qualite.paires.homonyme', { id: p.id })}
+ {/if}
+ {#if p.appartenance !== null}
+ {p.appartenance}
+ {/if}
+
+{/snippet}
+
+{#snippet groupe(titre, paires)}
+
+ {titre}
+
+
+ | {t('qualite.paires.colonne.paire')} |
+ {t('qualite.paires.colonne.fois')} |
+ {t('qualite.paires.colonne.tours')} |
+
+
+
+ {#each paires as paire (paire.cle)}
+ {@const noms = { a: nomAccessible(paire.a), b: nomAccessible(paire.b) }}
+
+ | {@render personne(paire.a)}{@render personne(paire.b)} |
+ {t('qualite.paires.fois', { fois: format.nombre(paire.fois) })} |
+
+
+ {#each paire.tours as { tour, table: numero } (tour)}
+
+ {/each}
+ |
+
+ {/each}
+
+
+{/snippet}
+
+{#snippet etroite()}
+ {t('qualite.paires.matrice.etroite')}
+{/snippet}
+
-
+
+ {#if detail !== null}
+
+ {#if cas === 'uneTable'}
+
{t('qualite.paires.uneTable')}
+ {:else if cas === 'unTour'}
+
{t('qualite.paires.unTour')}
+ {:else if cas === 'aucune'}
+
{t('qualite.paires.aucune')}
+ {:else if pageMontree !== null}
+
+
+ {#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}
+
+ {/if}
+
+ {#if cas !== 'uneTable'}
+
+ {t('qualite.paires.matrice.titre')}
+ {#if matrice === null}
+
+ {t('qualite.paires.matrice.seuil', {
+ participants: format.nombre(detail.population.mesuree),
+ seuil: format.nombre(constante('SEUIL_MATRICE_PARTICIPANTS')),
+ })}
+
+ {:else}
+
+ {#if dessinable}
+
+ {/if}
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/src/interface/qualite/VuePerformance.svelte b/src/interface/qualite/VuePerformance.svelte
index a3d1811..12ab5cf 100644
--- a/src/interface/qualite/VuePerformance.svelte
+++ b/src/interface/qualite/VuePerformance.svelte
@@ -2,21 +2,154 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+ {#if remplie}
+
+
+
+ {:else}
+
+ {/if}
+
+
diff --git a/src/interface/qualite/VueProfil.svelte b/src/interface/qualite/VueProfil.svelte
index c3b4f04..c498c4a 100644
--- a/src/interface/qualite/VueProfil.svelte
+++ b/src/interface/qualite/VueProfil.svelte
@@ -2,21 +2,115 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
+{#snippet synthesePourcentages()}
+ {profil.pourcentages.synthese.texte}
+ {#if profil.pourcentages.synthese.pourcentage !== null}
+
+ {/if}
+{/snippet}
+
-
+
+ {#if profil !== null}
+
+ {#if profil.phrase !== null}
+
{t(`qualite.profil.${profil.phrase}`)}
+ {/if}
+ {#if profil.cas === 'profil'}
+
+
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/src/interface/qualite/VueTables.svelte b/src/interface/qualite/VueTables.svelte
index d147733..00954ce 100644
--- a/src/interface/qualite/VueTables.svelte
+++ b/src/interface/qualite/VueTables.svelte
@@ -2,21 +2,178 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
-
+
+ {#if modele !== null}
+
+ {/if}
+
+
+
diff --git a/src/interface/qualite/animateurs.js b/src/interface/qualite/animateurs.js
new file mode 100644
index 0000000..7b7f390
--- /dev/null
+++ b/src/interface/qualite/animateurs.js
@@ -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 }],
+ };
+}
diff --git a/src/interface/qualite/animateurs.test.js b/src/interface/qualite/animateurs.test.js
new file mode 100644
index 0000000..9712434
--- /dev/null
+++ b/src/interface/qualite/animateurs.test.js
@@ -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);
+ }
+ }
+ });
+});
diff --git a/src/interface/qualite/appartenances.js b/src/interface/qualite/appartenances.js
new file mode 100644
index 0000000..f9bafa7
--- /dev/null
+++ b/src/interface/qualite/appartenances.js
@@ -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}} 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,
+ };
+}
diff --git a/src/interface/qualite/appartenances.test.js b/src/interface/qualite/appartenances.test.js
new file mode 100644
index 0000000..05ced23
--- /dev/null
+++ b/src/interface/qualite/appartenances.test.js
@@ -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));
+ }
+ });
+});
diff --git a/src/interface/qualite/entete.js b/src/interface/qualite/entete.js
new file mode 100644
index 0000000..c66a4f2
--- /dev/null
+++ b/src/interface/qualite/entete.js
@@ -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., 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.
+ * @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 }),
+ };
+}
diff --git a/src/interface/qualite/entete.test.js b/src/interface/qualite/entete.test.js
new file mode 100644
index 0000000..2bdb1c9
--- /dev/null
+++ b/src/interface/qualite/entete.test.js
@@ -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}`);
+ }
+ }
+ });
+});
diff --git a/src/interface/qualite/graphiques/Graphique.svelte b/src/interface/qualite/graphiques/Graphique.svelte
index 064c7ed..493ad66 100644
--- a/src/interface/qualite/graphiques/Graphique.svelte
+++ b/src/interface/qualite/graphiques/Graphique.svelte
@@ -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 @@
{/snippet}
-