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} + + + + + + + + + + {#each rencontres.lignes as rencontre (rencontre.id)} + + + + + + {/each} + +
{t('participants.fiche.rencontres.personne')}{t('participants.fiche.rencontres.fois')}{t('participants.fiche.rencontres.tours')}
+ {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(' ; ')}
+ {/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)} +
  1. + {tour.nom} + +
  2. + {/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)} + + + + + + + + + + + {#each paires as paire (paire.cle)} + {@const noms = { a: nomAccessible(paire.a), b: nomAccessible(paire.b) }} + + + + + + {/each} + +
{titre}
{t('qualite.paires.colonne.paire')}{t('qualite.paires.colonne.fois')}{t('qualite.paires.colonne.tours')}
{@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} +
+{/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} + +
+ {#each vue.phrases as phrase (phrase.code)} +

{phrase.texte}

+ {/each} + {#if vue.tete !== null} +
    + {#each vue.tete as morceau, rang (rang)} +
  • {morceau}
  • + {/each} +
+ {/if} + {#if vue.multiples.length > 0} +

{vue.synthese}

+
+ {#each vue.multiples as multiple, rang (multiple.nom)} +
+ marges[rang] ?? null, (requises) => (marges[rang] = requises)} + bind:pointe + /> +
+ {/each} +
+ {/if} + {#if vue.dernierChangement !== null} +

{vue.dernierChangement}

+ {/if} + {#if vue.jumeau !== null} + + {/if} +
+
+ {: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 modele.selecteur} +
+ {t('qualite.tables.selecteur')} + + + +
+ {/if} + {#if modele.phrase !== null} +

{modele.phrase}

+ {/if} + {#if modele.grille !== null} + {@const g = modele.grille} +
+ +
+ {/if} + {#if modele.tableaux.length > 0} + + {/if} +
+ {/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} -
+
+
+ redessine quand la carte s'élargit. Elle se lit sur une règle de + hauteur nulle, comme dans Graphique : la hauteur de la grille suit sa + largeur, et le conteneur qui la porte ne se mesure pas. --> +
+
{#if disposition !== null && !disposition.dessinable}

{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}

{:else} @@ -152,6 +159,11 @@