[ADD] quality page views: header, profiles, memberships, pairs, goals
The Qualité tab now draws its five views from the placement's detail. A numeric header with each figure's percentage and worst case; the profile as counts with the diversity bound dotted, and as percentages on a second chart; membership collisions and a table grid behind a three-position selector, seats by default; repeated pairs with matrix and a card section; goals per round on servable couples and facilitators over the evening and per round; the course of the search. Each chart has its twin table and keyboard access. Checked: node 5083, navigateur 1102, node-long 128; captures stable. --- FR --- [ADD] vues de la qualité : en-tête, profils, appartenances, paires L'onglet Qualité dessine désormais ses cinq vues à partir du détail du placement : un en-tête chiffré avec le pourcentage et le pire cas de chaque chiffre ; le profil en comptes, borne de diversité en pointillé, et en pourcentages sur un second graphique ; les collisions et la grille des tables derrière un sélecteur à trois positions, les sièges par défaut ; les paires répétées, leur matrice et la fiche ; les objectifs par tour et les animateurs sur la soirée et par tour ; la course. Chaque graphique a son tableau jumeau et se parcourt au clavier. Vérifié : node 5083, navigateur 1102, longues 128 ; captures stables. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
26f70cf8c3
commit
01c9296c73
47 changed files with 8887 additions and 115 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
313
src/interface/participants/Fiche.navigateur.test.js
Normal file
313
src/interface/participants/Fiche.navigateur.test.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La rubrique « Rencontres » de la fiche dans un vrai moteur de rendu
|
||||
// (§ 12.5, B8, B14, D33) : la ligne de la personne dans le placement que le
|
||||
// plan montre, sous la ligne de source, les plus revues d'abord, égale à sa
|
||||
// ligne de la matrice de la page de qualité ; une phrase pour une source qui
|
||||
// ne se mesure pas et pour une personne exclue ; absente sans placement,
|
||||
// sans phrase ; vivante en lecture et sur un plan bloqué. Chaque épreuve
|
||||
// monte App par monterApp, au cadre de 1 366 × 700, ouvre la section des
|
||||
// participants et la fiche, et lit le rendu par le texte et les attributs
|
||||
// data-…, jamais par une classe ; la dernière monte la fiche seule, sur
|
||||
// une séance en lecture simulée, et change la proposition demandée au
|
||||
// plan. Les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { rencontresDePersonne } from '../../application/evaluation.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { creerEtatUi } from '../etat_ui.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Fiche from './Fiche.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const V1 = VERSION.affichee;
|
||||
const BASE = 'soiree';
|
||||
|
||||
// Douze personnes inventées, quatre tables de trois numérotées 11 à 14, six
|
||||
// tours ; les deux premières réservées ensemble à la table 11 à chaque tour.
|
||||
const NOMS = [
|
||||
['Ombrelle', 'Iris'],
|
||||
['Pervenche', 'Théo'],
|
||||
['Lacasse', 'Ondine'],
|
||||
['Tourteau', 'Basile'],
|
||||
['Quenouille', 'Maëlle'],
|
||||
['Varech', 'Sidonie'],
|
||||
['Bruyère', 'Aurèle'],
|
||||
['Embrun', 'Clovis'],
|
||||
['Sablière', 'Noé'],
|
||||
['Goémon', 'Perrine'],
|
||||
['Ajonc', 'Tancrède'],
|
||||
['Fougeraie', 'Ysaline'],
|
||||
];
|
||||
|
||||
function chargeDouze() {
|
||||
const charge = creerCharge({ id: 'evt-fiche', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 6 });
|
||||
charge.participants = NOMS.map(([nom, prenom], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: rang % 2 === 0 ? 'Coopérative Aubier' : 'Atelier Ressac',
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: 10 + id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
function generee(charge) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, { graine: 5, arret: 60, nombre: 2 });
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
let montes = [];
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La charge posée en fichier d'état, ouverte — en écriture sauf mode
|
||||
// 'lecture' —, la proposition montrer demandée au plan, la section des
|
||||
// participants et la fiche de la personne ouvertes.
|
||||
async function ouvrirFiche(charge, personne, { mode = 'ecriture', montrer = null } = {}) {
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
if (mode === 'ecriture') await m.seance.passerEnEcriture();
|
||||
if (montrer !== null) m.ui.montrerProposition(montrer);
|
||||
m.ui.allerA('participants');
|
||||
m.ui.ouvrirFiche(personne);
|
||||
flushSync();
|
||||
const fiche = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector(`[data-fiche="${personne}"]`);
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
return { m, fiche };
|
||||
}
|
||||
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const rubrique = (fiche) => fiche.querySelector('[data-rubrique-rencontres]');
|
||||
|
||||
// Les lignes de la rubrique : [nom, fois, tours], le nom lu dans le premier
|
||||
// élément de l'intitulé de ligne, le reste dans le texte de chaque cellule.
|
||||
const lignesDe = (section) =>
|
||||
[...section.querySelectorAll('tbody tr')].map((ligne) => {
|
||||
const [intitule, ...cellules] = ligne.querySelectorAll('th, td');
|
||||
return [texteDe(intitule.firstElementChild), ...cellules.map(texteDe)];
|
||||
});
|
||||
|
||||
// Ce que l'intitulé d'une ligne écrit après le nom, un texte par élément :
|
||||
// l'identifiant d'un homonyme, puis l'appartenance.
|
||||
const secondairesDe = (ligne) => [...ligne.querySelector('th').children].slice(1).map(texteDe);
|
||||
|
||||
describe('la rubrique « Rencontres » de la fiche (§ 12.5, D33)', () => {
|
||||
const lue = generee(chargeDouze());
|
||||
const id = lue.propositions[0].id;
|
||||
|
||||
test('sous la ligne de source, les personnes rencontrées dans la proposition montrée, les plus revues d’abord, leurs tours et tables écrits', async () => {
|
||||
const { fiche } = await ouvrirFiche(lue, 1, { montrer: id });
|
||||
const section = rubrique(fiche);
|
||||
expect(section).not.toBeNull();
|
||||
expect(texteDe(section.querySelector('h4'))).toBe(t('participants.fiche.rencontres.titre'));
|
||||
expect(texteDe(section)).toContain(
|
||||
`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`,
|
||||
);
|
||||
const attendues = rencontresDePersonne(lue, id, 1).lignes.map(({ nom, fois, tours }) => [
|
||||
nom,
|
||||
String(fois),
|
||||
tours.map(({ tour, table }) => t('participants.fiche.rencontres.tour', { tour: tour + 1, table: 10 + table })).join(' ; '),
|
||||
]);
|
||||
expect(lignesDe(section)).toEqual(attendues);
|
||||
expect(lignesDe(section)[0].slice(0, 2)).toEqual(['Théo Pervenche', '6']);
|
||||
});
|
||||
|
||||
test('la ligne de la fiche égale celle de la matrice de la page de qualité, pour chaque personne', async () => {
|
||||
const { m } = await ouvrirFiche(lue, 1, { montrer: id });
|
||||
m.ui.allerA('qualite');
|
||||
const grille = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector('[data-vue-qualite="paires"] [data-graphique="matrice"]');
|
||||
expect(trouvee?.querySelectorAll('[data-case]')).toHaveLength(144);
|
||||
return trouvee;
|
||||
});
|
||||
// Les intitulés de ligne, « rang. nom », dans l'ordre de la matrice.
|
||||
const ordre = [...grille.querySelectorAll('text')].map((texte) => texte.textContent).filter((texte) => /^\d+\. /.test(texte));
|
||||
const nomDe = (intitule) => intitule.replace(/^\d+\. /, '');
|
||||
const matrice = ordre.map((_, i) =>
|
||||
ordre
|
||||
.map((intitule, j) => [nomDe(intitule), texteDe(grille.querySelector(`[data-case="${i}:${j}"]`))])
|
||||
.filter(([, fois]) => fois !== '' && fois !== '0'),
|
||||
);
|
||||
for (const [i, intitule] of ordre.entries()) {
|
||||
const personne = lue.participants.find(({ nom, prenom }) => `${prenom} ${nom}` === nomDe(intitule)).id;
|
||||
m.ui.allerA('participants');
|
||||
m.ui.ouvrirFiche(personne);
|
||||
const section = await attendre(() => {
|
||||
const trouvee = rubrique(m.racine.querySelector(`[data-fiche="${personne}"]`));
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
const deLaFiche = lignesDe(section).map(([nom, fois]) => [nom, fois]);
|
||||
const parNom = (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
||||
expect([...deLaFiche].sort(parNom), intitule).toEqual([...matrice[i]].sort(parNom));
|
||||
}
|
||||
});
|
||||
|
||||
test('sans proposition montrée, le retenu ; vivante en lecture et sur un plan bloqué, sans écriture', async () => {
|
||||
const retenue = executer(lue, 'retenirProposition', { id: lue.propositions[1].id });
|
||||
for (const charge of [retenue, executer(retenue, 'changerEtat', { etat: 'bloque' })]) {
|
||||
const { m, fiche } = await ouvrirFiche(charge, 3, { mode: 'lecture' });
|
||||
const ecritures = m.trace.length;
|
||||
const section = rubrique(fiche);
|
||||
expect(section).not.toBeNull();
|
||||
expect(texteDe(section)).toContain(`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.retenu')}`);
|
||||
expect(lignesDe(section).length).toBe(rencontresDePersonne(charge, 'retenu', 3).lignes.length);
|
||||
expect(section.querySelector('button, [data-refus]')).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
}
|
||||
});
|
||||
|
||||
test('une proposition demandée au plan l’emporte sur le retenu : la fiche mesure la même source que la page de qualité', async () => {
|
||||
const retenue = executer(lue, 'retenirProposition', { id: lue.propositions[1].id });
|
||||
expect(id).not.toBe(lue.propositions[1].id);
|
||||
const { fiche } = await ouvrirFiche(retenue, 3, { montrer: id });
|
||||
const section = rubrique(fiche);
|
||||
expect(texteDe(section)).toContain(
|
||||
`${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`,
|
||||
);
|
||||
expect(texteDe(section)).not.toContain(t('participants.fiche.rencontres.retenu'));
|
||||
const attendues = rencontresDePersonne(retenue, id, 3).lignes.map(({ nom, fois }) => [nom, String(fois)]);
|
||||
expect(lignesDe(section).map(([nom, fois]) => [nom, fois])).toEqual(attendues);
|
||||
});
|
||||
|
||||
test('une personne exclue : la phrase qui le dit, sans ligne ; une source en dérive : la phrase du placement non mesurable', async () => {
|
||||
const exclue = structuredClone(lue);
|
||||
exclue.participants[2].exclu = true;
|
||||
const { fiche } = await ouvrirFiche(exclue, 3, { montrer: id });
|
||||
expect(texteDe(rubrique(fiche))).toContain(t('participants.fiche.rencontres.exclue'));
|
||||
expect(rubrique(fiche).querySelector('table')).toBeNull();
|
||||
const { fiche: autre } = await ouvrirFiche(exclue, 4, { montrer: id });
|
||||
expect(texteDe(rubrique(autre))).toContain(t('participants.fiche.rencontres.nonMesurable'));
|
||||
expect(rubrique(autre).querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('deux homonymes rencontrés : chaque ligne écrit, après le nom, l’identifiant puis l’appartenance, et les deux lignes diffèrent', async () => {
|
||||
// La personne 2, réservée avec la personne 1, prend son nom : qui
|
||||
// rencontre l'une rencontre l'autre.
|
||||
const homonymes = structuredClone(lue);
|
||||
Object.assign(homonymes.participants[1], { nom: 'Ombrelle', prenom: 'Iris' });
|
||||
const temoin = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((personne) =>
|
||||
rencontresDePersonne(homonymes, id, personne).lignes.some((ligne) => ligne.id === 1),
|
||||
);
|
||||
const { fiche } = await ouvrirFiche(homonymes, temoin, { montrer: id });
|
||||
const rangees = [...rubrique(fiche).querySelectorAll('tbody tr')];
|
||||
const deLaPersonne = (personne) =>
|
||||
rangees[rencontresDePersonne(homonymes, id, temoin).lignes.findIndex((ligne) => ligne.id === personne)];
|
||||
const [une, autre] = [1, 2].map(deLaPersonne);
|
||||
expect(texteDe(une.querySelector('th').firstElementChild)).toBe('Iris Ombrelle');
|
||||
expect(texteDe(autre.querySelector('th').firstElementChild)).toBe('Iris Ombrelle');
|
||||
expect(secondairesDe(une)).toEqual([t('qualite.paires.homonyme', { id: 1 }), 'Coopérative Aubier']);
|
||||
expect(secondairesDe(autre)).toEqual([t('qualite.paires.homonyme', { id: 2 }), 'Atelier Ressac']);
|
||||
expect(texteDe(une)).not.toBe(texteDe(autre));
|
||||
// Les autres : le nom, puis l'appartenance seule.
|
||||
for (const rangee of rangees.filter((r) => r !== une && r !== autre)) {
|
||||
expect(secondairesDe(rangee)).toHaveLength(1);
|
||||
expect(secondairesDe(rangee)[0]).not.toMatch(/n°/);
|
||||
}
|
||||
});
|
||||
|
||||
test('sans placement — ni proposition ni retenu —, la rubrique est absente, sans phrase', async () => {
|
||||
const { fiche } = await ouvrirFiche(chargeDouze(), 1);
|
||||
expect(rubrique(fiche)).toBeNull();
|
||||
for (const cle of ['titre', 'nonMesurable', 'exclue', 'aucune']) {
|
||||
expect(texteDe(fiche)).not.toContain(t(`participants.fiche.rencontres.${cle}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('la rubrique suit le placement que le plan montre (§ 12.5, D25)', () => {
|
||||
test('la fiche montée seule : une autre proposition demandée au plan change la source et les lignes ; aucune, et sans retenu, la rubrique disparaît', async () => {
|
||||
const lue = generee(chargeDouze());
|
||||
const [premiere, seconde] = lue.propositions.map(({ id }) => id);
|
||||
const etat = { mode: 'lecture', evenement: { etat: 'brouillon', revision: 1 }, charge: lue };
|
||||
const magasin = writable(etat);
|
||||
const seance = {
|
||||
subscribe: magasin.subscribe,
|
||||
etat: () => etat,
|
||||
surAnnulation: () => () => {},
|
||||
debuterGeste() {},
|
||||
finirGeste() {},
|
||||
async executer() {
|
||||
throw new Error('la rubrique n’écrit rien');
|
||||
},
|
||||
};
|
||||
const ui = creerEtatUi();
|
||||
ui.montrerProposition(premiere);
|
||||
const cible = document.createElement('div');
|
||||
document.body.prepend(cible);
|
||||
const composant = mount(Fiche, {
|
||||
target: cible,
|
||||
props: { participant: lue.participants[4], charge: lue },
|
||||
context: new Map([
|
||||
['t', t],
|
||||
['ui', ui],
|
||||
['seance', seance],
|
||||
]),
|
||||
});
|
||||
try {
|
||||
flushSync();
|
||||
const lignesAttendues = (id) => rencontresDePersonne(lue, id, 5).lignes.map(({ nom, fois }) => [nom, String(fois)]);
|
||||
const source = (id) => `${t('participants.fiche.rencontres.intitule')} ${t('participants.fiche.rencontres.proposition', { proposition: id })}`;
|
||||
expect(texteDe(rubrique(cible))).toContain(source(premiere));
|
||||
expect(lignesDe(rubrique(cible)).map(([nom, fois]) => [nom, fois])).toEqual(lignesAttendues(premiere));
|
||||
ui.montrerProposition(seconde);
|
||||
await tick();
|
||||
flushSync();
|
||||
expect(texteDe(rubrique(cible))).toContain(source(seconde));
|
||||
expect(lignesDe(rubrique(cible)).map(([nom, fois]) => [nom, fois])).toEqual(lignesAttendues(seconde));
|
||||
ui.montrerProposition(null);
|
||||
await tick();
|
||||
flushSync();
|
||||
expect(rubrique(cible)).toBeNull();
|
||||
} finally {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,19 @@
|
|||
// Ouverte, ou passée à une autre personne, la fiche se fait défiler
|
||||
// jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la
|
||||
// grille.
|
||||
//
|
||||
// La rubrique « Rencontres » (§ 12.5, D33, data-rubrique-rencontres) porte
|
||||
// la ligne de la personne dans le placement que le plan montre — celui
|
||||
// que la page de qualité mesure —, par rubriqueRencontres
|
||||
// (qualite/paires.js) : la ligne de source qui le nomme, puis chaque
|
||||
// personne rencontrée, le nombre de fois, et chaque tour avec sa table,
|
||||
// les plus revues d'abord. Comme au tableau des paires, une personne
|
||||
// s'écrit par son nom, puis en encre secondaire l'identifiant d'un
|
||||
// homonyme et son appartenance : deux homonymes n'y font pas deux lignes
|
||||
// pareilles. Une source qui ne se mesure pas, une personne
|
||||
// exclue : une phrase le dit. Sans placement, la rubrique est absente,
|
||||
// sans phrase. Elle n'a aucune commande : elle se lit en lecture et sur un
|
||||
// plan bloqué comme en écriture.
|
||||
import { getContext, onDestroy, tick, untrack } from 'svelte';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { etatPlaces, statuts } from '../../application/places.js';
|
||||
|
|
@ -58,6 +71,7 @@
|
|||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { creerGeste } from '../primitives/geste.js';
|
||||
import { rubriqueRencontres } from '../qualite/paires.js';
|
||||
import { ajoutEtiquettes, etiquettesDeLaFiche, retraitEtiquette, saisieApresAjout, signauxOrthographe } from './etiquettes.js';
|
||||
import { cleDeColonne, cleDeStatut, itineraire, ligneVoisine, reservationsDe } from './grille.js';
|
||||
|
||||
|
|
@ -80,6 +94,7 @@
|
|||
});
|
||||
const reservations = $derived(reservationsDe(charge, participant.id));
|
||||
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
|
||||
const rencontres = $derived(rubriqueRencontres(charge, $ui.proposition, participant.id));
|
||||
|
||||
// La fiche et son champ d'ajout, montés : le défilement vise la fiche,
|
||||
// le focus d'après un retrait ses commandes de retrait ou le champ.
|
||||
|
|
@ -314,6 +329,50 @@
|
|||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
{#if rencontres.statut !== 'sansPlacement'}
|
||||
<section class="rencontres" data-rubrique-rencontres aria-labelledby={`${uid}-titre-rencontres`}>
|
||||
<h4 id={`${uid}-titre-rencontres`}>{t('participants.fiche.rencontres.titre')}</h4>
|
||||
<p class="source">
|
||||
<span>{t('participants.fiche.rencontres.intitule')}</span>
|
||||
<span class="placement">{t(rencontres.source.cle, rencontres.source.details)}</span>
|
||||
</p>
|
||||
{#if rencontres.statut === 'exclue'}
|
||||
<p>{t('participants.fiche.rencontres.exclue')}</p>
|
||||
{:else if rencontres.statut === 'nonMesurable'}
|
||||
<p>{t('participants.fiche.rencontres.nonMesurable')}</p>
|
||||
{:else if rencontres.lignes.length === 0}
|
||||
<p>{t('participants.fiche.rencontres.aucune')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('participants.fiche.rencontres.personne')}</th>
|
||||
<th scope="col" class="fois">{t('participants.fiche.rencontres.fois')}</th>
|
||||
<th scope="col">{t('participants.fiche.rencontres.tours')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rencontres.lignes as rencontre (rencontre.id)}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<span class="nom">{rencontre.nom}</span>
|
||||
{#if rencontre.homonyme}
|
||||
<span class="secondaire">{t('qualite.paires.homonyme', { id: rencontre.id })}</span>
|
||||
{/if}
|
||||
{#if rencontre.appartenance !== null}
|
||||
<span class="secondaire">{rencontre.appartenance}</span>
|
||||
{/if}
|
||||
</th>
|
||||
<td class="fois">{rencontre.fois}</td>
|
||||
<td>{rencontre.tours.map((etape) => t('participants.fiche.rencontres.tour', etape)).join(' ; ')}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
|
|
@ -490,6 +549,44 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* La rubrique des rencontres : l'intitulé en petites capitales, la ligne
|
||||
de source, le placement en gras, puis le tableau ; l'intitulé de ligne
|
||||
sur le fond de la carte, le nombre de fois aligné à droite. */
|
||||
.rencontres {
|
||||
margin: var(--espace-3) 0;
|
||||
}
|
||||
|
||||
.rencontres .source {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--espace-1);
|
||||
}
|
||||
|
||||
.placement {
|
||||
color: var(--couleur-page-texte);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rencontres tbody th {
|
||||
background-color: var(--couleur-carte-fond);
|
||||
border-bottom-color: var(--couleur-ligne-filet);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Une personne rencontrée : son nom, puis l'identifiant d'un homonyme et
|
||||
son appartenance, chacun à la ligne, en petit corps d'encre secondaire. */
|
||||
.rencontres tbody th .secondaire {
|
||||
display: block;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.fois {
|
||||
width: 4rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* L'orthographe retenue : un avis, son signe, un texte de l'encre de la
|
||||
page. */
|
||||
.signal {
|
||||
|
|
|
|||
|
|
@ -203,16 +203,35 @@ function certificat(ligne) {
|
|||
return { colonne: 'certificat', valeur, cle, decimales: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* La cellule d'une colonne de mesure pour une ligne comparable de la table
|
||||
* d'évaluation : { colonne, valeur, cle, decimales }, celle que le tableau
|
||||
* écrit sous cette colonne. La mesure se lit dans la ligne par le nom de sa
|
||||
* colonne, que le tableau porte la colonne ou non : une tuile de la page de
|
||||
* qualité lit ainsi la même mesure qu'une colonne absente — critère décoché,
|
||||
* valeur que le mode réglé ne compare pas — porterait (§ 12.2, D48). L'a
|
||||
* priori est inconnu quand troisChiffres ne porte pas d'écart d'itinéraire
|
||||
* du tout : ses deux colonnes s'écrivent alors « inconnu ». Lève TypeError
|
||||
* pour un nom qui n'est pas celui d'une colonne de mesure.
|
||||
*
|
||||
* @param {import('../../application/evaluation.js').LigneEvaluation} ligne comparable
|
||||
* @param {string} nom le nom d'une colonne de mesure de COLONNES
|
||||
* @returns {{colonne: string, valeur: number|boolean|null, cle: string|null, decimales: number}}
|
||||
*/
|
||||
export function lireColonne(ligne, nom) {
|
||||
const lire = LECTURES.get(nom);
|
||||
if (lire === undefined) throw new TypeError(`colonne de mesure inconnue : ${nom}`);
|
||||
if (nom === 'certificat') return certificat(ligne);
|
||||
if (ligne.troisChiffres.ecartItineraireMax === null && SELON_A_PRIORI.has(nom)) {
|
||||
return { colonne: nom, valeur: null, cle: INCONNU, decimales: 0 };
|
||||
}
|
||||
return nombre(nom, lire(ligne));
|
||||
}
|
||||
|
||||
// Les cellules de mesure d'une ligne comparable, une par colonne de mesure
|
||||
// portée, dans l'ordre de LECTURES. L'a priori est inconnu quand
|
||||
// troisChiffres ne porte pas d'écart d'itinéraire du tout.
|
||||
// portée, dans l'ordre de LECTURES.
|
||||
function cellules(ligne, portees) {
|
||||
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
|
||||
return [...LECTURES].filter(([colonne]) => portees.has(colonne)).map(([colonne, lire]) => {
|
||||
if (colonne === 'certificat') return certificat(ligne);
|
||||
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
|
||||
return nombre(colonne, lire(ligne));
|
||||
});
|
||||
return [...LECTURES.keys()].filter((colonne) => portees.has(colonne)).map((colonne) => lireColonne(ligne, colonne));
|
||||
}
|
||||
|
||||
// Ce que les réservations imposent, énoncé à part (§ 5.4) : les retours et
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../.
|
|||
import { planDepuisFichier } from '../../stockage/placements.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import * as MODELE from './modele.js';
|
||||
import {
|
||||
COLONNES,
|
||||
GROUPES_COLONNES,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
demandeConfirmation,
|
||||
evaluation,
|
||||
explicationDeColonne,
|
||||
lireColonne,
|
||||
nomDe,
|
||||
nombreDePages,
|
||||
pageDe,
|
||||
|
|
@ -1566,3 +1568,60 @@ describe('pagination : le texte de l’étendue (libelles/propositions.js)', ()
|
|||
assert.equal(texteEtendue(pagine(vueDe(237), 2), { t, format }), 'Propositions 101 à 200 sur 237');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lireColonne : la mesure d’une ligne par le nom de sa colonne (§ 12.2, D48)', () => {
|
||||
// Les colonnes de mesure, dans l'ordre de COLONNES : celles qu'un groupe
|
||||
// d'en-têtes couvre, puis le certificat.
|
||||
const MESURES = [...GROUPES_COLONNES.filter(({ cle }) => cle !== null).flatMap(({ colonnes }) => colonnes), 'certificat'];
|
||||
|
||||
test('chaque colonne que le tableau porte : la cellule même du tableau, valeur, clé et décimales ; le retenu aussi', () => {
|
||||
let charge = generee(petiteAObjectifs(), { graine: 3, nombre: 2, arret: 200 });
|
||||
charge = executer(charge, 'retenirProposition', { id: 2 });
|
||||
const table = evaluer(charge);
|
||||
const vue = tableau(table);
|
||||
const lignes = [...vue.lignes.map((ligne, rang) => [ligne, table.propositions[rang]]), [vue.retenu, table.retenu]];
|
||||
let lues = 0;
|
||||
for (const [ligne, source] of lignes) {
|
||||
for (const attendue of ligne.cellules) {
|
||||
assert.deepEqual(lireColonne(source, attendue.colonne), attendue, attendue.colonne);
|
||||
lues += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(lues, 3 * (COLONNES_PAR_DEFAUT.length - 4 + 2));
|
||||
});
|
||||
|
||||
test('une colonne que le tableau ne porte pas, critère décoché : la même mesure, lue dans la ligne', () => {
|
||||
const table = evaluer(generee(petiteAObjectifs({ respecterObjectifs: false, modeObjectifs: 'chacun' }), { graine: 3, nombre: 2, arret: 200 }));
|
||||
const [source] = table.propositions;
|
||||
assert.ok(!tableau(table).colonnes.some(({ nom }) => SIXIEME.includes(nom)));
|
||||
const { objectifs } = source.mesures;
|
||||
assert.deepEqual(lireColonne(source, 'jamaisServies'), { colonne: 'jamaisServies', valeur: objectifs.jamaisServies, cle: null, decimales: 0 });
|
||||
assert.equal(lireColonne(source, 'horsObjectifMax').valeur, objectifs.horsObjectifMax);
|
||||
assert.equal(lireColonne(source, 'toursHorsObjectif').valeur, objectifs.horsObjectif);
|
||||
assert.equal(lireColonne(source, 'toursSelonObjectif').valeur, objectifs.agregat.tous.min);
|
||||
// Sans personne concernée par les animateurs, la mesure manque : « — ».
|
||||
assert.deepEqual(lireColonne(source, 'animateursRencontres'), { colonne: 'animateursRencontres', valeur: null, cle: 'format.vide', decimales: 0 });
|
||||
});
|
||||
|
||||
test('a priori inconnu : « inconnu » sous les deux colonnes de l’a priori, comme au tableau ; le taux de diversité au centième', () => {
|
||||
const charge = generee(chargeDemo('petite'), { graine: 2, nombre: 1, arret: 300 });
|
||||
const table = evaluer(charge, { plafondsAPriori: null });
|
||||
const [ligne] = tableau(table).lignes;
|
||||
const [source] = table.propositions;
|
||||
for (const nom of ['ecartItineraireMax', 'ecartAuPlafondAPriori']) {
|
||||
assert.deepEqual(lireColonne(source, nom), cellule(ligne, nom), nom);
|
||||
assert.equal(lireColonne(source, nom).cle, 'propositions.valeur.inconnu', nom);
|
||||
}
|
||||
assert.equal(lireColonne(source, 'diversite').decimales, 2);
|
||||
assert.equal(texte(lireColonne(source, 'certificat')), 'atteignabilité inconnue');
|
||||
});
|
||||
|
||||
test('chaque colonne de mesure se lit ; une colonne qui n’est pas une mesure, ou inconnue, lève ; la table des lectures reste privée', () => {
|
||||
const [source] = evaluer(generee(chargeSix(), { graine: 1, nombre: 1, arret: 100 })).propositions;
|
||||
for (const nom of MESURES) assert.equal(lireColonne(source, nom).colonne, nom);
|
||||
for (const nom of ['rang', 'proposition', 'imposes', 'commandes', 'inconnue']) {
|
||||
assert.throws(() => lireColonne(source, nom), TypeError, nom);
|
||||
}
|
||||
assert.equal(Object.hasOwn(MODELE, 'LECTURES'), false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
286
src/interface/qualite/Animateurs.navigateur.test.js
Normal file
286
src/interface/qualite/Animateurs.navigateur.test.js
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les deux vues des animateurs dans un vrai moteur de rendu (§ 12.13, B22,
|
||||
// D37, D38) : le profil des animateurs rencontrés, une série et la borne en
|
||||
// repère pointillé, sans manque ni pourcentage ; la diversité par animateur,
|
||||
// la soirée en deux colonnes juxtaposées, chacune son axe, le moins varié en
|
||||
// tête, les trois nombres de chaque colonne, et le jumeau qui porte chaque
|
||||
// tour animé. Chaque vue se monte seule, par mount, avec les contextes que
|
||||
// pose App — 't' et 'format' —, sur le détail d'une génération faite dans
|
||||
// le navigateur. Le rendu se lit par ses attributs data-… et son texte,
|
||||
// jamais par une classe. Les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import '../jetons.css';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { configurationDe } from '../../application/configuration.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import VueAnimateurs from './VueAnimateurs.svelte';
|
||||
import VueDiversite from './VueDiversite.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const CONTEXTE = new Map([
|
||||
['t', t],
|
||||
['format', format],
|
||||
]);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Neuf personnes inventées, leurs appartenances et leurs étiquettes.
|
||||
const PERSONNES = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier', []],
|
||||
['Pervenche', 'Théo', 'Atelier Ressac', []],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac', ['finance']],
|
||||
['Tourteau', 'Basile', 'Coopérative Aubier', ['bénévole']],
|
||||
['Quenouille', 'Maëlle', 'Atelier Ressac', ['finance', 'Montréal']],
|
||||
['Rocaille', 'Anselme', null, ['bénévole']],
|
||||
['Brindille', 'Pélagie', 'Coopérative Aubier', []],
|
||||
['Cassonade', 'Firmin', null, ['Montréal']],
|
||||
['Grésil', 'Ysaline', 'Atelier Ressac', []],
|
||||
];
|
||||
|
||||
// Trois tables de trois places, trois tours ; un titre au siège 1 des tables
|
||||
// 1 et 2. Iris Ombrelle, réservée à la table 1 pour la soirée, y anime à
|
||||
// chaque tour ; Théo Pervenche, réservé à la table 2 au seul tour 2, y anime
|
||||
// ce tour-là, avec Ondine Lacasse et Ysaline Grésil, réservées à sa table à
|
||||
// ce tour : une appartenance et une étiquette, moins qu'Iris Ombrelle sur sa
|
||||
// soirée, de sorte que le moins varié et le plus varié diffèrent dans chaque
|
||||
// colonne. appartenances : false pour une seule appartenance ; etiquettes :
|
||||
// false pour aucune ; titres : false pour aucun animateur.
|
||||
function chargeAnimateurs({ appartenances = true, etiquettes = true, titres = true } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-animateurs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
|
||||
charge.participants = PERSONNES.map(([nom, prenom, appartenance, liste], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: appartenances ? appartenance : 'Coopérative Aubier',
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...(etiquettes && liste.length > 0 ? { etiquettes: liste } : {}),
|
||||
}));
|
||||
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id + 10, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
if (titres) {
|
||||
charge.titres = [1, 2].map((table) => ({ table, siege: 1, libelle: 'Animation' }));
|
||||
charge.reservations = [
|
||||
{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null },
|
||||
{ participant: 2, table: 2, siege: null, portee: 'tour', tour: 2 },
|
||||
{ participant: 3, table: 2, siege: null, portee: 'tour', tour: 2 },
|
||||
{ participant: 9, table: 2, siege: null, portee: 'tour', tour: 2 },
|
||||
];
|
||||
}
|
||||
charge.reglages.rencontrerAnimateurs = true;
|
||||
charge.prochainsIds = { participant: 10, table: 4, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Le détail de la première proposition d'une génération.
|
||||
function detailDe(options) {
|
||||
const charge = chargeAnimateurs(options);
|
||||
const configuration = configurationDe(charge);
|
||||
const propositions = rechercher(configuration, { graine: 11, nombre: 1, arret: 200 });
|
||||
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
return detaillerQualite(generee, generee.propositions[0].id);
|
||||
}
|
||||
|
||||
const montes = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { composant, cible } of montes.splice(0)) {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Monte une vue sur un détail, dans un conteneur de 900 px au début du
|
||||
// corps, puis laisse son graphique mesurer sa largeur et son texte ; rend
|
||||
// l'élément [data-vue-qualite] de la vue.
|
||||
async function monter(Vue, detail) {
|
||||
const cible = document.createElement('div');
|
||||
cible.style.width = '900px';
|
||||
document.body.prepend(cible);
|
||||
const composant = mount(Vue, { target: cible, props: { detail, ligne: null, table: null }, context: CONTEXTE });
|
||||
montes.push({ composant, cible });
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
flushSync();
|
||||
await tick();
|
||||
}
|
||||
return cible.querySelector('[data-vue-qualite]');
|
||||
}
|
||||
|
||||
// Ouvre le jumeau de la vue par sa commande ; rend ses tableaux.
|
||||
async function ouvrirJumeau(vue) {
|
||||
vue.querySelector('[data-commande="ouvrirJumeau"]').click();
|
||||
flushSync();
|
||||
await tick();
|
||||
return [...vue.querySelectorAll('[data-jumeau]')];
|
||||
}
|
||||
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const nombre = (texte) => Number(texte.replace(/\D/g, ''));
|
||||
|
||||
// Les cellules d'une rangée de tableau, par la clé de leur colonne.
|
||||
const cellules = (rangee) => Object.fromEntries([...rangee.children].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)]));
|
||||
|
||||
describe('animateurs rencontrés : le profil (§ 12.13, D37)', () => {
|
||||
test('une seule série et la borne des animateurs en repère pointillé ; la synthèse au minimum ; le jumeau, une ligne par personne concernée', async () => {
|
||||
const detail = detailDe();
|
||||
const vue = await monter(VueAnimateurs, detail);
|
||||
const svg = vue.querySelector('svg[data-graphique="animateurs"]');
|
||||
expect(svg.querySelectorAll('path[data-serie]').length).toBe(1);
|
||||
expect(svg.querySelector('path[data-serie]').dataset.serie).toBe('serie1');
|
||||
expect([...svg.querySelectorAll('path[data-repere]')].map((trace) => trace.dataset.repere)).toEqual(['borne']);
|
||||
const [premiere] = detail.animateurs.repartition;
|
||||
expect(texteDe(vue)).toContain(t('qualite.animateurs.synthese', { minimum: premiere.valeur, effectif: premiere.effectif }));
|
||||
const [profil, repartition] = await ouvrirJumeau(vue);
|
||||
expect(profil.querySelectorAll('tbody tr').length).toBe(detail.animateurs.profil.length);
|
||||
expect(repartition.querySelectorAll('tbody tr').length).toBe(detail.animateurs.repartition.length);
|
||||
expect(
|
||||
[...profil.querySelectorAll('thead th')].map((th) => th.dataset.colonne),
|
||||
).toEqual(['nom', 'rencontres', 'borne', 'statut']);
|
||||
});
|
||||
|
||||
test('rouge : ni pourcentage de la borne, ni manque, ni « plafond », le jumeau ouvert compris', async () => {
|
||||
const vue = await monter(VueAnimateurs, detailDe());
|
||||
await ouvrirJumeau(vue);
|
||||
expect(vue.querySelector('[data-pourcentage]')).toBeNull();
|
||||
expect(texteDe(vue)).not.toMatch(/%|plafond|manque/i);
|
||||
});
|
||||
|
||||
test('sans animateur : la phrase, aucun graphique', async () => {
|
||||
const vue = await monter(VueAnimateurs, detailDe({ titres: false }));
|
||||
expect(texteDe(vue.querySelector('[data-phrase-qualite="animateurs.aucun"]'))).toBe(t('qualite.animateurs.aucun'));
|
||||
expect(vue.querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('diversité par animateur (§ 12.13, D38)', () => {
|
||||
test('rouge : deux colonnes juxtaposées, chacune son axe, jamais empilées ; la soirée, le moins varié en tête', async () => {
|
||||
const detail = detailDe();
|
||||
const { lignes } = detail.animateurs.diversite;
|
||||
const vue = await monter(VueDiversite, detail);
|
||||
const svg = vue.querySelector('svg[data-graphique="diversite"]');
|
||||
expect(svg.querySelectorAll('line[data-marque="graphiqueAxe"]').length).toBe(2);
|
||||
const barres = [...svg.querySelectorAll('[data-ligne-graphique]')];
|
||||
expect(barres.map((ligne) => Number(ligne.dataset.ligneGraphique))).toEqual(lignes.map(({ id }) => id));
|
||||
expect(lignes[0].appartenances).toBeLessThan(lignes[1].appartenances);
|
||||
const fins = [...svg.querySelectorAll('rect[data-segment="serie1"]')].map((rect) => rect.x.baseVal.value + rect.width.baseVal.value);
|
||||
const debuts = [...svg.querySelectorAll('rect[data-segment="serie2"]')].map((rect) => rect.x.baseVal.value);
|
||||
expect(fins.length).toBeGreaterThan(0);
|
||||
expect(debuts.length).toBeGreaterThan(0);
|
||||
expect(Math.min(...debuts)).toBeGreaterThan(Math.max(...fins));
|
||||
barres.forEach((ligne, rang) => {
|
||||
const { toursAnimes, personnes } = lignes[rang];
|
||||
expect(texteDe(ligne)).toContain(t('qualite.diversite.secondaire', { tours: toursAnimes, personnes }));
|
||||
});
|
||||
});
|
||||
|
||||
test('au-dessus de chaque colonne, ses trois nombres : le moins varié, le plus varié, au minimum', async () => {
|
||||
const detail = detailDe();
|
||||
const vue = await monter(VueDiversite, detail);
|
||||
const { equite } = detail.animateurs.diversite;
|
||||
for (const genre of ['appartenances', 'etiquettes']) {
|
||||
// Les deux animateurs diffèrent : chaque nombre se lit à sa place.
|
||||
expect(equite[genre].moins, genre).toBeLessThan(equite[genre].plus);
|
||||
const bloc = vue.querySelector(`[data-equite="${genre}"]`);
|
||||
expect([...bloc.querySelectorAll('dt')].map(texteDe)).toEqual([
|
||||
t('qualite.diversite.moins'),
|
||||
t('qualite.diversite.plus'),
|
||||
t('qualite.diversite.auMinimum'),
|
||||
]);
|
||||
expect([...bloc.querySelectorAll('dd')].map(texteDe)).toEqual(
|
||||
[equite[genre].moins, equite[genre].plus, equite[genre].auMinimum].map((valeur) => format.nombre(valeur)),
|
||||
);
|
||||
}
|
||||
// Au-dessus : chaque bloc précède le graphique dans le document. Dans
|
||||
// l'ordre des colonnes : appartenances, la série 1 à gauche du dessin,
|
||||
// puis étiquettes, la série 2 à sa droite, à l'écran comme au document.
|
||||
const svg = vue.querySelector('svg[data-graphique="diversite"]');
|
||||
const blocs = [...vue.querySelectorAll('[data-equite]')];
|
||||
expect(blocs.map((bloc) => bloc.dataset.equite)).toEqual(['appartenances', 'etiquettes']);
|
||||
for (const bloc of blocs) {
|
||||
expect(bloc.compareDocumentPosition(svg) & Node.DOCUMENT_POSITION_FOLLOWING, bloc.dataset.equite).toBeTruthy();
|
||||
expect(bloc.getBoundingClientRect().bottom, bloc.dataset.equite).toBeLessThanOrEqual(svg.getBoundingClientRect().top);
|
||||
}
|
||||
const [gauche, droite] = blocs.map((bloc) => bloc.getBoundingClientRect().left);
|
||||
expect(gauche).toBeLessThan(droite);
|
||||
});
|
||||
|
||||
test('rouge : le jumeau porte la soirée de chaque animateur puis une ligne par tour animé, chacune au plus la soirée', async () => {
|
||||
const detail = detailDe();
|
||||
const { lignes } = detail.animateurs.diversite;
|
||||
const vue = await monter(VueDiversite, detail);
|
||||
const [jumeau] = await ouvrirJumeau(vue);
|
||||
const rangees = [...jumeau.querySelectorAll('tbody tr')].map(cellules);
|
||||
expect(rangees.length).toBe(lignes.reduce((somme, ligne) => somme + 1 + ligne.toursAnimes, 0));
|
||||
let rang = 0;
|
||||
for (const ligne of lignes) {
|
||||
const soiree = rangees[rang];
|
||||
expect(soiree.portee).toBe(t('qualite.diversite.soiree'));
|
||||
expect(nombre(soiree.toursAnimes)).toBe(ligne.toursAnimes);
|
||||
for (let k = 1; k <= ligne.toursAnimes; k += 1) {
|
||||
const tour = rangees[rang + k];
|
||||
expect(tour.animateur).toBe(soiree.animateur);
|
||||
expect(tour.portee).not.toBe(t('qualite.diversite.soiree'));
|
||||
for (const cle of ['personnes', 'appartenances', 'etiquettes']) {
|
||||
expect(nombre(tour[cle]), `${tour.animateur}, ${tour.portee}, ${cle}`).toBeLessThanOrEqual(nombre(soiree[cle]));
|
||||
}
|
||||
}
|
||||
rang += 1 + ligne.toursAnimes;
|
||||
}
|
||||
expect(texteDe(vue)).not.toMatch(/équité|plafond|%/i);
|
||||
});
|
||||
|
||||
test('une seule appartenance et des étiquettes : la colonne des étiquettes seule, et la phrase', async () => {
|
||||
const detail = detailDe({ appartenances: false });
|
||||
const vue = await monter(VueDiversite, detail);
|
||||
expect(vue.querySelector('[data-phrase-qualite="diversite.sansAppartenance"]')).not.toBeNull();
|
||||
expect(vue.querySelector('[data-equite="appartenances"]')).toBeNull();
|
||||
expect(vue.querySelector('[data-equite="etiquettes"]')).not.toBeNull();
|
||||
const svg = vue.querySelector('svg[data-graphique="diversite"]');
|
||||
expect(svg.querySelectorAll('line[data-marque="graphiqueAxe"]').length).toBe(1);
|
||||
expect(svg.querySelectorAll('rect[data-segment="serie1"]').length).toBe(0);
|
||||
expect(svg.querySelectorAll('rect[data-segment="serie2"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('aucune étiquette : la colonne des appartenances seule, et la phrase', async () => {
|
||||
const vue = await monter(VueDiversite, detailDe({ etiquettes: false }));
|
||||
expect(vue.querySelector('[data-phrase-qualite="diversite.sansEtiquette"]')).not.toBeNull();
|
||||
expect(vue.querySelector('[data-equite="etiquettes"]')).toBeNull();
|
||||
expect(vue.querySelectorAll('svg rect[data-segment="serie2"]').length).toBe(0);
|
||||
});
|
||||
|
||||
test('ni étiquette ni plus d’une appartenance : la phrase, aucun graphique, le jumeau garde tours animés et personnes', async () => {
|
||||
const vue = await monter(VueDiversite, detailDe({ appartenances: false, etiquettes: false }));
|
||||
expect(vue.querySelector('[data-phrase-qualite="diversite.rien"]')).not.toBeNull();
|
||||
expect(vue.querySelector('svg')).toBeNull();
|
||||
expect(vue.querySelector('[data-equite]')).toBeNull();
|
||||
const [jumeau] = await ouvrirJumeau(vue);
|
||||
expect([...jumeau.querySelectorAll('thead th')].map((th) => th.dataset.colonne)).toEqual([
|
||||
'animateur',
|
||||
'portee',
|
||||
'toursAnimes',
|
||||
'personnes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sans animateur : la phrase seule', async () => {
|
||||
const vue = await monter(VueDiversite, detailDe({ titres: false }));
|
||||
expect(vue.querySelector('[data-phrase-qualite="diversite.aucunAnimateur"]')).not.toBeNull();
|
||||
expect(vue.querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
683
src/interface/qualite/Appartenances.navigateur.test.js
Normal file
683
src/interface/qualite/Appartenances.navigateur.test.js
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les vues des collisions d'appartenance (§ 12.4, B13, D31) et des
|
||||
// appartenances à chaque table (§ 12.11, B20, D35, D54) dans un vrai moteur
|
||||
// de rendu. Collisions : le segment « prouvé inévitable » égale le plancher
|
||||
// que le diagnostic affiche ; les barres suivent l'excédent, non leur
|
||||
// longueur ; chaque segment est peint de son jeton de graphique, jamais
|
||||
// d'une teinte d'appartenance ou de conflit ; hors du domaine des
|
||||
// planchers, un seul segment et la phrase. Tables : sous chacune des trois
|
||||
// positions, la pire table en tête et l'ordre de la position ; chaque case
|
||||
// écrit le compte que sa couleur porte, sans signe « % » ; une table de
|
||||
// bénévoles sans appartenance écrit « — » et se range en dernier ; la
|
||||
// position se choisit au clavier, survit à un aller-retour vers le plan,
|
||||
// repart de « sièges » à la réouverture, et la choisir en lecture ou sur un
|
||||
// plan bloqué n'écrit rien ; quarante tours au cadre de 1 366 px. Chaque
|
||||
// épreuve monte App par monterApp, sur le système de fichiers simulé, et lit
|
||||
// le rendu par les attributs data-…, le texte et les couleurs calculées —
|
||||
// jamais une classe. Les comptes attendus sont calculés à la main ; les noms
|
||||
// d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { diagnostiquerCharge } from '../../application/diagnostic.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { niveau } from '../../geometrie/graphique.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre, touche, pointer } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { teintesAppartenances } from '../appartenances.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { CASE_MAX_TEXTES, sansCollision } from './appartenances.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const V1 = VERSION.affichee;
|
||||
const BASE = 'soiree';
|
||||
const AUBIER = 'Coopérative Aubier';
|
||||
const RESSAC = 'Atelier Ressac';
|
||||
const POSITIONS = ['sieges', 'assis', 'doublons'];
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------------
|
||||
|
||||
// Une charge de personnes inventées, de tables de capacités données, et une
|
||||
// proposition, n° 1, écrite à la main : à chaque tour, les listes de chaque
|
||||
// table dans l'ordre des tables ; reserves, les personnes en réserve de
|
||||
// chaque tour ; premier, le numéro de la première table, les suivantes
|
||||
// numérotées à la suite.
|
||||
function chargeDe({ personnes, capacites, tours, reserves = tours.map(() => []), premier = 1 }) {
|
||||
const charge = creerCharge({ id: 'evt-appartenances', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: tours.length });
|
||||
charge.participants = personnes.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = capacites.map((sieges, rang) => ({ id: rang + 1, numero: premier + rang, sieges, forme: 'ronde', position: { x: 300 * rang, y: 0 } }));
|
||||
charge.propositions = [
|
||||
{
|
||||
id: 1,
|
||||
graine: 1,
|
||||
arret: 40,
|
||||
historique: 1000,
|
||||
produitVersion: V1,
|
||||
siegesAttribues: false,
|
||||
tables: charge.tables.map(({ id }) => id),
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: tours.length,
|
||||
participants: charge.participants.map(({ id }) => id),
|
||||
placement: tours.map((listes, r) => ({ sieges: listes, reserve: reserves[r] })),
|
||||
},
|
||||
];
|
||||
charge.prochainsIds = { participant: personnes.length + 1, table: capacites.length + 1, proposition: 2 };
|
||||
charge.evenement.etat = 'propose';
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Treize personnes, quatre tables de 5, 4, 4 et 3 places, le même plan aux
|
||||
// deux tours. Pour chaque case (c, o, α, δ) : table 1 (5, 2, 2, 2), table 2
|
||||
// (4, 4, 4, 2), table 3 (4, 4, 2, 1), table 4, trois bénévoles sans
|
||||
// appartenance. Comptes sièges, assis, doublons : table 1 (3, 0, 0), table
|
||||
// 2 (2, 2, 2), table 3 (3, 3, 1). Ordres : sièges 1, 3, 2, 4 ; assis 3, 2,
|
||||
// 1, 4 ; doublons 2, 3, 1, 4.
|
||||
const PERSONNES_MELANGE = [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Lacasse', 'Ondine', RESSAC],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Quenouille', 'Maëlle', AUBIER],
|
||||
['Tourteau', 'Basile', RESSAC],
|
||||
['Brindille', 'Anselme', RESSAC],
|
||||
['Saule', 'Clothilde', AUBIER],
|
||||
['Galet', 'Eudes', AUBIER],
|
||||
['Mousse', 'Félicie', null],
|
||||
['Ruisseau', 'Gaspard', null],
|
||||
['Lichen', 'Hortense', null],
|
||||
['Chardon', 'Jules', null],
|
||||
['Ronce', 'Léonie', null],
|
||||
];
|
||||
const PLAN_MELANGE = [[1, 2], [3, 4, 5, 6], [7, 8, 9, 10], [11, 12, 13]];
|
||||
const chargeMelange = () => chargeDe({ personnes: PERSONNES_MELANGE, capacites: [5, 4, 4, 3], tours: [PLAN_MELANGE, PLAN_MELANGE] });
|
||||
const ORDRES = { sieges: [1, 3, 2, 4], assis: [3, 2, 1, 4], doublons: [2, 3, 1, 4] };
|
||||
// Les comptes des deux tours de chaque table, par numéro, sous chaque
|
||||
// position ; null pour « — ».
|
||||
const COMPTES = {
|
||||
sieges: { 1: 3, 2: 2, 3: 3, 4: null },
|
||||
assis: { 1: 0, 2: 2, 3: 3, 4: null },
|
||||
doublons: { 1: 0, 2: 2, 3: 1, 4: null },
|
||||
};
|
||||
|
||||
// Six personnes, deux appartenances de deux, deux tables de trois : aucun
|
||||
// membre ne retrouve l'autre membre de son appartenance, sur le nombre de
|
||||
// tours donné.
|
||||
const PERSONNES_SIX = [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Lacasse', 'Ondine', RESSAC],
|
||||
['Tourteau', 'Basile', RESSAC],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
const chargeSix = ({ personnes = PERSONNES_SIX, tours = 2 } = {}) =>
|
||||
chargeDe({
|
||||
personnes,
|
||||
capacites: [3, 3],
|
||||
tours: Array.from({ length: tours }, (_, r) =>
|
||||
r % 2 === 0
|
||||
? [
|
||||
[1, 3, 5],
|
||||
[2, 4, 6],
|
||||
]
|
||||
: [
|
||||
[1, 4, 6],
|
||||
[2, 3, 5],
|
||||
],
|
||||
),
|
||||
});
|
||||
|
||||
// Douze personnes, quatre tables de trois, trois tours. Ressac (1 et 2) se
|
||||
// retrouve aux trois tours : 3 collisions cumulées sur une paire, excédent
|
||||
// 2. Aubier (3 à 6) réunit quatre paires distinctes, une fois chacune : 4
|
||||
// collisions cumulées, excédent 0. La barre la plus longue vient en second.
|
||||
const chargeExcedent = () =>
|
||||
chargeDe({
|
||||
personnes: [
|
||||
['Ombrelle', 'Iris', RESSAC],
|
||||
['Pervenche', 'Théo', RESSAC],
|
||||
['Lacasse', 'Ondine', AUBIER],
|
||||
['Tourteau', 'Basile', AUBIER],
|
||||
['Quenouille', 'Maëlle', AUBIER],
|
||||
['Brindille', 'Anselme', AUBIER],
|
||||
['Saule', 'Clothilde', null],
|
||||
['Galet', 'Eudes', null],
|
||||
['Mousse', 'Félicie', null],
|
||||
['Ruisseau', 'Gaspard', null],
|
||||
['Lichen', 'Hortense', null],
|
||||
['Chardon', 'Jules', null],
|
||||
],
|
||||
capacites: [3, 3, 3, 3],
|
||||
tours: [
|
||||
[[1, 2, 7], [3, 4, 8], [5, 6, 9], [10, 11, 12]],
|
||||
[[1, 2, 10], [3, 5, 11], [4, 6, 12], [7, 8, 9]],
|
||||
[[1, 2, 3], [4, 7, 8], [5, 9, 10], [6, 11, 12]],
|
||||
],
|
||||
});
|
||||
|
||||
// Six personnes dont deux seules ont une appartenance, la même, et se
|
||||
// retrouvent aux deux tours : une appartenance unique, 2 collisions
|
||||
// cumulées sur une paire.
|
||||
const chargeUneAppartenance = () =>
|
||||
chargeDe({
|
||||
personnes: [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
['Mousse', 'Félicie', null],
|
||||
['Ruisseau', 'Gaspard', null],
|
||||
],
|
||||
capacites: [3, 3],
|
||||
tours: [
|
||||
[
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
[
|
||||
[1, 2, 4],
|
||||
[3, 5, 6],
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// La charge, une génération du moteur de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
// --- Le montage --------------------------------------------------------------------
|
||||
|
||||
let montes = [];
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La charge posée en fichier d'état, ouverte par la séance — en écriture
|
||||
// sauf mode 'lecture' —, la proposition demandée au plan, la page de la
|
||||
// qualité ouverte.
|
||||
async function ouvrir(charge, { mode = 'ecriture', proposition = charge.propositions[0]?.id ?? null } = {}) {
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
if (mode === 'ecriture') await m.seance.passerEnEcriture();
|
||||
if (proposition !== null) m.ui.montrerProposition(proposition);
|
||||
m.ui.allerA('qualite');
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- Les lectures du rendu ---------------------------------------------------------
|
||||
|
||||
const vue = (m, nom) => m.racine.querySelector(`[data-section="qualite"] [data-vue-qualite="${nom}"]`);
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const radio = (m, position) => vue(m, 'tables').querySelector(`[data-position-tables="${position}"]`);
|
||||
const positionCochee = (m) => [...vue(m, 'tables').querySelectorAll('[data-position-tables]')].find((r) => r.checked)?.dataset.positionTables;
|
||||
|
||||
// Les intitulés des lignes de la grille, dans l'ordre rendu : les textes du
|
||||
// dessin qui nomment une table.
|
||||
const intitulesGrille = (svg) =>
|
||||
[...svg.querySelectorAll('text')].map((x) => x.textContent).filter((x) => /^Table \d+$/.test(x));
|
||||
|
||||
// Les cases de la grille : par ligne et colonne, le texte écrit et le
|
||||
// remplissage du niveau, null sans remplissage.
|
||||
function casesDe(svg) {
|
||||
return [...svg.querySelectorAll('[data-case]')].map((g) => {
|
||||
const [ligne, colonne] = g.dataset.case.split(':').map(Number);
|
||||
const fond = [...g.querySelectorAll('rect')].find((r) => r.style.fill !== '') ?? null;
|
||||
return {
|
||||
ligne,
|
||||
colonne,
|
||||
texte: g.querySelector('text').textContent,
|
||||
marque: fond?.getAttribute('data-marque') ?? null,
|
||||
fond: fond === null ? null : getComputedStyle(fond).fill,
|
||||
cote: Number(g.querySelector('[data-marque="graphiqueBordureCase"]').getAttribute('width')) + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// La couleur calculée d'un jeton, lue sur un élément témoin.
|
||||
function couleurDuJeton(jeton) {
|
||||
const temoin = document.createElement('span');
|
||||
temoin.style.color = `var(${jeton})`;
|
||||
document.body.append(temoin);
|
||||
const couleur = getComputedStyle(temoin).color;
|
||||
temoin.remove();
|
||||
return couleur;
|
||||
}
|
||||
|
||||
// Les lignes d'un jumeau ouvert : par ligne, chaque cellule sous sa colonne.
|
||||
function lignesJumeau(tableau) {
|
||||
return [...tableau.querySelectorAll('tbody tr')].map((tr) =>
|
||||
Object.fromEntries([...tr.children].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)])),
|
||||
);
|
||||
}
|
||||
|
||||
async function ouvrirJumeau(racine) {
|
||||
racine.querySelector('[data-commande="ouvrirJumeau"]').click();
|
||||
return attendre(() => {
|
||||
const tableau = racine.querySelector('[data-jumeau="0"]');
|
||||
expect(tableau).not.toBeNull();
|
||||
return tableau;
|
||||
});
|
||||
}
|
||||
|
||||
// Choisit une position par un clic sur son bouton radio.
|
||||
async function choisir(m, position) {
|
||||
radio(m, position).click();
|
||||
await attendre(() => expect(positionCochee(m)).toBe(position));
|
||||
}
|
||||
|
||||
// --- Les collisions ----------------------------------------------------------------
|
||||
|
||||
describe('collisions d’appartenance (§ 12.4, B13, D31)', () => {
|
||||
test('petite démonstration au conflit inévitable : le segment prouvé vaut 4 et égale le plancher que le diagnostic affiche', async () => {
|
||||
const charge = generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 1, arret: 300 });
|
||||
const plancher = diagnostiquerCharge(charge).diagnostic.collisions;
|
||||
expect(plancher).toHaveLength(1);
|
||||
expect(plancher[0].plancherCumulees).toBe(4);
|
||||
const m = await ouvrir(charge);
|
||||
const collisions = await attendre(() => {
|
||||
const v = vue(m, 'collisions');
|
||||
expect(v.querySelector('svg[data-graphique="collisions"]')).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
const ligne = collisions.querySelector(`[data-ligne-graphique="${plancher[0].groupe}"]`);
|
||||
expect(ligne.querySelector('[data-segment="prouve"]')).not.toBeNull();
|
||||
const tableau = await ouvrirJumeau(collisions);
|
||||
const lignes = lignesJumeau(tableau);
|
||||
const sienne = lignes.find((l) => l.appartenance === plancher[0].groupe);
|
||||
expect(sienne.prouve).toBe('4');
|
||||
expect(Number(sienne.prouve) + Number(sienne.reste)).toBe(Number(sienne.cumulees));
|
||||
// Le plancher que la section du diagnostic écrit, au même chiffre.
|
||||
m.ui.allerA('diagnostic');
|
||||
const enonce = await attendre(() => {
|
||||
const e = m.racine.querySelector('[data-section="diagnostic"] [data-enonce="diagnostic.collisions"]');
|
||||
expect(e).not.toBeNull();
|
||||
return e;
|
||||
});
|
||||
expect(texteDe(enonce).match(/au moins (\d+) collisions? cumulées?/)[1]).toBe(sienne.prouve);
|
||||
});
|
||||
|
||||
test('les barres suivent l’excédent, non leur longueur ; chaque segment est peint de son jeton de graphique, aucun d’une teinte d’appartenance ou de conflit ; aucun « évitable »', async () => {
|
||||
const charge = chargeExcedent();
|
||||
const detail = detaillerQualite(charge, 1);
|
||||
expect(detail.collisions.lignes.map(({ groupe, cumulees, excedent }) => [groupe, cumulees, excedent])).toEqual([
|
||||
[RESSAC, 3, 2],
|
||||
[AUBIER, 4, 0],
|
||||
]);
|
||||
const m = await ouvrir(charge);
|
||||
const collisions = await attendre(() => {
|
||||
const v = vue(m, 'collisions');
|
||||
expect(v.querySelectorAll('[data-ligne-graphique]')).toHaveLength(2);
|
||||
return v;
|
||||
});
|
||||
expect([...collisions.querySelectorAll('[data-ligne-graphique]')].map((g) => g.dataset.ligneGraphique)).toEqual([RESSAC, AUBIER]);
|
||||
const interdites = new Set(
|
||||
[
|
||||
'--couleur-conflit-appartenance',
|
||||
'--couleur-conflit-voisins',
|
||||
'--couleur-conflit-table',
|
||||
...[1, 2, 3, 4, 5, 6].flatMap((k) => [`--couleur-appartenance-${k}-fond`, `--couleur-appartenance-${k}-marque`]),
|
||||
].map(couleurDuJeton),
|
||||
);
|
||||
const segments = [...collisions.querySelectorAll('[data-segment]')];
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
for (const segment of segments) {
|
||||
const genre = segment.dataset.segment;
|
||||
expect(['prouve', 'reste']).toContain(genre);
|
||||
const peint = getComputedStyle(segment).fill;
|
||||
expect(peint).toBe(couleurDuJeton(`--couleur-graphique-${genre}`));
|
||||
expect(interdites.has(peint), `${genre} peint ${peint}`).toBe(false);
|
||||
}
|
||||
// La pastille de chaque intitulé : le fond et le point de la teinte que
|
||||
// teintesAppartenances donne à l'appartenance sur les participants de
|
||||
// l'événement — deux teintes distinctes, aucune neutre.
|
||||
const teintes = teintesAppartenances(charge.participants);
|
||||
const pastilles = [...collisions.querySelectorAll('[data-ligne-graphique]')].map((g) => {
|
||||
const rang = teintes.get(g.dataset.ligneGraphique);
|
||||
expect(rang, g.dataset.ligneGraphique).toBeGreaterThanOrEqual(1);
|
||||
const fond = [...g.querySelectorAll('rect')].find((r) => !r.hasAttribute('data-segment'));
|
||||
const point = g.querySelector('circle');
|
||||
expect(getComputedStyle(fond).fill, g.dataset.ligneGraphique).toBe(couleurDuJeton(`--couleur-appartenance-${rang}-fond`));
|
||||
expect(getComputedStyle(point).fill, g.dataset.ligneGraphique).toBe(couleurDuJeton(`--couleur-appartenance-${rang}-marque`));
|
||||
return getComputedStyle(point).fill;
|
||||
});
|
||||
expect(new Set(pastilles).size).toBe(2);
|
||||
expect(pastilles).not.toContain(couleurDuJeton('--couleur-neutre-marque'));
|
||||
// Les chiffres de chaque ligne, en encre secondaire, après sa barre.
|
||||
expect(texteDe(collisions)).toContain(t('qualite.collisions.secondaire', { effectif: 2, paires: 1, excedent: 2 }));
|
||||
expect(texteDe(collisions)).not.toMatch(/(?<!in)évitable/i);
|
||||
const nom = collisions.querySelector('svg[data-graphique="collisions"]').getAttribute('aria-labelledby');
|
||||
expect(texteDe(document.getElementById(nom))).toBe(t('qualite.collisions.graphique'));
|
||||
});
|
||||
|
||||
test('un plan qui laisse quelqu’un en réserve : un seul segment par barre, les collisions cumulées, et la phrase qui dit pourquoi', async () => {
|
||||
const reduit = [[1, 2], [3, 4, 5, 6], [7, 8, 9, 10], [11, 12]];
|
||||
const charge = chargeDe({ personnes: PERSONNES_MELANGE, capacites: [5, 4, 4, 3], tours: [PLAN_MELANGE, reduit], reserves: [[], [13]] });
|
||||
const m = await ouvrir(charge);
|
||||
const collisions = await attendre(() => {
|
||||
const v = vue(m, 'collisions');
|
||||
expect(v.querySelector('[data-phrase-qualite="collisionsHorsDomaine"]')).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
expect(texteDe(collisions.querySelector('[data-phrase-qualite="collisionsHorsDomaine"]'))).toBe(t('qualite.collisions.horsDomaine'));
|
||||
for (const ligne of collisions.querySelectorAll('[data-ligne-graphique]')) {
|
||||
expect([...ligne.querySelectorAll('[data-segment]')].map((s) => s.dataset.segment)).toEqual(['serie1']);
|
||||
}
|
||||
expect(collisions.querySelector('[data-segment="prouve"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('une seule table, aucune appartenance, aucune collision : la phrase du cas, sans graphique', async () => {
|
||||
const cas = [
|
||||
[chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] }), 'collisionsUneTable'],
|
||||
[chargeSix({ personnes: PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]) }), 'collisionsAucuneAppartenance'],
|
||||
[chargeSix(), 'collisionsAucune'],
|
||||
];
|
||||
for (const [charge, code] of cas) {
|
||||
const m = await ouvrir(charge);
|
||||
const collisions = await attendre(() => {
|
||||
const v = vue(m, 'collisions');
|
||||
expect(v.querySelector(`[data-phrase-qualite="${code}"]`)).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
expect(collisions.querySelector('svg[data-graphique]')).toBeNull();
|
||||
expect(collisions.querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les appartenances à chaque table ----------------------------------------------
|
||||
|
||||
describe('appartenances à chaque table (§ 12.11, B20, D35)', () => {
|
||||
test('sous chacune des trois positions : la pire table en tête, l’ordre de la position, chaque case écrit le compte que son niveau colore, sans « % » ; la table de bénévoles écrit « — » et vient en dernier', async () => {
|
||||
const charge = chargeMelange();
|
||||
const detail = detaillerQualite(charge, 1);
|
||||
const m = await ouvrir(charge);
|
||||
await attendre(() => expect(positionCochee(m)).toBe('sieges'));
|
||||
for (const position of POSITIONS) {
|
||||
await choisir(m, position);
|
||||
const svg = await attendre(() => {
|
||||
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
|
||||
expect(s).not.toBeNull();
|
||||
expect(intitulesGrille(s)).toEqual(ORDRES[position].map((numero) => `Table ${numero}`));
|
||||
return s;
|
||||
});
|
||||
expect(detail.tables.ordres[position].map((i) => detail.numerosTables[i])).toEqual(ORDRES[position]);
|
||||
const cases = casesDe(svg);
|
||||
expect(cases).toHaveLength(8);
|
||||
// Deux tours ne font pas de cases géantes : la case ne dépasse pas
|
||||
// CASE_MAX_TEXTES hauteurs de ligne du compte qu'elle écrit.
|
||||
const ligneTexte = svg.querySelector('[data-case] text').getBBox().height;
|
||||
expect(Math.max(...cases.map((c) => c.cote))).toBeLessThanOrEqual(CASE_MAX_TEXTES * ligneTexte + 1);
|
||||
for (const c of cases) {
|
||||
const attendu = COMPTES[position][ORDRES[position][c.ligne]];
|
||||
expect(c.texte).not.toMatch(/%/);
|
||||
if (attendu === null) {
|
||||
expect(c.texte, position).toBe(t('format.vide'));
|
||||
expect(c.fond).toBeNull();
|
||||
continue;
|
||||
}
|
||||
expect(c.texte, `${position} ${c.ligne}:${c.colonne}`).toBe(String(attendu));
|
||||
const n = niveau(Number(c.texte));
|
||||
expect(c.fond).toBe(couleurDuJeton(`--couleur-graphique-niveau-${n}`));
|
||||
expect(c.marque).toBe(n === 0 ? null : `graphiqueNiveau${n}`);
|
||||
}
|
||||
// La table de bénévoles, sans affilié, en dernière ligne.
|
||||
expect(intitulesGrille(svg).at(-1)).toBe('Table 4');
|
||||
}
|
||||
});
|
||||
|
||||
test('l’infobulle d’une case : le pourcentage de la position puis ses deux comptes, occupants, affiliés, capacité', async () => {
|
||||
const m = await ouvrir(chargeMelange());
|
||||
const svg = await attendre(() => {
|
||||
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
|
||||
expect(s).not.toBeNull();
|
||||
return s;
|
||||
});
|
||||
// Sous « sièges », la case 0:0 est la table 1 au tour 1 : δ = 2 pour 5 sièges.
|
||||
const case00 = svg.querySelector('[data-case="0:0"]');
|
||||
case00.scrollIntoView({ block: 'center' });
|
||||
const boite = case00.getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||||
const bulle = await attendre(() => {
|
||||
const b = vue(m, 'tables').querySelector('[role="tooltip"]');
|
||||
expect(b.hidden).toBe(false);
|
||||
return b;
|
||||
});
|
||||
expect(bulle.querySelector('[data-pourcentage]').textContent).toBe('40\u{202F}%');
|
||||
expect(texteDe(bulle.querySelector('[data-compte-pire]'))).toBe('2 appartenances pour 5 sièges');
|
||||
for (const fragment of ['Table 1', 'Tour 1', t('qualite.tables.colonne.occupants'), t('qualite.tables.colonne.capacite')]) {
|
||||
expect(texteDe(bulle)).toContain(fragment);
|
||||
}
|
||||
await pointer('bouger', 2, 2);
|
||||
});
|
||||
|
||||
test('le jumeau : une ligne par table et tour dans l’ordre de la position, les trois comptes, chaque pourcentage suivi du compte de sa ligne', async () => {
|
||||
const m = await ouvrir(chargeMelange());
|
||||
await choisir(m, 'doublons');
|
||||
const tableau = await ouvrirJumeau(vue(m, 'tables'));
|
||||
const lignes = lignesJumeau(tableau);
|
||||
expect(lignes.map((l) => l.table)).toEqual(['Table 2', 'Table 2', 'Table 3', 'Table 3', 'Table 1', 'Table 1', 'Table 4', 'Table 4']);
|
||||
expect(lignes[2]).toMatchObject({ tour: 'Tour 1', sieges: '3', assis: '3', doublons: '1', occupants: '4', affilies: '2', capacite: '4' });
|
||||
const cellule = tableau.querySelectorAll('tbody tr')[2].querySelector('[data-colonne="pourcentDoublons"]');
|
||||
expect(cellule.querySelector('[data-pourcentage]').textContent).toBe('50\u{202F}%');
|
||||
expect(texteDe(cellule.querySelector('[data-compte-pire]'))).toBe('1 appartenance pour 2 affiliés');
|
||||
expect(lignes[7].doublons).toBe(t('format.vide'));
|
||||
for (const pourcentage of tableau.querySelectorAll('[data-pourcentage]')) {
|
||||
expect(pourcentage.nextElementSibling?.hasAttribute('data-compte-pire')).toBe(true);
|
||||
}
|
||||
expect(tableau.querySelectorAll('[data-pourcentage]')).toHaveLength(8 * 3);
|
||||
});
|
||||
|
||||
test('le sélecteur au clavier : la flèche passe à la position suivante ; les deux graphiques de la page ont des noms distincts', async () => {
|
||||
const m = await ouvrir(chargeMelange());
|
||||
await attendre(() => expect(radio(m, 'sieges')).not.toBeNull());
|
||||
radio(m, 'sieges').focus();
|
||||
await touche('ArrowRight');
|
||||
await attendre(() => expect(positionCochee(m)).toBe('assis'));
|
||||
expect(m.ui.etat().melangeTables).toBe('assis');
|
||||
expect(document.activeElement).toBe(radio(m, 'assis'));
|
||||
await touche('ArrowRight');
|
||||
await attendre(() => expect(m.ui.etat().melangeTables).toBe('doublons'));
|
||||
const noms = ['collisions', 'tables'].map((nom) => {
|
||||
const svg = vue(m, nom).querySelector(`svg[data-graphique="${nom}"]`);
|
||||
return texteDe(document.getElementById(svg.getAttribute('aria-labelledby')));
|
||||
});
|
||||
expect(noms[0]).not.toBe(noms[1]);
|
||||
expect(noms.every((nom) => nom.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('la position survit à un aller-retour vers le plan et repart de « sièges » à la réouverture de l’événement', async () => {
|
||||
const m = await ouvrir(chargeMelange());
|
||||
await choisir(m, 'doublons');
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
m.ui.allerA('qualite');
|
||||
await attendre(() => expect(positionCochee(m)).toBe('doublons'));
|
||||
await m.seance.fermer();
|
||||
await m.seance.ouvrir(BASE);
|
||||
m.ui.montrerProposition(1);
|
||||
m.ui.allerA('qualite');
|
||||
await attendre(() => expect(positionCochee(m)).toBe('sieges'));
|
||||
});
|
||||
|
||||
for (const [cas, fabriquer] of [
|
||||
['en lecture', () => chargeMelange()],
|
||||
[
|
||||
'sur un plan bloqué',
|
||||
() => executer(executer(chargeMelange(), 'retenirProposition', { id: 1 }), 'changerEtat', { etat: 'bloque' }),
|
||||
],
|
||||
]) {
|
||||
test(`${cas} : chaque position se choisit sans refus ni écriture`, async () => {
|
||||
const charge = fabriquer();
|
||||
const m = await ouvrir(charge, { mode: 'lecture', proposition: cas === 'en lecture' ? 1 : null });
|
||||
await attendre(() => expect(radio(m, 'sieges')).not.toBeNull());
|
||||
const ecritures = m.trace.length;
|
||||
for (const position of ['assis', 'doublons', 'sieges']) {
|
||||
await choisir(m, position);
|
||||
expect(m.ui.etat().melangeTables).toBe(position);
|
||||
}
|
||||
expect(m.racine.querySelector('[data-refus]')).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
});
|
||||
}
|
||||
|
||||
test('sans collision : sous « doublons », la phrase des collisions tient lieu de grille, le sélecteur reste ; sous « sièges », la grille', async () => {
|
||||
const charge = chargeSix();
|
||||
expect(sansCollision(detaillerQualite(charge, 1))).toBe(true);
|
||||
const m = await ouvrir(charge);
|
||||
const tables = await attendre(() => {
|
||||
const v = vue(m, 'tables');
|
||||
expect(v.querySelector('svg[data-graphique="tables"]')).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
await choisir(m, 'doublons');
|
||||
await attendre(() => expect(tables.querySelector('[data-phrase-qualite="tablesSansCollision"]')).not.toBeNull());
|
||||
expect(texteDe(tables.querySelector('[data-phrase-qualite="tablesSansCollision"]'))).toBe(t('qualite.collisions.aucune'));
|
||||
expect(tables.querySelector('svg[data-graphique="tables"]')).toBeNull();
|
||||
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('une seule appartenance : les barres, la grille et le sélecteur, comme pour plusieurs (B15)', async () => {
|
||||
const m = await ouvrir(chargeUneAppartenance());
|
||||
const tables = await attendre(() => {
|
||||
const v = vue(m, 'tables');
|
||||
expect(v.querySelector('svg[data-graphique="tables"]')).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);
|
||||
expect(tables.querySelector('[data-phrase-qualite]')).toBeNull();
|
||||
const collisions = vue(m, 'collisions');
|
||||
expect(collisions.querySelector('svg[data-graphique="collisions"]')).not.toBeNull();
|
||||
expect([...collisions.querySelectorAll('[data-ligne-graphique]')].map((g) => g.dataset.ligneGraphique)).toEqual([AUBIER]);
|
||||
expect(collisions.querySelector('[data-phrase-qualite]')).toBeNull();
|
||||
for (const position of ['assis', 'doublons']) {
|
||||
await choisir(m, position);
|
||||
await attendre(() => expect(tables.querySelector('svg[data-graphique="tables"]')).not.toBeNull());
|
||||
}
|
||||
});
|
||||
|
||||
test('la vue ne borne pas la largeur de la grille : à un tour, des intitulés longs, le dessin prend la largeur de la carte', async () => {
|
||||
const charge = chargeDe({ personnes: PERSONNES_SIX, capacites: [3, 3], tours: [[[1, 2, 5], [3, 4, 6]]], premier: 1000 });
|
||||
const m = await ouvrir(charge);
|
||||
const svg = await attendre(() => {
|
||||
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
|
||||
expect(s).not.toBeNull();
|
||||
expect(intitulesGrille(s)).toEqual(['Table 1000', 'Table 1001']);
|
||||
return s;
|
||||
});
|
||||
const carte = vue(m, 'tables').getBoundingClientRect().width;
|
||||
expect(svg.getBoundingClientRect().width).toBeGreaterThanOrEqual(0.8 * carte);
|
||||
});
|
||||
|
||||
test('une seule table, aucune appartenance : la phrase, sans sélecteur ni grille', async () => {
|
||||
const cas = [
|
||||
[chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] }), 'tablesUneTable'],
|
||||
[chargeSix({ personnes: PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]) }), 'tablesAucuneAppartenance'],
|
||||
];
|
||||
for (const [charge, code] of cas) {
|
||||
const m = await ouvrir(charge);
|
||||
const tables = await attendre(() => {
|
||||
const v = vue(m, 'tables');
|
||||
expect(v.querySelector(`[data-phrase-qualite="${code}"]`)).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
expect(tables.querySelector('[data-position-tables]')).toBeNull();
|
||||
expect(tables.querySelector('svg[data-graphique]')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('quarante tours au cadre de 1 366 px : chaque case tient 24 px dans la carte ; cent tours : la phrase et le jumeau ouvert, le sélecteur restant', async () => {
|
||||
const m = await ouvrir(chargeSix({ tours: 40 }));
|
||||
const svg = await attendre(() => {
|
||||
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
|
||||
expect(s).not.toBeNull();
|
||||
expect(s.querySelectorAll('[data-case]')).toHaveLength(80);
|
||||
return s;
|
||||
});
|
||||
const cases = casesDe(svg);
|
||||
expect(Math.min(...cases.map((c) => c.cote))).toBeGreaterThanOrEqual(24);
|
||||
const carte = vue(m, 'tables').getBoundingClientRect();
|
||||
for (const g of svg.querySelectorAll('[data-case]')) {
|
||||
const boite = g.getBoundingClientRect();
|
||||
expect(boite.right).toBeLessThanOrEqual(carte.right);
|
||||
expect(boite.left).toBeGreaterThanOrEqual(carte.left);
|
||||
}
|
||||
// Cent tours ne tiennent pas : la grille cède la place à sa phrase et à
|
||||
// son jumeau, ouvert ; le sélecteur reste.
|
||||
const large = await ouvrir(chargeSix({ tours: 100 }));
|
||||
const tables = await attendre(() => {
|
||||
const v = vue(large, 'tables');
|
||||
expect(v.querySelector('[data-jumeau="0"]')).not.toBeNull();
|
||||
return v;
|
||||
});
|
||||
expect(tables.querySelector('svg[data-graphique="tables"]')).toBeNull();
|
||||
expect(texteDe(tables)).toContain(t('qualite.tables.tropLarge'));
|
||||
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);
|
||||
expect(tables.querySelector('[data-commande="fermerJumeau"]')).not.toBeNull();
|
||||
expect(tables.querySelectorAll('[data-jumeau="0"] tbody tr')).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la grande démonstration : le poids des deux vues', () => {
|
||||
test('les nœuds des vues des collisions et des tables, relevés sur la grande', async () => {
|
||||
const charge = generee(chargeDemo('grande'), { graine: 2, nombre: 1, arret: 50 });
|
||||
const m = await ouvrir(charge);
|
||||
const noeuds = await attendre(() => {
|
||||
const comptes = ['collisions', 'tables'].map((nom) => {
|
||||
const v = vue(m, nom);
|
||||
expect(v.querySelector('svg[data-graphique]')).not.toBeNull();
|
||||
return v.querySelectorAll('*').length + 1;
|
||||
});
|
||||
return comptes;
|
||||
});
|
||||
console.info(`nœuds : collisions ${noeuds[0]}, tables ${noeuds[1]}`);
|
||||
expect(noeuds[0] + noeuds[1]).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
379
src/interface/qualite/Objectifs.navigateur.test.js
Normal file
379
src/interface/qualite/Objectifs.navigateur.test.js
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La vue des objectifs des tables dans un vrai moteur de rendu (§ 12.12,
|
||||
// B21, D34, D36, D52) : les personnes jamais servies, la soirée en texte,
|
||||
// une barre par tour — le tour au plus grand reste en tête, aucune pour la
|
||||
// soirée, aucune couleur propre à un tour —, chaque pourcentage suivi de
|
||||
// son compte, les assis sans table possible de chaque ligne, le jumeau au
|
||||
// clavier. La vue se monte seule, par mount, avec les contextes que pose App
|
||||
// — 't' et 'format' —, sur le détail d'une génération faite dans le
|
||||
// navigateur ; une épreuve monte App entière, pour recouper la vue avec le
|
||||
// tableau des propositions et la section Diagnostic. Le rendu se lit par
|
||||
// ses attributs data-… et son texte, jamais par une classe. Les noms
|
||||
// d'épreuve sont inventés.
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import '../jetons.css';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { configurationDe } from '../../application/configuration.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import VueObjectifs from './VueObjectifs.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const CONTEXTE = new Map([
|
||||
['t', t],
|
||||
['format', creerFormat(t)],
|
||||
]);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Neuf personnes inventées : les sept premières portent « finance ».
|
||||
const PERSONNES = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', 'Coopérative Aubier'],
|
||||
['Rocaille', 'Anselme', 'Atelier Ressac'],
|
||||
['Brindille', 'Pélagie', null],
|
||||
['Cassonade', 'Firmin', null],
|
||||
['Grésil', 'Ysaline', 'Atelier Ressac'],
|
||||
];
|
||||
|
||||
// Trois tables de trois places, tours tours ; la table 1 a l'objectif
|
||||
// « finance » aux tours toursObjectif — un tour hors de la liste n'a aucun
|
||||
// couple possible. Sept personnes pour trois places à objectif : à chaque
|
||||
// tour, quatre au moins restent hors objectif, une au moins n'est jamais
|
||||
// servie.
|
||||
function chargeObjectifs({ tours = 3, toursObjectif = [1, 2], respecter = true, mode = null } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours });
|
||||
charge.participants = PERSONNES.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...(rang < 7 ? { etiquettes: ['finance'] } : {}),
|
||||
}));
|
||||
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.tables[0].objectifs = toursObjectif.map((tour) => ({ portee: 'tour', tour, regle: null, etiquettes: ['finance'] }));
|
||||
charge.prochainsIds = { participant: 10, table: 4, proposition: 1 };
|
||||
if (!respecter) charge.reglages.respecterObjectifs = false;
|
||||
if (mode !== null) charge.reglages.modeObjectifs = mode;
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération enregistrée de plus.
|
||||
function generee(charge) {
|
||||
const configuration = configurationDe(charge);
|
||||
const propositions = rechercher(configuration, { graine: 7, nombre: 2, arret: 200 });
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
// Le détail de la première proposition d'une génération.
|
||||
function detailDe(options) {
|
||||
const charge = generee(chargeObjectifs(options));
|
||||
return detaillerQualite(charge, charge.propositions[0].id);
|
||||
}
|
||||
|
||||
// Le détail de la première proposition, sous une réservation ajoutée après
|
||||
// la génération que son plan ne tient pas : la personne 8, sans étiquette,
|
||||
// réservée au tour 1 à une table où le plan ne l'assoit pas. Hors du
|
||||
// domaine des planchers (§ 12.4).
|
||||
function detailHorsDomaine() {
|
||||
const charge = generee(chargeObjectifs());
|
||||
const [proposition] = charge.propositions;
|
||||
const rang = proposition.placement[0].sieges.findIndex((sieges) => !sieges.includes(8));
|
||||
const apres = structuredClone(charge);
|
||||
apres.reservations.push({ participant: 8, table: proposition.tables[rang], siege: null, portee: 'tour', tour: 1 });
|
||||
return detaillerQualite(apres, proposition.id);
|
||||
}
|
||||
|
||||
const montes = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const monte of montes.splice(0)) monte.defaire();
|
||||
});
|
||||
|
||||
// Monte la vue sur un détail, dans un conteneur de 900 px au début du
|
||||
// corps, puis laisse ses graphiques mesurer leur largeur et leur texte.
|
||||
async function monter(detail) {
|
||||
const cible = document.createElement('div');
|
||||
cible.style.width = '900px';
|
||||
document.body.prepend(cible);
|
||||
const composant = mount(VueObjectifs, { target: cible, props: { detail, ligne: null, table: null }, context: CONTEXTE });
|
||||
montes.push({
|
||||
defaire: () => {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
flushSync();
|
||||
await tick();
|
||||
}
|
||||
return cible.querySelector('[data-vue-qualite="objectifs"]');
|
||||
}
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const lignesDuGraphique = (vue) => [...vue.querySelectorAll('svg[data-graphique="objectifs"] [data-ligne-graphique]')];
|
||||
|
||||
// Le compte qui suit chaque [data-pourcentage] de la racine, son voisin
|
||||
// immédiat, et le pourcentage, chacun en son texte exact, espace fine
|
||||
// comprise ; lève quand un pourcentage n'en a pas.
|
||||
function comptesQuiSuivent(racine) {
|
||||
return [...racine.querySelectorAll('[data-pourcentage]')].map((pourcentage) => {
|
||||
const compte = pourcentage.nextElementSibling;
|
||||
expect(compte?.hasAttribute('data-compte-pire'), texteDe(pourcentage)).toBe(true);
|
||||
expect(texteDe(compte)).not.toBe('');
|
||||
return { pourcentage: pourcentage.textContent.trim(), compte: compte.textContent.trim() };
|
||||
});
|
||||
}
|
||||
|
||||
// Le pourcentage du § D52, recalculé ici pour l'épreuve.
|
||||
function pourcentAttendu(n, d) {
|
||||
if (d === 0) return null;
|
||||
const brut = Math.floor((100 * n) / d);
|
||||
return n > 0 && n < d ? Math.min(99, Math.max(1, brut)) : brut;
|
||||
}
|
||||
|
||||
describe('objectifs : la vue rendue (§ 12.12, B21, D36)', () => {
|
||||
test('les jamais servies d’abord, nommées avec leurs tours possibles ; la soirée en texte, son pourcentage suivi des jamais servies', async () => {
|
||||
const detail = detailDe();
|
||||
const vue = await monter(detail);
|
||||
const { jamaisServies, parTour } = detail.objectifs;
|
||||
const liste = vue.querySelector('[data-jamais-servies]');
|
||||
const nomDe = (id) => detail.personnes.find((personne) => personne.id === id).nom;
|
||||
expect([...liste.querySelectorAll('li')].map(texteDe)).toEqual(
|
||||
jamaisServies.map(({ id, possibles }) => `${nomDe(id)}${t('qualite.objectifs.toursPossibles', { possibles })}`),
|
||||
);
|
||||
const servis = parTour.reduce((somme, tour) => somme + (tour.servis ?? 0), 0);
|
||||
const possibles = parTour.reduce((somme, tour) => somme + tour.possibles, 0);
|
||||
const soiree = vue.querySelector('[data-soiree-objectifs]');
|
||||
expect(comptesQuiSuivent(soiree)).toEqual([
|
||||
{
|
||||
pourcentage: t('qualite.pourcentage.servis', { pourcent: pourcentAttendu(servis, possibles) }),
|
||||
compte: t('qualite.objectifs.pire', { personnes: jamaisServies.length }),
|
||||
},
|
||||
]);
|
||||
expect(texteDe(soiree)).toContain(t('qualite.objectifs.comptes', { servis, possibles }));
|
||||
// Ses tours hors objectif, Σ possibles − Σ servis, et leur plancher, Σ des planchers des tours.
|
||||
const plancher = parTour.reduce((somme, tour) => somme + (tour.plancher ?? 0), 0);
|
||||
expect(texteDe(soiree)).toContain(t('qualite.objectifs.horsObjectif', { hors: possibles - servis, plancher }));
|
||||
// La liste des jamais servies vient avant la soirée, la soirée avant le graphique.
|
||||
const graphique = vue.querySelector('svg[data-graphique="objectifs"]');
|
||||
expect(liste.compareDocumentPosition(soiree) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(soiree.compareDocumentPosition(graphique) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test('rouge : une barre par tour, aucune pour la soirée, la première au plus grand reste ; aucune couleur propre à un tour', async () => {
|
||||
const detail = structuredClone(detailDe());
|
||||
detail.objectifs.pireTour = 1;
|
||||
const vue = await monter(detail);
|
||||
const lignes = lignesDuGraphique(vue);
|
||||
expect(lignes.map((ligne) => ligne.dataset.ligneGraphique)).toEqual(['2', '1', '3']);
|
||||
expect(texteDe(lignes[0])).toContain(t('qualite.objectifs.tour', { tour: 2 }));
|
||||
for (const genre of ['prouve', 'reste']) {
|
||||
const remplissages = new Set(
|
||||
[...vue.querySelectorAll(`svg[data-graphique="objectifs"] rect[data-segment="${genre}"]`)].map((rect) => getComputedStyle(rect).fill),
|
||||
);
|
||||
expect(remplissages.size, genre).toBe(1);
|
||||
}
|
||||
for (const ligne of lignes) expect(ligne.querySelectorAll('rect:not([data-segment]), circle').length).toBe(0);
|
||||
});
|
||||
|
||||
test('chaque ligne écrit ses assis sans table possible ; un tour sans couple écrit « — », sans barre ni pourcentage', async () => {
|
||||
// L'objectif aux tours 2 et 3 : le tour 2 est en tête, et l'ordre des
|
||||
// barres, [2, 1, 3], n'est pas celui des tours.
|
||||
const detail = detailDe({ toursObjectif: [2, 3] });
|
||||
expect(detail.objectifs.pireTour).toBe(1);
|
||||
const vue = await monter(detail);
|
||||
expect(lignesDuGraphique(vue).map((ligne) => ligne.dataset.ligneGraphique)).toEqual(['2', '1', '3']);
|
||||
for (const ligne of lignesDuGraphique(vue)) {
|
||||
const tour = Number(ligne.dataset.ligneGraphique);
|
||||
const { possibles, sansCouple } = detail.objectifs.parTour[tour - 1];
|
||||
expect(texteDe(ligne)).toContain(t('qualite.objectifs.assisSansTable', { assis: sansCouple }));
|
||||
if (possibles === 0) {
|
||||
expect(texteDe(ligne)).toContain(t('format.vide'));
|
||||
expect(ligne.querySelectorAll('rect[data-segment]').length).toBe(0);
|
||||
} else {
|
||||
expect(ligne.querySelectorAll('rect[data-segment]').length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
const parTour = [...vue.querySelectorAll('ol li')];
|
||||
expect(parTour.map((li) => texteDe(li.firstElementChild))).toEqual([2, 1, 3].map((tour) => t('qualite.objectifs.tour', { tour })));
|
||||
const sansCouple = parTour.find((li) => texteDe(li.firstElementChild) === t('qualite.objectifs.tour', { tour: 1 }));
|
||||
expect(comptesQuiSuivent(sansCouple)).toEqual([{ pourcentage: t('format.vide'), compte: t('qualite.objectifs.sansCouple') }]);
|
||||
expect(texteDe(sansCouple)).not.toMatch(/%/);
|
||||
});
|
||||
|
||||
test('rouge : chaque pourcentage de tour sur ses seuls possibles, suivi de ses servis sur ses possibles', async () => {
|
||||
const detail = detailDe();
|
||||
const vue = await monter(detail);
|
||||
const lis = [...vue.querySelectorAll('ol li')];
|
||||
expect(lis.length).toBe(detail.R);
|
||||
for (const li of lis) {
|
||||
const tour = Number(texteDe(li.firstElementChild).replace(/\D/g, ''));
|
||||
const { possibles, servis } = detail.objectifs.parTour[tour - 1];
|
||||
if (possibles === 0) continue;
|
||||
expect(comptesQuiSuivent(li)).toEqual([
|
||||
{
|
||||
pourcentage: t('qualite.pourcentage.valeur', { pourcent: pourcentAttendu(servis, possibles) }),
|
||||
compte: t('qualite.objectifs.comptes', { servis, possibles }),
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('aucune personne jamais servie : la phrase prend la place de la liste, sans aucun nom', async () => {
|
||||
const detail = structuredClone(detailDe());
|
||||
detail.objectifs.jamaisServies = [];
|
||||
const vue = await monter(detail);
|
||||
const jamais = vue.querySelectorAll('[data-jamais-servies]');
|
||||
expect(jamais.length).toBe(1);
|
||||
expect(texteDe(jamais[0])).toBe(t('qualite.objectifs.aucuneJamaisServie'));
|
||||
expect(jamais[0].tagName).toBe('P');
|
||||
expect(jamais[0].querySelectorAll('li').length).toBe(0);
|
||||
});
|
||||
|
||||
test('R = 1 : aucune ligne de soirée', async () => {
|
||||
const vue = await monter(detailDe({ tours: 1, toursObjectif: [1] }));
|
||||
expect(vue.querySelector('[data-soiree-objectifs]')).toBeNull();
|
||||
expect(lignesDuGraphique(vue).length).toBe(1);
|
||||
});
|
||||
|
||||
test('critère décoché : la phrase dit qu’il ne classe pas, les planchers se dessinent quand même', async () => {
|
||||
const vue = await monter(detailDe({ respecter: false }));
|
||||
expect(vue.querySelector('[data-phrase-qualite="objectifs.critereDecoche"]')).not.toBeNull();
|
||||
expect(vue.querySelectorAll('rect[data-segment="prouve"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('hors du domaine des planchers : la phrase, un seul segment par barre, les tours hors objectif, et aucun prouvé inévitable', async () => {
|
||||
const detail = detailHorsDomaine();
|
||||
expect(detail.collisions.planchersValides).toBe(false);
|
||||
const vue = await monter(detail);
|
||||
expect(texteDe(vue.querySelector('[data-phrase-qualite="objectifs.horsDomaine"]'))).toBe(t('qualite.objectifs.horsDomaine'));
|
||||
const genres = new Set([...vue.querySelectorAll('svg[data-graphique="objectifs"] rect[data-segment]')].map((rect) => rect.dataset.segment));
|
||||
expect([...genres]).toEqual(['serie1']);
|
||||
expect(vue.querySelector('rect[data-segment="prouve"]')).toBeNull();
|
||||
expect(texteDe(vue.querySelector('[data-soiree-objectifs]'))).toContain(t('qualite.objectifs.hors', { hors: detail.objectifs.soiree.hors }));
|
||||
});
|
||||
|
||||
test('sans personne concernée : la phrase, aucun graphique', async () => {
|
||||
const vue = await monter(detailDe({ toursObjectif: [] }));
|
||||
expect(texteDe(vue.querySelector('[data-phrase-qualite="objectifs.aucunConcerne"]'))).toBe(t('qualite.objectifs.aucunConcerne'));
|
||||
expect(vue.querySelector('svg')).toBeNull();
|
||||
expect(vue.querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('le jumeau s’ouvre au clavier : chaque tour, la soirée, les jamais servies, la répartition ; chaque pourcentage suivi du compte de sa ligne', async () => {
|
||||
const detail = detailDe();
|
||||
const vue = await monter(detail);
|
||||
const commande = vue.querySelector('[data-commande="ouvrirJumeau"]');
|
||||
commande.focus();
|
||||
await touche('Enter');
|
||||
await attendre(() => expect(vue.querySelectorAll('[data-jumeau]').length).toBe(3));
|
||||
const [tours, jamais, repartition] = vue.querySelectorAll('[data-jumeau]');
|
||||
expect(tours.querySelectorAll('tbody tr').length).toBe(detail.R + 1);
|
||||
expect(jamais.querySelectorAll('tbody tr').length).toBe(detail.objectifs.jamaisServies.length);
|
||||
expect(repartition.querySelectorAll('tbody tr').length).toBe(detail.objectifs.repartition.length);
|
||||
const { soiree } = detail.objectifs;
|
||||
expect(comptesQuiSuivent(tours).at(-1)).toEqual({
|
||||
pourcentage: t('qualite.pourcentage.valeur', { pourcent: soiree.pourcent }),
|
||||
compte: t('qualite.objectifs.comptes', soiree),
|
||||
});
|
||||
const noms = [...vue.querySelectorAll('[data-jumeau]')].map((table) => table.getAttribute('aria-label'));
|
||||
expect(new Set([...noms, t('qualite.objectifs.graphique')]).size).toBe(4);
|
||||
const fermer = vue.querySelector('[data-commande="fermerJumeau"]');
|
||||
expect(document.activeElement).toBe(fermer);
|
||||
await touche('Enter');
|
||||
await attendre(() => expect(vue.querySelector('[data-jumeau]')).toBeNull());
|
||||
expect(document.activeElement).toBe(vue.querySelector('[data-commande="ouvrirJumeau"]'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : recoupements dans l’application (§ 12.12, § 5.6)', () => {
|
||||
test('jamais servies = colonne du tableau des propositions ; possibles − servis de la soirée = tours hors objectif ; Σ planchers des tours = plancher de la section Diagnostic', async () => {
|
||||
const lue = generee(chargeObjectifs({ mode: 'chacun' }));
|
||||
const texte = serialiser(lue, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `soiree${SUFFIXES.etat}`, texte }] });
|
||||
montes.push({ defaire: () => m.demonter() });
|
||||
await m.seance.ouvrir('soiree');
|
||||
const id = lue.propositions[0].id;
|
||||
m.ui.montrerProposition(id);
|
||||
m.ui.allerA('qualite');
|
||||
const vue = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector('[data-vue-qualite="objectifs"]');
|
||||
expect(trouvee?.querySelector('[data-soiree-objectifs]')).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
const jamais = vue.querySelectorAll('[data-jamais-servies] li').length;
|
||||
vue.querySelector('[data-commande="ouvrirJumeau"]').click();
|
||||
const tours = await attendre(() => {
|
||||
const table = vue.querySelector('[data-jumeau="0"]');
|
||||
expect(table).not.toBeNull();
|
||||
return table;
|
||||
});
|
||||
const nombre = (cellule) => Number(texteDe(cellule).replace(/\D/g, ''));
|
||||
const rangees = [...tours.querySelectorAll('tbody tr')];
|
||||
const soiree = rangees.at(-1);
|
||||
const horsDeLaSoiree =
|
||||
nombre(soiree.querySelector('[data-colonne="possibles"]')) - nombre(soiree.querySelector('[data-colonne="servis"]'));
|
||||
const planchers = rangees
|
||||
.slice(0, -1)
|
||||
.map((rangee) => texteDe(rangee.querySelector('[data-colonne="plancher"]')))
|
||||
.filter((texte) => texte !== t('format.vide'))
|
||||
.map(Number);
|
||||
const somme = planchers.reduce((total, plancher) => total + plancher, 0);
|
||||
expect(planchers.length).toBe(2);
|
||||
|
||||
m.ui.allerA('propositions');
|
||||
const ligne = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector(`[data-proposition="${id}"]`);
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
expect(nombre(ligne.querySelector('[data-colonne="jamaisServies"]'))).toBe(jamais);
|
||||
expect(nombre(ligne.querySelector('[data-colonne="toursHorsObjectif"]'))).toBe(horsDeLaSoiree);
|
||||
|
||||
m.ui.allerA('diagnostic');
|
||||
const enonce = await attendre(() => {
|
||||
const trouve = m.racine.querySelector('[data-plancher-objectifs="toursHorsObjectif"]');
|
||||
expect(trouve).not.toBeNull();
|
||||
return trouve;
|
||||
});
|
||||
expect(texteDe(enonce)).toContain(t('diagnostic.objectifs.toursHorsObjectif', { plancher: somme }));
|
||||
expect(m.erreurs()).toEqual([]);
|
||||
});
|
||||
});
|
||||
496
src/interface/qualite/Paires.navigateur.test.js
Normal file
496
src/interface/qualite/Paires.navigateur.test.js
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La vue des paires répétées dans un vrai moteur de rendu (§ 12.5, B14,
|
||||
// D32, D33, D50) : sur 12 / 4 × 3 / 6, le tableau des paires choisies puis,
|
||||
// sous leur intitulé, des paires imposées ; la première commande d'une
|
||||
// ligne mène au plan au premier tour où la paire se revoit, la première
|
||||
// personne mise en évidence, et chaque tour à son tour ; la ligne n'est pas
|
||||
// une cible ; la matrice dans l'ordre du profil, et sa phrase quand elle
|
||||
// est trop étroite ou au-delà du seuil ; les pages de cent ; les cas d'une
|
||||
// table et d'un tour. Les épreuves de la page montent App (monterApp) au
|
||||
// cadre de 1 366 × 700 ; celles des pages montent la vue seule, sur un
|
||||
// détail forgé. Le rendu se lit par le texte et les attributs data-…,
|
||||
// jamais par une classe. Les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import '../jetons.css';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { creerEtatUi } from '../etat_ui.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import VuePaires from './VuePaires.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const V1 = VERSION.affichee;
|
||||
const BASE = 'soiree';
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------------
|
||||
|
||||
// Des noms inventés, assez pour quarante personnes : prénom et nom se
|
||||
// combinent.
|
||||
const PRENOMS = ['Iris', 'Théo', 'Ondine', 'Basile', 'Maëlle', 'Sidonie', 'Aurèle', 'Clovis'];
|
||||
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Tourteau', 'Quenouille'];
|
||||
const APPARTENANCES = ['Coopérative Aubier', 'Atelier Ressac', null];
|
||||
|
||||
// n personnes inventées, t tables de s places numérotées 10 + identifiant,
|
||||
// r tours ; avec reserver, les deux premières sont réservées ensemble à la
|
||||
// table 1 à chaque tour : leur paire est imposée.
|
||||
function chargeDe({ n, tables, sieges, tours, reserver = false }) {
|
||||
const charge = creerCharge({ id: 'evt-paires', nom: 'Atelier Ressac', siegesParDefaut: sieges, tours });
|
||||
charge.participants = Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
nom: NOMS[Math.floor(i / PRENOMS.length) % NOMS.length],
|
||||
prenom: PRENOMS[i % PRENOMS.length],
|
||||
appartenance: APPARTENANCES[i % APPARTENANCES.length],
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = Array.from({ length: tables }, (_, i) => ({
|
||||
id: i + 1,
|
||||
numero: 11 + i,
|
||||
sieges: null,
|
||||
forme: 'ronde',
|
||||
position: { x: 300 * (i + 1), y: 0 },
|
||||
}));
|
||||
if (reserver) charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
charge.prochainsIds = { participant: n + 1, table: tables + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// 12 personnes, 4 tables de 3, 6 tours (§ 12.5) : une répétition au moins
|
||||
// pour chacun ; la paire 1:2 est imposée.
|
||||
const chargeDouze = () => chargeDe({ n: 12, tables: 4, sieges: 3, tours: 6, reserver: true });
|
||||
|
||||
// La charge, une génération de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages = { graine: 5, arret: 60, nombre: 2 }) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// --- Le montage --------------------------------------------------------------------
|
||||
|
||||
let montes = [];
|
||||
let vues = [];
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(async () => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
for (const { composant, cible } of vues.splice(0)) {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
await fixerCadre(1366, 700);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La charge posée en fichier d'état, ouverte en écriture par la séance, sa
|
||||
// première proposition montrée au plan, la page de qualité ouverte.
|
||||
async function ouvrir(charge, { montrer = charge.propositions[0]?.id ?? null } = {}) {
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
await m.seance.passerEnEcriture();
|
||||
if (montrer !== null) m.ui.montrerProposition(montrer);
|
||||
m.ui.allerA('qualite');
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
const vueDe = (m) => m.racine.querySelector('[data-vue-qualite="paires"]');
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const commandes = (racine, nom) => [...racine.querySelectorAll(`[data-commande="${nom}"]`)];
|
||||
const phrases = (racine) => [...racine.querySelectorAll('[data-phrase-qualite]')].map((p) => p.dataset.phraseQualite);
|
||||
|
||||
// La vue des paires, rendue, sous la page ouverte.
|
||||
const vueRendue = (m) =>
|
||||
attendre(() => {
|
||||
const vue = vueDe(m);
|
||||
expect(vue).not.toBeNull();
|
||||
expect(vue.querySelector('[data-paire], [data-phrase-qualite]')).not.toBeNull();
|
||||
return vue;
|
||||
});
|
||||
|
||||
// La vue seule, montée sur un détail forgé, dans une boîte de la largeur
|
||||
// donnée. Le détail se lit dans une SvelteMap, sous la clé 'detail' : la
|
||||
// changer change la propriété de la vue, comme une autre proposition
|
||||
// montrée.
|
||||
async function monterVue(detail, largeur = 1100) {
|
||||
const cible = document.createElement('div');
|
||||
cible.style.width = `${largeur}px`;
|
||||
document.body.prepend(cible);
|
||||
const ui = creerEtatUi();
|
||||
const contexte = new Map([
|
||||
['t', t],
|
||||
['format', format],
|
||||
['ui', ui],
|
||||
]);
|
||||
const proprietes = new SvelteMap([['detail', detail]]);
|
||||
const props = {
|
||||
get detail() {
|
||||
return proprietes.get('detail');
|
||||
},
|
||||
ligne: null,
|
||||
table: null,
|
||||
};
|
||||
const composant = mount(VuePaires, { target: cible, props, context: contexte });
|
||||
vues.push({ composant, cible });
|
||||
flushSync();
|
||||
await tick();
|
||||
flushSync();
|
||||
return { cible, ui, proprietes };
|
||||
}
|
||||
|
||||
// Un détail forgé : n personnes, une table, quatre tours, les paires
|
||||
// choisies et imposées données, sans matrice.
|
||||
function detailForge({ n, choisies, imposees }) {
|
||||
return {
|
||||
id: 1,
|
||||
statut: 'comparable',
|
||||
raisons: [],
|
||||
population: { mesuree: n, exclue: 0 },
|
||||
T: 4,
|
||||
R: 4,
|
||||
G: 1,
|
||||
idsTables: [1, 2, 3, 4],
|
||||
numerosTables: [1, 2, 3, 4],
|
||||
personnes: Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
nom: `${PRENOMS[i % PRENOMS.length]} ${NOMS[Math.floor(i / PRENOMS.length) % NOMS.length]}`,
|
||||
appartenance: null,
|
||||
statut: 'mobile',
|
||||
})),
|
||||
paires: { choisies, imposees },
|
||||
matrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Les épreuves -----------------------------------------------------------------
|
||||
|
||||
describe('le tableau des paires sur 12 / 4 × 3 / 6 (§ 12.5, D32)', () => {
|
||||
const lue = generee(chargeDouze());
|
||||
const id = lue.propositions[0].id;
|
||||
const detail = detaillerQualite(lue, id);
|
||||
const nomDe = (personne) => detail.personnes.find((candidate) => candidate.id === personne).nom;
|
||||
|
||||
test('les paires choisies, puis les imposées sous leur intitulé : chaque ligne nomme la paire, son nombre de fois et ses tours', async () => {
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
const tableaux = [...vue.querySelectorAll('table')].filter((tableau) => tableau.querySelector('[data-paire]') !== null);
|
||||
expect(tableaux.map((tableau) => texteDe(tableau.querySelector('caption')))).toEqual([
|
||||
t('qualite.paires.choisies'),
|
||||
t('qualite.paires.imposees'),
|
||||
]);
|
||||
const [choisies, imposees] = tableaux.map((tableau) => [...tableau.querySelectorAll('[data-paire]')].map((ligne) => ligne.dataset.paire));
|
||||
expect(choisies.length).toBeGreaterThan(0);
|
||||
expect(choisies).toEqual(detail.paires.choisies.map(({ a, b }) => `${a}:${b}`));
|
||||
expect(imposees).toEqual(['1:2']);
|
||||
expect(choisies).not.toContain('1:2');
|
||||
const imposee = vue.querySelector('[data-paire="1:2"]');
|
||||
expect(texteDe(imposee)).toContain('Iris Ombrelle');
|
||||
expect(texteDe(imposee)).toContain('Théo Ombrelle');
|
||||
expect(texteDe(imposee)).toContain('Coopérative Aubier');
|
||||
expect(texteDe(imposee)).toContain(t('qualite.paires.fois', { fois: 6 }));
|
||||
const tours = commandes(imposee, 'allerAuTour').map(texteDe);
|
||||
expect(tours).toEqual([
|
||||
t('qualite.paires.premierTour', { tour: 2 }),
|
||||
...[1, 2, 3, 4, 5, 6].map((tour) => t('qualite.paires.tour', { tour, table: 11 })),
|
||||
]);
|
||||
expect(texteDe(vue)).toContain(t('qualite.paires.pages.etendue', { premier: 1, dernier: choisies.length + 1, total: choisies.length + 1 }));
|
||||
});
|
||||
|
||||
test('la première commande de chaque ligne mène au plan au premier tour où la paire se revoit, la première personne mise en évidence', async () => {
|
||||
const m = await ouvrir(lue);
|
||||
const paires = [...detail.paires.choisies, ...detail.paires.imposees];
|
||||
for (const { a, b, tours } of paires) {
|
||||
m.ui.allerA('qualite');
|
||||
const vue = await vueRendue(m);
|
||||
const premiere = commandes(vue.querySelector(`[data-paire="${a}:${b}"]`), 'allerAuTour')[0];
|
||||
expect(texteDe(premiere)).toBe(t('qualite.paires.premierTour', { tour: tours[1].tour + 1 }));
|
||||
premiere.click();
|
||||
flushSync();
|
||||
const etat = m.ui.etat();
|
||||
expect([etat.section, etat.tour, etat.miseEnEvidence, etat.proposition], `${a}:${b}`).toEqual(['plan', tours[1].tour + 1, a, id]);
|
||||
expect(tours[1].tour).not.toBe(tours[0].tour);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque tour d’une ligne est une commande sœur qui mène au plan à ce tour', async () => {
|
||||
const m = await ouvrir(lue);
|
||||
const { a, b, tours } = detail.paires.choisies[0];
|
||||
for (const [rang, { tour, table }] of tours.entries()) {
|
||||
m.ui.allerA('qualite');
|
||||
const vue = await vueRendue(m);
|
||||
const ligne = vue.querySelector(`[data-paire="${a}:${b}"]`);
|
||||
const bouton = commandes(ligne, 'allerAuTour')[rang + 1];
|
||||
const libelle = t('qualite.paires.tour', { tour: tour + 1, table: 10 + table });
|
||||
expect(texteDe(bouton)).toBe(libelle);
|
||||
expect(bouton.getAttribute('aria-label')).toBe(t('qualite.paires.tourNom', { tour: tour + 1, table: 10 + table, a: nomDe(a), b: nomDe(b) }));
|
||||
bouton.click();
|
||||
flushSync();
|
||||
expect([m.ui.etat().section, m.ui.etat().tour, m.ui.etat().miseEnEvidence]).toEqual(['plan', tour + 1, a]);
|
||||
}
|
||||
});
|
||||
|
||||
test('une ligne n’est pas une cible : un clic sur la paire ou son nombre de fois ne quitte pas la page, et rien d’autre que ses commandes ne prend le focus', async () => {
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
for (const ligne of vue.querySelectorAll('[data-paire]')) {
|
||||
expect(ligne.hasAttribute('tabindex')).toBe(false);
|
||||
expect(ligne.getAttribute('role')).toBeNull();
|
||||
for (const cellule of ligne.querySelectorAll('th, td:not(:last-child)')) {
|
||||
cellule.click();
|
||||
flushSync();
|
||||
expect(m.ui.etat().section).toBe('qualite');
|
||||
}
|
||||
const focalisables = [...ligne.querySelectorAll('button, a, [tabindex]')];
|
||||
expect(focalisables.every((element) => element.dataset.commande === 'allerAuTour')).toBe(true);
|
||||
}
|
||||
expect(m.ui.etat().miseEnEvidence).toBe(null);
|
||||
});
|
||||
|
||||
test('les commandes portent des noms accessibles distincts, chacun contenant son libellé', async () => {
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
const noms = commandes(vue, 'allerAuTour').map((bouton) => [texteDe(bouton), bouton.getAttribute('aria-label')]);
|
||||
expect(noms.every(([libelle, nom]) => nom !== null && nom.startsWith(libelle))).toBe(true);
|
||||
expect(new Set(noms.map(([, nom]) => nom)).size).toBe(noms.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la matrice « qui a rencontré qui » (§ 12.5, D33)', () => {
|
||||
const lue = generee(chargeDouze());
|
||||
const id = lue.propositions[0].id;
|
||||
const detail = detaillerQualite(lue, id);
|
||||
|
||||
test('sous le seuil, à 1 366 px : la grille 12 × 12 dans l’ordre du profil, les comptes du détail écrits, la diagonale vide, aucune case cible', async () => {
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
const grille = await attendre(() => {
|
||||
const trouvee = vue.querySelector('[data-graphique="matrice"]');
|
||||
expect(trouvee).not.toBeNull();
|
||||
expect(trouvee.querySelectorAll('[data-case]')).toHaveLength(144);
|
||||
return trouvee;
|
||||
});
|
||||
const ordre = detail.profil.map(({ id: personne }) => personne);
|
||||
expect(detail.matrice.ordre).toEqual(ordre);
|
||||
const parId = new Map(detail.personnes.map(({ id: personne, nom }) => [personne, nom]));
|
||||
const intitules = [...grille.querySelectorAll('text')].map((texte) => texte.textContent).filter((texte) => /^\d+\. /.test(texte));
|
||||
expect(intitules).toEqual(ordre.map((personne, i) => `${i + 1}. ${parId.get(personne)}`));
|
||||
const n = ordre.length;
|
||||
let k = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
expect(texteDe(grille.querySelector(`[data-case="${i}:${i}"]`))).toBe('');
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
const attendu = String(detail.matrice.comptes[k]);
|
||||
expect(texteDe(grille.querySelector(`[data-case="${i}:${j}"]`)), `${i}:${j}`).toBe(attendu);
|
||||
expect(texteDe(grille.querySelector(`[data-case="${j}:${i}"]`)), `${j}:${i}`).toBe(attendu);
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
expect(grille.querySelector('[data-case] [tabindex], [data-case] button, [data-case][tabindex]')).toBeNull();
|
||||
expect(phrases(vue)).not.toContain('matriceEtroite');
|
||||
// Le jumeau porte les mêmes comptes, une ligne par personne.
|
||||
commandes(vue, 'ouvrirJumeau')[0].click();
|
||||
flushSync();
|
||||
const jumeau = vue.querySelector('[data-jumeau]');
|
||||
expect(jumeau.querySelectorAll('tbody tr')).toHaveLength(n);
|
||||
const i = ordre.indexOf(1);
|
||||
const j = ordre.indexOf(2);
|
||||
expect(texteDe(jumeau.querySelectorAll('tbody tr')[i].querySelector(`[data-colonne="c${j}"]`))).toBe('6');
|
||||
});
|
||||
|
||||
test('sous le seuil mais trop étroite à 1 024 px : la phrase de la matrice non dessinée, qui renvoie à la fiche', async () => {
|
||||
const n = constante('SEUIL_MATRICE_PARTICIPANTS');
|
||||
const lue40 = generee(chargeDe({ n, tables: n / 4, sieges: 4, tours: 3 }), { graine: 2, arret: 20, nombre: 1 });
|
||||
await fixerCadre(1024, 700);
|
||||
const m = await ouvrir(lue40);
|
||||
const vue = await vueRendue(m);
|
||||
await attendre(() => expect(phrases(vue)).toContain('matriceEtroite'));
|
||||
expect(vue.querySelector('[data-graphique="matrice"]')).toBeNull();
|
||||
expect(texteDe(vue)).toContain(t('qualite.paires.matrice.etroite'));
|
||||
expect(commandes(vue, 'ouvrirJumeau')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Un arrêt court laisse beaucoup de paires revues : la page de cent est
|
||||
// pleine, la vue à son plus lourd. Le budget de 1 500 nœuds vaut pour la
|
||||
// page entière et se mesure avec elle ; cette épreuve borne la part de la
|
||||
// vue seule, PART_DES_PAIRES, sa page pleine et une marge de quelques
|
||||
// lignes.
|
||||
const PART_DES_PAIRES = 1400;
|
||||
test('la grande : aucune matrice, la phrase du seuil ; cent lignes au plus, la vue dans sa part du budget de la page', async () => {
|
||||
const lueGrande = generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50 });
|
||||
const m = await ouvrir(lueGrande);
|
||||
const vue = await vueRendue(m);
|
||||
expect(phrases(vue)).toContain('matriceSeuil');
|
||||
expect(vue.querySelector('[data-graphique]')).toBeNull();
|
||||
const mesuree = detaillerQualite(lueGrande, lueGrande.propositions[0].id).population.mesuree;
|
||||
expect(texteDe(vue)).toContain(
|
||||
t('qualite.paires.matrice.seuil', { participants: format.nombre(mesuree), seuil: format.nombre(constante('SEUIL_MATRICE_PARTICIPANTS')) }),
|
||||
);
|
||||
expect(vue.querySelectorAll('[data-paire]').length).toBeLessThanOrEqual(100);
|
||||
expect(vue.querySelectorAll('*').length).toBeLessThanOrEqual(PART_DES_PAIRES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les pages de cent (§ 5.7, D50)', () => {
|
||||
// 250 paires forgées sur 30 personnes : 180 choisies, puis 70 imposées.
|
||||
const paires = [];
|
||||
for (let a = 1; a <= 30 && paires.length < 250; a += 1) {
|
||||
for (let b = a + 1; b <= 30 && paires.length < 250; b += 1) {
|
||||
paires.push({ a, b, fois: 2, tours: [{ tour: 0, table: 1 }, { tour: 2, table: 2 }] });
|
||||
}
|
||||
}
|
||||
const detail = detailForge({ n: 30, choisies: paires.slice(0, 180), imposees: paires.slice(180) });
|
||||
|
||||
test('250 paires font trois pages : l’étendue et le total écrits, les commandes de page, les imposées à la suite des choisies', async () => {
|
||||
const { cible } = await monterVue(detail);
|
||||
const lignes = () => [...cible.querySelectorAll('[data-paire]')].map((ligne) => ligne.dataset.paire);
|
||||
const etendue = (premier, dernier) => t('qualite.paires.pages.etendue', { premier, dernier, total: 250 });
|
||||
expect(lignes()).toHaveLength(100);
|
||||
expect(texteDe(cible)).toContain(etendue(1, 100));
|
||||
expect(commandes(cible, 'precedentes')).toHaveLength(0);
|
||||
commandes(cible, 'suivantes')[0].click();
|
||||
await tick();
|
||||
flushSync();
|
||||
expect(lignes()).toHaveLength(100);
|
||||
expect(lignes()[0]).toBe(`${paires[100].a}:${paires[100].b}`);
|
||||
expect(lignes()[80]).toBe(`${paires[180].a}:${paires[180].b}`);
|
||||
expect(texteDe(cible)).toContain(etendue(101, 200));
|
||||
expect([...cible.querySelectorAll('caption')].map(texteDe)).toEqual([t('qualite.paires.choisies'), t('qualite.paires.imposees')]);
|
||||
commandes(cible, 'suivantes')[0].click();
|
||||
await tick();
|
||||
flushSync();
|
||||
expect(lignes()).toHaveLength(50);
|
||||
expect(texteDe(cible)).toContain(etendue(201, 250));
|
||||
expect([...cible.querySelectorAll('caption')].map(texteDe)).toEqual([t('qualite.paires.imposees')]);
|
||||
expect(commandes(cible, 'suivantes')).toHaveLength(0);
|
||||
await attendre(() => expect(texteDe(document.activeElement)).toBe(etendue(201, 250)));
|
||||
commandes(cible, 'precedentes')[0].click();
|
||||
await tick();
|
||||
flushSync();
|
||||
expect(texteDe(cible)).toContain(etendue(101, 200));
|
||||
});
|
||||
|
||||
test('une autre proposition montrée s’ouvre à sa première page ; la même, remesurée, garde la sienne', async () => {
|
||||
const { cible, proprietes } = await monterVue(detail);
|
||||
const etendue = (premier, dernier) => t('qualite.paires.pages.etendue', { premier, dernier, total: 250 });
|
||||
commandes(cible, 'suivantes')[0].click();
|
||||
await tick();
|
||||
flushSync();
|
||||
expect(texteDe(cible)).toContain(etendue(101, 200));
|
||||
// Le même détail, remesuré : un nouvel objet, le même identifiant.
|
||||
proprietes.set('detail', structuredClone(detail));
|
||||
flushSync();
|
||||
expect(texteDe(cible)).toContain(etendue(101, 200));
|
||||
// Une autre proposition, autant de paires.
|
||||
proprietes.set('detail', { ...structuredClone(detail), id: detail.id + 1 });
|
||||
flushSync();
|
||||
expect(texteDe(cible)).toContain(etendue(1, 100));
|
||||
expect(texteDe(cible)).not.toContain(etendue(101, 200));
|
||||
expect(cible.querySelector('[data-paire]').dataset.paire).toBe(`${paires[0].a}:${paires[0].b}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les homonymes (§ 12.6)', () => {
|
||||
// Deux personnes mesurées du même nom et de la même appartenance, chacune
|
||||
// revue par la troisième au tour 3 : seul l'identifiant les distingue.
|
||||
const detail = {
|
||||
...detailForge({ n: 3, choisies: [], imposees: [] }),
|
||||
personnes: [
|
||||
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', statut: 'mobile' },
|
||||
{ id: 2, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', statut: 'mobile' },
|
||||
{ id: 3, nom: 'Ondine Lacasse', appartenance: 'Atelier Ressac', statut: 'mobile' },
|
||||
],
|
||||
paires: {
|
||||
choisies: [
|
||||
{ a: 1, b: 3, fois: 2, tours: [{ tour: 0, table: 1 }, { tour: 2, table: 1 }] },
|
||||
{ a: 2, b: 3, fois: 2, tours: [{ tour: 1, table: 2 }, { tour: 2, table: 1 }] },
|
||||
],
|
||||
imposees: [],
|
||||
},
|
||||
};
|
||||
|
||||
test('chaque ligne écrit l’identifiant de l’homonyme, et les noms accessibles de ses commandes le portent : aucun ne se répète', async () => {
|
||||
const { cible } = await monterVue(detail);
|
||||
for (const id of [1, 2]) {
|
||||
const ligne = cible.querySelector(`[data-paire="${id}:3"]`);
|
||||
expect(texteDe(ligne)).toContain(t('qualite.paires.homonyme', { id }));
|
||||
expect(texteDe(ligne)).not.toContain(t('qualite.paires.homonyme', { id: 3 - id }));
|
||||
for (const bouton of commandes(ligne, 'allerAuTour')) {
|
||||
expect(bouton.getAttribute('aria-label')).toContain(t('qualite.paires.homonyme', { id }));
|
||||
}
|
||||
}
|
||||
const noms = commandes(cible, 'allerAuTour').map((bouton) => bouton.getAttribute('aria-label'));
|
||||
expect(noms).toHaveLength(6);
|
||||
expect(new Set(noms).size).toBe(noms.length);
|
||||
expect(noms.every((nom) => !nom.includes(t('qualite.paires.homonyme', { id: 3 })))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les cas de la vue (§ 12.6)', () => {
|
||||
test('une seule table : la phrase, ni tableau ni matrice', async () => {
|
||||
const lue = generee(chargeDe({ n: 4, tables: 1, sieges: 4, tours: 2 }), { graine: 1, arret: 10, nombre: 1 });
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
expect(phrases(vue)).toEqual(['pairesUneTable']);
|
||||
expect(vue.querySelector('[data-paire], [data-graphique]')).toBeNull();
|
||||
expect(texteDe(vue)).toContain(t('qualite.paires.uneTable'));
|
||||
});
|
||||
|
||||
test('un seul tour : la phrase au lieu du tableau, jamais un tableau vide ; la matrice reste', async () => {
|
||||
const lue = generee(chargeDe({ n: 6, tables: 2, sieges: 3, tours: 1 }), { graine: 1, arret: 10, nombre: 1 });
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
expect(phrases(vue)).toEqual(['pairesUnTour']);
|
||||
expect(vue.querySelector('[data-paire], table')).toBeNull();
|
||||
await attendre(() => expect(vue.querySelector('[data-graphique="matrice"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
test('aucune paire revue : la phrase, sans tableau', async () => {
|
||||
const lue = generee(chargeDe({ n: 9, tables: 3, sieges: 3, tours: 2 }), { graine: 1, arret: 200, nombre: 1 });
|
||||
const detail = detaillerQualite(lue, lue.propositions[0].id);
|
||||
expect(detail.paires.choisies.length + detail.paires.imposees.length).toBe(0);
|
||||
const m = await ouvrir(lue);
|
||||
const vue = await vueRendue(m);
|
||||
expect(phrases(vue)).toEqual(['pairesAucune']);
|
||||
expect(vue.querySelector('[data-paire]')).toBeNull();
|
||||
});
|
||||
});
|
||||
457
src/interface/qualite/Performance.navigateur.test.js
Normal file
457
src/interface/qualite/Performance.navigateur.test.js
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La vue « Performance du calcul » dans un vrai moteur de rendu (§ 12.14,
|
||||
// B23, D40, D49) : une génération de la séance sous l'horloge pilotée,
|
||||
// avancée d'une durée écrite pendant le calcul, donne la durée et la cadence
|
||||
// exactes ; sans avance, « durée inconnue » et aucune cadence ; les petits
|
||||
// multiples marquent les seuls points de relevé, sur une abscisse
|
||||
// logarithmique, chacun son seul axe vertical ; le dernier point du jumeau
|
||||
// égale la ligne de la proposition au tableau des propositions, avant et
|
||||
// après un changement de l'ordre des priorités ou d'un objectif ; rouverte
|
||||
// par une autre séance, la proposition montre les mêmes points, durée
|
||||
// inconnue ; compte 1, zéro mouvement, retenu non ajusté puis ajusté,
|
||||
// proposition sans course. Chaque épreuve monte App par monterApp, sur le
|
||||
// système de fichiers simulé où un fichier d'état d'épreuve est posé puis
|
||||
// ouvert, au cadre de 1 366 × 700, et lit le rendu : le texte, les
|
||||
// attributs data-…, l'élément focalisé — jamais une classe. Les noms
|
||||
// d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { creerExecuteurDirect } from '../../application/generation.js';
|
||||
import { creerHorlogePilotee } from '../../application/horloge.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { colonneDeValeur } from '../propositions/modele.js';
|
||||
import { cleIndicateur } from './performance.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const V1 = VERSION.affichee;
|
||||
const BASE = 'soiree';
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------
|
||||
|
||||
// Des personnes inventées, de deux appartenances ou sans — aucune quand
|
||||
// appartenances est faux —, réparties sur des tables numérotées de 1 à
|
||||
// tables, sans proposition ni retenu.
|
||||
const PERSONNES = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
['Aubépin', 'Lucien', 'Coopérative Aubier'],
|
||||
['Bruyère', 'Aglaé', 'Atelier Ressac'],
|
||||
['Cormier', 'Félix', null],
|
||||
['Dorine', 'Ysé', 'Coopérative Aubier'],
|
||||
['Escourgeon', 'Paulin', 'Atelier Ressac'],
|
||||
['Fenouil', 'Rosalie', null],
|
||||
];
|
||||
|
||||
function chargeDe({ personnes = 6, tables = 2, sieges = 3, tours = 2, appartenances = true } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-performance', nom: 'Atelier Ressac', siegesParDefaut: sieges, tours });
|
||||
charge.participants = PERSONNES.slice(0, personnes).map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: appartenances ? appartenance : null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = Array.from({ length: tables }, (_, rang) => ({
|
||||
id: rang + 1,
|
||||
numero: rang + 1,
|
||||
sieges: null,
|
||||
forme: 'ronde',
|
||||
position: { x: 300 * (rang + 1), y: 0 },
|
||||
}));
|
||||
charge.prochainsIds = { participant: personnes + 1, table: tables + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Douze personnes, trois tables de quatre, trois tours ; les six premières
|
||||
// portent l'étiquette « finance », que la table 1 a pour objectif.
|
||||
function chargeAObjectifs() {
|
||||
const charge = chargeDe({ personnes: 12, tables: 3, sieges: 4, tours: 3 });
|
||||
for (const personne of charge.participants) if (personne.id <= 6) personne.etiquettes = ['finance'];
|
||||
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
return charge;
|
||||
}
|
||||
|
||||
// --- Le montage ------------------------------------------------------------
|
||||
|
||||
let montes = [];
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// L'exécuteur direct, dont chaque calcul avance l'horloge pilotée de
|
||||
// dureeMs avant de rendre ses propositions, et qui dit employer coeurs
|
||||
// cœurs : la durée que le contrôleur prend autour du calcul est dureeMs.
|
||||
function executeurQuiDure(horloge, dureeMs, coeurs) {
|
||||
const direct = creerExecuteurDirect({ coeursDisponibles: coeurs });
|
||||
return {
|
||||
coeursDisponibles: direct.coeursDisponibles,
|
||||
lancer(configuration, reglages, options) {
|
||||
const execution = direct.lancer(configuration, reglages, options);
|
||||
const resultat = execution.resultat.then((propositions) => {
|
||||
horloge.avancer(dureeMs);
|
||||
return propositions;
|
||||
});
|
||||
return { ...execution, resultat, coeurs };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// La charge posée en fichier d'état, ouverte par la séance — en écriture
|
||||
// sauf mode 'lecture' — ; options passe à monterApp.
|
||||
async function ouvrir(charge, { mode = 'ecriture', ...options } = {}) {
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }], ...options });
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
if (mode === 'ecriture') await m.seance.passerEnEcriture();
|
||||
return m;
|
||||
}
|
||||
|
||||
// La page de qualité ouverte sur la proposition id, ou sur le retenu pour
|
||||
// null.
|
||||
function montrer(m, id) {
|
||||
m.ui.montrerProposition(id);
|
||||
m.ui.allerA('qualite');
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// --- Les lectures du rendu -------------------------------------------------
|
||||
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const vue = (m) => m.racine.querySelector('[data-vue-qualite="performance"]');
|
||||
const tete = (m) => [...vue(m).querySelectorAll('[data-course] li')].map(texteDe);
|
||||
const phrases = (m) => [...vue(m).querySelectorAll('[data-phrase-qualite]')].map((p) => [p.dataset.phraseQualite, texteDe(p)]);
|
||||
const graphiques = (m) => [...vue(m).querySelectorAll('[data-graphique]')];
|
||||
|
||||
// Le jumeau de la vue ouvert : ses colonnes, puis chaque ligne, une Map
|
||||
// colonne → texte de sa cellule.
|
||||
async function lireJumeau(m) {
|
||||
const ouvrirJumeau = vue(m).querySelector('[data-commande="ouvrirJumeau"]');
|
||||
if (ouvrirJumeau !== null) ouvrirJumeau.click();
|
||||
const table = await attendre(() => {
|
||||
const trouvee = vue(m).querySelector('[data-jumeau="0"]');
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
const colonnes = [...table.querySelectorAll('thead th')].map((th) => th.dataset.colonne);
|
||||
const lignes = [...table.querySelectorAll('tbody tr')].map(
|
||||
(tr) => new Map([...tr.querySelectorAll('[data-colonne]')].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)])),
|
||||
);
|
||||
return { colonnes, lignes };
|
||||
}
|
||||
|
||||
// La ligne de la proposition id au tableau des propositions : une Map
|
||||
// colonne → texte de sa cellule.
|
||||
async function ligneAuTableau(m, id) {
|
||||
m.ui.allerA('propositions');
|
||||
const ligne = await attendre(() => {
|
||||
const trouvee = m.racine.querySelector(`[data-proposition="${id}"]`);
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
return new Map([...ligne.querySelectorAll('td[data-colonne]')].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)]));
|
||||
}
|
||||
|
||||
// La colonne du tableau des propositions qui porte une valeur de point de
|
||||
// relevé : celle du minimum des rencontres pour le minimum brut.
|
||||
const colonneAuTableau = (nom) => (nom === 'minimumBrut' ? 'rencontresTous' : colonneDeValeur(nom));
|
||||
|
||||
// Le jumeau lu sur la page de qualité restée ouverte sur la proposition
|
||||
// id, puis la ligne de cette proposition au tableau des propositions : le
|
||||
// dernier point égale la ligne, valeur par valeur. La page est rouverte
|
||||
// sur la proposition id avant de rendre le jumeau lu, et le reste pendant
|
||||
// le changement qui suit : la vue reçoit le détail de la charge changée
|
||||
// sans être montée de nouveau.
|
||||
async function dernierPointEgaleLaLigne(m, id, quoi) {
|
||||
const jumeau = await lireJumeau(m);
|
||||
const auTableau = await ligneAuTableau(m, id);
|
||||
const dernier = jumeau.lignes.at(-1);
|
||||
for (const nom of jumeau.colonnes.slice(1)) {
|
||||
expect(dernier.get(nom), `${quoi} : ${nom}`).toBe(auTableau.get(colonneAuTableau(nom)));
|
||||
}
|
||||
montrer(m, id);
|
||||
return jumeau;
|
||||
}
|
||||
|
||||
// --- Les épreuves ----------------------------------------------------------
|
||||
|
||||
describe('la ligne de tête : la génération de la séance (§ 12.14, § 5.10, D40)', () => {
|
||||
const REGLAGES = { graine: 3, nombre: 2, arret: 400, historique: 1000 };
|
||||
|
||||
test('rouge : horloge avancée de 2 500 ms pendant le calcul, sur deux cœurs : durée, cœurs, cadence et nombre exacts', async () => {
|
||||
const horloge = creerHorlogePilotee();
|
||||
const charge = chargeDe();
|
||||
const m = await ouvrir(charge, { horloge, executeur: executeurQuiDure(horloge, 2500, 2) });
|
||||
await m.app.generation().generer(REGLAGES);
|
||||
montrer(m, 1);
|
||||
const propositions = rechercher(configurationDepuisCharge(charge), REGLAGES);
|
||||
const evalues = propositions[0].evalues + propositions[1].evalues;
|
||||
await attendre(() =>
|
||||
expect(tete(m)).toEqual([
|
||||
t('qualite.performance.mouvements', { evalues: format.nombre(propositions[0].evalues), arret: format.nombre(400) }),
|
||||
'durée observée 2,5 s',
|
||||
'2 cœurs de calcul',
|
||||
`cadence ${format.nombre(Math.round((evalues * 1000) / 2500))} mouvements par seconde`,
|
||||
'génération de 2 propositions',
|
||||
]),
|
||||
);
|
||||
expect(evalues).toBe(800);
|
||||
expect(tete(m)[3]).toBe('cadence 320 mouvements par seconde');
|
||||
});
|
||||
|
||||
test('rouge : sans avance de l’horloge, durée nulle : « durée inconnue », aucune cadence', async () => {
|
||||
const m = await ouvrir(chargeDe());
|
||||
await m.app.generation().generer(REGLAGES);
|
||||
montrer(m, 1);
|
||||
await attendre(() => expect(tete(m)).toContain(t('qualite.performance.dureeInconnue')));
|
||||
expect(tete(m).filter((morceau) => morceau.includes('cadence'))).toEqual([]);
|
||||
expect(tete(m).filter((morceau) => morceau.startsWith('durée observée'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les petits multiples et le jumeau (§ 12.14, D49)', () => {
|
||||
test('rouge : des points aux seuls points de relevé, reliés tout droit, sur une abscisse logarithmique ; un seul axe vertical par graphique', async () => {
|
||||
const m = await ouvrir(chargeDe());
|
||||
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 400, historique: 1000 });
|
||||
montrer(m, 1);
|
||||
const svgs = await attendre(() => {
|
||||
const trouves = graphiques(m);
|
||||
expect(trouves.length).toBeGreaterThan(1);
|
||||
expect(trouves[0].querySelectorAll('[data-point-releve]').length).toBe(4);
|
||||
return trouves;
|
||||
});
|
||||
expect(svgs[0].dataset.graphique).toBe('performance-minimumBrut');
|
||||
const noms = svgs.map((svg) => document.getElementById(svg.getAttribute('aria-labelledby')).textContent);
|
||||
expect(new Set(noms).size).toBe(noms.length);
|
||||
for (const svg of svgs) {
|
||||
const points = [...svg.querySelectorAll('[data-point-releve]')];
|
||||
expect(points.map((p) => p.dataset.pointReleve)).toEqual(['1', '10', '100', '400']);
|
||||
const x = points.map((p) => Number(p.getAttribute('cx')));
|
||||
// Une décade a la même largeur : 1 → 10 comme 10 → 100.
|
||||
expect(Math.abs(x[1] - x[0] - (x[2] - x[1]))).toBeLessThan(0.5);
|
||||
expect(x[3] - x[2]).toBeLessThan(x[2] - x[1]);
|
||||
// Le trait passe d'un point au suivant, sans palier entre eux.
|
||||
const d = svg.querySelector('path[data-serie="serie1"]').getAttribute('d');
|
||||
expect(d.replace(/[^A-Za-z]/g, '')).toBe('MLLL');
|
||||
const sommets = [...d.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map(([, px, py]) => [Number(px), Number(py)]);
|
||||
points.forEach((point, i) => {
|
||||
expect(sommets[i][0]).toBeCloseTo(Number(point.getAttribute('cx')), 1);
|
||||
expect(sommets[i][1]).toBeCloseTo(Number(point.getAttribute('cy')), 1);
|
||||
});
|
||||
expect(svg.querySelectorAll('[data-serie]:not(text)')).toHaveLength(1);
|
||||
const verticaux = [...svg.querySelectorAll('[data-marque="graphiqueAxe"]')].filter(
|
||||
(axe) => axe.getAttribute('x1') === axe.getAttribute('x2'),
|
||||
);
|
||||
expect(verticaux).toHaveLength(1);
|
||||
}
|
||||
// Les petits multiples alignent leurs abscisses, point contre point.
|
||||
const abscisses = svgs.map((svg) => [...svg.querySelectorAll('[data-point-releve]')].map((p) => p.getAttribute('cx')));
|
||||
const colonnes = svgs.map((svg) => Math.round(svg.getBoundingClientRect().left));
|
||||
svgs.forEach((svg, i) => {
|
||||
if (colonnes[i] === colonnes[0]) expect(abscisses[i]).toEqual(abscisses[0]);
|
||||
});
|
||||
expect(texteDe(vue(m).querySelector('[data-dernier-changement]')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('rouge : le jumeau, au clavier : une ligne par point, ses mouvements puis une colonne par valeur, aucune durée ; refermé, le focus reste sur la commande', async () => {
|
||||
const m = await ouvrir(chargeDe());
|
||||
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 400, historique: 1000 });
|
||||
montrer(m, 1);
|
||||
const ouvrirJumeau = await attendre(() => {
|
||||
const trouvee = vue(m).querySelector('[data-commande="ouvrirJumeau"]');
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
ouvrirJumeau.focus();
|
||||
await touche('Enter');
|
||||
const table = await attendre(() => {
|
||||
const trouvee = vue(m).querySelector('[data-jumeau="0"]');
|
||||
expect(trouvee).not.toBeNull();
|
||||
return trouvee;
|
||||
});
|
||||
expect(table.getAttribute('aria-label')).toBe(t('qualite.performance.jumeau'));
|
||||
const colonnes = [...table.querySelectorAll('thead th')].map((th) => th.dataset.colonne);
|
||||
expect(colonnes[0]).toBe('mouvements');
|
||||
expect(colonnes.slice(1)).toEqual(graphiques(m).map((svg) => svg.dataset.graphique.replace('performance-', '')));
|
||||
expect(colonnes.filter((colonne) => /duree|temps|cadence/i.test(colonne))).toEqual([]);
|
||||
expect([...table.querySelectorAll('thead th')].map(texteDe)).toEqual([
|
||||
t('qualite.performance.colonne.mouvements'),
|
||||
...colonnes.slice(1).map((nom) => t(cleIndicateur(nom))),
|
||||
]);
|
||||
expect([...table.querySelectorAll('tbody tr')].map((tr) => texteDe(tr.querySelector('[data-colonne="mouvements"]')))).toEqual([
|
||||
'1',
|
||||
'10',
|
||||
'100',
|
||||
'400',
|
||||
]);
|
||||
const fermer = vue(m).querySelector('[data-commande="fermerJumeau"]');
|
||||
fermer.focus();
|
||||
await touche('Enter');
|
||||
await attendre(() => expect(vue(m).querySelector('[data-jumeau]')).toBeNull());
|
||||
expect(document.activeElement?.dataset.commande).toBe('ouvrirJumeau');
|
||||
});
|
||||
});
|
||||
|
||||
describe('le dernier point égale la ligne du tableau, sous les réglages courants (§ 12.14, D40)', () => {
|
||||
test('rouge : après un changement de l’ordre des priorités puis d’un objectif, les points remesurés, le dernier toujours égal à la nouvelle ligne', async () => {
|
||||
const m = await ouvrir(chargeAObjectifs());
|
||||
await m.app.generation().generer({ graine: 4, nombre: 1, arret: 400, historique: 1000 });
|
||||
montrer(m, 1);
|
||||
const avant = await dernierPointEgaleLaLigne(m, 1, 'à la génération');
|
||||
expect(avant.colonnes).toContain('toursHorsObjectif');
|
||||
|
||||
const paliers = [
|
||||
[{ critere: 'ecartAuPlafondAPrioriMax', poids: 1 }],
|
||||
[{ critere: 'redondance', poids: 1 }],
|
||||
[{ critere: 'excedentCollisions', poids: 1 }],
|
||||
[{ critere: 'collisionsCumulees', poids: 1 }],
|
||||
[{ critere: 'toursHorsObjectif', poids: 1 }],
|
||||
[{ critere: 'rencontresRepetees', poids: 1 }],
|
||||
[{ critere: 'animateursNonRencontres', poids: 1 }],
|
||||
];
|
||||
await m.seance.executer('changerPriorites', { paliers });
|
||||
const reglee = await dernierPointEgaleLaLigne(m, 1, 'priorités');
|
||||
expect(reglee.colonnes).not.toEqual(avant.colonnes);
|
||||
expect(reglee.colonnes.slice(0, 4)).toEqual(['mouvements', 'minimumBrut', 'ecartAuPlafondAPrioriMax', 'redondance']);
|
||||
|
||||
await m.seance.executer('poserObjectif', { table: 2, tour: null, etiquettes: 'finance', regle: null });
|
||||
const objectif = await dernierPointEgaleLaLigne(m, 1, 'objectif');
|
||||
const hors = (jumeau) => jumeau.lignes.map((ligne) => ligne.get('toursHorsObjectif'));
|
||||
expect(hors(objectif)).not.toEqual(hors(reglee));
|
||||
expect(objectif.lignes.map((ligne) => ligne.get('mouvements'))).toEqual(reglee.lignes.map((ligne) => ligne.get('mouvements')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('une proposition rouverte, sa course lue du compagnon (§ 8.6, B23, D53)', () => {
|
||||
test('rouge : une autre séance rouvre l’événement : les mêmes points et les mêmes valeurs, la course montrée, « durée inconnue », aucune cadence', async () => {
|
||||
const horloge = creerHorlogePilotee();
|
||||
const premiere = await ouvrir(chargeDe(), { horloge, executeur: executeurQuiDure(horloge, 2500, 2) });
|
||||
await premiere.app.generation().generer({ graine: 3, nombre: 2, arret: 400, historique: 1000 });
|
||||
montrer(premiere, 2);
|
||||
await attendre(() => expect(tete(premiere)).toContain('durée observée 2,5 s'));
|
||||
const ecrit = await lireJumeau(premiere);
|
||||
|
||||
const seconde = await monterApp({ systeme: { disque: premiere.disque } });
|
||||
montes.push(seconde);
|
||||
await seconde.seance.ouvrir(BASE);
|
||||
montrer(seconde, 2);
|
||||
const relu = await lireJumeau(seconde);
|
||||
expect(relu.colonnes).toEqual(ecrit.colonnes);
|
||||
expect(relu.lignes.map((ligne) => [...ligne])).toEqual(ecrit.lignes.map((ligne) => [...ligne]));
|
||||
expect(phrases(seconde)).toEqual([]);
|
||||
expect(graphiques(seconde).length).toBeGreaterThan(0);
|
||||
expect(tete(seconde)).toContain(t('qualite.performance.dureeInconnue'));
|
||||
expect(tete(seconde).filter((morceau) => /cadence|durée observée|cœur/.test(morceau))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les cas d’un seul point, du retenu et de la course absente (§ 12.6, B15)', () => {
|
||||
test('compte 1 : un seul point, sa phrase, aucun graphique ; le jumeau garde sa ligne', async () => {
|
||||
const m = await ouvrir(chargeDe());
|
||||
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 1, historique: 1000 });
|
||||
montrer(m, 1);
|
||||
await attendre(() => expect(phrases(m)).toEqual([['unPoint', t('qualite.performance.unPoint')]]));
|
||||
expect(tete(m)[0]).toBe('1 mouvement évalué sur 1');
|
||||
expect(graphiques(m)).toEqual([]);
|
||||
expect(vue(m).querySelector('[data-dernier-changement]')).toBeNull();
|
||||
expect((await lireJumeau(m)).lignes.map((ligne) => ligne.get('mouvements'))).toEqual(['1']);
|
||||
});
|
||||
|
||||
test('zéro mouvement : « le plan initial n’avait rien à reprendre », aucun graphique', async () => {
|
||||
const m = await ouvrir(chargeDe({ personnes: 4, tables: 1, sieges: 4, tours: 1, appartenances: false }));
|
||||
await m.app.generation().generer({ graine: 3, nombre: 1, arret: 50, historique: 1000 });
|
||||
montrer(m, 1);
|
||||
await attendre(() => expect(tete(m)[0]).toBe(t('qualite.performance.planInitial')));
|
||||
expect(graphiques(m)).toEqual([]);
|
||||
expect(phrases(m)).toEqual([]);
|
||||
expect((await lireJumeau(m)).lignes.map((ligne) => ligne.get('mouvements'))).toEqual(['0']);
|
||||
});
|
||||
|
||||
test('un retenu non ajusté nomme sa proposition et en montre la course ; ajusté, sa phrase seule', async () => {
|
||||
const m = await ouvrir(chargeDe());
|
||||
await m.app.generation().generer({ graine: 3, nombre: 2, arret: 400, historique: 1000 });
|
||||
await m.seance.executer('retenirProposition', { id: 2 });
|
||||
montrer(m, null);
|
||||
await attendre(() => expect(phrases(m)).toEqual([['retenu', t('qualite.performance.retenu', { proposition: 2 })]]));
|
||||
expect(graphiques(m).length).toBeGreaterThan(0);
|
||||
expect(tete(m).length).toBeGreaterThan(0);
|
||||
|
||||
const [premierTour] = m.seance.etat().charge.retenu.placement;
|
||||
const [a] = premierTour.sieges[0];
|
||||
const [b] = premierTour.sieges[1];
|
||||
await m.seance.executer('echangerDansRetenu', { tour: 1, a, b });
|
||||
montrer(m, null);
|
||||
await attendre(() => expect(phrases(m)).toEqual([['retenuSansCourse', t('qualite.performance.retenuSansCourse')]]));
|
||||
expect(graphiques(m)).toEqual([]);
|
||||
expect(vue(m).querySelector('[data-course]')).toBeNull();
|
||||
expect(vue(m).querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('une proposition écrite sans compagnon des courses : « Sa course n’est pas gardée. », rien d’autre', async () => {
|
||||
const charge = chargeDe();
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, { graine: 3, nombre: 1, arret: 400 });
|
||||
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
const m = await ouvrir(generee, { mode: 'lecture' });
|
||||
montrer(m, 1);
|
||||
await attendre(() => expect(phrases(m)).toEqual([['sansCourse', "Sa course n'est pas gardée."]]));
|
||||
expect(graphiques(m)).toEqual([]);
|
||||
expect(vue(m).querySelector('[data-course]')).toBeNull();
|
||||
expect(vue(m).querySelector('[data-commande="ouvrirJumeau"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('le poids de la vue sur la grande démonstration (§ 12.14, E2s)', () => {
|
||||
test('une course de sept points : la vue tient sous le budget de nœuds de la page entière', async () => {
|
||||
const charge = analyser(texteLivre('grande')).charge;
|
||||
const m = await ouvrir(charge);
|
||||
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 200000, historique: 1000 }, { confirmee: true });
|
||||
const id = m.seance.etat().charge.propositions.at(-1).id;
|
||||
montrer(m, id);
|
||||
await attendre(() => expect(graphiques(m)[0]?.querySelectorAll('[data-point-releve]').length).toBe(7));
|
||||
const noeuds = vue(m).querySelectorAll('*').length + 1;
|
||||
expect(noeuds).toBeLessThan(1500);
|
||||
}, 120_000);
|
||||
});
|
||||
432
src/interface/qualite/Profil.navigateur.test.js
Normal file
432
src/interface/qualite/Profil.navigateur.test.js
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'en-tête chiffré et le profil trié de la page de qualité dans un vrai
|
||||
// moteur de rendu (§ 12.2, § 12.3, B11, B12, B15, D29, D30, D34, D41, D48,
|
||||
// D51) : chaque tuile qui a sa colonne écrit la cellule du tableau des
|
||||
// propositions ; trois tuiles seulement portent un pourcentage, chacun
|
||||
// suivi de son compte du pire ; les deux graphiques du profil, au même
|
||||
// ordre de rangs, désignent le même rang pointé ; chaque trait a son motif
|
||||
// — la borne de diversité en pointillés, le plafond et la règle à 100 % en
|
||||
// trait mixte — ; le jumeau s'ouvre et se referme au clavier. Les vues se
|
||||
// montent par mount sur le détail d'une génération, avec les contextes que
|
||||
// pose App — 't' et 'format' — ; une épreuve monte App entier et lit les
|
||||
// deux sections. Le texte attendu vient de la même traduction que celle de
|
||||
// l'application ; les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import '../jetons.css';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { detaillerQualite, evaluer } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { tableau, texteCellule } from '../propositions/modele.js';
|
||||
import VueEntete from './VueEntete.svelte';
|
||||
import VueProfil from './VueProfil.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const CONTEXTE = new Map([
|
||||
['t', t],
|
||||
['format', format],
|
||||
]);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------------
|
||||
|
||||
// Six personnes inventées, deux tables de trois places, trois tours ;
|
||||
// appartenances : deux (les quatre premières), ou aucune ; la première
|
||||
// table attend « finance », que portent les trois premières, quand
|
||||
// objectifs est vrai ; reservation ancre la première à la première table.
|
||||
function chargeSix({ appartenances = true, objectifs = false, reservation = false, reglages = {} } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-profil', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
|
||||
const noms = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: appartenances ? appartenance : null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...(objectifs && rang < 3 ? { etiquettes: ['finance'] } : {}),
|
||||
}));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
if (objectifs) charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
if (reservation) charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
Object.assign(charge.reglages, reglages);
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages = { graine: 3, nombre: 2, arret: 200 }) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
// Le contrat de propriétés des vues pour une proposition de la charge.
|
||||
function vueDe(charge, id = charge.propositions[0].id) {
|
||||
const table = evaluer(charge);
|
||||
return { detail: detaillerQualite(charge, id), ligne: table.propositions.find((ligne) => ligne.id === id), table };
|
||||
}
|
||||
|
||||
// --- Le montage --------------------------------------------------------------------
|
||||
|
||||
const montes = [];
|
||||
const apps = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { composant, cible } of montes.splice(0)) {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
}
|
||||
const erreurs = apps.flatMap((m) => m.erreurs());
|
||||
for (const m of apps.splice(0).reverse()) m.demonter();
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// Monte une vue dans un conteneur neuf de 1 000 px, au début du corps, puis
|
||||
// laisse ses graphiques mesurer leur largeur et leur texte.
|
||||
async function monter(Vue, props) {
|
||||
const cible = document.createElement('div');
|
||||
cible.style.width = '1000px';
|
||||
document.body.prepend(cible);
|
||||
const composant = mount(Vue, { target: cible, props, context: CONTEXTE });
|
||||
montes.push({ composant, cible });
|
||||
for (let tour = 0; tour < 3; tour += 1) {
|
||||
flushSync();
|
||||
await tick();
|
||||
}
|
||||
flushSync();
|
||||
return cible;
|
||||
}
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Les lectures du rendu ---------------------------------------------------------
|
||||
|
||||
// Le texte d'un élément, ses blancs ordinaires réduits : l'espace fine
|
||||
// insécable du pourcentage reste.
|
||||
const texteDe = (element) => element.textContent.replace(/[\t\n\r ]+/g, ' ').trim();
|
||||
const tuileDe = (racine, nom) => racine.querySelector(`[data-tuile="${nom}"]`);
|
||||
const valeurDe = (tuile) => texteDe(tuile.querySelector('.valeur'));
|
||||
const svgDe = (racine, nom) => racine.querySelector(`svg[data-graphique="${nom}"]`);
|
||||
const surfaceDe = (svg) => svg.querySelector(':scope > rect[pointer-events="all"]');
|
||||
const motif = (element) => getComputedStyle(element).strokeDasharray;
|
||||
|
||||
// Chaque [data-pourcentage] de la racine, suivi de son [data-compte-pire]
|
||||
// non vide, son voisin immédiat.
|
||||
function exigerComptes(racine) {
|
||||
const pourcentages = [...racine.querySelectorAll('[data-pourcentage]')];
|
||||
for (const valeur of pourcentages) {
|
||||
const compte = valeur.nextElementSibling;
|
||||
expect(compte?.hasAttribute('data-compte-pire'), texteDe(valeur)).toBe(true);
|
||||
expect(texteDe(compte)).not.toBe('');
|
||||
}
|
||||
return pourcentages;
|
||||
}
|
||||
|
||||
// --- L'en-tête chiffré -------------------------------------------------------------
|
||||
|
||||
describe('l’en-tête chiffré (§ 12.2, B11, D48)', () => {
|
||||
test('chaque tuile qui a sa colonne écrit la cellule du tableau, sous les groupes du tableau ; la population et le certificat', async () => {
|
||||
const vue = vueDe(generee(chargeSix({ objectifs: true })));
|
||||
const cible = await monter(VueEntete, vue);
|
||||
const ligneDuTableau = tableau(vue.table).lignes.find(({ id }) => id === vue.ligne.id);
|
||||
let comparees = 0;
|
||||
for (const cellule of ligneDuTableau.cellules) {
|
||||
if (cellule.colonne === 'certificat') continue;
|
||||
const tuile = tuileDe(cible, cellule.colonne);
|
||||
expect(tuile, cellule.colonne).not.toBeNull();
|
||||
expect(valeurDe(tuile), cellule.colonne).toBe(texteCellule(cellule, { t, format }));
|
||||
expect(texteDe(tuile)).not.toContain(t('qualite.tuile.neClassePas'));
|
||||
comparees += 1;
|
||||
}
|
||||
expect(comparees).toBeGreaterThan(15);
|
||||
const vueQualite = cible.querySelector('[data-vue-qualite="entete"]');
|
||||
expect(texteDe(vueQualite)).toContain(t('qualite.tuile.population', { mesuree: 6, exclue: 0 }));
|
||||
expect([...vueQualite.querySelectorAll('h4')].map(texteDe)).toEqual(
|
||||
['rencontres', 'collisions', 'repetitions', 'diversite', 'manque', 'objectifs'].map((nom) => t(`propositions.groupe.${nom}`)),
|
||||
);
|
||||
});
|
||||
|
||||
test('un critère décoché : sa tuile lit la même mesure et dit « ne classe pas »', async () => {
|
||||
const vue = vueDe(generee(chargeSix({ objectifs: true, reglages: { respecterObjectifs: false } })));
|
||||
const cible = await monter(VueEntete, vue);
|
||||
const tuile = tuileDe(cible, 'toursHorsObjectif');
|
||||
expect(texteDe(tuile)).toContain(t('qualite.tuile.toursHorsObjectif'));
|
||||
expect(texteDe(tuile)).toContain(t('qualite.tuile.neClassePas'));
|
||||
expect(valeurDe(tuile)).toBe(format.nombre(vue.ligne.mesures.objectifs.horsObjectif));
|
||||
});
|
||||
|
||||
test('le minimum brut nomme la personne au rang 1, son plafond réalisé, son plafond a priori et son pourcentage, le compte du pire dessous', async () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const cible = await monter(VueEntete, vue);
|
||||
const { rang1 } = vue.detail.bornes;
|
||||
const nom = vue.detail.personnes.find(({ id }) => id === rang1.id).nom;
|
||||
const tuile = tuileDe(cible, 'rencontresTous');
|
||||
expect(texteDe(tuile)).toContain(t('qualite.tuile.personne', { nom }));
|
||||
expect(texteDe(tuile)).toContain(t('qualite.tuile.plafondRealise', { plafond: rang1.plafondRealise }));
|
||||
expect(texteDe(tuile)).toContain(t('qualite.tuile.plafondAPriori', { plafond: rang1.plafondAPriori }));
|
||||
expect(texteDe(tuile.querySelector('[data-pourcentage]'))).toBe(t('qualite.pourcentage.valeur', { pourcent: rang1.pourcent }));
|
||||
expect(texteDe(tuile.querySelector('[data-compte-pire]'))).toBe(`minimum ${rang1.rencontres} sur ${rang1.plafondRealise}`);
|
||||
});
|
||||
|
||||
test('trois pourcentages, chacun suivi de son compte du pire ; aucun signe du pourcentage ailleurs dans l’en-tête', async () => {
|
||||
const cible = await monter(VueEntete, vueDe(generee(chargeSix({ objectifs: true }))));
|
||||
const pourcentages = exigerComptes(cible);
|
||||
expect(pourcentages.map((valeur) => valeur.closest('[data-tuile]').dataset.tuile)).toEqual([
|
||||
'rencontresTous',
|
||||
'toursHorsObjectif',
|
||||
'toursSelonObjectif',
|
||||
]);
|
||||
// Le texte de l'en-tête, les pourcentages retirés, ne porte aucun « % ».
|
||||
const copie = cible.cloneNode(true);
|
||||
for (const valeur of copie.querySelectorAll('[data-pourcentage]')) valeur.remove();
|
||||
expect(copie.textContent).not.toContain('%');
|
||||
});
|
||||
|
||||
test('le libellé d’une tuile s’explique au focus du clavier : l’infobulle porte l’explication de la colonne', async () => {
|
||||
const cible = await monter(VueEntete, vueDe(generee(chargeSix())));
|
||||
const terme = tuileDe(cible, 'manqueMax').querySelector('[data-terme]');
|
||||
terme.focus();
|
||||
flushSync();
|
||||
const bulle = cible.querySelector('[role="tooltip"]');
|
||||
await attendre(() => expect(bulle.hidden).toBe(false));
|
||||
expect(texteDe(bulle)).toBe(t('propositions.explication.manqueMax').replace(/\s+/g, ' ').trim());
|
||||
expect(terme.getAttribute('aria-describedby')).toBe(bulle.id);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le profil trié -------------------------------------------------------------------
|
||||
|
||||
describe('le profil trié, deux graphiques au même ordre (§ 12.3, B12, D30, D41, D51)', () => {
|
||||
test('le premier : deux séries et deux repères, la borne de diversité en pointillés, distincte des tirets ; le plafond en trait mixte, distinct du plein', async () => {
|
||||
const cible = await monter(VueProfil, vueDe(generee(chargeSix({ reservation: true }))));
|
||||
const svg = svgDe(cible, 'profilComptes');
|
||||
const trace = (attribut, genre) => svg.querySelector(`path[${attribut}="${genre}"]`);
|
||||
const borne = trace('data-repere', 'borne');
|
||||
const plafond = trace('data-repere', 'plafond');
|
||||
const plein = trace('data-serie', 'serie1');
|
||||
const tirets = trace('data-serie', 'serie2');
|
||||
for (const element of [borne, plafond, plein, tirets]) expect(element).not.toBeNull();
|
||||
expect(motif(plein)).toBe('none');
|
||||
expect(motif(tirets)).not.toBe('none');
|
||||
expect(motif(borne)).not.toBe(motif(tirets));
|
||||
expect(motif(borne)).not.toBe(motif(plafond));
|
||||
expect(motif(plafond)).not.toBe(motif(plein));
|
||||
expect(motif(plafond)).not.toBe(motif(tirets));
|
||||
// Chaque repère est nommé sur le tracé, la borne jamais « plafond ».
|
||||
expect(texteDe(svg.querySelector('text[data-repere="borne"]'))).toBe('borne de diversité');
|
||||
expect(texteDe(svg.querySelector('text[data-repere="plafond"]'))).toBe(t('qualite.profil.repere.plafond'));
|
||||
// Le rang ancré porte son signe ; la phrase de synthèse, dessous.
|
||||
expect(svg.querySelectorAll('[data-marque="graphiqueMarque"]').length).toBeGreaterThan(0);
|
||||
const { detail } = vueDe(generee(chargeSix({ reservation: true })));
|
||||
expect(texteDe(cible)).toContain(t('qualite.profil.synthese', detail.synthese));
|
||||
});
|
||||
|
||||
test('le second : une série en pourcentages, la règle à 100 % du motif du plafond, libellée « 100 % », l’axe de 0 à 100 %', async () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const cible = await monter(VueProfil, vue);
|
||||
const svg = svgDe(cible, 'profilPourcentages');
|
||||
expect(svg.querySelectorAll('path[data-serie]')).toHaveLength(1);
|
||||
const cent = svg.querySelector('path[data-repere="cent"]');
|
||||
expect(motif(cent)).toBe(motif(svgDe(cible, 'profilComptes').querySelector('path[data-repere="plafond"]')));
|
||||
expect(texteDe(svg.querySelector('text[data-repere="cent"]'))).toBe(t('qualite.pourcentage.cent'));
|
||||
const graduations = [...svg.querySelectorAll('text')].map(texteDe);
|
||||
expect(graduations).toContain(t('qualite.pourcentage.axe', { valeur: '0' }));
|
||||
expect(graduations).toContain(t('qualite.pourcentage.axe', { valeur: '100' }));
|
||||
// Deux noms distincts, une phrase de synthèse qui porte le pire en compte.
|
||||
expect(svg.getAttribute('aria-labelledby')).not.toBe(svgDe(cible, 'profilComptes').getAttribute('aria-labelledby'));
|
||||
const nomDe = (graphique) => texteDe(document.getElementById(graphique.getAttribute('aria-labelledby')));
|
||||
expect(nomDe(svg)).toBe(t('qualite.profil.graphique.pourcentages'));
|
||||
expect(nomDe(svgDe(cible, 'profilComptes'))).toBe(t('qualite.profil.graphique.comptes'));
|
||||
const synthese = document.getElementById(svg.getAttribute('aria-describedby'));
|
||||
const { rang1 } = vue.detail.bornes;
|
||||
expect(texteDe(synthese.querySelector('[data-compte-pire]'))).toBe(`minimum ${rang1.rencontres} sur ${rang1.plafondRealise}`);
|
||||
exigerComptes(cible);
|
||||
});
|
||||
|
||||
test('le rang pointé est le même dans les deux graphiques : le rang 1, la personne au minimum ; l’infobulle porte sa ligne, le pourcentage suivi de « r sur p »', async () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const cible = await monter(VueProfil, vue);
|
||||
const comptes = svgDe(cible, 'profilComptes');
|
||||
// Le document a le focus, comme quand l'opérateur survole puis tape :
|
||||
// une touche n'atteint que le document focalisé. Le focus fait défiler
|
||||
// la page : la surface se mesure après lui.
|
||||
const focalisable = document.createElement('button');
|
||||
cible.append(focalisable);
|
||||
focalisable.focus({ preventScroll: true });
|
||||
const boite = surfaceDe(comptes).getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + 2, boite.top + boite.height / 2);
|
||||
flushSync();
|
||||
expect(comptes.querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('1');
|
||||
expect(svgDe(cible, 'profilPourcentages').querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('1');
|
||||
// Les abscisses s'alignent rang contre rang : la même zone de tracé.
|
||||
const zones = ['profilComptes', 'profilPourcentages'].map((nom) => ['x', 'width'].map((a) => surfaceDe(svgDe(cible, nom)).getAttribute(a)));
|
||||
expect(zones[1]).toEqual(zones[0]);
|
||||
const [premier] = vue.detail.profil;
|
||||
expect(premier.rencontres).toBe(vue.ligne.mesures.aggRencontres.tous.min);
|
||||
const nom = vue.detail.personnes.find(({ id }) => id === premier.id).nom;
|
||||
const bulles = [...cible.querySelectorAll('[role="tooltip"]')].filter((bulle) => !bulle.hidden);
|
||||
expect(bulles).toHaveLength(1);
|
||||
expect(texteDe(bulles[0])).toContain(nom);
|
||||
expect(texteDe(bulles[0].querySelector('[data-compte-pire]'))).toBe(`${premier.rencontres} sur ${premier.plafondRealise}`);
|
||||
await touche('Escape');
|
||||
flushSync();
|
||||
expect([...cible.querySelectorAll('[role="tooltip"]')].every((bulle) => bulle.hidden)).toBe(true);
|
||||
});
|
||||
|
||||
test('le jumeau s’ouvre au clavier : une ligne par personne, puis la répartition ; refermé, le focus reste sur sa commande', async () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const cible = await monter(VueProfil, vue);
|
||||
const commande = cible.querySelector('[data-commande="ouvrirJumeau"]');
|
||||
commande.focus();
|
||||
await touche('Enter');
|
||||
await attendre(() => expect(cible.querySelector('[data-jumeau="0"]')).not.toBeNull());
|
||||
const lignes = cible.querySelectorAll('[data-jumeau="0"] tbody tr');
|
||||
expect(lignes).toHaveLength(6);
|
||||
expect(cible.querySelectorAll('[data-jumeau="1"] tbody tr')).toHaveLength(vue.detail.repartitionRencontres.length);
|
||||
const nomDuPremier = vue.detail.personnes.find(({ id }) => id === vue.detail.profil[0].id).nom;
|
||||
expect(texteDe(lignes[0].querySelector('[data-colonne="nom"]'))).toBe(nomDuPremier);
|
||||
expect(lignes[0].querySelector('[data-colonne="pourcent"] [data-pourcentage] + [data-compte-pire]')).not.toBeNull();
|
||||
exigerComptes(cible);
|
||||
const fermer = cible.querySelector('[data-commande="fermerJumeau"]');
|
||||
fermer.focus();
|
||||
await touche('Enter');
|
||||
await attendre(() => expect(cible.querySelector('[data-jumeau]')).toBeNull());
|
||||
expect(document.activeElement?.dataset.commande).toBe('ouvrirJumeau');
|
||||
});
|
||||
|
||||
test('un rang au plafond réalisé nul : son signe dans les deux graphiques, au même rang ; aucun point au second, où le tracé s’interrompt', async () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const detail = structuredClone(vue.detail);
|
||||
Object.assign(detail.profil[3], { rencontres: 0, plafondRealise: 0, pourcent: null, marque: 'plafondNul' });
|
||||
const cible = await monter(VueProfil, { ...vue, detail });
|
||||
const marques = detail.profil.filter(({ marque }) => marque !== null).length;
|
||||
// L'abscisse du centre de chaque signe, au pixel : les deux graphiques
|
||||
// alignent leurs rangs, un même rang y a la même abscisse.
|
||||
const abscisses = (svg) =>
|
||||
[...svg.querySelectorAll('[data-marque="graphiqueMarque"]')].map((signe) => {
|
||||
const boite = signe.getBoundingClientRect();
|
||||
return Math.round(boite.left + boite.width / 2);
|
||||
});
|
||||
const comptes = svgDe(cible, 'profilComptes');
|
||||
const pourcentages = svgDe(cible, 'profilPourcentages');
|
||||
expect(abscisses(comptes)).toHaveLength(marques);
|
||||
expect(abscisses(pourcentages)).toEqual(abscisses(comptes));
|
||||
const serie = pourcentages.querySelector('path[data-serie="serie1"]');
|
||||
// Deux morceaux : le tracé s'interrompt au rang sans valeur.
|
||||
expect(serie.getAttribute('d').match(/M/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('la personne au rang 1 au plafond réalisé nul : la synthèse du second graphique, une phrase entière, sans pourcentage ni compte', async () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const detail = structuredClone(vue.detail);
|
||||
Object.assign(detail.profil[0], { rencontres: 0, plafondRealise: 0, pourcent: null, marque: 'plafondNul' });
|
||||
Object.assign(detail.bornes.rang1, { id: detail.profil[0].id, rencontres: 0, plafondRealise: 0, pourcent: null });
|
||||
const cible = await monter(VueProfil, { ...vue, detail });
|
||||
const svg = svgDe(cible, 'profilPourcentages');
|
||||
const synthese = document.getElementById(svg.getAttribute('aria-describedby'));
|
||||
const nom = detail.personnes.find(({ id }) => id === detail.profil[0].id).nom;
|
||||
expect(texteDe(synthese)).toBe(t('qualite.profil.plafondNul', { nom }));
|
||||
expect(synthese.querySelector('[data-pourcentage]')).toBeNull();
|
||||
expect(synthese.querySelector('[data-compte-pire]')).toBeNull();
|
||||
});
|
||||
|
||||
test('aucune appartenance : une seule série, ni borne de diversité, et la phrase ; une seule table : la phrase seule', async () => {
|
||||
const cible = await monter(VueProfil, vueDe(generee(chargeSix({ appartenances: false }))));
|
||||
const svg = svgDe(cible, 'profilComptes');
|
||||
expect(svg.querySelectorAll('path[data-serie]')).toHaveLength(1);
|
||||
expect(svg.querySelector('[data-repere="borne"]')).toBeNull();
|
||||
expect(texteDe(cible.querySelector('[data-phrase-qualite="aucuneAppartenance"]'))).toBe(t('qualite.profil.aucuneAppartenance'));
|
||||
|
||||
const seule = chargeSix();
|
||||
seule.tables = [{ id: 1, numero: 1, sieges: 6, forme: 'ronde', position: { x: 300, y: 0 } }];
|
||||
const uneTable = await monter(VueProfil, vueDe(generee(seule)));
|
||||
expect(uneTable.querySelector('[data-phrase-qualite="uneTable"]')).not.toBeNull();
|
||||
expect(uneTable.querySelector('svg[data-graphique]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- La grande démonstration, et l'application entière ------------------------------
|
||||
|
||||
describe('l’en-tête et le profil dans la page (§ 12, § 19.4)', () => {
|
||||
test('sur la grande : le nombre de nœuds des deux vues, jumeau fermé, sous le budget de la page', async () => {
|
||||
const charge = generee(analyser(texteLivre('grande')).charge, { graine: 1, nombre: 1, arret: 300 });
|
||||
const vue = vueDe(charge);
|
||||
const entete = await monter(VueEntete, vue);
|
||||
const profil = await monter(VueProfil, vue);
|
||||
const noeuds = entete.querySelectorAll('*').length + profil.querySelectorAll('*').length;
|
||||
// Le relevé que le rapport de la tâche cite.
|
||||
console.info(`noeuds en-tête et profil, grande : ${entete.querySelectorAll('*').length} + ${profil.querySelectorAll('*').length} = ${noeuds}`);
|
||||
expect(noeuds).toBeLessThan(1500);
|
||||
expect(svgDe(profil, 'profilComptes').querySelector('path[data-serie="serie1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('dans l’application : la page de qualité de la proposition demandée écrit, en tuiles, les cellules de sa ligne au tableau des propositions', async () => {
|
||||
const charge = generee(chargeSix({ objectifs: true }));
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `soiree${SUFFIXES.etat}`, texte }] });
|
||||
apps.push(m);
|
||||
await m.seance.ouvrir('soiree');
|
||||
const id = charge.propositions[1].id;
|
||||
m.ui.allerA('propositions');
|
||||
const cellules = await attendre(() => {
|
||||
const ligne = m.racine.querySelector(`[data-proposition="${id}"]`);
|
||||
expect(ligne).not.toBeNull();
|
||||
return new Map([...ligne.querySelectorAll('td[data-colonne]')].map((td) => [td.dataset.colonne, texteDe(td)]));
|
||||
});
|
||||
m.ui.montrerProposition(id);
|
||||
m.ui.allerA('qualite');
|
||||
const page = await attendre(() => {
|
||||
const section = m.racine.querySelector('[data-section="qualite"]');
|
||||
expect(section.querySelector('[data-tuile]')).not.toBeNull();
|
||||
expect(svgDe(section, 'profilComptes')).not.toBeNull();
|
||||
return section;
|
||||
});
|
||||
let lues = 0;
|
||||
for (const tuile of page.querySelectorAll('[data-tuile]')) {
|
||||
if (!cellules.has(tuile.dataset.tuile)) continue;
|
||||
expect(valeurDe(tuile), tuile.dataset.tuile).toBe(cellules.get(tuile.dataset.tuile));
|
||||
lues += 1;
|
||||
}
|
||||
expect(lues).toBeGreaterThan(15);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,21 +2,66 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Animateurs rencontrés » (§ 12.13) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// La vue « Animateurs rencontrés » (§ 12.13, B22, D37) de la page de
|
||||
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
|
||||
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne,
|
||||
// sa ligne d'évaluation ; table, la table d'évaluation de la charge — et
|
||||
// ne lit que detail, mis en forme par modeleAnimateurs (animateurs.js) :
|
||||
// la vue ne calcule rien.
|
||||
//
|
||||
// Un profil trié de m(p), sur les personnes concernées, de la moins servie
|
||||
// à la mieux servie : une seule série, et la borne des animateurs B(p) en
|
||||
// repère pointillé, sous ce nom. Ni manque ni pourcentage : la borne ne se
|
||||
// garantit pas (§ 4.5). Puis le jumeau, le profil et la répartition de
|
||||
// m(p). Sans animateur ou sans personne concernée, la vue cède la place à
|
||||
// sa phrase.
|
||||
//
|
||||
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte, que
|
||||
// la carte ne transmet pas ; data-phrase-qualite sur la phrase, par son
|
||||
// cas.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import { modeleAnimateurs } from './animateurs.js';
|
||||
import Escalier from './graphiques/Escalier.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const modele = $derived(modeleAnimateurs(detail, t));
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="animateurs">
|
||||
<Carte titre={t('qualite.vue.animateurs.titre')} sousTitre={t('qualite.vue.animateurs.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.animateurs.titre')} sousTitre={t('qualite.vue.animateurs.question')} niveau={3}>
|
||||
{#if modele !== null}
|
||||
{#if modele.cas === 'aucun'}
|
||||
<p data-phrase-qualite="animateurs.aucun">{t('qualite.animateurs.aucun')}</p>
|
||||
{:else}
|
||||
<div class="vue">
|
||||
<Escalier
|
||||
graphique="animateurs"
|
||||
nom={modele.escalier.nom}
|
||||
synthese={modele.escalier.synthese}
|
||||
series={modele.escalier.series}
|
||||
reperes={modele.escalier.reperes}
|
||||
infobulle={modele.infobulle}
|
||||
/>
|
||||
<Jumeau tableaux={modele.jumeau} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.vue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,75 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Collisions d'appartenance » (§ 12.4) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// La vue « Collisions d'appartenance » (§ 12.4, B13, D31) de la page de
|
||||
// qualité : des barres horizontales, une par appartenance, la pire en
|
||||
// haut — l'excédent décroissant, dans l'ordre du détail —, chacune en deux
|
||||
// segments qui partent de zéro, « prouvé inévitable » et « reste », sur un
|
||||
// axe commun, puis en encre secondaire son effectif, ses paires distinctes
|
||||
// et son excédent ; l'intitulé porte la pastille de l'appartenance, de la
|
||||
// teinte que teintesAppartenances donne sur tous les participants de
|
||||
// l'événement, exclus compris, et le tracé n'en prend aucune. Hors du
|
||||
// domaine des planchers, chaque barre n'a qu'un segment, les collisions
|
||||
// cumulées, et une phrase dit pourquoi. Une seule table, aucune
|
||||
// appartenance, aucune collision : la phrase du cas en tient lieu
|
||||
// (§ 12.6, B15). Sous le graphique, son jumeau.
|
||||
//
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne et table, que
|
||||
// cette vue ne lit pas — et met en forme le détail par modeleCollisions
|
||||
// (appartenances.js), sans rien calculer. Son <div> porte data-vue-qualite,
|
||||
// que la carte ne transmet pas : il enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import { teintesAppartenances } from '../appartenances.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import { modeleCollisions } from './appartenances.js';
|
||||
import Barres from './graphiques/Barres.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
|
||||
const teintes = $derived(teintesAppartenances($seance.charge?.participants ?? []));
|
||||
const modele = $derived(detail === null || detail.collisions === null ? null : modeleCollisions(detail, { t, teintes }));
|
||||
let pointe = $state(null);
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="collisions">
|
||||
<Carte titre={t('qualite.vue.collisions.titre')} sousTitre={t('qualite.vue.collisions.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.collisions.titre')} sousTitre={t('qualite.vue.collisions.question')} niveau={3}>
|
||||
{#if modele !== null}
|
||||
<div class="corps">
|
||||
{#if modele.phrase !== null}
|
||||
<p data-phrase-qualite={modele.code}>{modele.phrase}</p>
|
||||
{/if}
|
||||
{#if modele.graphique !== null}
|
||||
{@const g = modele.graphique}
|
||||
<Barres
|
||||
graphique="collisions"
|
||||
nom={g.nom}
|
||||
synthese={g.synthese}
|
||||
lignes={g.lignes}
|
||||
segments={g.segments}
|
||||
infobulle={g.infobulle}
|
||||
bind:pointe
|
||||
/>
|
||||
<Jumeau tableaux={modele.tableaux} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.corps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,131 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Diversité par animateur » (§ 12.13) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// La vue « Diversité par animateur » (§ 12.13, B22, D38) de la page de
|
||||
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
|
||||
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne,
|
||||
// sa ligne d'évaluation ; table, la table d'évaluation de la charge — et
|
||||
// ne lit que detail, mis en forme par modeleDiversite (animateurs.js) : la
|
||||
// vue ne calcule rien.
|
||||
//
|
||||
// Le graphique dessine la soirée, le moins varié en tête : les
|
||||
// appartenances et les étiquettes rencontrées en animant, en deux colonnes
|
||||
// de barres juxtaposées, chacune son axe — une seule quand le détail n'a
|
||||
// que celle-là, et la phrase qui le dit. Au-dessus, pour chaque colonne
|
||||
// dessinée, ses trois nombres : le moins varié, le plus varié, au minimum.
|
||||
// Chaque ligne porte en encre secondaire ses tours animés et ses personnes
|
||||
// rencontrées en animant. Le jumeau porte la soirée de chaque animateur,
|
||||
// puis une ligne par tour animé ; sans colonne à dessiner, il reste seul
|
||||
// sous la phrase. Sans animateur, la vue cède la place à sa phrase.
|
||||
//
|
||||
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte, que
|
||||
// la carte ne transmet pas ; data-equite sur les trois nombres d'une
|
||||
// colonne, par sa grandeur ; data-phrase-qualite sur chaque phrase, par son
|
||||
// cas.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import { modeleDiversite } from './animateurs.js';
|
||||
import Barres from './graphiques/Barres.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const modele = $derived(modeleDiversite(detail, t));
|
||||
</script>
|
||||
|
||||
<!-- Les trois nombres d'une colonne, sous son titre. -->
|
||||
{#snippet troisNombres(colonne)}
|
||||
<div><dt>{t('qualite.diversite.moins')}</dt><dd>{format.nombre(colonne.moins)}</dd></div>
|
||||
<div><dt>{t('qualite.diversite.plus')}</dt><dd>{format.nombre(colonne.plus)}</dd></div>
|
||||
<div><dt>{t('qualite.diversite.auMinimum')}</dt><dd>{format.nombre(colonne.auMinimum)}</dd></div>
|
||||
{/snippet}
|
||||
|
||||
<div data-vue-qualite="diversite">
|
||||
<Carte titre={t('qualite.vue.diversite.titre')} sousTitre={t('qualite.vue.diversite.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.diversite.titre')} sousTitre={t('qualite.vue.diversite.question')} niveau={3}>
|
||||
{#if modele !== null}
|
||||
{#if modele.cas === 'aucunAnimateur'}
|
||||
<p data-phrase-qualite="diversite.aucunAnimateur">{t('qualite.diversite.aucunAnimateur')}</p>
|
||||
{:else}
|
||||
<div class="vue">
|
||||
{#if modele.phrase !== null}
|
||||
<p data-phrase-qualite="diversite.{modele.phrase}">{t(`qualite.diversite.${modele.phrase}`)}</p>
|
||||
{/if}
|
||||
{#if modele.equite.length > 0}
|
||||
<div class="equite">
|
||||
{#each modele.equite as colonne (colonne.genre)}
|
||||
<div class="colonne">
|
||||
<p class="titre">{colonne.titre}</p>
|
||||
{#if colonne.genre === 'appartenances'}
|
||||
<dl data-equite="appartenances">{@render troisNombres(colonne)}</dl>
|
||||
{:else}
|
||||
<dl data-equite="etiquettes">{@render troisNombres(colonne)}</dl>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if modele.barres !== null}
|
||||
<Barres
|
||||
graphique="diversite"
|
||||
nom={modele.barres.nom}
|
||||
synthese={modele.barres.synthese}
|
||||
lignes={modele.barres.lignes}
|
||||
colonnes={modele.barres.colonnes}
|
||||
segments={modele.barres.segments}
|
||||
infobulle={modele.infobulle}
|
||||
/>
|
||||
{/if}
|
||||
<Jumeau tableaux={modele.jumeau} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.vue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Les trois nombres de chaque colonne, côte à côte, dans l'ordre des
|
||||
colonnes du graphique. */
|
||||
.equite {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--espace-2) var(--espace-5);
|
||||
}
|
||||
|
||||
.titre {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: var(--espace-1) 0 0;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,185 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « En-tête chiffré » (§ 12.2) de la page de qualité, en attente de
|
||||
// son contenu : la carte titrée de sa question, et rien d'autre. Elle reçoit
|
||||
// le contrat de propriétés des vues — detail, le détail du placement mesuré,
|
||||
// null tant qu'il n'est pas calculé ; ligne, sa ligne d'évaluation ; table,
|
||||
// la table d'évaluation de la charge — et n'en lit rien. Son <div> porte
|
||||
// data-vue-qualite, que la carte ne transmet pas : il enveloppe la carte.
|
||||
// La vue « En-tête chiffré » (§ 12.2, B11, D29, D34, D48) de la page de
|
||||
// qualité : la population mesurée, puis une tuile par colonne de mesure
|
||||
// du tableau des propositions, sous les groupes de ses en-têtes, puis la
|
||||
// mention du certificat. Elle reçoit le contrat de propriétés des vues —
|
||||
// detail, le détail du placement mesuré, null tant qu'il n'est pas
|
||||
// calculé ; ligne, sa ligne d'évaluation ; table, la table d'évaluation de
|
||||
// la charge — et rend ce que enteteDe (entete.js) en tire : rien ne se
|
||||
// calcule ici.
|
||||
//
|
||||
// Une tuile porte son libellé, sa valeur écrite comme au tableau, « ne
|
||||
// classe pas » quand le tableau ne porte pas sa colonne, son pourcentage
|
||||
// et le compte du pire (Pourcentage) sur trois tuiles seulement, puis les
|
||||
// lignes de sa borne, chacune d'une personne nommée. Le libellé est un
|
||||
// terme : le survol et le focus du clavier ouvrent l'infobulle de son
|
||||
// explication, celle de la colonne au tableau (Infobulle, primitive).
|
||||
//
|
||||
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte,
|
||||
// que la carte ne transmet pas ; data-tuile sur chaque tuile, par le nom
|
||||
// de sa colonne ; data-terme sur son libellé.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Infobulle from '../primitives/Infobulle.svelte';
|
||||
import Pourcentage from './graphiques/Pourcentage.svelte';
|
||||
import { enteteDe } from './entete.js';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const uid = $props.id();
|
||||
const idExplication = `${uid}-explication`;
|
||||
|
||||
const entete = $derived(enteteDe({ detail, ligne, table }, { t, format }));
|
||||
|
||||
// Le terme expliqué : {ancre, terme, cle, details}, null sans infobulle.
|
||||
// Ouvrir sur un terme remplace l'explication d'un autre ; quitter un terme
|
||||
// ne ferme que la sienne.
|
||||
let expliquee = $state(null);
|
||||
function expliquer(evenement, tuile) {
|
||||
expliquee = { ancre: evenement.currentTarget, terme: tuile.nom, cle: tuile.explication, details: {} };
|
||||
}
|
||||
function taire(tuile) {
|
||||
if (expliquee?.terme === tuile.nom) expliquee = null;
|
||||
}
|
||||
const fermer = () => (expliquee = null);
|
||||
const decrit = (tuile) => (expliquee?.terme === tuile.nom ? idExplication : undefined);
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="entete">
|
||||
<Carte titre={t('qualite.vue.entete.titre')} sousTitre={t('qualite.vue.entete.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.entete.titre')} sousTitre={t('qualite.vue.entete.question')} niveau={3}>
|
||||
{#if entete !== null}
|
||||
<div class="entete">
|
||||
<p class="population">{entete.population}</p>
|
||||
{#each entete.groupes as groupe (groupe.cle)}
|
||||
<section class="groupe" aria-labelledby="{uid}-{groupe.cle}">
|
||||
<h4 id="{uid}-{groupe.cle}">{groupe.libelle}</h4>
|
||||
<ul class="tuiles">
|
||||
{#each groupe.tuiles as tuile (tuile.nom)}
|
||||
<li class="tuile" data-tuile={tuile.nom}>
|
||||
<!-- Le libellé est un terme, non une commande : le focus ne
|
||||
sert qu'à montrer au clavier l'explication que le
|
||||
pointeur montre au survol. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="libelle terme"
|
||||
data-terme="tuile:{tuile.nom}"
|
||||
tabindex="0"
|
||||
aria-describedby={decrit(tuile)}
|
||||
onmouseenter={(evenement) => expliquer(evenement, tuile)}
|
||||
onmouseleave={() => taire(tuile)}
|
||||
onfocus={(evenement) => expliquer(evenement, tuile)}
|
||||
onblur={() => taire(tuile)}>{tuile.libelle}</span
|
||||
>
|
||||
<span class="valeur">{tuile.texte}</span>
|
||||
{#if tuile.neClassePas !== null}
|
||||
<span class="ne-classe-pas">{tuile.neClassePas}</span>
|
||||
{/if}
|
||||
{#if tuile.pourcentage !== null}
|
||||
<Pourcentage texte={tuile.pourcentage.texte} compte={tuile.pourcentage.compte} />
|
||||
{/if}
|
||||
{#each tuile.bornes as borne, rang (rang)}
|
||||
<span class="borne">{borne}</span>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
<p class="certificat">{entete.certificat}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Carte>
|
||||
<Infobulle id={idExplication} ouverte={expliquee} {fermer} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.entete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.population,
|
||||
.certificat {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.groupe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
/* L'intitulé d'un groupe : un intertitre en petit corps, comme la rangée
|
||||
des groupes du tableau. */
|
||||
h4 {
|
||||
margin: 0;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Les tuiles d'un groupe, côte à côte, qui passent à la ligne ; chacune
|
||||
sur le fond sourd de la carte, sous un filet. */
|
||||
.tuiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||
gap: var(--espace-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tuile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
min-width: 0;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.libelle {
|
||||
align-self: flex-start;
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
/* Un libellé qui s'explique se signale par un soulignement pointillé ;
|
||||
focalisé, par le contour du focus. */
|
||||
.terme {
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.terme:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.valeur {
|
||||
font-size: var(--taille-titre);
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ne-classe-pas,
|
||||
.borne {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.ne-classe-pas {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,172 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Objectifs des tables » (§ 12.12) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// La vue « Objectifs des tables » (§ 12.12, B21, D34, D36) de la page de
|
||||
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
|
||||
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne,
|
||||
// sa ligne d'évaluation ; table, la table d'évaluation de la charge — et
|
||||
// ne lit que detail, mis en forme par modeleObjectifs (objectifs.js) : la
|
||||
// vue ne calcule rien.
|
||||
//
|
||||
// Dans l'ordre : les phrases du critère décoché — la vue ne classe pas,
|
||||
// ses planchers se dessinent quand même — et du domaine des planchers ;
|
||||
// les personnes jamais servies, à place fixe, chacune avec ses tours
|
||||
// possibles ; la soirée en texte, absente à un seul tour — la part des
|
||||
// tours possibles servis, suivie des personnes jamais servies, puis ses
|
||||
// deux comptes, ses tours hors objectif et leur plancher ; les barres
|
||||
// par tour, le tour au plus grand reste en tête, chaque ligne suivie de
|
||||
// ses assis sans table possible ; dessous, dans l'ordre des barres et en
|
||||
// encre secondaire, le pourcentage de chaque tour et ses deux comptes —
|
||||
// un pourcentage s'écrit hors du dessin, par Pourcentage, son compte juste
|
||||
// après lui ; enfin le jumeau. Sans personne concernée, la vue cède la
|
||||
// place à sa phrase.
|
||||
//
|
||||
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte, que
|
||||
// la carte ne transmet pas ; data-jamais-servies sur la liste des
|
||||
// personnes jamais servies, data-soiree-objectifs sur la ligne de la
|
||||
// soirée, data-phrase-qualite sur chaque phrase, par son cas.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Barres from './graphiques/Barres.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
import Pourcentage from './graphiques/Pourcentage.svelte';
|
||||
import { modeleObjectifs } from './objectifs.js';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
const idJamais = `${uid}-jamais`;
|
||||
const modele = $derived(modeleObjectifs(detail, t));
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="objectifs">
|
||||
<Carte titre={t('qualite.vue.objectifs.titre')} sousTitre={t('qualite.vue.objectifs.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.objectifs.titre')} sousTitre={t('qualite.vue.objectifs.question')} niveau={3}>
|
||||
{#if modele !== null}
|
||||
{#if modele.cas === 'aucunConcerne'}
|
||||
<p data-phrase-qualite="objectifs.aucunConcerne">{t('qualite.objectifs.aucunConcerne')}</p>
|
||||
{:else}
|
||||
<div class="vue">
|
||||
{#if modele.neClassePas}
|
||||
<p data-phrase-qualite="objectifs.critereDecoche">{t('qualite.objectifs.critereDecoche')}</p>
|
||||
{/if}
|
||||
{#if !modele.planchersValides}
|
||||
<p data-phrase-qualite="objectifs.horsDomaine">{t('qualite.objectifs.horsDomaine')}</p>
|
||||
{/if}
|
||||
<section class="jamais" aria-labelledby={idJamais}>
|
||||
<h4 id={idJamais}>{t('qualite.objectifs.jamaisServies')}</h4>
|
||||
{#if modele.jamaisServies.length === 0}
|
||||
<p class="secondaire" data-jamais-servies>{t('qualite.objectifs.aucuneJamaisServie')}</p>
|
||||
{:else}
|
||||
<ul data-jamais-servies>
|
||||
{#each modele.jamaisServies as personne (personne.id)}
|
||||
<li><span class="nom">{personne.nom}</span><span class="secondaire">{personne.possibles}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{#if modele.soiree !== null}
|
||||
<div class="soiree" data-soiree-objectifs>
|
||||
<Pourcentage texte={modele.soiree.pourcentage.texte} compte={modele.soiree.pourcentage.compte} />
|
||||
<p>{modele.soiree.comptes}</p>
|
||||
<p>{modele.soiree.hors}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<Barres
|
||||
graphique="objectifs"
|
||||
nom={modele.barres.nom}
|
||||
synthese={modele.barres.synthese}
|
||||
lignes={modele.barres.lignes}
|
||||
segments={modele.barres.segments}
|
||||
infobulle={modele.infobulle}
|
||||
/>
|
||||
<ol class="tours" aria-label={t('qualite.objectifs.parTour')}>
|
||||
{#each modele.tours as tour (tour.tour)}
|
||||
<li>
|
||||
<span class="tour">{tour.nom}</span>
|
||||
<Pourcentage texte={tour.pourcentage.texte} compte={tour.pourcentage.compte} />
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<Jumeau tableaux={modele.jumeau} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.vue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 var(--espace-1);
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
/* Les personnes jamais servies : le nom, puis ses tours possibles en
|
||||
encre secondaire, une par ligne. */
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--espace-2);
|
||||
}
|
||||
|
||||
.nom {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.secondaire {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* La soirée, en texte : la part servie en gras, son compte dessous, puis
|
||||
ses comptes. */
|
||||
.soiree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.soiree :global([data-pourcentage]) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Le pourcentage de chaque tour, dans l'ordre des barres, en encre
|
||||
secondaire : côte à côte, chacun son compte dessous. */
|
||||
.tours {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2) var(--espace-5);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.tours li {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tour {
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,355 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Paires répétées » (§ 12.5) de la page de qualité, en attente de
|
||||
// son contenu : la carte titrée de sa question, et rien d'autre. Elle reçoit
|
||||
// le contrat de propriétés des vues — detail, le détail du placement mesuré,
|
||||
// null tant qu'il n'est pas calculé ; ligne, sa ligne d'évaluation ; table,
|
||||
// la table d'évaluation de la charge — et n'en lit rien. Son <div> porte
|
||||
// La vue « Paires répétées » (§ 12.5, B14, D32, D33, D50) de la page de
|
||||
// qualité. Elle reçoit le contrat de propriétés des vues — detail, le
|
||||
// détail du placement mesuré, null tant qu'il n'est pas calculé ; ligne et
|
||||
// table, qu'elle ne lit pas — et met en forme ce que le détail porte, par
|
||||
// paires.js : elle ne trie, ne compte ni ne mesure rien. Son <div> porte
|
||||
// data-vue-qualite, que la carte ne transmet pas : il enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
//
|
||||
// Le tableau des paires : les paires choisies, puis, sous leur intitulé,
|
||||
// les paires que les réservations imposent, chacune dans l'ordre du
|
||||
// détail — chaque groupe est un tableau titré de sa légende. Une ligne
|
||||
// (data-paire, « a:b ») nomme les deux personnes, leur appartenance et,
|
||||
// pour un homonyme, son identifiant ; puis le nombre de fois ; puis ses
|
||||
// commandes. Une ligne n'est pas une cible : seule sa première commande
|
||||
// mène au plan au premier tour où la paire se revoit, la première
|
||||
// personne mise en évidence, et chaque tour de la paire, avec sa table,
|
||||
// est une commande sœur vers le plan à ce tour, la même personne mise en
|
||||
// évidence. Chaque commande se nomme par son libellé suivi de la paire,
|
||||
// l'identifiant d'un homonyme compris.
|
||||
// Les commandes de ligne sont des boutons simples, sans refus possible :
|
||||
// une ligne coûte une quinzaine de nœuds, cent lignes par page.
|
||||
// Le tableau se pagine comme celui des propositions, par pages de cent,
|
||||
// l'étendue et le total écrits ; changer de page porte le focus sur
|
||||
// l'étendue. Une seule table, un seul tour, aucune paire revue : la
|
||||
// phrase du cas (data-phrase-qualite) remplace le tableau.
|
||||
//
|
||||
// La matrice « qui a rencontré qui », sauf sur une seule table : quand le
|
||||
// détail la porte — sous SEUIL_MATRICE_PARTICIPANTS —, la grille n × n
|
||||
// dans l'ordre du profil, et son jumeau ; quand ses cases ne tiennent pas
|
||||
// dans la largeur de la carte (Grille), la phrase de la matrice non
|
||||
// dessinée remplace la grille et le jumeau. Au-delà du seuil, la phrase
|
||||
// du seuil. L'une et l'autre renvoient à la fiche de chaque personne.
|
||||
//
|
||||
// Regarder n'écrit rien : la vue vit en lecture et sur un plan bloqué.
|
||||
import { getContext, tick } from 'svelte';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Grille from './graphiques/Grille.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
import { casDesPaires, lignesDesPaires, matriceDesPaires, nomDistinct, pageDesPaires, syntheseMatrice } from './paires.js';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const ui = getContext('ui');
|
||||
|
||||
const cas = $derived(detail === null ? null : casDesPaires(detail));
|
||||
const lignes = $derived(cas === 'tableau' ? lignesDesPaires(detail) : null);
|
||||
// La page montrée, à partir de 1, retenue avec l'identifiant du détail où
|
||||
// elle a été choisie : un autre détail — une autre proposition montrée —
|
||||
// s'ouvre à sa première page ; le même détail remesuré garde la sienne,
|
||||
// que pageDesPaires borne aux pages existantes.
|
||||
let choix = $state({ detail: null, page: 1 });
|
||||
const page = $derived(choix.detail === (detail?.id ?? null) ? choix.page : 1);
|
||||
const pageMontree = $derived(lignes === null ? null : pageDesPaires(lignes, page));
|
||||
const matrice = $derived(detail === null || cas === 'uneTable' ? null : matriceDesPaires(detail, { t, format }));
|
||||
const synthese = $derived(detail === null ? null : syntheseMatrice(detail));
|
||||
let dessinable = $state(true);
|
||||
|
||||
// L'étendue de la page, où le focus revient après un changement de page.
|
||||
let etendue = $state(null);
|
||||
|
||||
function tourner(numero) {
|
||||
choix = { detail: detail?.id ?? null, page: numero };
|
||||
tick().then(() => etendue?.focus());
|
||||
}
|
||||
|
||||
// Le nom d'une personne dans le nom accessible d'une commande.
|
||||
const nomAccessible = (p) => nomDistinct(p, t);
|
||||
|
||||
// Montre la paire au plan au tour donné, à partir de 1, sa première
|
||||
// personne mise en évidence.
|
||||
function voir(paire, tour) {
|
||||
ui.montrerTour(tour);
|
||||
ui.mettreEnEvidence(paire.a.id);
|
||||
ui.allerA('plan');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet personne(p)}
|
||||
<span class="personne">
|
||||
<span class="nom">{p.nom}</span>
|
||||
{#if p.homonyme}
|
||||
<span class="secondaire">{t('qualite.paires.homonyme', { id: p.id })}</span>
|
||||
{/if}
|
||||
{#if p.appartenance !== null}
|
||||
<span class="secondaire">{p.appartenance}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet groupe(titre, paires)}
|
||||
<table>
|
||||
<caption>{titre}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('qualite.paires.colonne.paire')}</th>
|
||||
<th scope="col" class="nombre">{t('qualite.paires.colonne.fois')}</th>
|
||||
<th scope="col">{t('qualite.paires.colonne.tours')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each paires as paire (paire.cle)}
|
||||
{@const noms = { a: nomAccessible(paire.a), b: nomAccessible(paire.b) }}
|
||||
<tr data-paire={paire.cle}>
|
||||
<th scope="row">{@render personne(paire.a)}{@render personne(paire.b)}</th>
|
||||
<td class="nombre">{t('qualite.paires.fois', { fois: format.nombre(paire.fois) })}</td>
|
||||
<td class="tours">
|
||||
<button
|
||||
type="button"
|
||||
class="premiere"
|
||||
data-commande="allerAuTour"
|
||||
aria-label={t('qualite.paires.premierTourNom', { tour: paire.repetition, ...noms })}
|
||||
onclick={() => voir(paire, paire.repetition)}>{t('qualite.paires.premierTour', { tour: paire.repetition })}</button
|
||||
>
|
||||
{#each paire.tours as { tour, table: numero } (tour)}
|
||||
<button
|
||||
type="button"
|
||||
data-commande="allerAuTour"
|
||||
aria-label={t('qualite.paires.tourNom', { tour, table: numero, ...noms })}
|
||||
onclick={() => voir(paire, tour)}>{t('qualite.paires.tour', { tour, table: numero })}</button
|
||||
>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/snippet}
|
||||
|
||||
{#snippet etroite()}
|
||||
<span data-phrase-qualite="matriceEtroite">{t('qualite.paires.matrice.etroite')}</span>
|
||||
{/snippet}
|
||||
|
||||
<div data-vue-qualite="paires">
|
||||
<Carte titre={t('qualite.vue.paires.titre')} sousTitre={t('qualite.vue.paires.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.paires.titre')} sousTitre={t('qualite.vue.paires.question')} niveau={3}>
|
||||
{#if detail !== null}
|
||||
<div class="corps">
|
||||
{#if cas === 'uneTable'}
|
||||
<p data-phrase-qualite="pairesUneTable">{t('qualite.paires.uneTable')}</p>
|
||||
{:else if cas === 'unTour'}
|
||||
<p data-phrase-qualite="pairesUnTour">{t('qualite.paires.unTour')}</p>
|
||||
{:else if cas === 'aucune'}
|
||||
<p data-phrase-qualite="pairesAucune">{t('qualite.paires.aucune')}</p>
|
||||
{:else if pageMontree !== null}
|
||||
<nav class="pages" aria-label={t('qualite.paires.pages.nom')}>
|
||||
{#if pageMontree.page > 1}
|
||||
<Commande cle="qualite.paires.pages.precedentes" nom="precedentes" executer={() => tourner(pageMontree.page - 1)} />
|
||||
{/if}
|
||||
<p class="etendue" bind:this={etendue} role="status" tabindex="-1">
|
||||
{t('qualite.paires.pages.etendue', {
|
||||
premier: format.nombre(pageMontree.premier),
|
||||
dernier: format.nombre(pageMontree.dernier),
|
||||
total: format.nombre(pageMontree.total),
|
||||
})}
|
||||
</p>
|
||||
{#if pageMontree.page < pageMontree.pages}
|
||||
<Commande cle="qualite.paires.pages.suivantes" nom="suivantes" executer={() => tourner(pageMontree.page + 1)} />
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="defilement">
|
||||
{#if pageMontree.choisies.length > 0}
|
||||
{@render groupe(t('qualite.paires.choisies'), pageMontree.choisies)}
|
||||
{/if}
|
||||
{#if pageMontree.imposees.length > 0}
|
||||
{@render groupe(t('qualite.paires.imposees'), pageMontree.imposees)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if cas !== 'uneTable'}
|
||||
<section class="matrice">
|
||||
<h4>{t('qualite.paires.matrice.titre')}</h4>
|
||||
{#if matrice === null}
|
||||
<p data-phrase-qualite="matriceSeuil">
|
||||
{t('qualite.paires.matrice.seuil', {
|
||||
participants: format.nombre(detail.population.mesuree),
|
||||
seuil: format.nombre(constante('SEUIL_MATRICE_PARTICIPANTS')),
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
<Grille
|
||||
graphique="matrice"
|
||||
nom={t('qualite.paires.matrice.titre')}
|
||||
synthese={t(synthese.cle, synthese.details)}
|
||||
lignes={matrice.lignes}
|
||||
colonnes={matrice.colonnes}
|
||||
valeurs={matrice.valeurs}
|
||||
diagonale
|
||||
phrase={etroite}
|
||||
infobulle={matrice.infobulle}
|
||||
bind:dessinable
|
||||
/>
|
||||
{#if dessinable}
|
||||
<Jumeau tableaux={[matrice.jumeau]} />
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.corps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* La nav des pages : précédentes, l'étendue, suivantes, sur une ligne
|
||||
quand elle tient. */
|
||||
.pages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
.etendue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.etendue:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.defilement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-bottom: var(--espace-2);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-align: start;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Un filet léger entre deux lignes, aucun filet vertical. */
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
vertical-align: top;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
border-bottom-color: var(--couleur-carte-filet);
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.nombre {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Les deux personnes d'une paire, l'une sous l'autre : le nom en gras,
|
||||
l'identifiant d'un homonyme et l'appartenance en encre secondaire. */
|
||||
.personne {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 var(--espace-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.nom {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.secondaire {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
/* Les commandes d'une ligne : la première, à filet, puis chaque tour,
|
||||
sans fond ni filet au repos, en rangée qui se replie. */
|
||||
.tours {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-1) var(--espace-2);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: var(--cible-minimale);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: transparent;
|
||||
color: var(--couleur-action-encre);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.premiere {
|
||||
border-color: var(--couleur-page-filet);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.matrice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre-carte);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,154 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Performance du calcul » (§ 12.14) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// La vue « Performance du calcul » (§ 12.14, B23, D40, D49) de la page de
|
||||
// qualité : relancer plus longtemps aurait-il donné mieux ? Elle rend ce
|
||||
// que vuePerformance (performance.js) tire de la course du détail, sans
|
||||
// rien calculer (contrainte 41) :
|
||||
//
|
||||
// - la phrase de la source, sous [data-phrase-qualite] : sur le retenu,
|
||||
// la proposition qu'elle mesure ; sans course gardée, que sa course ne
|
||||
// l'est pas ; d'un seul point de relevé, qu'il n'y a rien à relier ;
|
||||
// - la ligne de tête [data-course], un morceau par élément de liste :
|
||||
// mouvements évalués sur le compte, puis durée, cœurs, cadence et
|
||||
// nombre de propositions de la génération, ou « durée inconnue » ;
|
||||
// - les petits multiples : un Escalier en forme points par valeur, sur
|
||||
// l'abscisse logarithmique des mouvements, graduée aux points de
|
||||
// relevé ; ils partagent leurs marges — l'abscisse alignée, point
|
||||
// contre point — et le point pointé, désigné dans tous à la fois ;
|
||||
// chaque graphique a son seul axe vertical, de zéro à sa plus grande
|
||||
// valeur, et son infobulle porte la ligne du jumeau ;
|
||||
// - la phrase du dernier changement [data-dernier-changement] ;
|
||||
// - le jumeau, une ligne par point de relevé, le chemin du clavier et du
|
||||
// lecteur d'écran vers chaque valeur.
|
||||
//
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et ne lit que
|
||||
// detail : le dernier point est le plan de la proposition, mesuré sous la
|
||||
// même charge que sa ligne. Son <div> porte data-vue-qualite, que la carte
|
||||
// ne transmet pas : il enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Escalier from './graphiques/Escalier.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
import { margesCommunes } from './graphiques/pointage.js';
|
||||
import { vuePerformance } from './performance.js';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
const vue = $derived(vuePerformance(detail, { t, format }));
|
||||
const remplie = $derived(vue.phrases.length > 0 || vue.tete !== null);
|
||||
// L'infobulle de chaque petit multiple : la ligne du jumeau du point
|
||||
// pointé.
|
||||
const infobulle = $derived(vue.jumeau === null ? null : { colonnes: vue.jumeau.colonnes, lignes: vue.jumeau.lignes });
|
||||
|
||||
// Les marges que chaque petit multiple demande pour son texte, par rang,
|
||||
// absentes tant qu'il ne les a pas mesurées — la liaison lit alors null ;
|
||||
// tous reçoivent les plus grandes, et leurs abscisses s'alignent.
|
||||
let marges = $state([]);
|
||||
const alignees = $derived(margesCommunes(marges));
|
||||
// Le point de relevé pointé, commun aux petits multiples.
|
||||
let pointe = $state(null);
|
||||
let ouvert = $state(false);
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="performance">
|
||||
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3} />
|
||||
{#if remplie}
|
||||
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3}>
|
||||
<div class="performance">
|
||||
{#each vue.phrases as phrase (phrase.code)}
|
||||
<p data-phrase-qualite={phrase.code}>{phrase.texte}</p>
|
||||
{/each}
|
||||
{#if vue.tete !== null}
|
||||
<ul class="tete" data-course>
|
||||
{#each vue.tete as morceau, rang (rang)}
|
||||
<li>{morceau}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if vue.multiples.length > 0}
|
||||
<p class="synthese">{vue.synthese}</p>
|
||||
<div class="multiples">
|
||||
{#each vue.multiples as multiple, rang (multiple.nom)}
|
||||
<div class="multiple">
|
||||
<Escalier
|
||||
graphique={multiple.graphique}
|
||||
nom={multiple.titre}
|
||||
synthese={multiple.synthese}
|
||||
series={[{ libelle: multiple.libelle, valeurs: multiple.valeurs }]}
|
||||
forme="points"
|
||||
abscisses={vue.abscisses}
|
||||
hauteur={150}
|
||||
titreX={t('qualite.performance.axe')}
|
||||
{infobulle}
|
||||
alignerSur={alignees}
|
||||
bind:marges={() => marges[rang] ?? null, (requises) => (marges[rang] = requises)}
|
||||
bind:pointe
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if vue.dernierChangement !== null}
|
||||
<p data-dernier-changement>{vue.dernierChangement}</p>
|
||||
{/if}
|
||||
{#if vue.jumeau !== null}
|
||||
<Jumeau tableaux={[vue.jumeau]} bind:ouvert />
|
||||
{/if}
|
||||
</div>
|
||||
</Carte>
|
||||
{:else}
|
||||
<Carte titre={t('qualite.vue.performance.titre')} sousTitre={t('qualite.vue.performance.question')} niveau={3} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.performance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* La ligne de tête : ses morceaux côte à côte, séparés par un filet,
|
||||
repliés quand ils ne tiennent pas. */
|
||||
.tete {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tete li + li {
|
||||
padding-left: var(--espace-3);
|
||||
border-left: 1px solid var(--couleur-carte-filet);
|
||||
}
|
||||
|
||||
.synthese {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* Les petits multiples : deux par ligne quand la carte le permet, un
|
||||
seul sinon. */
|
||||
.multiples {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
|
||||
gap: var(--espace-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.multiple {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,115 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Profil trié des rencontres » (§ 12.3) de la page de qualité, en
|
||||
// attente de son contenu : la carte titrée de sa question, et rien d'autre.
|
||||
// La vue « Profil trié des rencontres » (§ 12.3, B12, B15, D28, D30, D41,
|
||||
// D51) de la page de qualité : deux graphiques empilés au même ordre de
|
||||
// rangs, puis leur jumeau. Le premier, en comptes de personnes : les
|
||||
// rencontres en plein, les appartenances rencontrées en tirets, le plafond
|
||||
// réalisé en trait mixte, la borne de diversité en pointillés, les rangs
|
||||
// marqués de leur signe, et sa phrase de synthèse dessous. Le second, en
|
||||
// pourcentages : le pourcentage de chaque rang, interrompu aux rangs sans
|
||||
// plafond, la règle à 100 %, les mêmes signes aux mêmes rangs — celui
|
||||
// d'un rang sans point au pied de l'axe —, et sa synthèse, la personne au
|
||||
// rang 1, son pourcentage et le compte du pire. Une appartenance au plus :
|
||||
// une seule série, sans borne, et la phrase du cas ; une seule table : la
|
||||
// phrase seule.
|
||||
//
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne et table,
|
||||
// qu'elle ne lit pas — et rend ce que profilDe (profil.js) en tire : rien
|
||||
// ne se trie ni ne se calcule ici. Les deux graphiques partagent le rang
|
||||
// pointé (pointe) — l'infobulle porte la ligne entière du rang, dans le
|
||||
// seul graphique survolé — et leurs marges (margesCommunes), qui alignent
|
||||
// leurs abscisses rang contre rang.
|
||||
//
|
||||
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte ;
|
||||
// data-graphique profilComptes et profilPourcentages sur les deux <svg> ;
|
||||
// data-phrase-qualite sur la phrase d'un cas limite, par son code.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Escalier from './graphiques/Escalier.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
import Pourcentage from './graphiques/Pourcentage.svelte';
|
||||
import { margesCommunes } from './graphiques/pointage.js';
|
||||
import { graduerPourcent, profilDe } from './profil.js';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
const profil = $derived(profilDe(detail, { t, format }));
|
||||
const graduer = graduerPourcent({ t, format });
|
||||
const titreX = t('qualite.profil.axe.rangs');
|
||||
|
||||
let pointe = $state(null);
|
||||
let margesComptes = $state(null);
|
||||
let margesPourcentages = $state(null);
|
||||
const communes = $derived(margesCommunes([margesComptes, margesPourcentages]));
|
||||
</script>
|
||||
|
||||
{#snippet synthesePourcentages()}
|
||||
<span class="synthese">{profil.pourcentages.synthese.texte}</span>
|
||||
{#if profil.pourcentages.synthese.pourcentage !== null}
|
||||
<Pourcentage texte={profil.pourcentages.synthese.pourcentage.texte} compte={profil.pourcentages.synthese.pourcentage.compte} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div data-vue-qualite="profil">
|
||||
<Carte titre={t('qualite.vue.profil.titre')} sousTitre={t('qualite.vue.profil.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.profil.titre')} sousTitre={t('qualite.vue.profil.question')} niveau={3}>
|
||||
{#if profil !== null}
|
||||
<div class="profil">
|
||||
{#if profil.phrase !== null}
|
||||
<p data-phrase-qualite={profil.phrase}>{t(`qualite.profil.${profil.phrase}`)}</p>
|
||||
{/if}
|
||||
{#if profil.cas === 'profil'}
|
||||
<Escalier
|
||||
graphique="profilComptes"
|
||||
nom={profil.comptes.nom}
|
||||
synthese={profil.comptes.synthese}
|
||||
series={profil.comptes.series}
|
||||
reperes={profil.comptes.reperes}
|
||||
marques={profil.comptes.marques}
|
||||
infobulle={profil.infobulle}
|
||||
{titreX}
|
||||
alignerSur={communes}
|
||||
bind:marges={margesComptes}
|
||||
bind:pointe
|
||||
/>
|
||||
<Escalier
|
||||
graphique="profilPourcentages"
|
||||
nom={profil.pourcentages.nom}
|
||||
synthese={synthesePourcentages}
|
||||
series={profil.pourcentages.series}
|
||||
reperes={profil.pourcentages.reperes}
|
||||
marques={profil.pourcentages.marques}
|
||||
infobulle={profil.infobulle}
|
||||
{graduer}
|
||||
{titreX}
|
||||
alignerSur={communes}
|
||||
bind:marges={margesPourcentages}
|
||||
bind:pointe
|
||||
/>
|
||||
<Jumeau tableaux={profil.tableaux} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profil {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.synthese {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,178 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La vue « Appartenances à chaque table » (§ 12.11) de la page de qualité,
|
||||
// en attente de son contenu : la carte titrée de sa question, et rien
|
||||
// d'autre. Elle reçoit le contrat de propriétés des vues — detail, le détail
|
||||
// du placement mesuré, null tant qu'il n'est pas calculé ; ligne, sa ligne
|
||||
// d'évaluation ; table, la table d'évaluation de la charge — et n'en lit
|
||||
// rien. Son <div> porte data-vue-qualite, que la carte ne transmet pas : il
|
||||
// enveloppe la carte.
|
||||
// La vue « Appartenances à chaque table » (§ 12.11, B20, D35, D54) de la
|
||||
// page de qualité. Un sélecteur à trois positions — un groupe de boutons
|
||||
// radio, au clavier — choisit ce que la grille compte : les sièges, les
|
||||
// assis ou les doublons parmi les affiliés. La position est un état de la
|
||||
// séance (etat_ui.js, melangeTables) : elle survit au changement de
|
||||
// section, repart de « sièges » à l'ouverture d'un événement, n'écrit rien
|
||||
// et ne fait aucune entrée d'historique ; elle vit en lecture et sur un
|
||||
// plan bloqué.
|
||||
//
|
||||
// Puis la grille, une ligne par table dans l'ordre de la position — la
|
||||
// pire en tête, une table sans affilié en dernier —, une colonne par tour ;
|
||||
// chaque case écrit et colore le compte de la position, « — » sans
|
||||
// couleur sans affilié ; l'infobulle d'une case porte son pourcentage et
|
||||
// ses deux comptes. Une grille dont les cases ne tiennent pas dans la
|
||||
// largeur de la carte cède la place à sa phrase, et son jumeau s'ouvre ;
|
||||
// le sélecteur reste. Sous « doublons », sans collision, la phrase de la
|
||||
// vue des collisions tient lieu de grille. Une seule table, aucune
|
||||
// appartenance : la phrase du cas, sans sélecteur (§ 12.6, B15). Sous la
|
||||
// grille, son jumeau : les trois comptes de chaque table à chaque tour.
|
||||
//
|
||||
// Elle reçoit le contrat de propriétés des vues — detail, le détail du
|
||||
// placement mesuré, null tant qu'il n'est pas calculé ; ligne et table, que
|
||||
// cette vue ne lit pas — et met en forme le détail par modeleTables
|
||||
// (appartenances.js), sans rien calculer. Son <div> porte data-vue-qualite,
|
||||
// que la carte ne transmet pas : il enveloppe la carte.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import { CASE_MAX_TEXTES, modeleTables } from './appartenances.js';
|
||||
import Grille from './graphiques/Grille.svelte';
|
||||
import Jumeau from './graphiques/Jumeau.svelte';
|
||||
|
||||
let { detail = null, ligne = null, table = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
const nomGroupe = `${uid}-position`;
|
||||
|
||||
const position = $derived($ui.melangeTables);
|
||||
const modele = $derived(detail === null || detail.tables === null ? null : modeleTables(detail, position, { t }));
|
||||
let pointe = $state(null);
|
||||
let dessinable = $state(true);
|
||||
let ouvert = $state(false);
|
||||
|
||||
// Une grille qui ne tient pas cède la place à son jumeau, ouvert.
|
||||
$effect(() => {
|
||||
if (!dessinable) ouvert = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div data-vue-qualite="tables">
|
||||
<Carte titre={t('qualite.vue.tables.titre')} sousTitre={t('qualite.vue.tables.question')} niveau={3} />
|
||||
<Carte titre={t('qualite.vue.tables.titre')} sousTitre={t('qualite.vue.tables.question')} niveau={3}>
|
||||
{#if modele !== null}
|
||||
<div class="corps">
|
||||
{#if modele.selecteur}
|
||||
<fieldset class="selecteur">
|
||||
<legend>{t('qualite.tables.selecteur')}</legend>
|
||||
<label class="option">
|
||||
<input
|
||||
type="radio"
|
||||
name={nomGroupe}
|
||||
value="sieges"
|
||||
data-position-tables="sieges"
|
||||
checked={position === 'sieges'}
|
||||
onchange={() => ui.reglerMelangeTables('sieges')}
|
||||
/>
|
||||
<span>{t('qualite.tables.position.sieges')}</span>
|
||||
</label>
|
||||
<label class="option">
|
||||
<input
|
||||
type="radio"
|
||||
name={nomGroupe}
|
||||
value="assis"
|
||||
data-position-tables="assis"
|
||||
checked={position === 'assis'}
|
||||
onchange={() => ui.reglerMelangeTables('assis')}
|
||||
/>
|
||||
<span>{t('qualite.tables.position.assis')}</span>
|
||||
</label>
|
||||
<label class="option">
|
||||
<input
|
||||
type="radio"
|
||||
name={nomGroupe}
|
||||
value="doublons"
|
||||
data-position-tables="doublons"
|
||||
checked={position === 'doublons'}
|
||||
onchange={() => ui.reglerMelangeTables('doublons')}
|
||||
/>
|
||||
<span>{t('qualite.tables.position.doublons')}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{/if}
|
||||
{#if modele.phrase !== null}
|
||||
<p data-phrase-qualite={modele.code}>{modele.phrase}</p>
|
||||
{/if}
|
||||
{#if modele.grille !== null}
|
||||
{@const g = modele.grille}
|
||||
<div class="grille">
|
||||
<Grille
|
||||
graphique="tables"
|
||||
nom={g.nom}
|
||||
synthese={g.synthese}
|
||||
lignes={g.lignes}
|
||||
colonnes={g.colonnes}
|
||||
valeurs={g.valeurs}
|
||||
phrase={g.phrase}
|
||||
coteMaxTextes={CASE_MAX_TEXTES}
|
||||
infobulle={g.infobulle}
|
||||
bind:dessinable
|
||||
bind:pointe
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if modele.tableaux.length > 0}
|
||||
<Jumeau tableaux={modele.tableaux} bind:ouvert />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Carte>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.corps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* La grille reçoit toute la largeur du corps de la carte et la mesure ;
|
||||
min-width: 0 la laisse rétrécir avec la carte. */
|
||||
.grille {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Le sélecteur : sa légende comme le libellé d'un champ, puis les trois
|
||||
boutons côte à côte, qui passent à la ligne. */
|
||||
.selecteur {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
margin-right: var(--espace-2);
|
||||
padding: 0;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
min-height: var(--cible-minimale);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option input:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
179
src/interface/qualite/animateurs.js
Normal file
179
src/interface/qualite/animateurs.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les modèles des deux vues des animateurs (§ 12.13, B22, D37, D38), purs :
|
||||
// VueAnimateurs.svelte et VueDiversite.svelte les rendent. Ils mettent en
|
||||
// forme les animateurs du détail d'un placement (detaillerQualite), dans
|
||||
// l'ordre où le détail les porte, sans rien trier ni recompter.
|
||||
//
|
||||
// Les animateurs rencontrés : une série, m(p), et la borne des animateurs
|
||||
// B(p) en repère pointillé, sous ce nom. Ni manque ni pourcentage : la
|
||||
// borne n'est pas toujours atteignable (§ 4.5), et rien n'en mesure la part.
|
||||
//
|
||||
// La diversité par animateur : le graphique dessine la soirée, une ligne
|
||||
// par animateur, le moins varié en tête, dans l'ordre du détail ; les
|
||||
// appartenances et les étiquettes en deux colonnes juxtaposées, chacune son
|
||||
// axe, jamais empilées. Une colonne seule quand le détail n'a que celle-là,
|
||||
// sous le jeton de sa série ; aucune quand il n'en a ni l'une ni l'autre. Le
|
||||
// jumeau porte la soirée de chaque animateur, puis une ligne par tour animé,
|
||||
// dans l'ordre des tours : il détaille au-delà du graphique (§ 12.7).
|
||||
|
||||
// Les colonnes de la diversité, dans l'ordre : chacune sous le nom de son
|
||||
// drapeau dans detail.animateurs.diversite.colonnes, de son compte dans une
|
||||
// ligne et de son libellé, avec le genre de sa série.
|
||||
const COLONNES_DIVERSITE = [
|
||||
['appartenances', 'serie1'],
|
||||
['etiquettes', 'serie2'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Le modèle de la vue des animateurs rencontrés, ou ce qui la remplace.
|
||||
*
|
||||
* - null sans détail mesuré ;
|
||||
* - {cas: 'aucun'} sans animateur ou sans personne concernée : la vue cède
|
||||
* la place à sa phrase ;
|
||||
* - sinon {cas: 'animateurs', escalier, infobulle, jumeau} : escalier,
|
||||
* {nom, synthese, series, reperes} pour Escalier, une ligne par rang du
|
||||
* profil des animateurs ; la synthèse lit le minimum et son effectif à la
|
||||
* première ligne de la répartition, croissante ; infobulle, {colonnes,
|
||||
* lignes}, la ligne du jumeau de chaque rang ; jumeau, le profil — nom,
|
||||
* m(p), B(p), statut —, puis la répartition de m(p).
|
||||
*
|
||||
* @param {Object|null} detail le détail du placement (detaillerQualite)
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
*/
|
||||
export function modeleAnimateurs(detail, t) {
|
||||
if (detail === null) return null;
|
||||
const { animateurs, personnes } = detail;
|
||||
if (animateurs === null || animateurs.profil === null) return { cas: 'aucun' };
|
||||
const { profil, repartition } = animateurs;
|
||||
const nomDe = new Map(personnes.map(({ id, nom }) => [id, nom]));
|
||||
|
||||
const colonnes = [
|
||||
{ cle: 'nom', libelle: t('qualite.animateurs.colonne.nom'), genre: 'texte' },
|
||||
{ cle: 'rencontres', libelle: t('qualite.animateurs.colonne.rencontres'), genre: 'nombre' },
|
||||
{ cle: 'borne', libelle: t('qualite.animateurs.colonne.borne'), genre: 'nombre' },
|
||||
{ cle: 'statut', libelle: t('qualite.animateurs.colonne.statut'), genre: 'texte' },
|
||||
];
|
||||
const lignes = profil.map(({ id, rencontres, borne, statut }) => ({
|
||||
nom: nomDe.get(id),
|
||||
rencontres,
|
||||
borne,
|
||||
statut: t(`participants.statut.${statut}`),
|
||||
}));
|
||||
const [minimum] = repartition;
|
||||
|
||||
return {
|
||||
cas: 'animateurs',
|
||||
escalier: {
|
||||
nom: t('qualite.animateurs.graphique'),
|
||||
synthese: t('qualite.animateurs.synthese', { minimum: minimum.valeur, effectif: minimum.effectif }),
|
||||
series: [{ libelle: t('qualite.animateurs.serie'), valeurs: profil.map(({ rencontres }) => rencontres) }],
|
||||
reperes: [{ genre: 'borne', libelle: t('qualite.animateurs.repere.borne'), valeurs: profil.map(({ borne }) => borne) }],
|
||||
},
|
||||
infobulle: { colonnes, lignes },
|
||||
jumeau: [
|
||||
{ nom: t('qualite.animateurs.jumeau.profil'), colonnes, lignes },
|
||||
{
|
||||
nom: t('qualite.animateurs.repartition.titre'),
|
||||
colonnes: [
|
||||
{ cle: 'valeur', libelle: t('qualite.animateurs.repartition.valeur'), genre: 'nombre' },
|
||||
{ cle: 'effectif', libelle: t('qualite.animateurs.repartition.effectif'), genre: 'nombre' },
|
||||
],
|
||||
lignes: repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le modèle de la vue de la diversité par animateur, ou ce qui la remplace.
|
||||
*
|
||||
* - null sans détail mesuré ;
|
||||
* - {cas: 'aucunAnimateur'} sans animateur : la vue cède la place à sa
|
||||
* phrase ;
|
||||
* - sinon {cas: 'diversite', phrase, equite, barres, infobulle, jumeau} :
|
||||
* phrase, null quand les deux colonnes se dessinent, sinon le cas du
|
||||
* § 12.6 — 'sansEtiquette', 'sansAppartenance' ou 'rien' ;
|
||||
* equite, une entrée {genre, titre, moins, plus, auMinimum} par colonne
|
||||
* dessinée, ses trois nombres ;
|
||||
* barres, null sans colonne, sinon {nom, synthese, lignes, colonnes,
|
||||
* segments} pour Barres — deux colonnes juxtaposées, ou un segment seul
|
||||
* (colonnes null) —, une ligne {id, nom, valeurs, secondaire} par
|
||||
* animateur, id son identifiant, valeurs la soirée, secondaire ses tours
|
||||
* animés et ses personnes rencontrées en animant ;
|
||||
* infobulle, null sans barres, sinon la ligne de la soirée du jumeau de
|
||||
* chaque animateur ;
|
||||
* jumeau, un tableau : la soirée de chaque animateur, puis chacun de ses
|
||||
* tours animés — tours animés, personnes, et les colonnes dessinées.
|
||||
*
|
||||
* @param {Object|null} detail le détail du placement (detaillerQualite)
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
*/
|
||||
export function modeleDiversite(detail, t) {
|
||||
if (detail === null) return null;
|
||||
const { animateurs, personnes, idsTables, numerosTables } = detail;
|
||||
if (animateurs === null || animateurs.diversite.lignes.length === 0) return { cas: 'aucunAnimateur' };
|
||||
const { colonnes: drapeaux, lignes, equite } = animateurs.diversite;
|
||||
const nomDe = new Map(personnes.map(({ id, nom }) => [id, nom]));
|
||||
const numeroDe = new Map(idsTables.map((id, rang) => [id, numerosTables[rang]]));
|
||||
|
||||
const dessinees = COLONNES_DIVERSITE.filter(([cle]) => drapeaux[cle]);
|
||||
let phrase = null;
|
||||
if (dessinees.length === 0) phrase = 'rien';
|
||||
else if (!drapeaux.etiquettes) phrase = 'sansEtiquette';
|
||||
else if (!drapeaux.appartenances) phrase = 'sansAppartenance';
|
||||
const parts = dessinees.map(([cle, genre]) => ({ genre, libelle: t(`qualite.diversite.colonne.${cle}`) }));
|
||||
|
||||
const colonnes = [
|
||||
{ cle: 'animateur', libelle: t('qualite.diversite.colonne.animateur'), genre: 'texte' },
|
||||
{ cle: 'portee', libelle: t('qualite.diversite.colonne.portee'), genre: 'texte' },
|
||||
{ cle: 'toursAnimes', libelle: t('qualite.diversite.colonne.toursAnimes'), genre: 'nombre' },
|
||||
{ cle: 'personnes', libelle: t('qualite.diversite.colonne.personnes'), genre: 'nombre' },
|
||||
...dessinees.map(([cle]) => ({ cle, libelle: t(`qualite.diversite.colonne.${cle}`), genre: 'nombre' })),
|
||||
];
|
||||
// Une ligne du jumeau : l'animateur, sa portée, et les comptes des
|
||||
// colonnes dessinées.
|
||||
const rangee = (animateur, portee, toursAnimes, comptes) => {
|
||||
const ligne = { animateur, portee, toursAnimes, personnes: comptes.personnes };
|
||||
for (const [cle] of dessinees) ligne[cle] = comptes[cle];
|
||||
return ligne;
|
||||
};
|
||||
const soirees = [];
|
||||
const rangees = [];
|
||||
for (const ligne of lignes) {
|
||||
const nom = nomDe.get(ligne.id);
|
||||
const soiree = rangee(nom, t('qualite.diversite.soiree'), ligne.toursAnimes, ligne);
|
||||
soirees.push(soiree);
|
||||
rangees.push(soiree);
|
||||
for (const tour of ligne.parTour) {
|
||||
const portee = t('qualite.diversite.tour', { tour: tour.tour + 1, table: numeroDe.get(tour.table) });
|
||||
rangees.push(rangee(nom, portee, null, tour));
|
||||
}
|
||||
}
|
||||
|
||||
const barres =
|
||||
dessinees.length === 0
|
||||
? null
|
||||
: {
|
||||
nom: t('qualite.diversite.graphique'),
|
||||
synthese: t('qualite.diversite.synthese', equite),
|
||||
colonnes: dessinees.length === 2 ? parts : null,
|
||||
segments: dessinees.length === 2 ? null : parts,
|
||||
lignes: lignes.map((ligne) => ({
|
||||
id: ligne.id,
|
||||
nom: nomDe.get(ligne.id),
|
||||
valeurs: dessinees.map(([cle]) => ligne[cle]),
|
||||
secondaire: t('qualite.diversite.secondaire', { tours: ligne.toursAnimes, personnes: ligne.personnes }),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
cas: 'diversite',
|
||||
phrase,
|
||||
equite: dessinees.map(([cle]) => ({ genre: cle, titre: t(`qualite.diversite.colonne.${cle}`), ...equite[cle] })),
|
||||
barres,
|
||||
infobulle: barres === null ? null : { colonnes, lignes: soirees },
|
||||
jumeau: [{ nom: t('qualite.diversite.jumeau'), colonnes, lignes: rangees }],
|
||||
};
|
||||
}
|
||||
312
src/interface/qualite/animateurs.test.js
Normal file
312
src/interface/qualite/animateurs.test.js
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves des modèles des deux vues des animateurs (§ 12.13, B22, D37,
|
||||
// D38) : le profil trié des animateurs rencontrés, sa borne en repère, sans
|
||||
// manque ni pourcentage ; la diversité par animateur sur la soirée, en deux
|
||||
// colonnes juxtaposées, le moins varié en tête, et son jumeau, la soirée
|
||||
// puis chaque tour animé. Chaque nombre vient du détail du placement
|
||||
// (detaillerQualite). Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { configurationDe } from '../../application/configuration.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { modeleAnimateurs, modeleDiversite } from './animateurs.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Neuf personnes inventées, leurs appartenances et leurs étiquettes.
|
||||
const PERSONNES = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier', []],
|
||||
['Pervenche', 'Théo', 'Atelier Ressac', []],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac', ['finance']],
|
||||
['Tourteau', 'Basile', 'Coopérative Aubier', ['bénévole']],
|
||||
['Quenouille', 'Maëlle', 'Atelier Ressac', ['finance', 'Montréal']],
|
||||
['Rocaille', 'Anselme', null, ['bénévole']],
|
||||
['Brindille', 'Pélagie', 'Coopérative Aubier', []],
|
||||
['Cassonade', 'Firmin', null, ['Montréal']],
|
||||
['Grésil', 'Ysaline', 'Atelier Ressac', []],
|
||||
];
|
||||
|
||||
// Trois tables de trois places, trois tours ; un titre au siège 1 des tables
|
||||
// 1 et 2. Iris Ombrelle, réservée à la table 1 pour la soirée, y anime à
|
||||
// chaque tour ; Théo Pervenche, réservé à la table 2 au seul tour 2, y anime
|
||||
// ce tour-là. appartenances : false pour une seule appartenance ;
|
||||
// etiquettes : false pour aucune ; titres : false pour aucun animateur.
|
||||
function chargeAnimateurs({ appartenances = true, etiquettes = true, titres = true } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-animateurs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
|
||||
charge.participants = PERSONNES.map(([nom, prenom, appartenance, liste], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: appartenances ? appartenance : 'Coopérative Aubier',
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...(etiquettes && liste.length > 0 ? { etiquettes: liste } : {}),
|
||||
}));
|
||||
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id + 10, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
if (titres) {
|
||||
charge.titres = [1, 2].map((table) => ({ table, siege: 1, libelle: 'Animation' }));
|
||||
charge.reservations = [
|
||||
{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null },
|
||||
{ participant: 2, table: 2, siege: null, portee: 'tour', tour: 2 },
|
||||
];
|
||||
}
|
||||
charge.reglages.rencontrerAnimateurs = true;
|
||||
charge.prochainsIds = { participant: 10, table: 4, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Le détail de la première proposition d'une génération.
|
||||
function detailDe(options) {
|
||||
const charge = chargeAnimateurs(options);
|
||||
const configuration = configurationDe(charge);
|
||||
const propositions = rechercher(configuration, { graine: 11, nombre: 1, arret: 200 });
|
||||
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
return detaillerQualite(generee, generee.propositions[0].id);
|
||||
}
|
||||
|
||||
// Chaque texte qu'atteint une valeur, chaînes des objets et des listes
|
||||
// comprises.
|
||||
function textes(valeur, rendus = []) {
|
||||
if (typeof valeur === 'string') rendus.push(valeur);
|
||||
else if (valeur !== null && typeof valeur === 'object') for (const element of Object.values(valeur)) textes(element, rendus);
|
||||
return rendus;
|
||||
}
|
||||
|
||||
describe('animateurs rencontrés : le profil trié (§ 12.13, B22, D37)', () => {
|
||||
test('sans animateur, ou sans personne concernée, la vue cède la place à sa phrase', () => {
|
||||
const detail = detailDe({ titres: false });
|
||||
assert.equal(detail.animateurs, null);
|
||||
assert.deepEqual(modeleAnimateurs(detail, t), { cas: 'aucun' });
|
||||
const forge = structuredClone(detailDe());
|
||||
forge.animateurs.profil = null;
|
||||
forge.animateurs.repartition = null;
|
||||
assert.deepEqual(modeleAnimateurs(forge, t), { cas: 'aucun' });
|
||||
assert.equal(modeleAnimateurs(null, t), null);
|
||||
});
|
||||
|
||||
test('une seule série, m(p) dans l’ordre du profil, et la borne des animateurs en repère pointillé, sous ce nom', () => {
|
||||
const detail = detailDe();
|
||||
const { profil } = detail.animateurs;
|
||||
assert.ok(profil.length >= 2);
|
||||
const modele = modeleAnimateurs(detail, t);
|
||||
assert.equal(modele.cas, 'animateurs');
|
||||
assert.deepEqual(modele.escalier.series, [
|
||||
{ libelle: t('qualite.animateurs.serie'), valeurs: profil.map(({ rencontres }) => rencontres) },
|
||||
]);
|
||||
assert.deepEqual(modele.escalier.reperes, [
|
||||
{ genre: 'borne', libelle: t('qualite.animateurs.repere.borne'), valeurs: profil.map(({ borne }) => borne) },
|
||||
]);
|
||||
assert.equal(modele.escalier.nom, t('qualite.animateurs.graphique'));
|
||||
});
|
||||
|
||||
test('la synthèse : le minimum et son effectif, lus à la répartition', () => {
|
||||
const detail = detailDe();
|
||||
const [premiere] = detail.animateurs.repartition;
|
||||
assert.equal(premiere.valeur, detail.animateurs.profil[0].rencontres);
|
||||
assert.equal(
|
||||
modeleAnimateurs(detail, t).escalier.synthese,
|
||||
t('qualite.animateurs.synthese', { minimum: premiere.valeur, effectif: premiere.effectif }),
|
||||
);
|
||||
});
|
||||
|
||||
test('rouge : ni manque ni pourcentage de la borne, ni « plafond » — le jumeau porte m(p), B(p) et le statut', () => {
|
||||
const detail = detailDe();
|
||||
const modele = modeleAnimateurs(detail, t);
|
||||
const [profil, repartition] = modele.jumeau;
|
||||
assert.deepEqual(
|
||||
profil.colonnes.map(({ cle, genre }) => [cle, genre]),
|
||||
[
|
||||
['nom', 'texte'],
|
||||
['rencontres', 'nombre'],
|
||||
['borne', 'nombre'],
|
||||
['statut', 'texte'],
|
||||
],
|
||||
);
|
||||
const nomDe = new Map(detail.personnes.map(({ id, nom }) => [id, nom]));
|
||||
assert.deepEqual(
|
||||
profil.lignes,
|
||||
detail.animateurs.profil.map(({ id, rencontres, borne, statut }) => ({
|
||||
nom: nomDe.get(id),
|
||||
rencontres,
|
||||
borne,
|
||||
statut: t(`participants.statut.${statut}`),
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(
|
||||
repartition.lignes,
|
||||
detail.animateurs.repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
|
||||
);
|
||||
assert.deepEqual(modele.infobulle, { colonnes: profil.colonnes, lignes: profil.lignes });
|
||||
for (const texte of textes(modele)) {
|
||||
assert.doesNotMatch(texte, /%/, texte);
|
||||
assert.doesNotMatch(texte, /plafond|manque/i, texte);
|
||||
}
|
||||
assert.equal(
|
||||
textes(modele).some((texte) => /pourcent/i.test(texte)),
|
||||
false,
|
||||
);
|
||||
const noms = [modele.escalier.nom, ...modele.jumeau.map(({ nom }) => nom)];
|
||||
assert.equal(new Set(noms).size, noms.length, 'noms accessibles distincts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('diversité par animateur : la soirée en graphique, chaque tour au jumeau (§ 12.13, B22, D38)', () => {
|
||||
test('sans animateur, la vue cède la place à sa phrase', () => {
|
||||
assert.deepEqual(modeleDiversite(detailDe({ titres: false }), t), { cas: 'aucunAnimateur' });
|
||||
assert.equal(modeleDiversite(null, t), null);
|
||||
});
|
||||
|
||||
test('deux colonnes juxtaposées, appartenances puis étiquettes, chacune son axe ; les valeurs de la soirée ; le moins varié en tête', () => {
|
||||
const detail = detailDe();
|
||||
const { colonnes, lignes } = detail.animateurs.diversite;
|
||||
assert.deepEqual(colonnes, { appartenances: true, etiquettes: true });
|
||||
const modele = modeleDiversite(detail, t);
|
||||
assert.equal(modele.phrase, null);
|
||||
assert.equal(modele.barres.segments, null);
|
||||
assert.deepEqual(modele.barres.colonnes, [
|
||||
{ genre: 'serie1', libelle: t('qualite.diversite.colonne.appartenances') },
|
||||
{ genre: 'serie2', libelle: t('qualite.diversite.colonne.etiquettes') },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
modele.barres.lignes.map(({ id, valeurs }) => [id, valeurs]),
|
||||
lignes.map(({ id, appartenances, etiquettes }) => [id, [appartenances, etiquettes]]),
|
||||
);
|
||||
const premiere = modele.barres.lignes[0];
|
||||
const minimum = Math.min(...lignes.map(({ appartenances }) => appartenances));
|
||||
assert.equal(premiere.valeurs[0], minimum, 'le moins varié en tête');
|
||||
for (const ligne of modele.barres.lignes) {
|
||||
const source = lignes.find(({ id }) => id === ligne.id);
|
||||
assert.equal(ligne.secondaire, t('qualite.diversite.secondaire', { tours: source.toursAnimes, personnes: source.personnes }));
|
||||
assert.equal(Object.hasOwn(ligne, 'appartenance'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('rouge : le graphique dessine la soirée, une ligne par animateur, jamais une ligne par tour', () => {
|
||||
const detail = detailDe();
|
||||
const { lignes } = detail.animateurs.diversite;
|
||||
assert.ok(lignes.some(({ toursAnimes }) => toursAnimes > 1));
|
||||
const modele = modeleDiversite(detail, t);
|
||||
assert.equal(modele.barres.lignes.length, lignes.length);
|
||||
assert.equal(new Set(modele.barres.lignes.map(({ id }) => id)).size, lignes.length);
|
||||
});
|
||||
|
||||
test('au-dessus de chaque colonne, ses trois nombres : le moins varié, le plus varié, au minimum', () => {
|
||||
const detail = detailDe();
|
||||
const { equite } = detail.animateurs.diversite;
|
||||
assert.deepEqual(modeleDiversite(detail, t).equite, [
|
||||
{ genre: 'appartenances', titre: t('qualite.diversite.colonne.appartenances'), ...equite.appartenances },
|
||||
{ genre: 'etiquettes', titre: t('qualite.diversite.colonne.etiquettes'), ...equite.etiquettes },
|
||||
]);
|
||||
});
|
||||
|
||||
test('rouge : le jumeau porte la soirée de chaque animateur, puis une ligne par tour animé, chacune au plus la soirée', () => {
|
||||
const detail = detailDe();
|
||||
const { lignes } = detail.animateurs.diversite;
|
||||
const modele = modeleDiversite(detail, t);
|
||||
const [jumeau] = modele.jumeau;
|
||||
assert.deepEqual(
|
||||
jumeau.colonnes.map(({ cle }) => cle),
|
||||
['animateur', 'portee', 'toursAnimes', 'personnes', 'appartenances', 'etiquettes'],
|
||||
);
|
||||
const nomDe = new Map(detail.personnes.map(({ id, nom }) => [id, nom]));
|
||||
const numeroDe = new Map(detail.idsTables.map((id, rang) => [id, detail.numerosTables[rang]]));
|
||||
const attendues = lignes.flatMap((ligne) => [
|
||||
{
|
||||
animateur: nomDe.get(ligne.id),
|
||||
portee: t('qualite.diversite.soiree'),
|
||||
toursAnimes: ligne.toursAnimes,
|
||||
personnes: ligne.personnes,
|
||||
appartenances: ligne.appartenances,
|
||||
etiquettes: ligne.etiquettes,
|
||||
},
|
||||
...ligne.parTour.map((tour) => ({
|
||||
animateur: nomDe.get(ligne.id),
|
||||
portee: t('qualite.diversite.tour', { tour: tour.tour + 1, table: numeroDe.get(tour.table) }),
|
||||
toursAnimes: null,
|
||||
personnes: tour.personnes,
|
||||
appartenances: tour.appartenances,
|
||||
etiquettes: tour.etiquettes,
|
||||
})),
|
||||
]);
|
||||
assert.deepEqual(jumeau.lignes, attendues);
|
||||
assert.equal(
|
||||
jumeau.lignes.length,
|
||||
lignes.reduce((somme, ligne) => somme + 1 + ligne.toursAnimes, 0),
|
||||
);
|
||||
for (const ligne of lignes) {
|
||||
assert.equal(ligne.parTour.length, ligne.toursAnimes);
|
||||
for (const tour of ligne.parTour) {
|
||||
for (const cle of ['personnes', 'appartenances', 'etiquettes']) assert.ok(tour[cle] <= ligne[cle], `${ligne.id}, ${cle}`);
|
||||
}
|
||||
}
|
||||
assert.ok(attendues.some(({ portee }) => portee === t('qualite.diversite.tour', { tour: 2, table: 12 })));
|
||||
assert.deepEqual(modele.infobulle, {
|
||||
colonnes: jumeau.colonnes,
|
||||
lignes: jumeau.lignes.filter(({ portee }) => portee === t('qualite.diversite.soiree')),
|
||||
});
|
||||
const noms = [modele.barres.nom, ...modele.jumeau.map(({ nom }) => nom)];
|
||||
assert.equal(new Set(noms).size, noms.length, 'noms accessibles distincts');
|
||||
});
|
||||
|
||||
test('aucune étiquette : la colonne des appartenances seule, et la phrase', () => {
|
||||
const detail = detailDe({ etiquettes: false });
|
||||
assert.deepEqual(detail.animateurs.diversite.colonnes, { appartenances: true, etiquettes: false });
|
||||
const modele = modeleDiversite(detail, t);
|
||||
assert.equal(modele.phrase, 'sansEtiquette');
|
||||
assert.equal(modele.barres.colonnes, null);
|
||||
assert.deepEqual(modele.barres.segments, [{ genre: 'serie1', libelle: t('qualite.diversite.colonne.appartenances') }]);
|
||||
assert.deepEqual(
|
||||
modele.barres.lignes.map(({ valeurs }) => valeurs),
|
||||
detail.animateurs.diversite.lignes.map(({ appartenances }) => [appartenances]),
|
||||
);
|
||||
assert.deepEqual(modele.equite.map(({ genre }) => genre), ['appartenances']);
|
||||
assert.equal(modele.jumeau[0].colonnes.some(({ cle }) => cle === 'etiquettes'), false);
|
||||
});
|
||||
|
||||
test('une seule appartenance et des étiquettes : la colonne des étiquettes seule, triée sur E_f, et la phrase', () => {
|
||||
const detail = detailDe({ appartenances: false });
|
||||
assert.deepEqual(detail.animateurs.diversite.colonnes, { appartenances: false, etiquettes: true });
|
||||
const modele = modeleDiversite(detail, t);
|
||||
assert.equal(modele.phrase, 'sansAppartenance');
|
||||
assert.deepEqual(modele.barres.segments, [{ genre: 'serie2', libelle: t('qualite.diversite.colonne.etiquettes') }]);
|
||||
assert.deepEqual(
|
||||
modele.barres.lignes.map(({ id, valeurs }) => [id, valeurs]),
|
||||
detail.animateurs.diversite.lignes.map(({ id, etiquettes }) => [id, [etiquettes]]),
|
||||
);
|
||||
assert.deepEqual(modele.equite.map(({ genre }) => genre), ['etiquettes']);
|
||||
assert.equal(modele.jumeau[0].colonnes.some(({ cle }) => cle === 'appartenances'), false);
|
||||
});
|
||||
|
||||
test('ni étiquette ni plus d’une appartenance : la phrase, aucun graphique, le jumeau garde tours animés et personnes', () => {
|
||||
const detail = detailDe({ appartenances: false, etiquettes: false });
|
||||
const modele = modeleDiversite(detail, t);
|
||||
assert.equal(modele.phrase, 'rien');
|
||||
assert.equal(modele.barres, null);
|
||||
assert.equal(modele.infobulle, null);
|
||||
assert.deepEqual(modele.equite, []);
|
||||
assert.deepEqual(
|
||||
modele.jumeau[0].colonnes.map(({ cle }) => cle),
|
||||
['animateur', 'portee', 'toursAnimes', 'personnes'],
|
||||
);
|
||||
});
|
||||
|
||||
test('aucun « équité », aucun « plafond », aucun pourcentage', () => {
|
||||
for (const options of [{}, { etiquettes: false }, { appartenances: false }]) {
|
||||
for (const texte of textes(modeleDiversite(detailDe(options), t))) {
|
||||
assert.doesNotMatch(texte, /équité|equite|plafond|%/i, texte);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
254
src/interface/qualite/appartenances.js
Normal file
254
src/interface/qualite/appartenances.js
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle pur des vues des collisions d'appartenance (§ 12.4, B13, D31)
|
||||
// et des appartenances à chaque table (§ 12.11, B20, D35) :
|
||||
// VueCollisions.svelte et VueTables.svelte le câblent. Il met en forme ce
|
||||
// que le détail du placement porte, dans l'ordre où il le porte, et ne
|
||||
// trie, ne réduit ni ne recompte rien (contrainte 41) : chaque nombre rendu
|
||||
// est un champ du détail, chaque pourcentage un entier qu'il porte déjà,
|
||||
// écrit par la seule clé du signe, qualite.pourcentage.valeur, et suivi du
|
||||
// compte de sa ligne.
|
||||
//
|
||||
// Chaque modèle rend son cas — le dessin, ou la phrase qui en tient lieu —,
|
||||
// le code du cas, que la vue pose sur sa phrase (data-phrase-qualite), et
|
||||
// les données de ses composants de graphique : Barres et Grille reçoivent
|
||||
// leurs lignes, leurs valeurs et leur infobulle, Jumeau ses tableaux.
|
||||
import { POSITIONS_MELANGE } from '../etat_ui.js';
|
||||
|
||||
/**
|
||||
* Vrai quand aucune appartenance ne compte de collision : chaque ligne des
|
||||
* collisions a zéro collision cumulée (§ 12.6, B15).
|
||||
* @param {{collisions: {lignes: Array<{cumulees: number}>}}} detail
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function sansCollision(detail) {
|
||||
return detail.collisions.lignes.every(({ cumulees }) => cumulees === 0);
|
||||
}
|
||||
|
||||
// Un cas sans dessin : sa phrase, ni graphique ni jumeau.
|
||||
const phraseSeule = (cas, code, phrase) => ({ cas, code, phrase, graphique: null, tableaux: [] });
|
||||
|
||||
/**
|
||||
* La vue des collisions d'appartenance (§ 12.4, B13, D31). Une seule
|
||||
* table, aucune appartenance, aucune collision : la phrase de son cas,
|
||||
* dans cet ordre. Sinon des barres, une ligne par appartenance dans l'ordre
|
||||
* du détail — l'excédent décroissant —, chacune intitulée de l'appartenance,
|
||||
* sa pastille de la teinte que teintes lui donne, ses deux segments,
|
||||
* prouvé inévitable et reste, puis en encre secondaire son effectif, ses
|
||||
* paires distinctes et son excédent. Hors du domaine des planchers, chaque
|
||||
* barre n'a qu'un segment, les collisions cumulées, et la phrase dit
|
||||
* pourquoi. Le jumeau, et l'infobulle de chaque barre, portent la ligne de
|
||||
* l'appartenance : tous ses chiffres, prouvé et reste sur le domaine
|
||||
* seulement.
|
||||
*
|
||||
* @param {Object} detail le détail du placement (detaillerQualite), comparable
|
||||
* @param {{t: Function, teintes: Map<string, number|null>}} contexte
|
||||
* t, la traduction ; teintes, teintesAppartenances des participants
|
||||
* de l'événement, exclus compris
|
||||
* @returns {{cas: 'uneTable'|'aucuneAppartenance'|'aucune'|'barres',
|
||||
* code: string, phrase: string|null,
|
||||
* graphique: {nom, synthese, segments, lignes, infobulle}|null,
|
||||
* tableaux: Array<{nom, colonnes, lignes}>}}
|
||||
*/
|
||||
export function modeleCollisions(detail, { t, teintes }) {
|
||||
if (detail.T === 1) return phraseSeule('uneTable', 'collisionsUneTable', t('qualite.collisions.uneTable'));
|
||||
if (detail.G === 0) {
|
||||
return phraseSeule('aucuneAppartenance', 'collisionsAucuneAppartenance', t('qualite.collisions.aucuneAppartenance'));
|
||||
}
|
||||
if (sansCollision(detail)) return phraseSeule('aucune', 'collisionsAucune', t('qualite.collisions.aucune'));
|
||||
|
||||
const { planchersValides, lignes } = detail.collisions;
|
||||
const colonne = (cle, genre = 'nombre') => ({ cle, libelle: t(`qualite.collisions.colonne.${cle}`), genre });
|
||||
const colonnes = [
|
||||
colonne('appartenance', 'texte'),
|
||||
colonne('effectif'),
|
||||
colonne('cumulees'),
|
||||
...(planchersValides ? [colonne('prouve'), colonne('reste')] : []),
|
||||
colonne('pairesDistinctes'),
|
||||
colonne('excedent'),
|
||||
];
|
||||
const lignesJumeau = lignes.map(({ groupe, effectif, cumulees, prouve, reste, pairesDistinctes, excedent }) => ({
|
||||
appartenance: groupe,
|
||||
effectif,
|
||||
cumulees,
|
||||
...(planchersValides ? { prouve, reste } : {}),
|
||||
pairesDistinctes,
|
||||
excedent,
|
||||
}));
|
||||
const segments = planchersValides
|
||||
? [
|
||||
{ genre: 'prouve', libelle: t('qualite.collisions.segment.prouve') },
|
||||
{ genre: 'reste', libelle: t('qualite.collisions.segment.reste') },
|
||||
]
|
||||
: [{ genre: 'serie1', libelle: t('qualite.collisions.axe') }];
|
||||
const pire = lignes[0];
|
||||
return {
|
||||
cas: 'barres',
|
||||
code: planchersValides ? 'collisions' : 'collisionsHorsDomaine',
|
||||
phrase: planchersValides ? null : t('qualite.collisions.horsDomaine'),
|
||||
graphique: {
|
||||
nom: t('qualite.collisions.graphique'),
|
||||
synthese: t('qualite.collisions.synthese', { appartenance: pire.groupe, excedent: pire.excedent }),
|
||||
segments,
|
||||
lignes: lignes.map(({ groupe, effectif, cumulees, prouve, reste, pairesDistinctes, excedent }) => ({
|
||||
id: groupe,
|
||||
nom: groupe,
|
||||
appartenance: teintes.get(groupe) ?? null,
|
||||
valeurs: planchersValides ? [prouve, reste] : [cumulees],
|
||||
secondaire: t('qualite.collisions.secondaire', { effectif, paires: pairesDistinctes, excedent }),
|
||||
})),
|
||||
infobulle: { colonnes, lignes: lignesJumeau },
|
||||
},
|
||||
tableaux: [{ nom: t('qualite.collisions.graphique'), colonnes, lignes: lignesJumeau }],
|
||||
};
|
||||
}
|
||||
|
||||
/** La plus grande case de la grille des tables, en hauteurs de ligne du
|
||||
* compte qu'elle écrit (coteMaxTextes de Grille) : la case suit la taille
|
||||
* du texte mesurée, et deux tours ne remplissent pas la carte de cases de
|
||||
* plusieurs centaines de pixels. */
|
||||
export const CASE_MAX_TEXTES = 2;
|
||||
|
||||
// Le nom de la colonne du pourcentage de chaque position, au jumeau.
|
||||
const COLONNE_POURCENT = Object.freeze({ sieges: 'pourcentSieges', assis: 'pourcentAssis', doublons: 'pourcentDoublons' });
|
||||
|
||||
// Le pourcentage d'une case sous une position, pour Pourcentage : l'entier
|
||||
// du détail suivi du signe, ou null pour « — », et le compte de sa ligne —
|
||||
// δ appartenances pour c sièges, o assis ou α affiliés. Une case sans
|
||||
// affilié n'a ni pourcentage ni compte : elle le dit.
|
||||
function pourcentDe(t, cellule, position, capacite) {
|
||||
if (cellule === null) return { texte: null, compte: t('qualite.tables.sansAffilie') };
|
||||
const pourcent = cellule.pourcents[position];
|
||||
return {
|
||||
texte: pourcent === null ? null : t('qualite.pourcentage.valeur', { pourcent }),
|
||||
compte: t(`qualite.tables.ligne.${position}`, {
|
||||
appartenances: cellule.appartenances,
|
||||
sieges: capacite,
|
||||
assis: cellule.occupants,
|
||||
affilies: cellule.affilies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Les lignes du tableau des tables dans l'ordre de la position : pour
|
||||
// chacune, son index dans le détail, sa ligne et son intitulé.
|
||||
function rangees(detail, position, t) {
|
||||
return detail.tables.ordres[position].map((i) => ({
|
||||
ligne: detail.tables.lignes[i],
|
||||
libelle: t('qualite.tables.table', { numero: detail.numerosTables[i] }),
|
||||
}));
|
||||
}
|
||||
|
||||
// Le jumeau de la grille : une ligne par table et par tour, dans l'ordre
|
||||
// de la position, les trois comptes et leurs trois pourcentages, chacun
|
||||
// suivi du compte de sa ligne, puis occupants, affiliés et capacité.
|
||||
function jumeauTables(detail, position, t) {
|
||||
const colonne = (cle, genre = 'nombre') => ({ cle, libelle: t(`qualite.tables.colonne.${cle}`), genre });
|
||||
const colonnes = [colonne('table', 'texte'), colonne('tour', 'texte')];
|
||||
for (const p of POSITIONS_MELANGE) colonnes.push(colonne(p), colonne(COLONNE_POURCENT[p], 'pourcentage'));
|
||||
colonnes.push(colonne('occupants'), colonne('affilies'), colonne('capacite'));
|
||||
const lignes = [];
|
||||
for (const { ligne, libelle } of rangees(detail, position, t)) {
|
||||
ligne.tours.forEach((cellule, r) => {
|
||||
const valeurs = { table: libelle, tour: t('qualite.tables.tour', { tour: r + 1 }) };
|
||||
for (const p of POSITIONS_MELANGE) {
|
||||
valeurs[p] = cellule === null ? null : cellule.comptes[p];
|
||||
valeurs[COLONNE_POURCENT[p]] = pourcentDe(t, cellule, p, ligne.capacite);
|
||||
}
|
||||
lignes.push({
|
||||
...valeurs,
|
||||
occupants: cellule?.occupants ?? null,
|
||||
affilies: cellule?.affilies ?? null,
|
||||
capacite: ligne.capacite,
|
||||
});
|
||||
});
|
||||
}
|
||||
return [{ nom: t('qualite.tables.graphique'), colonnes, lignes }];
|
||||
}
|
||||
|
||||
/**
|
||||
* La vue des appartenances à chaque table sous une position du sélecteur
|
||||
* (§ 12.11, B20, D35). Une seule table, aucune appartenance : la phrase de
|
||||
* son cas sous les trois positions, sans sélecteur. Sous « doublons », sans
|
||||
* collision : la phrase de la vue des collisions, le sélecteur et le
|
||||
* jumeau restant. Sinon la grille, une ligne par table dans l'ordre de la
|
||||
* position — la pire en tête, une table sans affilié en dernier —, une
|
||||
* colonne par tour, chaque case le compte de la position, null pour une
|
||||
* case sans affilié ; son infobulle, une ligne par case dans l'ordre de la
|
||||
* grille : la table, le tour, le compte, son pourcentage suivi de ses deux
|
||||
* comptes, les occupants, les affiliés, la capacité. Le jumeau porte les
|
||||
* trois comptes de chaque table à chaque tour, dans l'ordre de la position.
|
||||
* La synthèse nomme la pire table et sa case la plus haute sous la
|
||||
* position.
|
||||
*
|
||||
* Lève RangeError sur une position hors de POSITIONS_MELANGE.
|
||||
*
|
||||
* @param {Object} detail le détail du placement (detaillerQualite), comparable
|
||||
* @param {'sieges'|'assis'|'doublons'} position
|
||||
* @param {{t: Function}} contexte
|
||||
* @returns {{cas: 'uneTable'|'aucuneAppartenance'|'sansCollision'|'grille',
|
||||
* code: string, phrase: string|null, selecteur: boolean,
|
||||
* grille: {nom, synthese, phrase, lignes, colonnes, valeurs, infobulle}|null,
|
||||
* tableaux: Array<{nom, colonnes, lignes}>}}
|
||||
*/
|
||||
export function modeleTables(detail, position, { t }) {
|
||||
if (!POSITIONS_MELANGE.includes(position)) {
|
||||
throw new RangeError(`mélange des tables : position hors de ${POSITIONS_MELANGE.join(', ')}, reçu ${String(position)}`);
|
||||
}
|
||||
const sansSelecteur = (cas, code, cle) => ({ cas, code, phrase: t(cle), selecteur: false, grille: null, tableaux: [] });
|
||||
if (detail.T === 1) return sansSelecteur('uneTable', 'tablesUneTable', 'qualite.tables.uneTable');
|
||||
if (detail.G === 0) return sansSelecteur('aucuneAppartenance', 'tablesAucuneAppartenance', 'qualite.tables.aucuneAppartenance');
|
||||
|
||||
const tableaux = jumeauTables(detail, position, t);
|
||||
if (position === 'doublons' && sansCollision(detail)) {
|
||||
const phrase = t('qualite.collisions.aucune');
|
||||
return { cas: 'sansCollision', code: 'tablesSansCollision', phrase, selecteur: true, grille: null, tableaux };
|
||||
}
|
||||
|
||||
const lignes = rangees(detail, position, t);
|
||||
const colonneInfobulle = (cle, cleLibelle, genre = 'nombre') => ({ cle, libelle: t(cleLibelle), genre });
|
||||
const infobulle = {
|
||||
colonnes: [
|
||||
colonneInfobulle('table', 'qualite.tables.colonne.table', 'texte'),
|
||||
colonneInfobulle('tour', 'qualite.tables.colonne.tour', 'texte'),
|
||||
colonneInfobulle('compte', `qualite.tables.colonne.${position}`),
|
||||
colonneInfobulle('pourcent', `qualite.tables.colonne.${COLONNE_POURCENT[position]}`, 'pourcentage'),
|
||||
colonneInfobulle('occupants', 'qualite.tables.colonne.occupants'),
|
||||
colonneInfobulle('affilies', 'qualite.tables.colonne.affilies'),
|
||||
colonneInfobulle('capacite', 'qualite.tables.colonne.capacite'),
|
||||
],
|
||||
lignes: lignes.flatMap(({ ligne, libelle }) =>
|
||||
ligne.tours.map((cellule, r) => ({
|
||||
table: libelle,
|
||||
tour: t('qualite.tables.tour', { tour: r + 1 }),
|
||||
compte: cellule === null ? null : cellule.comptes[position],
|
||||
pourcent: pourcentDe(t, cellule, position, ligne.capacite),
|
||||
occupants: cellule?.occupants ?? null,
|
||||
affilies: cellule?.affilies ?? null,
|
||||
capacite: ligne.capacite,
|
||||
})),
|
||||
),
|
||||
};
|
||||
const [tete] = lignes;
|
||||
const tetes = detail.tables.ordres[position];
|
||||
return {
|
||||
cas: 'grille',
|
||||
code: 'tables',
|
||||
phrase: null,
|
||||
selecteur: true,
|
||||
grille: {
|
||||
nom: t('qualite.tables.graphique'),
|
||||
synthese:
|
||||
tete.ligne.pire === null
|
||||
? t('qualite.tables.aucuneAppartenance')
|
||||
: t('qualite.tables.synthese', { numero: detail.numerosTables[tetes[0]], valeur: tete.ligne.pire[position] }),
|
||||
phrase: t('qualite.tables.tropLarge'),
|
||||
lignes: lignes.map(({ libelle }) => ({ libelle })),
|
||||
colonnes: Array.from({ length: detail.R }, (_, r) => ({ libelle: t('qualite.tables.tour', { tour: r + 1 }) })),
|
||||
valeurs: lignes.flatMap(({ ligne }) => ligne.tours.map((cellule) => (cellule === null ? null : cellule.comptes[position]))),
|
||||
infobulle,
|
||||
},
|
||||
tableaux,
|
||||
};
|
||||
}
|
||||
524
src/interface/qualite/appartenances.test.js
Normal file
524
src/interface/qualite/appartenances.test.js
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle des vues des collisions d'appartenance (§ 12.4, B13,
|
||||
// D31) et des appartenances à chaque table (§ 12.11, B20, D35) : chaque
|
||||
// nombre vient du détail du placement, dans son ordre ; les barres, leur
|
||||
// pastille, leurs segments et leur jumeau ; la grille de la position, ses
|
||||
// cases, son infobulle et son jumeau des trois comptes ; les cas qui cèdent
|
||||
// la place à une phrase. Les détails se mesurent par detaillerQualite sur
|
||||
// des plans écrits à la main, dont les comptes attendus sont calculés ici à
|
||||
// la main. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { capacite, creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { teintesAppartenances } from '../appartenances.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { modeleCollisions, modeleTables, sansCollision } from './appartenances.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const AUBIER = 'Coopérative Aubier';
|
||||
const RESSAC = 'Atelier Ressac';
|
||||
|
||||
// Une charge de personnes inventées, de tables de capacités données, et une
|
||||
// proposition, n° 1, écrite à la main : à chaque tour, les listes de chaque
|
||||
// table dans l'ordre des tables, sièges non attribués ; reserves, les
|
||||
// personnes en réserve de chaque tour.
|
||||
function chargeDe({ personnes, capacites, tours, reserves = tours.map(() => []) }) {
|
||||
const charge = creerCharge({ id: 'evt-appartenances', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: tours.length });
|
||||
charge.participants = personnes.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = capacites.map((sieges, rang) => ({
|
||||
id: rang + 1,
|
||||
numero: rang + 1,
|
||||
sieges,
|
||||
forme: 'ronde',
|
||||
position: { x: 300 * rang, y: 0 },
|
||||
}));
|
||||
charge.propositions = [
|
||||
{
|
||||
id: 1,
|
||||
graine: 1,
|
||||
arret: 40,
|
||||
historique: 1000,
|
||||
produitVersion: VERSION.affichee,
|
||||
siegesAttribues: false,
|
||||
tables: charge.tables.map(({ id }) => id),
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: tours.length,
|
||||
participants: charge.participants.map(({ id }) => id),
|
||||
placement: tours.map((listes, r) => ({ sieges: listes, reserve: reserves[r] })),
|
||||
},
|
||||
];
|
||||
charge.prochainsIds = { participant: personnes.length + 1, table: capacites.length + 1, proposition: 2 };
|
||||
charge.evenement.etat = 'propose';
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Treize personnes, quatre tables de 5, 4, 4 et 3 places, le même plan aux
|
||||
// deux tours. Table 1 : un membre de chaque appartenance, trois places
|
||||
// vides. Table 2 : deux membres de chacune. Table 3 : deux membres d'Aubier
|
||||
// et deux personnes sans appartenance. Table 4 : trois bénévoles sans
|
||||
// appartenance. Pour chaque case (c, o, α, δ) : table 1 (5, 2, 2, 2),
|
||||
// table 2 (4, 4, 4, 2), table 3 (4, 4, 2, 1), table 4 sans affilié. Les
|
||||
// comptes sièges, assis, doublons : table 1 (3, 0, 0), table 2 (2, 2, 2),
|
||||
// table 3 (3, 3, 1). Les trois ordres diffèrent : sièges 1, 3, 2, 4 (1 et 3
|
||||
// à égalité, départagées par l'ordre des tables) ; assis 3, 2, 1, 4 ;
|
||||
// doublons 2, 3, 1, 4. Aubier : 5 membres, deux paires réunies à chaque
|
||||
// tour, 4 collisions cumulées sur 2 paires distinctes ; Ressac : 3
|
||||
// membres, une paire, 2 collisions sur 1 paire.
|
||||
const PERSONNES_MELANGE = [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Lacasse', 'Ondine', RESSAC],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Quenouille', 'Maëlle', AUBIER],
|
||||
['Tourteau', 'Basile', RESSAC],
|
||||
['Brindille', 'Anselme', RESSAC],
|
||||
['Saule', 'Clothilde', AUBIER],
|
||||
['Galet', 'Eudes', AUBIER],
|
||||
['Mousse', 'Félicie', null],
|
||||
['Ruisseau', 'Gaspard', null],
|
||||
['Lichen', 'Hortense', null],
|
||||
['Chardon', 'Jules', null],
|
||||
['Ronce', 'Léonie', null],
|
||||
];
|
||||
const PLAN_MELANGE = [[1, 2], [3, 4, 5, 6], [7, 8, 9, 10], [11, 12, 13]];
|
||||
const chargeMelange = () =>
|
||||
chargeDe({ personnes: PERSONNES_MELANGE, capacites: [5, 4, 4, 3], tours: [PLAN_MELANGE, PLAN_MELANGE] });
|
||||
|
||||
// Six personnes, deux appartenances de deux, deux tables de trois, deux
|
||||
// tours : aucun membre ne retrouve l'autre membre de son appartenance.
|
||||
const PERSONNES_SIX = [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Lacasse', 'Ondine', RESSAC],
|
||||
['Tourteau', 'Basile', RESSAC],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
const chargeSix = (personnes = PERSONNES_SIX) =>
|
||||
chargeDe({
|
||||
personnes,
|
||||
capacites: [3, 3],
|
||||
tours: [
|
||||
[[1, 3, 5], [2, 4, 6]],
|
||||
[[1, 4, 6], [2, 3, 5]],
|
||||
],
|
||||
});
|
||||
|
||||
// Sept personnes, deux tables de 5 et 4 places, deux tours. Au tour 1, la
|
||||
// table 1 réunit deux membres d'Aubier, un de Ressac et une personne sans
|
||||
// appartenance (c, o, α, δ) = (5, 4, 3, 2), comptes (3, 2, 1) ; la table 2,
|
||||
// trois personnes sans appartenance, sans affilié. Au tour 2, la table 1
|
||||
// réunit les deux membres d'Aubier et deux personnes sans appartenance
|
||||
// (5, 4, 2, 1), comptes (4, 3, 1) ; la table 2, le membre de Ressac et deux
|
||||
// personnes sans appartenance (4, 3, 1, 1), comptes (3, 2, 0). La table 1
|
||||
// est en tête sous les trois positions, et ses cases les plus hautes
|
||||
// diffèrent d'une position à l'autre : 4, 3 et 1, chacune hors du tour 1
|
||||
// sous « sièges » et « assis ».
|
||||
const chargeSynthese = () =>
|
||||
chargeDe({
|
||||
personnes: [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Lacasse', 'Ondine', RESSAC],
|
||||
['Mousse', 'Félicie', null],
|
||||
['Ruisseau', 'Gaspard', null],
|
||||
['Lichen', 'Hortense', null],
|
||||
['Chardon', 'Jules', null],
|
||||
],
|
||||
capacites: [5, 4],
|
||||
tours: [
|
||||
[[1, 2, 3, 4], [5, 6, 7]],
|
||||
[[1, 2, 4, 5], [3, 6, 7]],
|
||||
],
|
||||
});
|
||||
|
||||
// Six personnes dont deux seules ont une appartenance, la même : une
|
||||
// appartenance unique, dont les deux membres se retrouvent aux deux tours,
|
||||
// 2 collisions cumulées sur une paire.
|
||||
const chargeUneAppartenance = () =>
|
||||
chargeDe({
|
||||
personnes: [
|
||||
['Ombrelle', 'Iris', AUBIER],
|
||||
['Pervenche', 'Théo', AUBIER],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
['Mousse', 'Félicie', null],
|
||||
['Ruisseau', 'Gaspard', null],
|
||||
],
|
||||
capacites: [3, 3],
|
||||
tours: [
|
||||
[[1, 2, 3], [4, 5, 6]],
|
||||
[[1, 2, 4], [3, 5, 6]],
|
||||
],
|
||||
});
|
||||
|
||||
const detailDe = (charge) => detaillerQualite(charge, 1);
|
||||
const teintesDe = (charge) => teintesAppartenances(charge.participants);
|
||||
|
||||
describe('modèle des collisions (§ 12.4, B13, D31)', () => {
|
||||
test('une barre par appartenance dans l’ordre du détail, sa pastille, ses deux segments et ses trois autres chiffres en encre secondaire', () => {
|
||||
const charge = chargeMelange();
|
||||
const detail = detailDe(charge);
|
||||
assert.equal(detail.statut, 'comparable');
|
||||
const modele = modeleCollisions(detail, { t, teintes: teintesDe(charge) });
|
||||
assert.equal(modele.cas, 'barres');
|
||||
assert.equal(modele.phrase, null);
|
||||
assert.deepEqual(
|
||||
modele.graphique.segments.map(({ genre }) => genre),
|
||||
['prouve', 'reste'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
modele.graphique.segments.map(({ libelle }) => libelle),
|
||||
[t('qualite.collisions.segment.prouve'), t('qualite.collisions.segment.reste')],
|
||||
);
|
||||
// Les comptes calculés à la main ; le plancher, lu au détail.
|
||||
assert.deepEqual(
|
||||
detail.collisions.lignes.map(({ groupe, effectif, cumulees, pairesDistinctes }) => [groupe, effectif, cumulees, pairesDistinctes]),
|
||||
[
|
||||
[AUBIER, 5, 4, 2],
|
||||
[RESSAC, 3, 2, 1],
|
||||
],
|
||||
);
|
||||
const teintes = teintesDe(charge);
|
||||
assert.deepEqual(
|
||||
modele.graphique.lignes,
|
||||
detail.collisions.lignes.map((l) => ({
|
||||
id: l.groupe,
|
||||
nom: l.groupe,
|
||||
appartenance: teintes.get(l.groupe),
|
||||
valeurs: [l.prouve, l.reste],
|
||||
secondaire: t('qualite.collisions.secondaire', { effectif: l.effectif, paires: l.pairesDistinctes, excedent: l.excedent }),
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(
|
||||
modele.graphique.lignes.map(({ appartenance }) => appartenance),
|
||||
[2, 1],
|
||||
'Ressac avant Aubier au comparateur de noms',
|
||||
);
|
||||
assert.match(modele.graphique.lignes[0].secondaire, /5/);
|
||||
assert.equal(modele.graphique.synthese, t('qualite.collisions.synthese', { appartenance: AUBIER, excedent: detail.collisions.lignes[0].excedent }));
|
||||
});
|
||||
|
||||
test('le jumeau et l’infobulle portent la même ligne par appartenance, tous ses chiffres, dans l’ordre des barres', () => {
|
||||
const charge = chargeMelange();
|
||||
const detail = detailDe(charge);
|
||||
const modele = modeleCollisions(detail, { t, teintes: teintesDe(charge) });
|
||||
const [tableau] = modele.tableaux;
|
||||
assert.equal(modele.tableaux.length, 1);
|
||||
assert.equal(tableau.nom, t('qualite.collisions.graphique'));
|
||||
assert.deepEqual(
|
||||
tableau.colonnes.map(({ cle, genre }) => [cle, genre]),
|
||||
[
|
||||
['appartenance', 'texte'],
|
||||
['effectif', 'nombre'],
|
||||
['cumulees', 'nombre'],
|
||||
['prouve', 'nombre'],
|
||||
['reste', 'nombre'],
|
||||
['pairesDistinctes', 'nombre'],
|
||||
['excedent', 'nombre'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
tableau.lignes,
|
||||
detail.collisions.lignes.map((l) => ({
|
||||
appartenance: l.groupe,
|
||||
effectif: l.effectif,
|
||||
cumulees: l.cumulees,
|
||||
prouve: l.prouve,
|
||||
reste: l.reste,
|
||||
pairesDistinctes: l.pairesDistinctes,
|
||||
excedent: l.excedent,
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(modele.graphique.infobulle, { colonnes: tableau.colonnes, lignes: tableau.lignes });
|
||||
});
|
||||
|
||||
test('hors du domaine des planchers : un seul segment, les collisions cumulées, la phrase qui dit pourquoi, ni prouvé ni reste au jumeau', () => {
|
||||
const charge = chargeMelange();
|
||||
const detail = detailDe(charge);
|
||||
const hors = {
|
||||
...detail,
|
||||
collisions: {
|
||||
planchersValides: false,
|
||||
lignes: detail.collisions.lignes.map((l) => ({ ...l, prouve: null, reste: null })),
|
||||
},
|
||||
};
|
||||
const modele = modeleCollisions(hors, { t, teintes: teintesDe(charge) });
|
||||
assert.equal(modele.cas, 'barres');
|
||||
assert.equal(modele.code, 'collisionsHorsDomaine');
|
||||
assert.equal(modele.phrase, t('qualite.collisions.horsDomaine'));
|
||||
assert.deepEqual(modele.graphique.segments, [{ genre: 'serie1', libelle: t('qualite.collisions.axe') }]);
|
||||
assert.deepEqual(
|
||||
modele.graphique.lignes.map(({ valeurs }) => valeurs),
|
||||
[[4], [2]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
modele.tableaux[0].colonnes.map(({ cle }) => cle),
|
||||
['appartenance', 'effectif', 'cumulees', 'pairesDistinctes', 'excedent'],
|
||||
);
|
||||
});
|
||||
|
||||
test('une seule appartenance : ses barres et son jumeau, comme pour plusieurs (B15)', () => {
|
||||
const charge = chargeUneAppartenance();
|
||||
const detail = detailDe(charge);
|
||||
assert.equal(detail.G, 1);
|
||||
const modele = modeleCollisions(detail, { t, teintes: teintesDe(charge) });
|
||||
assert.equal(modele.cas, 'barres');
|
||||
assert.equal(modele.code, 'collisions');
|
||||
assert.equal(modele.phrase, null);
|
||||
assert.deepEqual(
|
||||
modele.graphique.lignes.map(({ id, valeurs }) => [id, valeurs.reduce((a, b) => a + b, 0)]),
|
||||
[[AUBIER, 2]],
|
||||
);
|
||||
assert.equal(modele.tableaux.length, 1);
|
||||
});
|
||||
|
||||
test('une seule table, aucune appartenance, aucune collision : la phrase de chaque cas, sans graphique ni jumeau', () => {
|
||||
const uneTable = chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] });
|
||||
const sans = chargeSix(PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]));
|
||||
const six = chargeSix();
|
||||
const cas = [
|
||||
[uneTable, 'uneTable', 'collisionsUneTable', 'qualite.collisions.uneTable'],
|
||||
[sans, 'aucuneAppartenance', 'collisionsAucuneAppartenance', 'qualite.collisions.aucuneAppartenance'],
|
||||
[six, 'aucune', 'collisionsAucune', 'qualite.collisions.aucune'],
|
||||
];
|
||||
for (const [charge, nom, code, cle] of cas) {
|
||||
const modele = modeleCollisions(detailDe(charge), { t, teintes: teintesDe(charge) });
|
||||
assert.deepEqual(modele, { cas: nom, code, phrase: t(cle), graphique: null, tableaux: [] }, nom);
|
||||
}
|
||||
assert.equal(sansCollision(detailDe(six)), true);
|
||||
assert.equal(sansCollision(detailDe(chargeMelange())), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modèle des appartenances à chaque table (§ 12.11, B20, D35)', () => {
|
||||
// Le numéro de table de chaque ligne de la grille, dans l'ordre rendu.
|
||||
const numeros = (modele) => modele.grille.lignes.map(({ libelle }) => libelle);
|
||||
const tables = (...n) => n.map((numero) => t('qualite.tables.table', { numero }));
|
||||
|
||||
test('sous chaque position, les lignes suivent l’ordre de la position, la pire table en tête, la table sans affilié en dernier', () => {
|
||||
const detail = detailDe(chargeMelange());
|
||||
const attendus = { sieges: [1, 3, 2, 4], assis: [3, 2, 1, 4], doublons: [2, 3, 1, 4] };
|
||||
for (const [position, ordre] of Object.entries(attendus)) {
|
||||
const modele = modeleTables(detail, position, { t });
|
||||
assert.equal(modele.cas, 'grille', position);
|
||||
assert.equal(modele.selecteur, true);
|
||||
assert.deepEqual(numeros(modele), tables(...ordre), position);
|
||||
assert.deepEqual(
|
||||
detail.tables.ordres[position].map((i) => detail.numerosTables[i]),
|
||||
ordre,
|
||||
position,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque case écrit le compte de la position, null pour « — » ; une colonne par tour', () => {
|
||||
const detail = detailDe(chargeMelange());
|
||||
const valeurs = {
|
||||
sieges: [3, 3, 3, 3, 2, 2, null, null],
|
||||
assis: [3, 3, 2, 2, 0, 0, null, null],
|
||||
doublons: [2, 2, 1, 1, 0, 0, null, null],
|
||||
};
|
||||
for (const [position, attendues] of Object.entries(valeurs)) {
|
||||
const { grille } = modeleTables(detail, position, { t });
|
||||
assert.deepEqual(grille.valeurs, attendues, position);
|
||||
assert.deepEqual(
|
||||
grille.colonnes,
|
||||
[1, 2].map((tour) => ({ libelle: t('qualite.tables.tour', { tour }) })),
|
||||
);
|
||||
assert.equal(grille.nom, t('qualite.tables.graphique'));
|
||||
assert.equal(grille.phrase, t('qualite.tables.tropLarge'));
|
||||
}
|
||||
});
|
||||
|
||||
test('l’infobulle d’une case : sa table, son tour, le compte, le pourcentage de la position suivi de ses deux comptes, occupants, affiliés, capacité', () => {
|
||||
const detail = detailDe(chargeMelange());
|
||||
const pour = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
|
||||
const sieges = modeleTables(detail, 'sieges', { t }).grille.infobulle;
|
||||
assert.deepEqual(
|
||||
sieges.colonnes.map(({ cle, genre }) => [cle, genre]),
|
||||
[
|
||||
['table', 'texte'],
|
||||
['tour', 'texte'],
|
||||
['compte', 'nombre'],
|
||||
['pourcent', 'pourcentage'],
|
||||
['occupants', 'nombre'],
|
||||
['affilies', 'nombre'],
|
||||
['capacite', 'nombre'],
|
||||
],
|
||||
);
|
||||
// Table 1, tour 1 : δ = 2 sur 5 sièges, 40 %.
|
||||
assert.deepEqual(sieges.lignes[0], {
|
||||
table: t('qualite.tables.table', { numero: 1 }),
|
||||
tour: t('qualite.tables.tour', { tour: 1 }),
|
||||
compte: 3,
|
||||
pourcent: { texte: pour(40), compte: t('qualite.tables.ligne.sieges', { appartenances: 2, sieges: 5 }) },
|
||||
occupants: 2,
|
||||
affilies: 2,
|
||||
capacite: 5,
|
||||
});
|
||||
assert.equal(pour(40), '40\u{202F}%');
|
||||
assert.equal(sieges.lignes[0].pourcent.compte, '2 appartenances pour 5 sièges');
|
||||
// Table 3, tour 2, sous « assis » : δ = 1 pour 4 assis, 25 %.
|
||||
const assis = modeleTables(detail, 'assis', { t }).grille.infobulle;
|
||||
assert.deepEqual(assis.lignes[1].pourcent, { texte: pour(25), compte: '1 appartenance pour 4 assis' });
|
||||
// Table 2, sous « doublons » : δ = 2 pour 4 affiliés, 50 %.
|
||||
const doublons = modeleTables(detail, 'doublons', { t }).grille.infobulle;
|
||||
assert.deepEqual(doublons.lignes[0].pourcent, { texte: pour(50), compte: '2 appartenances pour 4 affiliés' });
|
||||
// Le compte de chaque case est celui de la position : la table 1 en
|
||||
// compte 3 sous « sièges », 0 sous « assis » et « doublons » ; la
|
||||
// table 3, 3 sous « assis », 1 sous « doublons ».
|
||||
const comptes = (infobulle) => infobulle.lignes.map(({ table, compte }) => [table, compte]);
|
||||
const table = (numero) => t('qualite.tables.table', { numero });
|
||||
const parCase = (liste) => liste.flatMap(([numero, compte]) => [[table(numero), compte], [table(numero), compte]]);
|
||||
assert.deepEqual(comptes(sieges), parCase([[1, 3], [3, 3], [2, 2], [4, null]]));
|
||||
assert.deepEqual(comptes(assis), parCase([[3, 3], [2, 2], [1, 0], [4, null]]));
|
||||
assert.deepEqual(comptes(doublons), parCase([[2, 2], [3, 1], [1, 0], [4, null]]));
|
||||
// Table 4, sans affilié : « — », sans compte inventé, sous les trois.
|
||||
for (const infobulle of [sieges, assis, doublons]) {
|
||||
assert.deepEqual(infobulle.lignes.slice(-2), [1, 2].map((tour) => ({
|
||||
table: t('qualite.tables.table', { numero: 4 }),
|
||||
tour: t('qualite.tables.tour', { tour }),
|
||||
compte: null,
|
||||
pourcent: { texte: null, compte: t('qualite.tables.sansAffilie') },
|
||||
occupants: null,
|
||||
affilies: null,
|
||||
capacite: 3,
|
||||
})));
|
||||
}
|
||||
});
|
||||
|
||||
test('le jumeau : par table et tour, dans l’ordre de la position, les trois comptes et leurs trois pourcentages, chacun suivi du compte de sa ligne', () => {
|
||||
const detail = detailDe(chargeMelange());
|
||||
const pour = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
|
||||
const { tableaux } = modeleTables(detail, 'doublons', { t });
|
||||
assert.equal(tableaux.length, 1);
|
||||
const [tableau] = tableaux;
|
||||
assert.equal(tableau.nom, t('qualite.tables.graphique'));
|
||||
assert.deepEqual(
|
||||
tableau.colonnes.map(({ cle, genre }) => [cle, genre]),
|
||||
[
|
||||
['table', 'texte'],
|
||||
['tour', 'texte'],
|
||||
['sieges', 'nombre'],
|
||||
['pourcentSieges', 'pourcentage'],
|
||||
['assis', 'nombre'],
|
||||
['pourcentAssis', 'pourcentage'],
|
||||
['doublons', 'nombre'],
|
||||
['pourcentDoublons', 'pourcentage'],
|
||||
['occupants', 'nombre'],
|
||||
['affilies', 'nombre'],
|
||||
['capacite', 'nombre'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
tableau.lignes.map(({ table }) => table),
|
||||
tables(2, 2, 3, 3, 1, 1, 4, 4),
|
||||
);
|
||||
// Table 3, tour 1 : (c, o, α, δ) = (4, 4, 2, 1).
|
||||
assert.deepEqual(tableau.lignes[2], {
|
||||
table: t('qualite.tables.table', { numero: 3 }),
|
||||
tour: t('qualite.tables.tour', { tour: 1 }),
|
||||
sieges: 3,
|
||||
pourcentSieges: { texte: pour(25), compte: '1 appartenance pour 4 sièges' },
|
||||
assis: 3,
|
||||
pourcentAssis: { texte: pour(25), compte: '1 appartenance pour 4 assis' },
|
||||
doublons: 1,
|
||||
pourcentDoublons: { texte: pour(50), compte: '1 appartenance pour 2 affiliés' },
|
||||
occupants: 4,
|
||||
affilies: 2,
|
||||
capacite: 4,
|
||||
});
|
||||
assert.deepEqual(tableau.lignes[7].pourcentDoublons, { texte: null, compte: t('qualite.tables.sansAffilie') });
|
||||
assert.equal(tableau.lignes[7].doublons, null);
|
||||
});
|
||||
|
||||
test('la synthèse nomme la pire table de la position et sa case la plus haute', () => {
|
||||
const detail = detailDe(chargeMelange());
|
||||
assert.equal(modeleTables(detail, 'sieges', { t }).grille.synthese, t('qualite.tables.synthese', { numero: 1, valeur: 3 }));
|
||||
assert.equal(modeleTables(detail, 'assis', { t }).grille.synthese, t('qualite.tables.synthese', { numero: 3, valeur: 3 }));
|
||||
assert.equal(modeleTables(detail, 'doublons', { t }).grille.synthese, t('qualite.tables.synthese', { numero: 2, valeur: 2 }));
|
||||
});
|
||||
|
||||
test('la synthèse prend la case la plus haute de la pire table sous la position choisie, non sous une autre', () => {
|
||||
const detail = detailDe(chargeSynthese());
|
||||
assert.deepEqual(detail.tables.lignes[0].pire, { sieges: 4, assis: 3, doublons: 1 });
|
||||
for (const [position, valeur] of [['sieges', 4], ['assis', 3], ['doublons', 1]]) {
|
||||
const modele = modeleTables(detail, position, { t });
|
||||
assert.deepEqual(numeros(modele), tables(1, 2), position);
|
||||
assert.equal(modele.grille.synthese, t('qualite.tables.synthese', { numero: 1, valeur }), position);
|
||||
}
|
||||
});
|
||||
|
||||
test('une seule appartenance : la grille sous les trois positions, le sélecteur et le jumeau (B15)', () => {
|
||||
const detail = detailDe(chargeUneAppartenance());
|
||||
assert.equal(detail.G, 1);
|
||||
for (const position of ['sieges', 'assis', 'doublons']) {
|
||||
const modele = modeleTables(detail, position, { t });
|
||||
assert.equal(modele.cas, 'grille', position);
|
||||
assert.equal(modele.selecteur, true, position);
|
||||
assert.equal(modele.tableaux.length, 1, position);
|
||||
assert.deepEqual(modele.grille.valeurs, { sieges: [2, 2, null, null], assis: [2, 2, null, null], doublons: [1, 1, null, null] }[position], position);
|
||||
}
|
||||
});
|
||||
|
||||
test('sans collision : sous « doublons » seulement, la phrase des collisions, le sélecteur et le jumeau restent ; sous « sièges » et « assis », la grille', () => {
|
||||
const detail = detailDe(chargeSix());
|
||||
const doublons = modeleTables(detail, 'doublons', { t });
|
||||
assert.equal(doublons.cas, 'sansCollision');
|
||||
assert.equal(doublons.code, 'tablesSansCollision');
|
||||
assert.equal(doublons.phrase, t('qualite.collisions.aucune'));
|
||||
assert.equal(doublons.selecteur, true);
|
||||
assert.equal(doublons.grille, null);
|
||||
assert.equal(doublons.tableaux.length, 1);
|
||||
for (const position of ['sieges', 'assis']) {
|
||||
const modele = modeleTables(detail, position, { t });
|
||||
assert.equal(modele.cas, 'grille', position);
|
||||
assert.deepEqual(modele.grille.valeurs, [1, 1, 1, 1], position);
|
||||
}
|
||||
});
|
||||
|
||||
test('une seule table, aucune appartenance : la phrase sous les trois positions, sans sélecteur, grille ni jumeau', () => {
|
||||
const uneTable = chargeDe({ personnes: PERSONNES_SIX, capacites: [6], tours: [[[1, 2, 3, 4, 5, 6]], [[1, 2, 3, 4, 5, 6]]] });
|
||||
const sans = chargeSix(PERSONNES_SIX.map(([nom, prenom]) => [nom, prenom, null]));
|
||||
for (const position of ['sieges', 'assis', 'doublons']) {
|
||||
assert.deepEqual(modeleTables(detailDe(uneTable), position, { t }), {
|
||||
cas: 'uneTable',
|
||||
code: 'tablesUneTable',
|
||||
phrase: t('qualite.tables.uneTable'),
|
||||
selecteur: false,
|
||||
grille: null,
|
||||
tableaux: [],
|
||||
});
|
||||
assert.deepEqual(modeleTables(detailDe(sans), position, { t }), {
|
||||
cas: 'aucuneAppartenance',
|
||||
code: 'tablesAucuneAppartenance',
|
||||
phrase: t('qualite.tables.aucuneAppartenance'),
|
||||
selecteur: false,
|
||||
grille: null,
|
||||
tableaux: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('une position hors des trois lève RangeError', () => {
|
||||
const detail = detailDe(chargeMelange());
|
||||
for (const faute of ['Sieges', 'tous', null, undefined]) {
|
||||
assert.throws(() => modeleTables(detail, faute, { t }), RangeError, String(faute));
|
||||
}
|
||||
});
|
||||
});
|
||||
169
src/interface/qualite/entete.js
Normal file
169
src/interface/qualite/entete.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de l'en-tête chiffré de la page de qualité (§ 12.2, B11, D29,
|
||||
// D34, D48, D52), pur : VueEntete.svelte le câble.
|
||||
//
|
||||
// Une tuile par colonne de mesure du tableau des propositions, dans l'ordre
|
||||
// de COLONNES et sous ses groupes (GROUPES_COLONNES) : la tuile lit sa
|
||||
// mesure dans la ligne d'évaluation de la source par lireColonne, la
|
||||
// cellule même que le tableau écrit, mise en forme par texteCellule. Une
|
||||
// colonne que le tableau porte donne à sa tuile son libellé et son
|
||||
// explication ; une colonne qu'il ne porte pas — critère décoché, valeur
|
||||
// que le mode réglé ne compare pas — donne la sienne sous le libellé
|
||||
// qualite.tuile.<nom>, qui dit qu'elle ne classe pas. Un critère sans objet
|
||||
// n'a pas de tuile : sa mesure est absente de la ligne (null), seul cas où
|
||||
// une colonne conditionnelle n'a pas de valeur.
|
||||
//
|
||||
// Les bornes des tuiles viennent du détail du placement (detail.bornes),
|
||||
// chacune celle d'une personne nommée, que la tuile nomme : rien ne s'y
|
||||
// recompte, rien ne s'y moyenne. Les tours hors objectif portent le
|
||||
// plancher de la soirée (detail.objectifs) quand le diagnostic en écrit
|
||||
// un — non nul, sous des planchers valides — ; les rencontres répétées et
|
||||
// les retours choisis énoncent à part les imposés, lus dans la ligne
|
||||
// d'évaluation et dans la table. Trois tuiles portent un pourcentage, un
|
||||
// entier que le détail porte déjà, suivi du compte du pire : le minimum
|
||||
// brut (la personne au rang 1 du profil), les tours hors objectif (la
|
||||
// soirée), le minimum de s(p) (la personne à ce minimum). Aucune autre.
|
||||
import { COLONNES, GROUPES_COLONNES, explicationDeColonne, lireColonne, tableau, texteCellule } from '../propositions/modele.js';
|
||||
|
||||
/** Les tuiles qui portent un pourcentage (B11, D34), dans l'ordre de
|
||||
* COLONNES : aucune autre n'en porte. */
|
||||
export const TUILES_A_POURCENTAGE = Object.freeze(['rencontresTous', 'toursHorsObjectif', 'toursSelonObjectif']);
|
||||
|
||||
// La clé de l'en-tête de chaque colonne, celle que le tableau écrit.
|
||||
const EN_TETES = new Map(COLONNES.map(({ nom, cle }) => [nom, cle]));
|
||||
|
||||
/**
|
||||
* @typedef {Object} Tuile
|
||||
* @property {string} nom le nom de sa colonne
|
||||
* @property {string} libelle traduit : celui de la colonne, ou qualite.tuile.<nom>
|
||||
* @property {string} explication la clé de l'explication de la colonne
|
||||
* @property {string} texte la valeur, écrite comme au tableau
|
||||
* @property {boolean} classe vrai quand le tableau porte la colonne
|
||||
* @property {string|null} neClassePas la mention, null quand elle classe
|
||||
* @property {string[]} bornes les lignes de la borne, traduites
|
||||
* @property {{texte: string|null, compte: string}|null} pourcentage
|
||||
* le pourcentage écrit, null quand son dénominateur est nul, et
|
||||
* le compte du pire ; null hors des trois tuiles
|
||||
*/
|
||||
|
||||
// Les lignes de borne et le pourcentage d'une tuile, lus dans le détail.
|
||||
// nommer(id) rend la ligne qui nomme la personne id ; lire(nom) la valeur
|
||||
// d'une colonne de la ligne d'évaluation ; ligne et table, la ligne
|
||||
// d'évaluation et la table, qui portent les imposés.
|
||||
function bornesDe(nom, { detail, ligne, table, t, nommer, lire }) {
|
||||
const { bornes } = detail;
|
||||
const pourcent = (valeur) => (valeur === null ? null : t('qualite.pourcentage.valeur', { pourcent: valeur }));
|
||||
const rendu = (lignes, pourcentage = null) => ({ bornes: lignes, pourcentage });
|
||||
switch (nom) {
|
||||
case 'rencontresTous': {
|
||||
const { rang1 } = bornes;
|
||||
if (rang1 === null) return rendu([]);
|
||||
const lignes = [nommer(rang1.id), t('qualite.tuile.plafondRealise', { plafond: rang1.plafondRealise })];
|
||||
if (rang1.plafondAPriori !== null) lignes.push(t('qualite.tuile.plafondAPriori', { plafond: rang1.plafondAPriori }));
|
||||
return rendu(lignes, {
|
||||
texte: pourcent(rang1.pourcent),
|
||||
compte: t('qualite.tuile.pireRencontres', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }),
|
||||
});
|
||||
}
|
||||
case 'manqueMax': {
|
||||
const { manque } = bornes;
|
||||
if (manque === null) return rendu([]);
|
||||
const effectif = lire('effectifManque');
|
||||
return rendu([
|
||||
nommer(manque.id),
|
||||
...(effectif === null ? [] : [t('qualite.tuile.effectifManque', { effectif })]),
|
||||
t('qualite.tuile.plafondRealise', { plafond: manque.plafondRealise }),
|
||||
]);
|
||||
}
|
||||
case 'appartenancesVues': {
|
||||
const { diversite } = bornes;
|
||||
if (diversite === null) return rendu([t('qualite.tuile.sansAppartenance')]);
|
||||
return rendu([nommer(diversite.id), t('qualite.tuile.borneDiversite', { borne: diversite.aMax })]);
|
||||
}
|
||||
case 'toursHorsObjectif': {
|
||||
const { soiree } = bornes;
|
||||
if (soiree === null) return rendu([]);
|
||||
const plancher = detail.objectifs?.soiree.plancher ?? null;
|
||||
const ecrit = detail.collisions.planchersValides && plancher !== null && plancher > 0;
|
||||
return rendu(ecrit ? [t('qualite.tuile.plancher', { plancher })] : [], {
|
||||
texte: soiree.pourcent === null ? null : t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
|
||||
compte: t('qualite.tuile.pireJamaisServies', { personnes: soiree.jamaisServies }),
|
||||
});
|
||||
}
|
||||
case 'toursSelonObjectif': {
|
||||
const { objectifs } = bornes;
|
||||
if (objectifs === null) return rendu([]);
|
||||
return rendu([nommer(objectifs.id), t('qualite.tuile.toursPossibles', { possibles: objectifs.possibles })], {
|
||||
texte: pourcent(objectifs.pourcent),
|
||||
compte: t('qualite.tuile.pireServis', { servis: objectifs.servis, possibles: objectifs.possibles }),
|
||||
});
|
||||
}
|
||||
case 'rencontresRepetees':
|
||||
return rendu([t('qualite.tuile.imposees', { imposees: ligne.mesures.rencontresRepetees.imposees })]);
|
||||
case 'retoursChoisis':
|
||||
return rendu([t('qualite.tuile.imposes', { imposes: table.imposes.retours })]);
|
||||
case 'animateursRencontres': {
|
||||
const { animateurs } = bornes;
|
||||
if (animateurs === null) return rendu([]);
|
||||
return rendu([nommer(animateurs.id), t('qualite.tuile.borneAnimateurs', { borne: animateurs.borne })]);
|
||||
}
|
||||
default:
|
||||
return rendu([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* L'en-tête chiffré d'une source mesurée, sous le contrat de propriétés des
|
||||
* vues : null sans détail comparable, sans ligne ou sans table.
|
||||
*
|
||||
* @param {{detail: Object|null, ligne: Object|null, table: Object|null}} vue
|
||||
* le détail du placement (detaillerQualite), sa ligne d'évaluation
|
||||
* et la table d'évaluation de la charge
|
||||
* @param {{t: Function, format: {nombre: Function}}} outils
|
||||
* @returns {null|{population: string, groupes: Array<{cle: string,
|
||||
* libelle: string, tuiles: Tuile[]}>, certificat: string}}
|
||||
* population : la mention de la population mesurée ; groupes : ceux de
|
||||
* GROUPES_COLONNES qui portent une tuile au moins, dans leur ordre ;
|
||||
* certificat : « minimum atteint » ou « atteignabilité inconnue », qui
|
||||
* suit les tuiles
|
||||
*/
|
||||
export function enteteDe({ detail, ligne, table }, { t, format }) {
|
||||
if (detail === null || detail.statut !== 'comparable' || ligne === null || table === null) return null;
|
||||
const portees = new Set(tableau(table).colonnes.map(({ nom }) => nom));
|
||||
const noms = new Map(detail.personnes.map(({ id, nom }) => [id, nom]));
|
||||
const contexte = {
|
||||
detail,
|
||||
ligne,
|
||||
table,
|
||||
t,
|
||||
nommer: (id) => t('qualite.tuile.personne', { nom: noms.get(id) }),
|
||||
lire: (nom) => lireColonne(ligne, nom).valeur,
|
||||
};
|
||||
const groupes = [];
|
||||
for (const { cle, colonnes } of GROUPES_COLONNES) {
|
||||
if (cle === null) continue;
|
||||
const deGroupe = [];
|
||||
for (const nom of colonnes) {
|
||||
const cellule = lireColonne(ligne, nom);
|
||||
const classe = portees.has(nom);
|
||||
if (!classe && cellule.valeur === null) continue;
|
||||
deGroupe.push({
|
||||
nom,
|
||||
libelle: t(classe ? EN_TETES.get(nom) : `qualite.tuile.${nom}`),
|
||||
explication: explicationDeColonne(nom),
|
||||
texte: texteCellule(cellule, { t, format }),
|
||||
classe,
|
||||
neClassePas: classe ? null : t('qualite.tuile.neClassePas'),
|
||||
...bornesDe(nom, contexte),
|
||||
});
|
||||
}
|
||||
if (deGroupe.length > 0) groupes.push({ cle, libelle: t(cle), tuiles: deGroupe });
|
||||
}
|
||||
return {
|
||||
population: t('qualite.tuile.population', detail.population),
|
||||
groupes,
|
||||
certificat: texteCellule(lireColonne(ligne, 'certificat'), { t, format }),
|
||||
};
|
||||
}
|
||||
329
src/interface/qualite/entete.test.js
Normal file
329
src/interface/qualite/entete.test.js
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle de l'en-tête chiffré de la page de qualité (§ 12.2,
|
||||
// B11, D29, D34, D48, D52) : une tuile par colonne de mesure du tableau des
|
||||
// propositions, dans son ordre et sous ses groupes, la cellule même du
|
||||
// tableau quand la colonne y est ; une tuile sans colonne lit la même
|
||||
// mesure et dit qu'elle ne classe pas ; un critère sans objet n'a pas de
|
||||
// tuile ; chaque borne est celle d'une personne nommée, lue dans le détail ;
|
||||
// trois tuiles seulement portent un pourcentage, suivi du compte du pire.
|
||||
// Les charges s'écrivent ici ou partent des démonstrations livrées ; leurs
|
||||
// propositions s'enregistrent par la commande. Les noms d'épreuve sont
|
||||
// inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { detaillerQualite, evaluer } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { COLONNES, GROUPES_COLONNES, explicationDeColonne, lireColonne, tableau, texteCellule } from '../propositions/modele.js';
|
||||
import { TUILES_A_POURCENTAGE, enteteDe } from './entete.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const outils = { t, format };
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Six personnes inventées, deux appartenances, deux tables de trois places,
|
||||
// trois tours ; les finance premières portent « finance », que la première
|
||||
// table attend pour la soirée quand objectifs est vrai ; reservees : les
|
||||
// deux dernières réservées ensemble à la seconde table pour la soirée.
|
||||
function chargeSix({ objectifs = false, appartenances = true, reglages = {}, finance = 3, reservees = false } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-entete', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 3 });
|
||||
const noms = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: appartenances ? appartenance : null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...(objectifs && rang < finance ? { etiquettes: ['finance'] } : {}),
|
||||
}));
|
||||
if (reservees) charge.reservations = [5, 6].map((participant) => ({ participant, table: 2, siege: null, portee: 'tous', tour: null }));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
if (objectifs) charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
Object.assign(charge.reglages, reglages);
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages = { graine: 3, nombre: 2, arret: 200 }) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
// La vue d'une proposition de la charge, comme la section la passe : son
|
||||
// détail, sa ligne d'évaluation et la table d'évaluation.
|
||||
function vueDe(charge, id = charge.propositions[0].id) {
|
||||
const table = evaluer(charge);
|
||||
const ligne = table.propositions.find((candidate) => candidate.id === id);
|
||||
return { detail: detaillerQualite(charge, id), ligne, table };
|
||||
}
|
||||
|
||||
const tuiles = (entete) => entete.groupes.flatMap(({ tuiles: deGroupe }) => deGroupe);
|
||||
const tuile = (entete, nom) => {
|
||||
const trouvee = tuiles(entete).find((candidate) => candidate.nom === nom);
|
||||
assert.ok(trouvee !== undefined, `tuile ${nom} absente`);
|
||||
return trouvee;
|
||||
};
|
||||
const nomDe = (detail, id) => detail.personnes.find((personne) => personne.id === id).nom;
|
||||
const valeur = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
|
||||
|
||||
// Les tuiles des objectifs et des animateurs.
|
||||
const SIXIEME = ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres'];
|
||||
|
||||
describe('enteteDe : une tuile par colonne de mesure (§ 12.2, D48)', () => {
|
||||
test('chaque tuile qui a sa colonne porte la cellule du tableau, son libellé et son explication, dans l’ordre et sous les groupes de COLONNES', () => {
|
||||
const charge = generee(chargeSix({ objectifs: true }));
|
||||
const vue = vueDe(charge);
|
||||
const entete = enteteDe(vue, outils);
|
||||
const lignes = tableau(vue.table).lignes;
|
||||
const ligneDuTableau = lignes.find(({ id }) => id === vue.ligne.id);
|
||||
const portees = ligneDuTableau.cellules.filter(({ colonne }) => colonne !== 'certificat');
|
||||
// Les tuiles des colonnes portées, dans l'ordre du tableau.
|
||||
const classees = tuiles(entete).filter(({ classe }) => classe);
|
||||
assert.deepEqual(classees.map(({ nom }) => nom), portees.map(({ colonne }) => colonne));
|
||||
for (const cellule of portees) {
|
||||
const tuileDe = tuile(entete, cellule.colonne);
|
||||
assert.equal(tuileDe.texte, texteCellule(cellule, outils), cellule.colonne);
|
||||
assert.equal(tuileDe.libelle, t(COLONNES.find(({ nom }) => nom === cellule.colonne).cle), cellule.colonne);
|
||||
assert.equal(tuileDe.explication, explicationDeColonne(cellule.colonne), cellule.colonne);
|
||||
}
|
||||
// Les groupes : ceux de COLONNES, dans leur ordre, chacun de ses tuiles.
|
||||
const attendus = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
|
||||
assert.deepEqual(entete.groupes.map(({ cle }) => cle), attendus.map(({ cle }) => cle));
|
||||
for (const groupe of entete.groupes) {
|
||||
assert.equal(groupe.libelle, t(groupe.cle));
|
||||
const colonnes = attendus.find(({ cle }) => cle === groupe.cle).colonnes;
|
||||
assert.ok(groupe.tuiles.every(({ nom }) => colonnes.includes(nom)), groupe.cle);
|
||||
}
|
||||
assert.equal(entete.certificat, texteCellule(lireColonne(vue.ligne, 'certificat'), outils));
|
||||
assert.equal(entete.population, t('qualite.tuile.population', { mesuree: 6, exclue: 0 }));
|
||||
});
|
||||
|
||||
test('un critère décoché : ses tuiles lisent la même mesure sous leur propre libellé, et ne classent pas ; sans objet, aucune tuile', () => {
|
||||
const decoche = vueDe(generee(chargeSix({ objectifs: true, reglages: { respecterObjectifs: false, modeObjectifs: 'chacun' } })));
|
||||
assert.ok(!tableau(decoche.table).colonnes.some(({ nom }) => SIXIEME.includes(nom)));
|
||||
const entete = enteteDe(decoche, outils);
|
||||
const objectifs = ['jamaisServies', 'toursHorsObjectif', 'toursSelonObjectif', 'horsObjectifMax'];
|
||||
for (const nom of objectifs) {
|
||||
const tuileDe = tuile(entete, nom);
|
||||
assert.equal(tuileDe.classe, false, nom);
|
||||
assert.equal(tuileDe.libelle, t(`qualite.tuile.${nom}`), nom);
|
||||
assert.equal(tuileDe.texte, texteCellule(lireColonne(decoche.ligne, nom), outils), nom);
|
||||
}
|
||||
assert.equal(tuile(entete, 'jamaisServies').texte, format.nombre(decoche.ligne.mesures.objectifs.jamaisServies));
|
||||
// Les tuiles du groupe suivent l'ordre de COLONNES.
|
||||
const groupe = entete.groupes.find(({ cle }) => cle === 'propositions.groupe.objectifs');
|
||||
assert.deepEqual(groupe.tuiles.map(({ nom }) => nom), ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif']);
|
||||
assert.ok(tuiles(entete).filter(({ classe }) => !classe).every(({ nom }) => objectifs.includes(nom)));
|
||||
|
||||
// Sans objet : ni objectif ni animateur, aucune tuile du sixième groupe.
|
||||
const sansObjet = enteteDe(vueDe(generee(chargeSix())), outils);
|
||||
assert.ok(!tuiles(sansObjet).some(({ nom }) => SIXIEME.includes(nom)));
|
||||
assert.ok(!sansObjet.groupes.some(({ cle }) => cle === 'propositions.groupe.objectifs'));
|
||||
});
|
||||
|
||||
test('sous « le plus de places servies », les personnes jamais servies et le plus de tours hors objectif d’une personne ne classent pas ; les deux autres classent', () => {
|
||||
const entete = enteteDe(vueDe(generee(chargeSix({ objectifs: true }))), outils);
|
||||
assert.deepEqual(
|
||||
['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif'].map((nom) => [nom, tuile(entete, nom).classe]),
|
||||
[['jamaisServies', false], ['horsObjectifMax', false], ['toursHorsObjectif', true], ['toursSelonObjectif', true]],
|
||||
);
|
||||
assert.equal(tuile(entete, 'jamaisServies').neClassePas, t('qualite.tuile.neClassePas'));
|
||||
assert.equal(tuile(entete, 'toursHorsObjectif').neClassePas, null);
|
||||
});
|
||||
|
||||
test('le taux de diversité au centième, comme au tableau ; une population vide et un a priori inconnu s’écrivent comme au tableau', () => {
|
||||
const charge = generee(chargeSix());
|
||||
const entete = enteteDe(vueDe(charge), outils);
|
||||
const { ligne } = vueDe(charge);
|
||||
assert.equal(tuile(entete, 'diversite').texte, format.nombre(ligne.mesures.aggDiversite.tous.min, { decimales: 2 }));
|
||||
// Aucun ancré : « — ».
|
||||
assert.equal(tuile(entete, 'rencontresAncres').texte, '\u{2014}');
|
||||
// Sans a priori, les deux écarts qui en dépendent écrivent « inconnu »,
|
||||
// jamais « — » ni un nombre, comme leurs colonnes au tableau.
|
||||
const sansAPriori = { ...vueDe(charge), table: evaluer(charge, { plafondsAPriori: null }) };
|
||||
sansAPriori.ligne = sansAPriori.table.propositions.find(({ id }) => id === charge.propositions[0].id);
|
||||
assert.equal(sansAPriori.ligne.troisChiffres.ecartItineraireMax, null);
|
||||
const enteteSansAPriori = enteteDe(sansAPriori, outils);
|
||||
for (const nom of ['ecartItineraireMax', 'ecartAuPlafondAPriori']) {
|
||||
assert.equal(tuile(enteteSansAPriori, nom).texte, t('propositions.valeur.inconnu'), nom);
|
||||
assert.notEqual(tuile(entete, nom).texte, t('propositions.valeur.inconnu'), `${nom}, a priori connu`);
|
||||
}
|
||||
});
|
||||
|
||||
test('aucune vue sans détail comparable, sans ligne ou sans table', () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
assert.equal(enteteDe({ ...vue, detail: null }, outils), null);
|
||||
assert.equal(enteteDe({ ...vue, ligne: null }, outils), null);
|
||||
assert.equal(enteteDe({ ...vue, table: null }, outils), null);
|
||||
assert.equal(enteteDe({ ...vue, detail: { ...vue.detail, statut: 'derive' } }, outils), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enteteDe : les bornes, chacune d’une personne nommée (§ 12.2, B11, D29)', () => {
|
||||
test('le minimum brut : la personne au rang 1 du profil, au minimum, son plafond réalisé, son plafond a priori et son pourcentage, le compte du pire dessous', () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const { rang1 } = vue.detail.bornes;
|
||||
assert.equal(rang1.id, vue.detail.profil[0].id);
|
||||
assert.equal(rang1.rencontres, vue.ligne.mesures.aggRencontres.tous.min);
|
||||
const minimum = tuile(enteteDe(vue, outils), 'rencontresTous');
|
||||
assert.deepEqual(minimum.bornes, [
|
||||
t('qualite.tuile.personne', { nom: nomDe(vue.detail, rang1.id) }),
|
||||
t('qualite.tuile.plafondRealise', { plafond: rang1.plafondRealise }),
|
||||
t('qualite.tuile.plafondAPriori', { plafond: rang1.plafondAPriori }),
|
||||
]);
|
||||
assert.deepEqual(minimum.pourcentage, {
|
||||
texte: valeur(rang1.pourcent),
|
||||
compte: t('qualite.tuile.pireRencontres', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }),
|
||||
});
|
||||
assert.match(minimum.pourcentage.compte, /^minimum \d+ sur \d+$/);
|
||||
});
|
||||
|
||||
test('les bornes et les pourcentages se lisent dans le détail, jamais recomptés : un détail forgé les fait suivre', () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const detail = structuredClone(vue.detail);
|
||||
const [, autre] = detail.personnes;
|
||||
detail.bornes.rang1 = { ...detail.bornes.rang1, id: autre.id, pourcent: 37, rencontres: 3, plafondRealise: 8, plafondAPriori: null };
|
||||
detail.bornes.manque = { id: autre.id, plafondRealise: 41 };
|
||||
detail.bornes.diversite = { id: autre.id, aMax: 17 };
|
||||
const entete = enteteDe({ ...vue, detail }, outils);
|
||||
const minimum = tuile(entete, 'rencontresTous');
|
||||
// Le pourcentage de la personne nommée, jamais une moyenne de la
|
||||
// population ; sans plafond a priori, aucune ligne pour lui.
|
||||
assert.deepEqual(minimum.pourcentage, { texte: valeur(37), compte: 'minimum 3 sur 8' });
|
||||
assert.deepEqual(minimum.bornes, [t('qualite.tuile.personne', { nom: autre.nom }), t('qualite.tuile.plafondRealise', { plafond: 8 })]);
|
||||
// Le manque maximal : le plafond réalisé de la tête du profil de manque,
|
||||
// jamais le plus grand de la population, et aucun pourcentage.
|
||||
const manque = tuile(entete, 'manqueMax');
|
||||
assert.ok(manque.bornes.includes(t('qualite.tuile.plafondRealise', { plafond: 41 })), manque.bornes.join(' | '));
|
||||
assert.ok(manque.bornes.includes(t('qualite.tuile.personne', { nom: autre.nom })));
|
||||
assert.equal(manque.pourcentage, null);
|
||||
// A(p) : la borne de diversité de la personne nommée, sans pourcentage.
|
||||
const diversite = tuile(entete, 'appartenancesVues');
|
||||
assert.deepEqual(diversite.bornes, [t('qualite.tuile.personne', { nom: autre.nom }), t('qualite.tuile.borneDiversite', { borne: 17 })]);
|
||||
assert.equal(diversite.pourcentage, null);
|
||||
assert.ok(!diversite.bornes.some((ligne) => /plafond|%/.test(ligne)), diversite.bornes.join(' | '));
|
||||
// Un pourcentage de dénominateur nul : « — », le compte reste écrit.
|
||||
detail.bornes.rang1 = { ...detail.bornes.rang1, pourcent: null, rencontres: 0, plafondRealise: 0 };
|
||||
assert.deepEqual(tuile(enteteDe({ ...vue, detail }, outils), 'rencontresTous').pourcentage, { texte: null, compte: 'minimum 0 sur 0' });
|
||||
});
|
||||
|
||||
test('le manque maximal porte l’effectif au manque maximal et le plafond réalisé de la tête du profil de manque', () => {
|
||||
const vue = vueDe(generee(chargeSix()));
|
||||
const { manque } = vue.detail.bornes;
|
||||
assert.deepEqual(tuile(enteteDe(vue, outils), 'manqueMax').bornes, [
|
||||
t('qualite.tuile.personne', { nom: nomDe(vue.detail, manque.id) }),
|
||||
t('qualite.tuile.effectifManque', { effectif: vue.ligne.troisChiffres.effectifManque.tous }),
|
||||
t('qualite.tuile.plafondRealise', { plafond: manque.plafondRealise }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('sans appartenance : la tuile de A(p) le dit, sans borne de diversité', () => {
|
||||
const vue = vueDe(generee(chargeSix({ appartenances: false })));
|
||||
assert.equal(vue.detail.bornes.diversite, null);
|
||||
assert.deepEqual(tuile(enteteDe(vue, outils), 'appartenancesVues').bornes, [t('qualite.tuile.sansAppartenance')]);
|
||||
});
|
||||
|
||||
test('les objectifs : la soirée sur les tours hors objectif, la personne au minimum de s(p) et ses tours possibles, chacun suivi du compte du pire', () => {
|
||||
const vue = vueDe(generee(chargeSix({ objectifs: true })));
|
||||
const { soiree, objectifs } = vue.detail.bornes;
|
||||
const entete = enteteDe(vue, outils);
|
||||
assert.deepEqual(tuile(entete, 'toursHorsObjectif').pourcentage, {
|
||||
texte: soiree.pourcent === null ? null : t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
|
||||
compte: t('qualite.tuile.pireJamaisServies', { personnes: soiree.jamaisServies }),
|
||||
});
|
||||
const selon = tuile(entete, 'toursSelonObjectif');
|
||||
assert.deepEqual(selon.bornes, [
|
||||
t('qualite.tuile.personne', { nom: nomDe(vue.detail, objectifs.id) }),
|
||||
t('qualite.tuile.toursPossibles', { possibles: objectifs.possibles }),
|
||||
]);
|
||||
assert.deepEqual(selon.pourcentage, {
|
||||
texte: valeur(objectifs.pourcent),
|
||||
compte: t('qualite.tuile.pireServis', { servis: objectifs.servis, possibles: objectifs.possibles }),
|
||||
});
|
||||
});
|
||||
|
||||
test('les tours hors objectif : le plancher de la soirée quand le diagnostic en écrit un, sous des planchers valides ; aucun sinon', () => {
|
||||
// Quatre personnes attendues à une table de trois places, deux autres
|
||||
// réservées à l'autre table : au moins trois tours hors objectif.
|
||||
const vue = vueDe(generee(chargeSix({ objectifs: true, finance: 4, reservees: true })));
|
||||
const { soiree } = vue.detail.objectifs;
|
||||
assert.equal(vue.detail.collisions.planchersValides, true);
|
||||
assert.ok(soiree.plancher > 0, `plancher ${soiree.plancher}`);
|
||||
const ligneDuPlancher = t('qualite.tuile.plancher', { plancher: soiree.plancher });
|
||||
assert.ok(tuile(enteteDe(vue, outils), 'toursHorsObjectif').bornes.includes(ligneDuPlancher));
|
||||
// Hors du domaine des planchers, le même plancher ne s'écrit pas.
|
||||
const detail = structuredClone(vue.detail);
|
||||
detail.collisions.planchersValides = false;
|
||||
assert.ok(!tuile(enteteDe({ ...vue, detail }, outils), 'toursHorsObjectif').bornes.some((ligne) => ligne.startsWith('plancher')));
|
||||
// Un plancher nul : le diagnostic se tait, aucune ligne.
|
||||
detail.collisions.planchersValides = true;
|
||||
detail.objectifs.soiree = { ...detail.objectifs.soiree, plancher: 0 };
|
||||
assert.ok(!tuile(enteteDe({ ...vue, detail }, outils), 'toursHorsObjectif').bornes.some((ligne) => ligne.startsWith('plancher')));
|
||||
});
|
||||
|
||||
test('les rencontres répétées et les retours : les choisis en valeur, les imposés énoncés à part', () => {
|
||||
const vue = vueDe(generee(chargeSix({ reservees: true })));
|
||||
const { imposees } = vue.ligne.mesures.rencontresRepetees;
|
||||
assert.ok(imposees > 0, `imposées ${imposees}`);
|
||||
assert.ok(vue.table.imposes.retours > 0, `retours imposés ${vue.table.imposes.retours}`);
|
||||
const entete = enteteDe(vue, outils);
|
||||
assert.deepEqual(tuile(entete, 'rencontresRepetees').bornes, [t('qualite.tuile.imposees', { imposees })]);
|
||||
assert.deepEqual(tuile(entete, 'retoursChoisis').bornes, [t('qualite.tuile.imposes', { imposes: vue.table.imposes.retours })]);
|
||||
// Sans réservation, les imposés valent zéro et restent énoncés.
|
||||
const libre = enteteDe(vueDe(generee(chargeSix())), outils);
|
||||
assert.deepEqual(tuile(libre, 'rencontresRepetees').bornes, [t('qualite.tuile.imposees', { imposees: 0 })]);
|
||||
assert.deepEqual(tuile(libre, 'retoursChoisis').bornes, [t('qualite.tuile.imposes', { imposes: 0 })]);
|
||||
});
|
||||
|
||||
test('les animateurs : la personne au minimum de m(p) et sa borne des animateurs, sans pourcentage', () => {
|
||||
const charge = structuredClone(analyser(texteLivre('grande')).charge);
|
||||
charge.reglages.rencontrerAnimateurs = true;
|
||||
const vue = vueDe(generee(charge, { graine: 1, nombre: 1, arret: 300 }));
|
||||
const { animateurs } = vue.detail.bornes;
|
||||
assert.notEqual(animateurs, null);
|
||||
const rencontres = tuile(enteteDe(vue, outils), 'animateursRencontres');
|
||||
assert.deepEqual(rencontres.bornes, [
|
||||
t('qualite.tuile.personne', { nom: nomDe(vue.detail, animateurs.id) }),
|
||||
t('qualite.tuile.borneAnimateurs', { borne: animateurs.borne }),
|
||||
]);
|
||||
assert.equal(rencontres.pourcentage, null);
|
||||
});
|
||||
|
||||
test('trois tuiles seulement portent un pourcentage : le minimum brut, les tours hors objectif, le minimum de s(p)', () => {
|
||||
assert.deepEqual([...TUILES_A_POURCENTAGE], ['rencontresTous', 'toursHorsObjectif', 'toursSelonObjectif']);
|
||||
for (const charge of [generee(chargeSix({ objectifs: true })), generee(chargeSix({ objectifs: true, reglages: { respecterObjectifs: false } }))]) {
|
||||
const avec = tuiles(enteteDe(vueDe(charge), outils)).filter(({ pourcentage }) => pourcentage !== null);
|
||||
assert.deepEqual(avec.map(({ nom }) => nom), [...TUILES_A_POURCENTAGE]);
|
||||
for (const { pourcentage } of avec) assert.ok(pourcentage.compte.length > 0);
|
||||
}
|
||||
// Aucun signe du pourcentage hors des trois pourcentages.
|
||||
const entete = enteteDe(vueDe(generee(chargeSix({ objectifs: true }))), outils);
|
||||
for (const tuileDe of tuiles(entete)) {
|
||||
for (const texte of [tuileDe.libelle, tuileDe.texte, ...tuileDe.bornes, tuileDe.pourcentage?.compte ?? '']) {
|
||||
assert.ok(!texte.includes('%'), `${tuileDe.nom} : ${texte}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,11 @@
|
|||
// légende ; la surface de pointage ; l'infobulle. Escalier, Barres et
|
||||
// Grille le remplissent : il ne dessine aucune marque et ne calcule rien.
|
||||
//
|
||||
// La largeur se mesure sur le conteneur (largeur, liable), et le texte sur
|
||||
// La largeur se mesure sur une règle de hauteur nulle en tête du
|
||||
// conteneur (largeur, liable) : sa hauteur ne suit pas le dessin, si bien
|
||||
// qu'un dessin dont la hauteur suit la largeur — une grille — ne change
|
||||
// pas, dans l'image même, la taille d'un élément déjà mesuré, ce que
|
||||
// ResizeObserver signale comme une boucle. Le texte se mesure sur
|
||||
// une sonde du dessin (mesures, liable : {mesurer, hauteurTexte}, null
|
||||
// tant que la sonde n'est pas rendue) : le composant qui remplit le cadre
|
||||
// en tire sa disposition (pointage.js), puis rend ses marques par
|
||||
|
|
@ -161,7 +165,8 @@
|
|||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class="graphique" bind:clientWidth={largeur}>
|
||||
<div class="graphique">
|
||||
<div class="mesure" bind:clientWidth={largeur}></div>
|
||||
<span id={idNom} hidden>{nom}</span>
|
||||
<svg
|
||||
bind:this={svg}
|
||||
|
|
@ -229,7 +234,18 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
/* La règle de la largeur : la largeur du conteneur, aucune hauteur ;
|
||||
posée hors du flux, elle n'ajoute pas d'écart à la colonne. */
|
||||
.mesure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.graphique {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@
|
|||
// pointe, liable, en porte l'indice, ligne × colonnes + colonne ;
|
||||
// infobulle, {colonnes, lignes}, une ligne par case dans cet ordre
|
||||
// (Graphique). ecrire(v) écrit un compte, par défaut le nombre mis en
|
||||
// forme.
|
||||
// forme. coteMaxTextes borne la case à ce nombre de hauteurs de ligne du
|
||||
// compte écrit, mesurée par la sonde ; null, sans borne : la case prend
|
||||
// alors toute la largeur que la carte donne à sa colonne.
|
||||
//
|
||||
// Désignations : data-graphique sur le <svg>, data-case sur chaque case,
|
||||
// par « ligne:colonne » à partir de 0, data-rang sur la désignation de la
|
||||
|
|
@ -42,6 +44,7 @@
|
|||
diagonale = false,
|
||||
phrase = null,
|
||||
ecrire = null,
|
||||
coteMaxTextes = null,
|
||||
infobulle = null,
|
||||
dessinable = $bindable(true),
|
||||
pointe = $bindable(null),
|
||||
|
|
@ -65,6 +68,7 @@
|
|||
mesurer: mesures.mesurer,
|
||||
hauteurTexte: mesures.hauteurTexte,
|
||||
ecrire: ecrire ?? ((v) => format.nombre(v)),
|
||||
coteMax: coteMaxTextes === null ? Number.POSITIVE_INFINITY : coteMaxTextes * mesures.hauteurTexte,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -85,8 +89,11 @@
|
|||
</script>
|
||||
|
||||
<!-- La largeur se mesure ici aussi : une grille qui a cédé la place se
|
||||
redessine quand la carte s'élargit. -->
|
||||
<div bind:clientWidth={largeur}>
|
||||
redessine quand la carte s'élargit. Elle se lit sur une règle de
|
||||
hauteur nulle, comme dans Graphique : la hauteur de la grille suit sa
|
||||
largeur, et le conteneur qui la porte ne se mesure pas. -->
|
||||
<div>
|
||||
<div class="mesure" bind:clientWidth={largeur}></div>
|
||||
{#if disposition !== null && !disposition.dessinable}
|
||||
<p class="phrase">{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}</p>
|
||||
{:else}
|
||||
|
|
@ -152,6 +159,11 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
/* La règle de la largeur : la largeur du conteneur, aucune hauteur. */
|
||||
.mesure {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.phrase {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -683,13 +683,23 @@ export function cibleLigne(y, disposition) {
|
|||
* se tournent d'un quart de tour quand l'un d'eux dépasse la case. ecrire(v)
|
||||
* écrit un compte.
|
||||
*/
|
||||
export function dispositionGrille({ largeur, lignes, colonnes, valeurs, diagonale = false, mesurer, hauteurTexte, ecrire }) {
|
||||
export function dispositionGrille({
|
||||
largeur,
|
||||
lignes,
|
||||
colonnes,
|
||||
valeurs,
|
||||
diagonale = false,
|
||||
mesurer,
|
||||
hauteurTexte,
|
||||
ecrire,
|
||||
coteMax = Number.POSITIVE_INFINITY,
|
||||
}) {
|
||||
exiger(Array.isArray(lignes) && Array.isArray(colonnes), 'grille : lignes et colonnes attendues', TypeError);
|
||||
const nl = lignes.length;
|
||||
const nc = colonnes.length;
|
||||
exiger(Array.isArray(valeurs) && valeurs.length === nl * nc, `grille : ${nl * nc} valeurs attendues`);
|
||||
const largeurIntitules = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
|
||||
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte);
|
||||
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte, coteMax);
|
||||
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
|
||||
const enTete = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
|
||||
const x0 = largeurIntitules;
|
||||
|
|
|
|||
201
src/interface/qualite/objectifs.js
Normal file
201
src/interface/qualite/objectifs.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de la vue des objectifs des tables (§ 12.12, B21, D34, D36,
|
||||
// D52), pur : VueObjectifs.svelte le rend. Il met en forme les objectifs du
|
||||
// détail d'un placement (detaillerQualite) dans l'ordre où le détail les
|
||||
// porte, sans rien trier, sommer ni recompter : chaque pourcentage est
|
||||
// l'entier du détail, chaque somme de la soirée est celle du détail.
|
||||
//
|
||||
// L'ordre des barres est le seul que la vue décide : le tour de pireTour —
|
||||
// au plus grand reste, que le moteur désigne — en tête, à place fixe, puis
|
||||
// les autres dans leur ordre. Aucune barre pour la soirée : elle s'écrit en
|
||||
// texte, et pas du tout à un seul tour, que sa ligne dit déjà. Aucune
|
||||
// couleur ne désigne un tour : une ligne ne porte pas de pastille.
|
||||
//
|
||||
// Une barre porte les tours hors objectif du tour en deux segments, le
|
||||
// plancher du tour, prouvé inévitable, et le reste, tous deux lus au détail
|
||||
// tels que le moteur les rend (B21) — la garde à quatre paramètres refuse un
|
||||
// reste négatif avant tout dessin. Hors du domaine des planchers — un plan
|
||||
// qui laisse quelqu'un en réserve —, la barre n'a qu'un segment, les tours
|
||||
// hors objectif, et ni le plancher ni le reste ne s'écrivent (§ 12.4), pas
|
||||
// même dans la phrase de synthèse.
|
||||
// Un tour sans couple possible n'a ni barre ni pourcentage : « — », puis ses
|
||||
// assis sans table possible, que chaque ligne écrit.
|
||||
|
||||
// La clé de traduction d'un segment, par son genre.
|
||||
const SEGMENTS_DANS_LE_DOMAINE = [
|
||||
['prouve', 'qualite.objectifs.segment.prouve'],
|
||||
['reste', 'qualite.objectifs.segment.reste'],
|
||||
];
|
||||
const SEGMENT_HORS_DU_DOMAINE = ['serie1', 'qualite.objectifs.segment.hors'];
|
||||
|
||||
// Les colonnes du jumeau par tour, dans l'ordre : la première est
|
||||
// l'en-tête de sa ligne.
|
||||
const COLONNES_PAR_TOUR = [
|
||||
['tour', 'texte'],
|
||||
['possibles', 'nombre'],
|
||||
['servis', 'nombre'],
|
||||
['pourcent', 'pourcentage'],
|
||||
['hors', 'nombre'],
|
||||
['plancher', 'nombre'],
|
||||
['assis', 'nombre'],
|
||||
];
|
||||
|
||||
// Les colonnes {cle, libelle, genre} d'un jumeau, chacune sous la clé
|
||||
// qualite.objectifs.colonne.<cle>.
|
||||
const colonnesDe = (t, liste) => liste.map(([cle, genre]) => ({ cle, libelle: t(`qualite.objectifs.colonne.${cle}`), genre }));
|
||||
|
||||
/**
|
||||
* Le modèle de la vue des objectifs, ou ce qui la remplace.
|
||||
*
|
||||
* - null sans détail mesuré ;
|
||||
* - {cas: 'aucunConcerne'} sans personne concernée par les objectifs : la
|
||||
* vue cède la place à sa phrase ;
|
||||
* - sinon {cas: 'objectifs', neClassePas, planchersValides, jamaisServies,
|
||||
* soiree, barres, tours, infobulle, jumeau} :
|
||||
* neClassePas, le critère des objectifs décoché ou sans objet ;
|
||||
* planchersValides, le domaine des planchers du détail ;
|
||||
* jamaisServies, [{id, nom, possibles}], possibles déjà écrit ;
|
||||
* soiree, null à un seul tour, sinon {pourcentage: {texte, compte},
|
||||
* comptes, hors} — la part des tours possibles servis, suivie des
|
||||
* personnes jamais servies, puis ses servis sur ses possibles, ses tours
|
||||
* hors objectif et leur plancher ;
|
||||
* barres, {nom, synthese, segments, lignes} pour Barres, synthese le tour
|
||||
* de tête, son reste puis ses tours hors objectif — ses tours hors
|
||||
* objectif seuls hors du domaine —, une ligne {id, nom, valeurs,
|
||||
* secondaire} par tour, id le numéro du tour, secondaire ses assis sans
|
||||
* table possible ;
|
||||
* tours, {tour, nom, pourcentage: {texte, compte}} dans l'ordre des
|
||||
* barres, le pourcentage du tour suivi de ses deux comptes ;
|
||||
* infobulle, {colonnes, lignes}, la ligne du jumeau de chaque barre ;
|
||||
* jumeau, les tableaux de Jumeau : par tour puis la soirée, les personnes
|
||||
* jamais servies quand il y en a, la répartition des tours hors objectif
|
||||
* d'une personne.
|
||||
*
|
||||
* Tout texte vient de t ; ne modifie pas le détail.
|
||||
*
|
||||
* @param {Object|null} detail le détail du placement (detaillerQualite)
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
*/
|
||||
export function modeleObjectifs(detail, t) {
|
||||
if (detail === null) return null;
|
||||
const { objectifs, personnes, R } = detail;
|
||||
if (objectifs === null) return { cas: 'aucunConcerne' };
|
||||
const planchersValides = detail.collisions.planchersValides;
|
||||
const { critere, parTour, pireTour, soiree, repartition } = objectifs;
|
||||
|
||||
const nomDe = new Map(personnes.map(({ id, nom }) => [id, nom]));
|
||||
const jamaisServies = objectifs.jamaisServies.map(({ id, possibles }) => ({
|
||||
id,
|
||||
nom: nomDe.get(id),
|
||||
possibles: t('qualite.objectifs.toursPossibles', { possibles }),
|
||||
}));
|
||||
|
||||
// Les indices des tours, celui de tête d'abord, les autres dans leur ordre.
|
||||
const ordre = [pireTour, ...parTour.map((_, r) => r).filter((r) => r !== pireTour)];
|
||||
|
||||
const segments = (planchersValides ? SEGMENTS_DANS_LE_DOMAINE : [SEGMENT_HORS_DU_DOMAINE]).map(([genre, cle]) => ({
|
||||
genre,
|
||||
libelle: t(cle),
|
||||
}));
|
||||
const pourcentageDe = (pourcent) => (pourcent === null ? null : t('qualite.pourcentage.valeur', { pourcent }));
|
||||
|
||||
const tours = [];
|
||||
const lignes = [];
|
||||
const rangees = [];
|
||||
for (const r of ordre) {
|
||||
const { possibles, servis, pourcent, hors, plancher, reste, sansCouple } = parTour[r];
|
||||
const tour = r + 1;
|
||||
const nom = t('qualite.objectifs.tour', { tour });
|
||||
const sansCoupleAuTour = possibles === 0;
|
||||
let valeurs;
|
||||
if (sansCoupleAuTour) valeurs = segments.map(() => 0);
|
||||
else valeurs = planchersValides ? [plancher, reste] : [hors];
|
||||
lignes.push({
|
||||
id: tour,
|
||||
nom,
|
||||
valeurs,
|
||||
secondaire: t(sansCoupleAuTour ? 'qualite.objectifs.secondaireSansCouple' : 'qualite.objectifs.assisSansTable', {
|
||||
assis: sansCouple,
|
||||
}),
|
||||
});
|
||||
const pourcentage = {
|
||||
texte: pourcentageDe(pourcent),
|
||||
compte: sansCoupleAuTour ? t('qualite.objectifs.sansCouple') : t('qualite.objectifs.comptes', { servis, possibles }),
|
||||
};
|
||||
tours.push({ tour, nom, pourcentage });
|
||||
rangees.push({ tour: nom, possibles, servis, pourcent: pourcentage, hors, plancher: planchersValides ? plancher : null, assis: sansCouple });
|
||||
}
|
||||
|
||||
const colonnes = colonnesDe(t, COLONNES_PAR_TOUR);
|
||||
const unTour = R === 1;
|
||||
const jumeau = [
|
||||
{
|
||||
nom: t('qualite.objectifs.jumeau.tours'),
|
||||
colonnes,
|
||||
lignes: unTour
|
||||
? rangees
|
||||
: [
|
||||
...rangees,
|
||||
{
|
||||
tour: t('qualite.objectifs.soiree'),
|
||||
possibles: soiree.possibles,
|
||||
servis: soiree.servis,
|
||||
pourcent: { texte: pourcentageDe(soiree.pourcent), compte: t('qualite.objectifs.comptes', soiree) },
|
||||
hors: soiree.hors,
|
||||
plancher: planchersValides ? soiree.plancher : null,
|
||||
assis: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
if (jamaisServies.length > 0) {
|
||||
jumeau.push({
|
||||
nom: t('qualite.objectifs.jamaisServies'),
|
||||
colonnes: [
|
||||
{ cle: 'nom', libelle: t('qualite.objectifs.colonne.personne'), genre: 'texte' },
|
||||
{ cle: 'possibles', libelle: t('qualite.objectifs.colonne.toursPossibles'), genre: 'nombre' },
|
||||
],
|
||||
lignes: objectifs.jamaisServies.map(({ id, possibles }) => ({ nom: nomDe.get(id), possibles })),
|
||||
});
|
||||
}
|
||||
jumeau.push({
|
||||
nom: t('qualite.objectifs.repartition.titre'),
|
||||
colonnes: [
|
||||
{ cle: 'valeur', libelle: t('qualite.objectifs.repartition.valeur'), genre: 'nombre' },
|
||||
{ cle: 'effectif', libelle: t('qualite.objectifs.repartition.effectif'), genre: 'nombre' },
|
||||
],
|
||||
lignes: repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
|
||||
});
|
||||
|
||||
return {
|
||||
cas: 'objectifs',
|
||||
neClassePas: !critere.coche || critere.sansObjet,
|
||||
planchersValides,
|
||||
jamaisServies,
|
||||
soiree: unTour
|
||||
? null
|
||||
: {
|
||||
pourcentage: {
|
||||
texte: t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
|
||||
compte: t('qualite.objectifs.pire', { personnes: detail.bornes.soiree.jamaisServies }),
|
||||
},
|
||||
comptes: t('qualite.objectifs.comptes', soiree),
|
||||
hors: planchersValides
|
||||
? t('qualite.objectifs.horsObjectif', { hors: soiree.hors, plancher: soiree.plancher })
|
||||
: t('qualite.objectifs.hors', { hors: soiree.hors }),
|
||||
},
|
||||
barres: {
|
||||
nom: t('qualite.objectifs.graphique'),
|
||||
synthese: planchersValides
|
||||
? t('qualite.objectifs.synthese', { tour: pireTour + 1, reste: parTour[pireTour].reste, hors: parTour[pireTour].hors })
|
||||
: t('qualite.objectifs.syntheseHorsDomaine', { tour: pireTour + 1, hors: parTour[pireTour].hors }),
|
||||
segments,
|
||||
lignes,
|
||||
},
|
||||
tours,
|
||||
infobulle: { colonnes, lignes: rangees },
|
||||
jumeau,
|
||||
};
|
||||
}
|
||||
433
src/interface/qualite/objectifs.test.js
Normal file
433
src/interface/qualite/objectifs.test.js
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle de la vue des objectifs des tables (§ 12.12, B21, D34,
|
||||
// D36, D52) : les personnes jamais servies, la soirée en texte, une barre
|
||||
// par tour — le tour au plus grand reste en tête —, le pourcentage de chaque
|
||||
// tour et ses deux comptes, ses assis sans table possible, et le jumeau.
|
||||
// Chaque nombre vient du détail du placement (detaillerQualite), recoupé
|
||||
// contre la ligne d'évaluation de la même proposition et contre le
|
||||
// diagnostic. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { configurationDe } from '../../application/configuration.js';
|
||||
import { detaillerQualite, evaluer } from '../../application/evaluation.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { diagnostiquer } from '../../moteur/diagnostic.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { modeleObjectifs } from './objectifs.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Neuf personnes inventées : les sept premières portent « finance ».
|
||||
const PERSONNES = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', 'Coopérative Aubier'],
|
||||
['Rocaille', 'Anselme', 'Atelier Ressac'],
|
||||
['Brindille', 'Pélagie', null],
|
||||
['Cassonade', 'Firmin', null],
|
||||
['Grésil', 'Ysaline', 'Atelier Ressac'],
|
||||
];
|
||||
|
||||
// n personnes, trois tables de trois places, tours tours ; la table 1 a
|
||||
// l'objectif « finance » aux tours toursObjectif, aucune autre table n'en a
|
||||
// — un tour hors de la liste n'a aucun couple possible. Sept personnes pour
|
||||
// trois places à objectif : à chaque tour, quatre au moins restent hors
|
||||
// objectif, et une au moins n'est jamais servie.
|
||||
function chargeObjectifs({ n = 9, tours = 3, toursObjectif = [1, 2], respecter = true, mode = null } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-objectifs', nom: 'Atelier Ressac', siegesParDefaut: 3, tours });
|
||||
charge.participants = PERSONNES.slice(0, n).map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
...(rang < 7 ? { etiquettes: ['finance'] } : {}),
|
||||
}));
|
||||
charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.tables[0].objectifs = toursObjectif.map((tour) => ({ portee: 'tour', tour, regle: null, etiquettes: ['finance'] }));
|
||||
charge.prochainsIds = { participant: n + 1, table: 4, proposition: 1 };
|
||||
if (!respecter) charge.reglages.respecterObjectifs = false;
|
||||
if (mode !== null) charge.reglages.modeObjectifs = mode;
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération enregistrée de plus.
|
||||
function generee(charge, reglages = { graine: 7, nombre: 2, arret: 200 }) {
|
||||
const configuration = configurationDe(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
// Le détail de la première proposition, et la charge qui le porte.
|
||||
function detailDe(options) {
|
||||
const charge = generee(chargeObjectifs(options));
|
||||
const id = charge.propositions[0].id;
|
||||
return { charge, id, detail: detaillerQualite(charge, id) };
|
||||
}
|
||||
|
||||
// Le pourcentage du § D52, recalculé ici pour l'épreuve : ⌊100 · n / d⌋,
|
||||
// borné à 1 et 99 entre les bords, null à dénominateur nul.
|
||||
function pourcentAttendu(n, d) {
|
||||
if (d === 0) return null;
|
||||
const brut = Math.floor((100 * n) / d);
|
||||
if (n > 0 && n < d) return Math.min(99, Math.max(1, brut));
|
||||
return brut;
|
||||
}
|
||||
|
||||
// Le détail de la première proposition, sous une réservation ajoutée après
|
||||
// la génération que son plan ne tient pas : la personne 8, sans étiquette,
|
||||
// réservée au tour 1 à une table où le plan ne l'assoit pas. Hors du
|
||||
// domaine des planchers (§ 12.4).
|
||||
function detailHorsDomaine() {
|
||||
const charge = generee(chargeObjectifs());
|
||||
const [proposition] = charge.propositions;
|
||||
const rang = proposition.placement[0].sieges.findIndex((sieges) => !sieges.includes(8));
|
||||
const apres = structuredClone(charge);
|
||||
apres.reservations.push({ participant: 8, table: proposition.tables[rang], siege: null, portee: 'tour', tour: 1 });
|
||||
return detaillerQualite(apres, proposition.id);
|
||||
}
|
||||
|
||||
// Une copie modifiable d'un détail.
|
||||
const copie = (detail) => structuredClone(detail);
|
||||
|
||||
// Le détail de la première proposition quand l'objectif tient aux tours 2
|
||||
// et 3 : le tour 1, sans couple possible, n'a pas de reste, et le tour de
|
||||
// tête est le tour 2. Les lignes vont alors dans l'ordre [2, 1, 3], que
|
||||
// l'ordre naturel des tours ne donne pas : une épreuve « dans l'ordre des
|
||||
// barres » qui le lit échoue dès qu'une liste retrie ses tours.
|
||||
function detailPireAuTour2() {
|
||||
const { detail } = detailDe({ toursObjectif: [2, 3] });
|
||||
assert.equal(detail.objectifs.pireTour, 1);
|
||||
return detail;
|
||||
}
|
||||
|
||||
describe('objectifs : les cas sans vue', () => {
|
||||
test('sans personne concernée, la vue cède la place à sa phrase', () => {
|
||||
const { detail } = detailDe({ toursObjectif: [] });
|
||||
assert.equal(detail.objectifs, null);
|
||||
assert.deepEqual(modeleObjectifs(detail, t), { cas: 'aucunConcerne' });
|
||||
});
|
||||
|
||||
test('sans détail mesuré, rien', () => {
|
||||
assert.equal(modeleObjectifs(null, t), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : les barres par tour (§ 12.12, B21, D36)', () => {
|
||||
test('une barre par tour, aucune pour la soirée ; le tour au plus grand reste en tête, les autres dans leur ordre', () => {
|
||||
const { detail } = detailDe();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.equal(modele.cas, 'objectifs');
|
||||
assert.equal(modele.barres.lignes.length, detail.R);
|
||||
const restes = detail.objectifs.parTour.map((tour) => (tour.possibles === 0 ? -Infinity : tour.hors - tour.plancher));
|
||||
const pire = restes.indexOf(Math.max(...restes));
|
||||
assert.equal(detail.objectifs.pireTour, pire);
|
||||
const autres = detail.objectifs.parTour.map((_, r) => r + 1).filter((tour) => tour !== pire + 1);
|
||||
assert.deepEqual(
|
||||
modele.barres.lignes.map(({ id }) => id),
|
||||
[pire + 1, ...autres],
|
||||
);
|
||||
assert.deepEqual(
|
||||
modele.barres.lignes.map(({ nom }) => nom),
|
||||
[pire + 1, ...autres].map((tour) => t('qualite.objectifs.tour', { tour })),
|
||||
);
|
||||
});
|
||||
|
||||
test('rouge : la première barre suit pireTour du détail, jamais le premier tour par défaut', () => {
|
||||
const { detail } = detailDe();
|
||||
const forge = copie(detail);
|
||||
forge.objectifs.pireTour = 1;
|
||||
assert.deepEqual(
|
||||
modeleObjectifs(forge, t).barres.lignes.map(({ id }) => id),
|
||||
[2, 1, 3],
|
||||
);
|
||||
forge.objectifs.pireTour = 2;
|
||||
forge.objectifs.parTour[2] = { ...forge.objectifs.parTour[0] };
|
||||
assert.deepEqual(
|
||||
modeleObjectifs(forge, t).barres.lignes.map(({ id }) => id),
|
||||
[3, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test('deux segments, prouvé inévitable puis reste, le plancher du tour et le reste ; aucune couleur propre à un tour', () => {
|
||||
const { detail } = detailDe();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.deepEqual(modele.barres.segments, [
|
||||
{ genre: 'prouve', libelle: t('qualite.objectifs.segment.prouve') },
|
||||
{ genre: 'reste', libelle: t('qualite.objectifs.segment.reste') },
|
||||
]);
|
||||
for (const ligne of modele.barres.lignes) {
|
||||
const tour = detail.objectifs.parTour[ligne.id - 1];
|
||||
if (tour.possibles === 0) continue;
|
||||
assert.equal(tour.reste, tour.hors - tour.plancher, `tour ${ligne.id}`);
|
||||
assert.deepEqual(ligne.valeurs, [tour.plancher, tour.reste], `tour ${ligne.id}`);
|
||||
assert.equal(Object.hasOwn(ligne, 'appartenance'), false, `tour ${ligne.id} : aucune pastille`);
|
||||
}
|
||||
});
|
||||
|
||||
test('rouge : le reste d’une barre est celui que le détail porte, lu sans soustraction', () => {
|
||||
const detail = structuredClone(detailDe().detail);
|
||||
const tour = detail.objectifs.parTour[0];
|
||||
tour.reste = tour.hors + 7;
|
||||
const ligne = modeleObjectifs(detail, t).barres.lignes.find(({ id }) => id === 1);
|
||||
assert.deepEqual(ligne.valeurs, [tour.plancher, tour.hors + 7]);
|
||||
});
|
||||
|
||||
test('un tour sans couple possible écrit « — », sans barre ni pourcentage, et garde ses assis sans table possible', () => {
|
||||
const { detail } = detailDe();
|
||||
const tour3 = detail.objectifs.parTour[2];
|
||||
assert.equal(tour3.possibles, 0);
|
||||
assert.equal(tour3.sansCouple, 9);
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
const ligne = modele.barres.lignes.find(({ id }) => id === 3);
|
||||
assert.deepEqual(ligne.valeurs, [0, 0]);
|
||||
assert.equal(ligne.secondaire, t('qualite.objectifs.secondaireSansCouple', { assis: 9 }));
|
||||
assert.ok(ligne.secondaire.startsWith(t('format.vide')), ligne.secondaire);
|
||||
const parTour = modele.tours.find(({ tour }) => tour === 3);
|
||||
assert.deepEqual(parTour.pourcentage, { texte: null, compte: t('qualite.objectifs.sansCouple') });
|
||||
});
|
||||
|
||||
test('chaque ligne écrit ses assis sans table possible, qui égalent sansCouple', () => {
|
||||
const { detail } = detailDe();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
for (const ligne of modele.barres.lignes) {
|
||||
const { possibles, sansCouple } = detail.objectifs.parTour[ligne.id - 1];
|
||||
const cle = possibles === 0 ? 'qualite.objectifs.secondaireSansCouple' : 'qualite.objectifs.assisSansTable';
|
||||
assert.equal(ligne.secondaire, t(cle, { assis: sansCouple }), `tour ${ligne.id}`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
detail.objectifs.parTour.map(({ sansCouple }) => sansCouple),
|
||||
[2, 2, 9],
|
||||
);
|
||||
});
|
||||
|
||||
test('le pourcentage de chaque tour : ses servis sur ses possibles seuls, suivi de ses deux comptes, dans l’ordre des barres', () => {
|
||||
const detail = detailPireAuTour2();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.deepEqual(
|
||||
modele.barres.lignes.map(({ id }) => id),
|
||||
[2, 1, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
modele.tours.map(({ tour }) => tour),
|
||||
[2, 1, 3],
|
||||
);
|
||||
for (const { tour, nom, pourcentage } of modele.tours) {
|
||||
const { possibles, servis, pourcent } = detail.objectifs.parTour[tour - 1];
|
||||
assert.equal(nom, t('qualite.objectifs.tour', { tour }));
|
||||
if (possibles === 0) continue;
|
||||
assert.equal(pourcent, pourcentAttendu(servis, possibles), `tour ${tour}`);
|
||||
assert.deepEqual(pourcentage, {
|
||||
texte: t('qualite.pourcentage.valeur', { pourcent }),
|
||||
compte: t('qualite.objectifs.comptes', { servis, possibles }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('la phrase de synthèse nomme le tour de tête, son reste, puis ses tours hors objectif', () => {
|
||||
const { detail } = detailDe();
|
||||
const { pireTour, parTour } = detail.objectifs;
|
||||
const { reste, hors } = parTour[pireTour];
|
||||
assert.deepEqual([pireTour, reste, hors], [0, 1, 5]);
|
||||
const synthese = modeleObjectifs(detail, t).barres.synthese;
|
||||
assert.equal(synthese, t('qualite.objectifs.synthese', { tour: 1, reste, hors }));
|
||||
assert.equal(synthese, 'En tête, le tour 1, au plus grand reste : 1 au-delà de son plancher, sur 5 tours hors objectif.');
|
||||
});
|
||||
|
||||
test('hors du domaine des planchers, la phrase de synthèse ne parle plus de reste', () => {
|
||||
const detail = detailHorsDomaine();
|
||||
const { pireTour, parTour } = detail.objectifs;
|
||||
const synthese = modeleObjectifs(detail, t).barres.synthese;
|
||||
assert.equal(synthese, t('qualite.objectifs.syntheseHorsDomaine', { tour: pireTour + 1, hors: parTour[pireTour].hors }));
|
||||
assert.doesNotMatch(synthese, /reste|plancher/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : les personnes jamais servies et la soirée', () => {
|
||||
test('les jamais servies, nommées, chacune avec ses tours possibles, dans l’ordre du détail', () => {
|
||||
const { detail } = detailDe();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.ok(detail.objectifs.jamaisServies.length >= 1);
|
||||
assert.deepEqual(
|
||||
modele.jamaisServies,
|
||||
detail.objectifs.jamaisServies.map(({ id, possibles }) => ({
|
||||
id,
|
||||
nom: detail.personnes.find((personne) => personne.id === id).nom,
|
||||
possibles: t('qualite.objectifs.toursPossibles', { possibles }),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test('la soirée : Σ servis sur Σ possibles, suivie des jamais servies, puis ses deux comptes, hors objectif et plancher', () => {
|
||||
const { detail } = detailDe();
|
||||
const { parTour, soiree, jamaisServies } = detail.objectifs;
|
||||
const servis = parTour.reduce((somme, tour) => somme + (tour.servis ?? 0), 0);
|
||||
const possibles = parTour.reduce((somme, tour) => somme + tour.possibles, 0);
|
||||
assert.deepEqual([soiree.servis, soiree.possibles], [servis, possibles]);
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.deepEqual(modele.soiree, {
|
||||
pourcentage: {
|
||||
texte: t('qualite.pourcentage.servis', { pourcent: pourcentAttendu(servis, possibles) }),
|
||||
compte: t('qualite.objectifs.pire', { personnes: jamaisServies.length }),
|
||||
},
|
||||
comptes: t('qualite.objectifs.comptes', { servis, possibles }),
|
||||
hors: t('qualite.objectifs.horsObjectif', { hors: soiree.hors, plancher: soiree.plancher }),
|
||||
});
|
||||
});
|
||||
|
||||
test('R = 1 : aucune ligne de soirée, ni au texte ni au jumeau', () => {
|
||||
const { detail } = detailDe({ tours: 1, toursObjectif: [1] });
|
||||
assert.equal(detail.R, 1);
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.equal(modele.soiree, null);
|
||||
assert.deepEqual(
|
||||
modele.jumeau[0].lignes.map(({ tour }) => tour),
|
||||
[t('qualite.objectifs.tour', { tour: 1 })],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : recoupements avec le tableau des propositions et le diagnostic', () => {
|
||||
test('possibles − servis de la soirée = tours hors objectif de la ligne ; jamais servies = sa colonne ; Σ planchers des tours = plancher du diagnostic', () => {
|
||||
const { charge, id, detail } = detailDe({ mode: 'chacun' });
|
||||
const ligne = evaluer(charge).propositions.find((element) => element.id === id);
|
||||
const { soiree, parTour, jamaisServies } = detail.objectifs;
|
||||
assert.equal(soiree.possibles - soiree.servis, ligne.mesures.objectifs.horsObjectif);
|
||||
assert.equal(jamaisServies.length, ligne.mesures.objectifs.jamaisServies);
|
||||
assert.equal(detail.bornes.soiree.jamaisServies, ligne.mesures.objectifs.jamaisServies);
|
||||
const diagnostic = diagnostiquer(configurationDe(charge));
|
||||
const somme = parTour.reduce((total, tour) => total + (tour.plancher ?? 0), 0);
|
||||
assert.equal(somme, diagnostic.objectifsInevitables.horsObjectif);
|
||||
assert.equal(somme, 8);
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.equal(
|
||||
modele.soiree.hors,
|
||||
t('qualite.objectifs.horsObjectif', { hors: ligne.mesures.objectifs.horsObjectif, plancher: diagnostic.objectifsInevitables.horsObjectif }),
|
||||
);
|
||||
assert.equal(modele.soiree.pourcentage.compte, t('qualite.objectifs.pire', { personnes: ligne.mesures.objectifs.jamaisServies }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : le critère décoché et le domaine des planchers (§ 12.4, B15)', () => {
|
||||
test('critère décoché : la vue dit qu’il ne classe pas, et les planchers se dessinent quand même', () => {
|
||||
const { detail } = detailDe({ respecter: false });
|
||||
assert.deepEqual(detail.objectifs.critere, { coche: false, sansObjet: false });
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.equal(modele.neClassePas, true);
|
||||
assert.deepEqual(
|
||||
modele.barres.segments.map(({ genre }) => genre),
|
||||
['prouve', 'reste'],
|
||||
);
|
||||
assert.ok(modele.barres.lignes.some(({ valeurs }) => valeurs[0] > 0));
|
||||
assert.equal(modeleObjectifs(detailDe().detail, t).neClassePas, false);
|
||||
});
|
||||
|
||||
test('hors du domaine des planchers : un seul segment, les tours hors objectif, et ni plancher ni reste', () => {
|
||||
const detail = detailHorsDomaine();
|
||||
assert.equal(detail.statut, 'comparable');
|
||||
assert.equal(detail.collisions.planchersValides, false);
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.equal(modele.planchersValides, false);
|
||||
assert.deepEqual(modele.barres.segments, [{ genre: 'serie1', libelle: t('qualite.objectifs.segment.hors') }]);
|
||||
for (const ligne of modele.barres.lignes) {
|
||||
const tour = detail.objectifs.parTour[ligne.id - 1];
|
||||
assert.deepEqual(ligne.valeurs, [tour.possibles === 0 ? 0 : tour.hors], `tour ${ligne.id}`);
|
||||
}
|
||||
assert.equal(modele.soiree.hors, t('qualite.objectifs.hors', { hors: detail.objectifs.soiree.hors }));
|
||||
for (const rangee of modele.jumeau[0].lignes) assert.equal(rangee.plancher, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectifs : le jumeau et l’infobulle (§ 12.7, B21)', () => {
|
||||
test('une ligne par tour dans l’ordre des barres, puis la soirée ; possibles, servis, pourcentage suivi du compte de la ligne, hors, plancher, assis', () => {
|
||||
const detail = detailPireAuTour2();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
const [tours, jamais, repartition] = modele.jumeau;
|
||||
assert.deepEqual(
|
||||
tours.colonnes.map(({ cle, genre }) => [cle, genre]),
|
||||
[
|
||||
['tour', 'texte'],
|
||||
['possibles', 'nombre'],
|
||||
['servis', 'nombre'],
|
||||
['pourcent', 'pourcentage'],
|
||||
['hors', 'nombre'],
|
||||
['plancher', 'nombre'],
|
||||
['assis', 'nombre'],
|
||||
],
|
||||
);
|
||||
const ordre = modele.barres.lignes.map(({ id }) => id);
|
||||
assert.deepEqual(ordre, [2, 1, 3]);
|
||||
assert.deepEqual(
|
||||
tours.lignes.map(({ tour }) => tour),
|
||||
[...ordre.map((tour) => t('qualite.objectifs.tour', { tour })), t('qualite.objectifs.soiree')],
|
||||
);
|
||||
ordre.forEach((numero, i) => {
|
||||
const tour = detail.objectifs.parTour[numero - 1];
|
||||
const rangee = tours.lignes[i];
|
||||
assert.deepEqual(
|
||||
[rangee.possibles, rangee.servis, rangee.hors, rangee.plancher, rangee.assis],
|
||||
[tour.possibles, tour.servis, tour.hors, tour.plancher, tour.sansCouple],
|
||||
);
|
||||
assert.deepEqual(rangee.pourcent, modele.tours[i].pourcentage);
|
||||
});
|
||||
const { soiree } = detail.objectifs;
|
||||
const derniere = tours.lignes.at(-1);
|
||||
assert.deepEqual(
|
||||
[derniere.possibles, derniere.servis, derniere.hors, derniere.plancher, derniere.assis],
|
||||
[soiree.possibles, soiree.servis, soiree.hors, soiree.plancher, null],
|
||||
);
|
||||
assert.deepEqual(derniere.pourcent, {
|
||||
texte: t('qualite.pourcentage.valeur', { pourcent: soiree.pourcent }),
|
||||
compte: t('qualite.objectifs.comptes', { servis: soiree.servis, possibles: soiree.possibles }),
|
||||
});
|
||||
assert.deepEqual(
|
||||
jamais.lignes.map(({ nom, possibles }) => [nom, possibles]),
|
||||
modele.jamaisServies.map(({ id }) => [
|
||||
detail.personnes.find((personne) => personne.id === id).nom,
|
||||
detail.objectifs.jamaisServies.find((element) => element.id === id).possibles,
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
repartition.lignes,
|
||||
detail.objectifs.repartition.map(({ valeur, effectif }) => ({ valeur, effectif })),
|
||||
);
|
||||
const noms = modele.jumeau.map(({ nom }) => nom);
|
||||
assert.equal(new Set([...noms, modele.barres.nom]).size, noms.length + 1, 'noms accessibles distincts');
|
||||
});
|
||||
|
||||
test('l’infobulle porte la ligne du jumeau de chaque barre, dans l’ordre des barres', () => {
|
||||
const detail = detailPireAuTour2();
|
||||
const modele = modeleObjectifs(detail, t);
|
||||
assert.deepEqual(modele.infobulle.colonnes, modele.jumeau[0].colonnes);
|
||||
assert.deepEqual(
|
||||
modele.infobulle.lignes.map(({ tour }) => tour),
|
||||
[2, 1, 3].map((tour) => t('qualite.objectifs.tour', { tour })),
|
||||
);
|
||||
assert.deepEqual(modele.infobulle.lignes, modele.jumeau[0].lignes.slice(0, detail.R));
|
||||
});
|
||||
|
||||
test('aucune personne jamais servie : le jumeau n’a pas de table vide pour elles', () => {
|
||||
const { detail } = detailDe();
|
||||
const forge = copie(detail);
|
||||
forge.objectifs.jamaisServies = [];
|
||||
const modele = modeleObjectifs(forge, t);
|
||||
assert.deepEqual(modele.jamaisServies, []);
|
||||
assert.deepEqual(
|
||||
modele.jumeau.map(({ nom }) => nom),
|
||||
[t('qualite.objectifs.jumeau.tours'), t('qualite.objectifs.repartition.titre')],
|
||||
);
|
||||
});
|
||||
});
|
||||
229
src/interface/qualite/paires.js
Normal file
229
src/interface/qualite/paires.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle de la vue des paires répétées (§ 12.5, B14, D32, D33, D50) et de
|
||||
// la rubrique « Rencontres » de la fiche, pur : VuePaires.svelte et
|
||||
// Fiche.svelte le câblent.
|
||||
//
|
||||
// Il met en forme ce que le détail du placement porte, dans l'ordre où il
|
||||
// le porte : les paires choisies puis les paires imposées, chacune triée par
|
||||
// le moteur (fois décroissant, puis identifiants) ; la matrice dans l'ordre
|
||||
// du profil (§ 12.3), jamais par l'alphabet. Il ne trie, ne recompte et ne
|
||||
// mesure rien : un tour du détail, compté à partir de 0, s'écrit à partir
|
||||
// de 1, une table, nommée par son identifiant, s'écrit par son numéro, et
|
||||
// une liste du détail se lit par sa longueur et sa tête, sans s'ajouter à
|
||||
// une autre ni s'y comparer.
|
||||
import { rencontresDePersonne } from '../../application/evaluation.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { pagine } from '../propositions/modele.js';
|
||||
|
||||
/** Les cas de la vue des paires, dans l'ordre où elle les éprouve :
|
||||
* uneTable, une seule table ; unTour, un seul tour ; aucune, aucune paire
|
||||
* ne se revoit ; tableau, le tableau des paires. */
|
||||
export const CAS_PAIRES = Object.freeze(['uneTable', 'unTour', 'aucune', 'tableau']);
|
||||
|
||||
/**
|
||||
* Le cas de la vue des paires pour un détail comparable : une seule table
|
||||
* l'emporte, puis un seul tour ; sans paire choisie ni imposée, aucune ;
|
||||
* sinon le tableau.
|
||||
*
|
||||
* @param {{T: number, R: number, paires: {choisies: Object[], imposees: Object[]}}} detail
|
||||
* @returns {string} l'un de CAS_PAIRES
|
||||
*/
|
||||
export function casDesPaires({ T, R, paires }) {
|
||||
if (T === 1) return 'uneTable';
|
||||
if (R === 1) return 'unTour';
|
||||
if (paires.choisies.length === 0 && paires.imposees.length === 0) return 'aucune';
|
||||
return 'tableau';
|
||||
}
|
||||
|
||||
// Les personnes données par identifiant, chacune {id, nom, appartenance,
|
||||
// homonyme} : homonyme vrai quand une autre personne de la liste porte le
|
||||
// même nom affiché — la vue écrit alors son identifiant à côté du nom.
|
||||
function personnesParId(personnes) {
|
||||
const parNom = new Map();
|
||||
for (const { nom } of personnes) parNom.set(nom, (parNom.get(nom) ?? 0) + 1);
|
||||
return new Map(
|
||||
personnes.map(({ id, nom, appartenance }) => [id, { id, nom, appartenance, homonyme: parNom.get(nom) > 1 }]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le nom d'une personne là où un seul texte la désigne — une infobulle, un
|
||||
* intitulé de la matrice, un nom accessible : son nom, suivi de son
|
||||
* identifiant pour un homonyme, comme une ligne l'écrit en deux morceaux.
|
||||
*
|
||||
* @param {{id: number, nom: string, homonyme: boolean}} personne
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
*/
|
||||
export const nomDistinct = ({ id, nom, homonyme }, t) => (homonyme ? t('qualite.paires.nomHomonyme', { nom, id }) : nom);
|
||||
|
||||
// Le numéro de chaque table du détail, par son identifiant.
|
||||
const numerosParTable = ({ idsTables, numerosTables }) => new Map(idsTables.map((id, t) => [id, numerosTables[t]]));
|
||||
|
||||
/**
|
||||
* Les lignes du tableau des paires (§ 12.5, D32) : {choisies, imposees},
|
||||
* chacune dans l'ordre du détail. Une ligne : {cle, genre, a, b, fois,
|
||||
* tours, repetition} — cle, « a:b » par identifiants ; genre, 'choisie' ou
|
||||
* 'imposee' ; a et b, {id, nom, appartenance, homonyme} ; tours, chaque
|
||||
* tour où la paire est assise ensemble, {tour, table}, tour à partir de 1,
|
||||
* table son numéro ; repetition, le premier tour où la paire se revoit —
|
||||
* son second tour ensemble —, où mène la première commande de la ligne.
|
||||
*
|
||||
* @param {Object} detail un détail comparable (detaillerQualite)
|
||||
* @returns {{choisies: Object[], imposees: Object[]}}
|
||||
*/
|
||||
export function lignesDesPaires(detail) {
|
||||
const personnes = personnesParId(detail.personnes);
|
||||
const numeros = numerosParTable(detail);
|
||||
const ligne = (genre) => ({ a, b, fois, tours }) => {
|
||||
const ecrits = tours.map(({ tour, table }) => ({ tour: tour + 1, table: numeros.get(table) }));
|
||||
return { cle: `${a}:${b}`, genre, a: personnes.get(a), b: personnes.get(b), fois, tours: ecrits, repetition: ecrits[1].tour };
|
||||
};
|
||||
return { choisies: detail.paires.choisies.map(ligne('choisie')), imposees: detail.paires.imposees.map(ligne('imposee')) };
|
||||
}
|
||||
|
||||
/**
|
||||
* La page p du tableau des paires (§ 12.5, D50) : le découpage du tableau
|
||||
* des propositions (pagine, TAILLE_PAGE lignes par page), les paires
|
||||
* choisies d'abord, puis les imposées, comptées à la suite. Rend
|
||||
* {choisies, imposees, page, pages, premier, dernier, total, paginee} :
|
||||
* les lignes de chaque groupe que la page porte, puis la forme de pagine.
|
||||
*
|
||||
* @param {{choisies: Object[], imposees: Object[]}} lignes lignesDesPaires
|
||||
* @param {number} page entière, à partir de 1 ; bornée aux pages existantes
|
||||
*/
|
||||
export function pageDesPaires({ choisies, imposees }, page) {
|
||||
const { lignes, horsClassement, ...reste } = pagine({ lignes: choisies, horsClassement: imposees }, page);
|
||||
return { choisies: lignes, imposees: horsClassement, ...reste };
|
||||
}
|
||||
|
||||
/**
|
||||
* La matrice « qui a rencontré qui » (§ 12.5, D33) mise en forme pour la
|
||||
* grille, son infobulle et son jumeau ; null quand le détail ne la porte
|
||||
* pas (au-delà du seuil). Les personnes viennent dans l'ordre de la
|
||||
* matrice, celui du profil, chacune sous son rang à partir de 1 : une ligne
|
||||
* de la grille s'intitule « rang. nom », une colonne par le rang seul. Le
|
||||
* nom d'une personne, celui de nomDistinct, s'écrit une fois et sert à
|
||||
* l'intitulé de sa ligne comme à l'infobulle de chacune de ses cases. Les
|
||||
* valeurs sont le triangle du détail déplié en n × n, ligne après ligne,
|
||||
* symétrique, null sur la diagonale, que la grille laisse vide.
|
||||
*
|
||||
* Rend {personnes, lignes, colonnes, valeurs, infobulle, jumeau} :
|
||||
* personnes, [{id, nom, rang}], nom celui de nomDistinct ; lignes et
|
||||
* colonnes, [{libelle}] ;
|
||||
* infobulle, {colonnes, lignes}, une ligne par case dans l'ordre des
|
||||
* valeurs — la paire et son compte, la personne seule sur la diagonale ;
|
||||
* jumeau, {nom, colonnes, lignes}, une ligne par personne, son intitulé
|
||||
* puis un compte par colonne, vide sur la diagonale.
|
||||
*
|
||||
* @param {Object} detail un détail comparable
|
||||
* @param {{t: Function, format: {nombre: Function}}} outils
|
||||
*/
|
||||
export function matriceDesPaires(detail, { t, format }) {
|
||||
if (detail.matrice === null) return null;
|
||||
const { ordre, comptes } = detail.matrice;
|
||||
const parId = personnesParId(detail.personnes);
|
||||
const n = ordre.length;
|
||||
const personnes = ordre.map((id, i) => ({ id, nom: nomDistinct(parId.get(id), t), rang: i + 1 }));
|
||||
// Le rang d'une paire i < j dans le triangle du détail, ligne après ligne.
|
||||
const rangPaire = (i, j) => (i * (2 * n - i - 1)) / 2 + (j - i - 1);
|
||||
const compte = (i, j) => (i === j ? null : comptes[i < j ? rangPaire(i, j) : rangPaire(j, i)]);
|
||||
|
||||
const lignes = personnes.map(({ nom, rang }) => ({ libelle: t('qualite.paires.matrice.ligne', { rang: format.nombre(rang), nom }) }));
|
||||
const colonnes = personnes.map(({ rang }) => ({ libelle: format.nombre(rang) }));
|
||||
const valeurs = [];
|
||||
const bulles = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
for (let j = 0; j < n; j += 1) {
|
||||
valeurs.push(compte(i, j));
|
||||
bulles.push({
|
||||
paire: i === j ? personnes[i].nom : t('qualite.paires.matrice.paire', { a: personnes[i].nom, b: personnes[j].nom }),
|
||||
fois: compte(i, j),
|
||||
});
|
||||
}
|
||||
}
|
||||
const colonnesBulle = [
|
||||
{ cle: 'paire', libelle: t('qualite.paires.colonne.paire'), genre: 'texte' },
|
||||
{ cle: 'fois', libelle: t('qualite.paires.colonne.fois'), genre: 'nombre' },
|
||||
];
|
||||
const colonnesJumeau = [
|
||||
{ cle: 'personne', libelle: t('qualite.paires.matrice.personne'), genre: 'texte' },
|
||||
...personnes.map(({ rang }, j) => ({ cle: `c${j}`, libelle: format.nombre(rang), genre: 'nombre' })),
|
||||
];
|
||||
const lignesJumeau = personnes.map((personne, i) => {
|
||||
const ligne = { personne: lignes[i].libelle };
|
||||
for (let j = 0; j < n; j += 1) ligne[`c${j}`] = i === j ? '' : compte(i, j);
|
||||
return ligne;
|
||||
});
|
||||
return {
|
||||
personnes,
|
||||
lignes,
|
||||
colonnes,
|
||||
valeurs,
|
||||
infobulle: { colonnes: colonnesBulle, lignes: bulles },
|
||||
jumeau: { nom: t('qualite.paires.matrice.titre'), colonnes: colonnesJumeau, lignes: lignesJumeau },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La phrase de synthèse de la matrice (§ 12.7) : chaque liste du détail qui
|
||||
* n'est pas vide, les choisies puis les imposées, lue telle que le moteur la
|
||||
* rend — son effectif et le fois de sa tête, la plus revue de la liste — ;
|
||||
* sans paire revue, la phrase qui le dit. Rien ne s'y additionne ni ne s'y
|
||||
* compare : les deux têtes s'écrivent l'une après l'autre. Rend {cle,
|
||||
* details}, details {choisies, imposees}, chacune {paires, fois}, null pour
|
||||
* une liste vide.
|
||||
*
|
||||
* @param {{paires: {choisies: Object[], imposees: Object[]}}} detail
|
||||
*/
|
||||
export function syntheseMatrice({ paires: { choisies, imposees } }) {
|
||||
if (choisies.length === 0 && imposees.length === 0) return { cle: 'qualite.paires.matrice.syntheseAucune', details: {} };
|
||||
const liste = (paires) => (paires.length === 0 ? null : { paires: paires.length, fois: paires[0].fois });
|
||||
return { cle: 'qualite.paires.matrice.synthese', details: { choisies: liste(choisies), imposees: liste(imposees) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* La rubrique « Rencontres » de la fiche d'une personne (§ 12.5, B14, D33)
|
||||
* : la ligne de la personne dans le placement que le plan montre, la
|
||||
* source de la page de qualité — la proposition demandée au plan quand la
|
||||
* liste la porte, sinon le retenu, sinon aucun, comme sourceQualite
|
||||
* (source.js) le choisit, sans en évaluer la table.
|
||||
*
|
||||
* Rend {statut, source, lignes} : statut, celui de rencontresDePersonne —
|
||||
* mesuree, nonMesurable, exclue ou sansPlacement ; source, {cle, details},
|
||||
* le libellé du placement, null sans placement ; lignes, chaque personne
|
||||
* rencontrée, {id, nom, appartenance, homonyme, fois, tours: [{tour,
|
||||
* table}]}, les plus revues d'abord, tour à partir de 1, table son numéro.
|
||||
* appartenance et homonyme sont ceux du tableau des paires, sur les
|
||||
* personnes non exclues de la charge, celles que le placement mesure. Ne
|
||||
* modifie pas la charge.
|
||||
*
|
||||
* @param {Object} charge la charge de l'état observable de la séance
|
||||
* @param {number|null} demandee la proposition demandée au plan (ui.proposition)
|
||||
* @param {number} personne l'identifiant de la personne de la fiche
|
||||
*/
|
||||
export function rubriqueRencontres(charge, demandee, personne) {
|
||||
let id = null;
|
||||
if (demandee !== null && charge.propositions.some((proposition) => proposition.id === demandee)) id = demandee;
|
||||
else if (charge.retenu !== null) id = 'retenu';
|
||||
const { statut, lignes } = rencontresDePersonne(charge, id, personne);
|
||||
let source = null;
|
||||
if (id === 'retenu') source = { cle: 'participants.fiche.rencontres.retenu', details: {} };
|
||||
else if (id !== null) source = { cle: 'participants.fiche.rencontres.proposition', details: { proposition: id } };
|
||||
const numeros = new Map(charge.tables.map(({ id: table, numero }) => [table, numero]));
|
||||
const personnes = personnesParId(
|
||||
charge.participants
|
||||
.filter(({ exclu }) => !exclu)
|
||||
.map((p) => ({ id: p.id, nom: nomAffiche(p), appartenance: (p.appartenance ?? '').trim() === '' ? null : p.appartenance })),
|
||||
);
|
||||
return {
|
||||
statut,
|
||||
source,
|
||||
lignes: lignes.map(({ id: autre, fois, tours }) => ({
|
||||
...personnes.get(autre),
|
||||
fois,
|
||||
tours: tours.map(({ tour, table }) => ({ tour: tour + 1, table: numeros.get(table) })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
407
src/interface/qualite/paires.test.js
Normal file
407
src/interface/qualite/paires.test.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle des paires répétées (§ 12.5, B14, D32, D33, D50) : les
|
||||
// cas de la vue ; les paires choisies, puis les imposées, dans l'ordre du
|
||||
// détail, tours écrits à partir de 1 et tables par leur numéro, la ligne
|
||||
// menant au premier tour où la paire se revoit ; les pages de cent ; la
|
||||
// matrice dans l'ordre du profil, jamais par l'alphabet, symétrique, vide
|
||||
// sur la diagonale ; la rubrique « Rencontres » de la fiche, égale à la
|
||||
// ligne de la matrice. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
CAS_PAIRES,
|
||||
casDesPaires,
|
||||
lignesDesPaires,
|
||||
matriceDesPaires,
|
||||
nomDistinct,
|
||||
pageDesPaires,
|
||||
rubriqueRencontres,
|
||||
syntheseMatrice,
|
||||
} from './paires.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Douze personnes inventées, deux appartenances, la dernière sans ; les deux
|
||||
// premières réservées ensemble à la table 1 à chaque tour : leur paire est
|
||||
// imposée.
|
||||
const NOMS = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', 'Coopérative Aubier'],
|
||||
['Varech', 'Sidonie', 'Atelier Ressac'],
|
||||
['Bruyère', 'Aurèle', 'Coopérative Aubier'],
|
||||
['Embrun', 'Clovis', 'Atelier Ressac'],
|
||||
['Sablière', 'Noé', 'Coopérative Aubier'],
|
||||
['Goémon', 'Perrine', 'Atelier Ressac'],
|
||||
['Ajonc', 'Tancrède', null],
|
||||
['Fougeraie', 'Ysaline', null],
|
||||
];
|
||||
|
||||
// Douze personnes, quatre tables de trois numérotées 11 à 14, six tours :
|
||||
// douze places de voisinage pour onze autres, une répétition au moins pour
|
||||
// chacun (§ 12.5).
|
||||
function chargeDouze() {
|
||||
const charge = creerCharge({ id: 'evt-paires', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 6 });
|
||||
charge.participants = NOMS.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: 10 + id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// La charge, une génération de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages = { graine: 5, arret: 60, nombre: 2 }) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const CHARGE = generee(chargeDouze());
|
||||
const PREMIERE = CHARGE.propositions[0].id;
|
||||
const DETAIL = detaillerQualite(CHARGE, PREMIERE);
|
||||
|
||||
// Une paire forgée du détail, a < b, rencontrée aux tours donnés, à partir
|
||||
// de 0, à la table 1.
|
||||
const paireForgee = (a, b, tours) => ({ a, b, fois: tours.length, tours: tours.map((tour) => ({ tour, table: 1 })) });
|
||||
|
||||
describe('le cas de la vue (§ 12.6)', () => {
|
||||
const paires = { choisies: [paireForgee(1, 2, [0, 1])], imposees: [] };
|
||||
const vide = { choisies: [], imposees: [] };
|
||||
|
||||
test('une seule table l’emporte, puis un seul tour ; sans paire, aucune ; sinon le tableau', () => {
|
||||
assert.deepEqual(CAS_PAIRES, ['uneTable', 'unTour', 'aucune', 'tableau']);
|
||||
assert.equal(casDesPaires({ T: 1, R: 1, paires }), 'uneTable');
|
||||
assert.equal(casDesPaires({ T: 1, R: 3, paires }), 'uneTable');
|
||||
assert.equal(casDesPaires({ T: 4, R: 1, paires: vide }), 'unTour');
|
||||
assert.equal(casDesPaires({ T: 4, R: 6, paires: vide }), 'aucune');
|
||||
assert.equal(casDesPaires({ T: 4, R: 6, paires }), 'tableau');
|
||||
assert.equal(casDesPaires({ T: 4, R: 6, paires: { choisies: [], imposees: paires.choisies } }), 'tableau');
|
||||
});
|
||||
|
||||
test('sur 12 / 4 × 3 / 6, le détail est comparable et sa vue est le tableau', () => {
|
||||
assert.equal(DETAIL.statut, 'comparable');
|
||||
assert.equal(casDesPaires(DETAIL), 'tableau');
|
||||
});
|
||||
});
|
||||
|
||||
describe('les lignes du tableau des paires (§ 12.5, D32)', () => {
|
||||
const { choisies, imposees } = lignesDesPaires(DETAIL);
|
||||
|
||||
test('les paires choisies d’abord, dans l’ordre du détail ; la paire réservée ensemble à chaque tour est imposée, et seulement elle', () => {
|
||||
assert.ok(choisies.length > 0, 'au moins une paire choisie sur 12 / 4 × 3 / 6');
|
||||
assert.deepEqual(
|
||||
choisies.map(({ cle }) => cle),
|
||||
DETAIL.paires.choisies.map(({ a, b }) => `${a}:${b}`),
|
||||
);
|
||||
assert.deepEqual(
|
||||
imposees.map(({ cle, genre, fois }) => [cle, genre, fois]),
|
||||
[['1:2', 'imposee', 6]],
|
||||
);
|
||||
assert.ok(choisies.every(({ genre }) => genre === 'choisie'));
|
||||
assert.ok(!choisies.some(({ cle }) => cle === '1:2'), 'la paire imposée n’est pas parmi les choisies');
|
||||
});
|
||||
|
||||
test('chaque ligne écrit ses tours à partir de 1 et ses tables par leur numéro, et mène au premier tour où la paire se revoit', () => {
|
||||
for (const [ligne, brute] of [...choisies, ...imposees].map((l, i) => [l, [...DETAIL.paires.choisies, ...DETAIL.paires.imposees][i]])) {
|
||||
assert.deepEqual(
|
||||
ligne.tours,
|
||||
brute.tours.map(({ tour, table }) => ({ tour: tour + 1, table: 10 + table })),
|
||||
);
|
||||
assert.equal(ligne.repetition, brute.tours[1].tour + 1, `${ligne.cle} : le second tour ensemble`);
|
||||
assert.notEqual(ligne.repetition, ligne.tours[0].tour, `${ligne.cle} : jamais le premier tour de rencontre`);
|
||||
assert.equal(ligne.fois, ligne.tours.length);
|
||||
}
|
||||
assert.deepEqual(imposees[0].tours.map(({ tour, table }) => [tour, table]), [1, 2, 3, 4, 5, 6].map((tour) => [tour, 11]));
|
||||
assert.equal(imposees[0].repetition, 2);
|
||||
});
|
||||
|
||||
test('chaque personne porte son nom affiché et son appartenance ; un homonyme se signale, pas les autres', () => {
|
||||
const { a, b } = imposees[0];
|
||||
assert.deepEqual(a, { id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', homonyme: false });
|
||||
assert.deepEqual(b, { id: 2, nom: 'Théo Pervenche', appartenance: 'Coopérative Aubier', homonyme: false });
|
||||
const forge = {
|
||||
...DETAIL,
|
||||
personnes: [
|
||||
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', statut: 'mobile' },
|
||||
{ id: 2, nom: 'Iris Ombrelle', appartenance: 'Atelier Ressac', statut: 'mobile' },
|
||||
{ id: 3, nom: 'Ondine Lacasse', appartenance: null, statut: 'mobile' },
|
||||
],
|
||||
idsTables: [1],
|
||||
numerosTables: [7],
|
||||
paires: { choisies: [paireForgee(1, 2, [0, 2]), paireForgee(1, 3, [1, 2])], imposees: [] },
|
||||
};
|
||||
const lignes = lignesDesPaires(forge).choisies;
|
||||
assert.deepEqual(
|
||||
lignes.map(({ a, b }) => [a.homonyme, b.homonyme]),
|
||||
[
|
||||
[true, true],
|
||||
[true, false],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(lignes[0].tours, [
|
||||
{ tour: 1, table: 7 },
|
||||
{ tour: 3, table: 7 },
|
||||
]);
|
||||
assert.equal(lignes[0].repetition, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('les pages du tableau des paires (§ 5.7, D50)', () => {
|
||||
// 180 paires choisies et 70 imposées, forgées : 250 lignes.
|
||||
const forgees = (n, genre) => Array.from({ length: n }, (_, i) => ({ cle: `${genre}${i}`, genre }));
|
||||
const lignes = { choisies: forgees(180, 'choisie'), imposees: forgees(70, 'imposee') };
|
||||
|
||||
test('250 paires font trois pages de cent : les choisies d’abord, puis les imposées, comptées à la suite', () => {
|
||||
const [une, deux, trois] = [1, 2, 3].map((page) => pageDesPaires(lignes, page));
|
||||
assert.deepEqual(
|
||||
[une, deux, trois].map(({ page, pages, premier, dernier, total, paginee }) => [page, pages, premier, dernier, total, paginee]),
|
||||
[
|
||||
[1, 3, 1, 100, 250, true],
|
||||
[2, 3, 101, 200, 250, true],
|
||||
[3, 3, 201, 250, 250, true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[une, deux, trois].map(({ choisies, imposees }) => [choisies.length, imposees.length]),
|
||||
[
|
||||
[100, 0],
|
||||
[80, 20],
|
||||
[0, 50],
|
||||
],
|
||||
);
|
||||
assert.equal(deux.choisies[0].cle, 'choisie100');
|
||||
assert.equal(deux.imposees[0].cle, 'imposee0');
|
||||
assert.equal(trois.imposees[0].cle, 'imposee20');
|
||||
});
|
||||
|
||||
test('une page hors des pages existantes se borne ; une seule page n’est pas paginée', () => {
|
||||
assert.equal(pageDesPaires(lignes, 9).page, 3);
|
||||
assert.equal(pageDesPaires(lignes, 0).page, 1);
|
||||
const seule = pageDesPaires({ choisies: forgees(3, 'choisie'), imposees: forgees(1, 'imposee') }, 1);
|
||||
assert.deepEqual([seule.pages, seule.premier, seule.dernier, seule.total, seule.paginee], [1, 1, 4, 4, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la matrice « qui a rencontré qui » (§ 12.5, D33)', () => {
|
||||
const matrice = matriceDesPaires(DETAIL, { t, format });
|
||||
const n = DETAIL.personnes.length;
|
||||
|
||||
test('les personnes viennent dans l’ordre du profil, jamais par l’alphabet', () => {
|
||||
const profil = DETAIL.profil.map(({ id }) => id);
|
||||
assert.deepEqual(DETAIL.matrice.ordre, profil);
|
||||
assert.deepEqual(
|
||||
matrice.personnes.map(({ id }) => id),
|
||||
profil,
|
||||
);
|
||||
const parNom = new Map(DETAIL.personnes.map(({ id, nom }) => [id, nom]));
|
||||
const alphabet = [...profil].sort((a, b) => (parNom.get(a) < parNom.get(b) ? -1 : 1));
|
||||
assert.notDeepEqual(profil, alphabet, 'l’épreuve exige un profil qui n’est pas l’ordre alphabétique');
|
||||
assert.deepEqual(
|
||||
matrice.lignes.map(({ libelle }) => libelle),
|
||||
profil.map((id, i) => `${i + 1}. ${parNom.get(id)}`),
|
||||
);
|
||||
assert.deepEqual(
|
||||
matrice.colonnes.map(({ libelle }) => libelle),
|
||||
profil.map((_, i) => String(i + 1)),
|
||||
);
|
||||
});
|
||||
|
||||
test('n × n valeurs, symétriques, vides sur la diagonale, chacune le compte du triangle du détail', () => {
|
||||
assert.equal(matrice.valeurs.length, n * n);
|
||||
let k = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
assert.equal(matrice.valeurs[i * n + i], null);
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
assert.equal(matrice.valeurs[i * n + j], DETAIL.matrice.comptes[k], `case ${i}:${j}`);
|
||||
assert.equal(matrice.valeurs[j * n + i], DETAIL.matrice.comptes[k], `case ${j}:${i}`);
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('la paire imposée se lit dans sa case ; l’infobulle porte une ligne par case, le jumeau une ligne par personne', () => {
|
||||
const i = matrice.personnes.findIndex(({ id }) => id === 1);
|
||||
const j = matrice.personnes.findIndex(({ id }) => id === 2);
|
||||
assert.equal(matrice.valeurs[i * n + j], 6);
|
||||
assert.deepEqual(matrice.infobulle.lignes[i * n + j], { paire: 'Iris Ombrelle et Théo Pervenche', fois: 6 });
|
||||
assert.deepEqual(matrice.infobulle.lignes[i * n + i], { paire: 'Iris Ombrelle', fois: null });
|
||||
assert.equal(matrice.infobulle.lignes.length, n * n);
|
||||
assert.deepEqual(
|
||||
matrice.infobulle.colonnes.map(({ cle, genre }) => [cle, genre]),
|
||||
[
|
||||
['paire', 'texte'],
|
||||
['fois', 'nombre'],
|
||||
],
|
||||
);
|
||||
const { jumeau } = matrice;
|
||||
assert.equal(jumeau.nom, 'Qui a rencontré qui');
|
||||
assert.equal(jumeau.colonnes.length, n + 1);
|
||||
assert.equal(jumeau.lignes.length, n);
|
||||
assert.equal(jumeau.lignes[i].personne, matrice.lignes[i].libelle);
|
||||
assert.equal(jumeau.lignes[i][`c${j}`], 6);
|
||||
assert.equal(jumeau.lignes[i][`c${i}`], '');
|
||||
});
|
||||
|
||||
test('deux homonymes : l’intitulé de leur ligne et l’infobulle de leurs cases portent leur identifiant, écrit une fois par personne', () => {
|
||||
// La personne 2 prend le nom affiché de la personne 1.
|
||||
const forge = {
|
||||
...DETAIL,
|
||||
personnes: DETAIL.personnes.map((p) => (p.id === 2 ? { ...p, nom: 'Iris Ombrelle' } : p)),
|
||||
};
|
||||
const homonymes = matriceDesPaires(forge, { t, format });
|
||||
const indice = (id) => homonymes.personnes.findIndex((p) => p.id === id);
|
||||
const [i1, i2, i3] = [1, 2, 3].map(indice);
|
||||
const nom1 = t('qualite.paires.nomHomonyme', { nom: 'Iris Ombrelle', id: 1 });
|
||||
const nom2 = t('qualite.paires.nomHomonyme', { nom: 'Iris Ombrelle', id: 2 });
|
||||
assert.equal(nom1, 'Iris Ombrelle (n° 1)');
|
||||
assert.deepEqual([homonymes.personnes[i1].nom, homonymes.personnes[i2].nom], [nom1, nom2]);
|
||||
assert.equal(homonymes.lignes[i1].libelle, `${i1 + 1}. ${nom1}`);
|
||||
assert.equal(homonymes.lignes[i2].libelle, `${i2 + 1}. ${nom2}`);
|
||||
const bulle = (i, j) => homonymes.infobulle.lignes[i * n + j].paire;
|
||||
assert.equal(bulle(i1, i3), `${nom1} et Ondine Lacasse`);
|
||||
assert.equal(bulle(i2, i3), `${nom2} et Ondine Lacasse`);
|
||||
assert.notEqual(bulle(i1, i3), bulle(i2, i3));
|
||||
assert.equal(bulle(i1, i1), nom1);
|
||||
// Les autres gardent leur nom seul, dans l'intitulé comme dans
|
||||
// l'infobulle.
|
||||
assert.equal(homonymes.personnes[i3].nom, 'Ondine Lacasse');
|
||||
assert.equal(homonymes.jumeau.lignes[i2].personne, homonymes.lignes[i2].libelle);
|
||||
assert.equal(nomDistinct({ id: 3, nom: 'Ondine Lacasse', homonyme: false }, t), 'Ondine Lacasse');
|
||||
});
|
||||
|
||||
test('sans matrice au détail, aucune', () => {
|
||||
assert.equal(matriceDesPaires({ ...DETAIL, matrice: null }, { t, format }), null);
|
||||
});
|
||||
|
||||
test('la synthèse lit chaque liste du détail, sans la recompter : son effectif et le fois de sa tête, les choisies puis les imposées', () => {
|
||||
const synthese = syntheseMatrice(DETAIL);
|
||||
assert.deepEqual(synthese, {
|
||||
cle: 'qualite.paires.matrice.synthese',
|
||||
details: {
|
||||
choisies: { paires: DETAIL.paires.choisies.length, fois: DETAIL.paires.choisies[0].fois },
|
||||
imposees: { paires: 1, fois: 6 },
|
||||
},
|
||||
});
|
||||
// Chaque liste garde sa tête : aucune des deux n'est retenue sur l'autre.
|
||||
const deux = { paires: { choisies: [paireForgee(1, 2, [0, 1])], imposees: [paireForgee(3, 4, [0, 1, 2]), paireForgee(5, 6, [0, 1])] } };
|
||||
assert.deepEqual(syntheseMatrice(deux).details, { choisies: { paires: 1, fois: 2 }, imposees: { paires: 2, fois: 3 } });
|
||||
const seuleChoisie = { paires: { choisies: [paireForgee(1, 2, [0, 1])], imposees: [] } };
|
||||
assert.deepEqual(syntheseMatrice(seuleChoisie).details, { choisies: { paires: 1, fois: 2 }, imposees: null });
|
||||
assert.deepEqual(syntheseMatrice({ paires: { choisies: [], imposees: [] } }), { cle: 'qualite.paires.matrice.syntheseAucune', details: {} });
|
||||
assert.equal(
|
||||
t(syntheseMatrice(deux).cle, syntheseMatrice(deux).details),
|
||||
'Chaque case porte le nombre de rencontres de la paire. 1 paire choisie se revoit, 2 fois ; 2 paires imposées se revoient, la plus revue 3 fois.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('la rubrique « Rencontres » de la fiche (§ 12.5, B14, D33)', () => {
|
||||
test('la ligne de la fiche égale celle de la matrice, pour chaque personne', () => {
|
||||
const matrice = matriceDesPaires(DETAIL, { t, format });
|
||||
const n = matrice.personnes.length;
|
||||
matrice.personnes.forEach(({ id }, i) => {
|
||||
const rubrique = rubriqueRencontres(CHARGE, PREMIERE, id);
|
||||
assert.equal(rubrique.statut, 'mesuree');
|
||||
assert.deepEqual(rubrique.source, { cle: 'participants.fiche.rencontres.proposition', details: { proposition: PREMIERE } });
|
||||
const deLaFiche = new Map(rubrique.lignes.map(({ id: autre, fois }) => [autre, fois]));
|
||||
const deLaMatrice = new Map();
|
||||
matrice.personnes.forEach(({ id: autre }, j) => {
|
||||
const fois = matrice.valeurs[i * n + j];
|
||||
if (fois !== null && fois > 0) deLaMatrice.set(autre, fois);
|
||||
});
|
||||
assert.deepEqual(deLaFiche, deLaMatrice, `personne ${id}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('les plus revues d’abord ; tours à partir de 1, tables par leur numéro', () => {
|
||||
const { lignes } = rubriqueRencontres(CHARGE, PREMIERE, 1);
|
||||
assert.deepEqual(lignes[0].id, 2);
|
||||
assert.equal(lignes[0].nom, 'Théo Pervenche');
|
||||
assert.deepEqual([lignes[0].appartenance, lignes[0].homonyme], ['Coopérative Aubier', false]);
|
||||
assert.equal(lignes[0].fois, 6);
|
||||
assert.deepEqual(lignes[0].tours, [1, 2, 3, 4, 5, 6].map((tour) => ({ tour, table: 11 })));
|
||||
for (let k = 1; k < lignes.length; k += 1) assert.ok(lignes[k - 1].fois >= lignes[k].fois);
|
||||
});
|
||||
|
||||
test('la source est la proposition demandée qu’a la liste, sinon le retenu, sinon aucune : sans placement, rien à écrire', () => {
|
||||
const autre = CHARGE.propositions[1].id;
|
||||
assert.deepEqual(rubriqueRencontres(CHARGE, autre, 3).source.details, { proposition: autre });
|
||||
const retenue = appliquerCommande('retenirProposition', CHARGE, { id: autre }).charge;
|
||||
const duRetenu = rubriqueRencontres(retenue, null, 3);
|
||||
assert.deepEqual([duRetenu.statut, duRetenu.source], ['mesuree', { cle: 'participants.fiche.rencontres.retenu', details: {} }]);
|
||||
assert.deepEqual(rubriqueRencontres(retenue, 999, 3).source.cle, 'participants.fiche.rencontres.retenu');
|
||||
// Une proposition demandée l'emporte sur le retenu : la fiche mesure ce
|
||||
// que la page de qualité mesure.
|
||||
assert.notEqual(PREMIERE, autre);
|
||||
const demandeeSurRetenu = rubriqueRencontres(retenue, PREMIERE, 3);
|
||||
assert.deepEqual(demandeeSurRetenu.source, { cle: 'participants.fiche.rencontres.proposition', details: { proposition: PREMIERE } });
|
||||
assert.deepEqual(demandeeSurRetenu.lignes, rubriqueRencontres(CHARGE, PREMIERE, 3).lignes);
|
||||
assert.deepEqual(rubriqueRencontres(CHARGE, null, 3), { statut: 'sansPlacement', source: null, lignes: [] });
|
||||
assert.deepEqual(rubriqueRencontres(chargeDouze(), null, 3), { statut: 'sansPlacement', source: null, lignes: [] });
|
||||
});
|
||||
|
||||
test('deux homonymes rencontrés : chaque ligne porte son identifiant, son appartenance et le signe homonyme, comme au tableau des paires', () => {
|
||||
// La personne 2, réservée avec la personne 1, prend son nom : qui
|
||||
// rencontre l'une rencontre l'autre. La 12, sans appartenance.
|
||||
const charge = structuredClone(CHARGE);
|
||||
Object.assign(charge.participants[1], { nom: 'Ombrelle', prenom: 'Iris' });
|
||||
const lignesDe = (personne) => rubriqueRencontres(charge, PREMIERE, personne).lignes;
|
||||
const temoin = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find((personne) => lignesDe(personne).some(({ id }) => id === 1));
|
||||
assert.notEqual(temoin, undefined);
|
||||
const lignes = lignesDe(temoin);
|
||||
const de = (id) => lignes.find((ligne) => ligne.id === id);
|
||||
assert.deepEqual(
|
||||
[1, 2].map((id) => {
|
||||
const { nom, appartenance, homonyme } = de(id);
|
||||
return { id, nom, appartenance, homonyme };
|
||||
}),
|
||||
[
|
||||
{ id: 1, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', homonyme: true },
|
||||
{ id: 2, nom: 'Iris Ombrelle', appartenance: 'Coopérative Aubier', homonyme: true },
|
||||
],
|
||||
);
|
||||
for (const ligne of lignes.filter(({ id }) => id > 2)) assert.equal(ligne.homonyme, false, `personne ${ligne.id}`);
|
||||
// Le même signe que celui du tableau des paires, sur les mêmes personnes.
|
||||
const detail = detaillerQualite(charge, PREMIERE);
|
||||
const paire = lignesDesPaires(detail).imposees[0];
|
||||
assert.deepEqual([paire.a.homonyme, paire.b.homonyme], [true, true]);
|
||||
// Une personne sans appartenance n'en porte aucune.
|
||||
const rencontreLa12 = [3, 4, 5, 6, 7, 8, 9, 10, 11].find((personne) => lignesDe(personne).some(({ id }) => id === 12));
|
||||
assert.equal(lignesDe(rencontreLa12).find(({ id }) => id === 12).appartenance, null);
|
||||
});
|
||||
|
||||
test('une personne exclue, une source qui ne se mesure pas : le statut le dit, sans ligne', () => {
|
||||
const exclue = structuredClone(CHARGE);
|
||||
exclue.participants[2].exclu = true;
|
||||
assert.deepEqual(rubriqueRencontres(exclue, PREMIERE, 3).statut, 'exclue');
|
||||
assert.deepEqual(rubriqueRencontres(exclue, PREMIERE, 3).lignes, []);
|
||||
// La proposition dérive pour les autres : l'exclue n'y est plus mesurée.
|
||||
const derive = rubriqueRencontres(exclue, PREMIERE, 4);
|
||||
assert.deepEqual([derive.statut, derive.lignes], ['nonMesurable', []]);
|
||||
assert.deepEqual(derive.source, { cle: 'participants.fiche.rencontres.proposition', details: { proposition: PREMIERE } });
|
||||
});
|
||||
});
|
||||
173
src/interface/qualite/performance.js
Normal file
173
src/interface/qualite/performance.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle pur de la vue « Performance du calcul » (§ 12.14, B23, D40,
|
||||
// D49) : il met en mots et en séries ce que la course du détail porte
|
||||
// (detaillerQualite, evaluation.js), dans l'ordre où elle le porte, sans
|
||||
// rien mesurer, trier ni recompter (contrainte 41). VuePerformance.svelte
|
||||
// le rend.
|
||||
//
|
||||
// La ligne de tête dit les mouvements évalués sur le compte d'arrêt — ou
|
||||
// « rien à reprendre après … » sous le compte, ou « le plan initial n'avait
|
||||
// rien à reprendre » à zéro mouvement —, puis la génération de la séance :
|
||||
// durée observée, cœurs, cadence, nombre de propositions. Une durée
|
||||
// inconnue — nulle, absente, d'une proposition rouverte — s'écrit « durée
|
||||
// inconnue », et aucune cadence ne s'écrit sans durée, même si le détail
|
||||
// en portait une : la cadence ne vaut que divisée par une durée mesurée.
|
||||
//
|
||||
// Les petits multiples : un graphique par valeur de la course, dans
|
||||
// l'ordre de ses indicateurs — le minimum brut, puis chaque valeur que les
|
||||
// critères appliqués classent —, chacun la valeur de chaque point de
|
||||
// relevé, sur les mouvements de ces points ; une valeur qu'aucun point ne
|
||||
// porte (null partout) n'a pas de graphique et garde sa colonne au jumeau.
|
||||
// Un seul point — compte 1, ou zéro mouvement — n'a rien à relier : aucun
|
||||
// graphique. Le jumeau porte une ligne par point de relevé, ses mouvements
|
||||
// puis une colonne par valeur, et aucune durée : la cadence est celle de
|
||||
// la génération entière, et ne date aucun point (D40).
|
||||
//
|
||||
// La phrase du dernier changement s'arrête aux mouvements évalués, le
|
||||
// dernier point, jamais au compte d'arrêt : elle n'affirme rien des
|
||||
// mouvements que la recherche n'a pas évalués (D49).
|
||||
//
|
||||
// Le texte passe par t et les nombres par format, ceux de la page : le
|
||||
// modèle les reçoit et rend du texte prêt à poser.
|
||||
|
||||
// Le rendu d'un détail absent, ou d'une source qui ne se compare pas.
|
||||
const RIEN = Object.freeze({
|
||||
phrases: [],
|
||||
tete: null,
|
||||
synthese: null,
|
||||
dernierChangement: null,
|
||||
abscisses: null,
|
||||
multiples: [],
|
||||
jumeau: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* La clé du nom d'une valeur de point de relevé : celle de la page pour le
|
||||
* minimum brut, moteur.valeur.<nom> pour une valeur qui classe — le nom que
|
||||
* la phrase de l'ordre du tableau des propositions lui donne.
|
||||
*
|
||||
* @param {string} nom un nom de course.indicateurs
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cleIndicateur(nom) {
|
||||
return nom === 'minimumBrut' ? 'qualite.performance.minimumBrut' : `moteur.valeur.${nom}`;
|
||||
}
|
||||
|
||||
// La phrase de tête de la vue : sur le retenu, qu'elle mesure une
|
||||
// proposition, laquelle, et si elle en montre la course ; sur une
|
||||
// proposition sans course gardée, que sa course ne l'est pas ; null sinon.
|
||||
function phraseDeSource({ id, propositionDuRetenu, course }, t) {
|
||||
if (id === 'retenu') {
|
||||
if (course !== null) return { code: 'retenu', texte: t('qualite.performance.retenu', { proposition: course.propositionDuRetenu }) };
|
||||
if (propositionDuRetenu === null) return { code: 'retenuSansCourse', texte: t('qualite.performance.retenuSansCourse') };
|
||||
return {
|
||||
code: 'retenuCourseNonGardee',
|
||||
texte: t('qualite.performance.retenuCourseNonGardee', { proposition: propositionDuRetenu }),
|
||||
};
|
||||
}
|
||||
return course === null ? { code: 'sansCourse', texte: t('qualite.performance.sansCourse') } : null;
|
||||
}
|
||||
|
||||
// Les morceaux de la ligne de tête, chacun un texte.
|
||||
function teteDe({ evalues, arret, generation }, { t, format }) {
|
||||
const nombres = { evalues: format.nombre(evalues), arret: format.nombre(arret) };
|
||||
let mouvements;
|
||||
if (evalues === 0) mouvements = t('qualite.performance.planInitial');
|
||||
else if (evalues < arret) mouvements = t('qualite.performance.rienAReprendre', nombres);
|
||||
else mouvements = t('qualite.performance.mouvements', nombres);
|
||||
const { dureeMs, coeurs, cadence, nombre } = generation;
|
||||
const connue = dureeMs !== null;
|
||||
const morceaux = [
|
||||
mouvements,
|
||||
connue ? t('qualite.performance.duree', { duree: format.duree(dureeMs) }) : t('qualite.performance.dureeInconnue'),
|
||||
];
|
||||
if (coeurs !== null) morceaux.push(t('qualite.performance.coeurs', { coeurs }));
|
||||
if (connue && cadence !== null) morceaux.push(t('qualite.performance.cadence', { cadence: format.nombre(cadence) }));
|
||||
if (nombre !== null) morceaux.push(t('qualite.performance.propositions', { nombre }));
|
||||
return morceaux;
|
||||
}
|
||||
|
||||
// La phrase du dernier changement : entre le dernier point où une valeur a
|
||||
// changé et le dernier point, aux mouvements évalués ; au dernier point
|
||||
// lui-même, qu'une valeur y a changé.
|
||||
function phraseDuChangement({ dernierChangement, evalues }, { t, format }) {
|
||||
const a = format.nombre(evalues);
|
||||
if (dernierChangement === evalues) return t('qualite.performance.changeAuDernier', { a });
|
||||
return t('qualite.performance.dernierChangement', { de: format.nombre(dernierChangement), a });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la vue de la performance montre du détail d'un placement.
|
||||
*
|
||||
* @param {import('../../application/evaluation.js').DetailQualite|null} detail
|
||||
* @param {{t: Function, format: {nombre: Function, duree: Function}}} ecrire
|
||||
* la traduction et l'écriture des nombres de la page
|
||||
* @returns {{phrases: Array<{code: string, texte: string}>,
|
||||
* tete: string[]|null, synthese: string|null,
|
||||
* dernierChangement: string|null, abscisses: number[]|null,
|
||||
* multiples: Array<{nom: string, graphique: string, titre: string,
|
||||
* libelle: string, valeurs: Array<number|null>, synthese: string}>,
|
||||
* jumeau: {nom: string, colonnes: Array<{cle: string, libelle: string,
|
||||
* genre: 'nombre'}>, lignes: Object[]}|null}}
|
||||
* phrases : la phrase du retenu, de la course absente ou d'un seul point,
|
||||
* chacune sous son code ; tete : les morceaux de la ligne de tête ;
|
||||
* abscisses : les mouvements de chaque point, null sans graphique ;
|
||||
* multiples : un graphique par valeur, graphique son nom de désignation,
|
||||
* titre son nom accessible, libelle le nom de sa série ; jumeau : un
|
||||
* tableau, une ligne par point. Sans course, ni tête, ni graphique, ni
|
||||
* jumeau.
|
||||
*/
|
||||
export function vuePerformance(detail, { t, format }) {
|
||||
if (detail === null || detail.statut !== 'comparable') return RIEN;
|
||||
const source = phraseDeSource(detail, t);
|
||||
const phrases = source === null ? [] : [source];
|
||||
const { course } = detail;
|
||||
if (course === null) return { ...RIEN, phrases };
|
||||
|
||||
const { indicateurs, releves } = course;
|
||||
const libelles = indicateurs.map((nom) => t(cleIndicateur(nom)));
|
||||
const jumeau = {
|
||||
nom: t('qualite.performance.jumeau'),
|
||||
colonnes: [
|
||||
{ cle: 'mouvements', libelle: t('qualite.performance.colonne.mouvements'), genre: 'nombre' },
|
||||
...indicateurs.map((nom, k) => ({ cle: nom, libelle: libelles[k], genre: 'nombre' })),
|
||||
],
|
||||
lignes: releves.map(({ mouvements, valeurs }) => ({ mouvements, ...valeurs })),
|
||||
};
|
||||
const tete = teteDe(course, { t, format });
|
||||
if (releves.length < 2) {
|
||||
if (course.evalues > 0) phrases.push({ code: 'unPoint', texte: t('qualite.performance.unPoint') });
|
||||
return { ...RIEN, phrases, tete, jumeau };
|
||||
}
|
||||
|
||||
const premier = releves[0];
|
||||
const dernier = releves[releves.length - 1];
|
||||
const multiples = [];
|
||||
indicateurs.forEach((nom, k) => {
|
||||
const valeurs = releves.map((point) => point.valeurs[nom]);
|
||||
if (valeurs.every((valeur) => valeur === null)) return;
|
||||
multiples.push({
|
||||
nom,
|
||||
graphique: `performance-${nom}`,
|
||||
titre: t('qualite.performance.graphique', { indicateur: libelles[k] }),
|
||||
libelle: libelles[k],
|
||||
valeurs,
|
||||
synthese: t('qualite.performance.syntheseIndicateur', {
|
||||
indicateur: libelles[k],
|
||||
premier: format.nombre(premier.valeurs[nom]),
|
||||
dernier: format.nombre(dernier.valeurs[nom]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
return {
|
||||
phrases,
|
||||
tete,
|
||||
synthese: t('qualite.performance.synthese'),
|
||||
dernierChangement: phraseDuChangement(course, { t, format }),
|
||||
abscisses: releves.map(({ mouvements }) => mouvements),
|
||||
multiples,
|
||||
jumeau,
|
||||
};
|
||||
}
|
||||
299
src/interface/qualite/performance.test.js
Normal file
299
src/interface/qualite/performance.test.js
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle de la vue « Performance du calcul » (§ 12.14, B23,
|
||||
// D40, D49) : la ligne de tête — mouvements évalués sur le compte, durée,
|
||||
// cœurs, cadence et nombre de la génération, ou « durée inconnue » sans
|
||||
// cadence ; « rien à reprendre » sous le compte, « le plan initial » à zéro
|
||||
// mouvement — ; un petit multiple par valeur, le minimum brut d'abord, sur
|
||||
// l'abscisse des points de relevé ; la phrase du dernier changement, bornée
|
||||
// aux mouvements évalués ; le jumeau, points × valeurs, sans durée ; les
|
||||
// phrases du retenu et de la course absente. Les détails sont écrits à la
|
||||
// main sur le contrat de detaillerQualite (evaluation.js), sauf la dernière
|
||||
// épreuve, qui le prend d'une génération du moteur.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { avecCourses, courseDe } from '../../stockage/courses.js';
|
||||
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { cleIndicateur, vuePerformance } from './performance.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const ecrire = { t, format };
|
||||
|
||||
// Les valeurs d'un point : le minimum brut, l'excédent de collisions, les
|
||||
// rencontres répétées.
|
||||
const valeurs = (minimumBrut, excedentCollisions, rencontresRepeteesChoisies) => ({
|
||||
minimumBrut,
|
||||
excedentCollisions,
|
||||
rencontresRepeteesChoisies,
|
||||
});
|
||||
|
||||
// Un détail comparable d'épreuve, sur le contrat de detaillerQualite : la
|
||||
// proposition 4, sa course de quatre points, une génération de la séance.
|
||||
function detailDe({ id = 4, propositionDuRetenu = null, course = {} } = {}) {
|
||||
const base = {
|
||||
source: id === 'retenu' ? 'retenu' : 'proposition',
|
||||
propositionDuRetenu: id === 'retenu' ? propositionDuRetenu : null,
|
||||
evalues: 200,
|
||||
arret: 200,
|
||||
indicateurs: ['minimumBrut', 'excedentCollisions', 'rencontresRepeteesChoisies'],
|
||||
releves: [
|
||||
{ mouvements: 1, valeurs: valeurs(1, 6, 9) },
|
||||
{ mouvements: 10, valeurs: valeurs(2, 4, 7) },
|
||||
{ mouvements: 100, valeurs: valeurs(3, 2, 5) },
|
||||
{ mouvements: 200, valeurs: valeurs(3, 2, 5) },
|
||||
],
|
||||
dernierChangement: 100,
|
||||
generation: { dureeMs: 2500, coeurs: 2, cadence: 1600, nombre: 3 },
|
||||
};
|
||||
return {
|
||||
id,
|
||||
statut: 'comparable',
|
||||
raisons: [],
|
||||
propositionDuRetenu,
|
||||
course: course === null ? null : { ...base, ...course },
|
||||
};
|
||||
}
|
||||
|
||||
// Les clés des colonnes du jumeau, dans leur ordre.
|
||||
const clesDuJumeau = (vue) => vue.jumeau.colonnes.map(({ cle }) => cle);
|
||||
|
||||
describe('qualite/performance : la ligne de tête (§ 12.14, § 5.10, D40)', () => {
|
||||
test('une génération de la séance : mouvements sur le compte, durée observée, cœurs, cadence, propositions', () => {
|
||||
const vue = vuePerformance(detailDe(), ecrire);
|
||||
assert.deepEqual(vue.tete, [
|
||||
t('qualite.performance.mouvements', { evalues: '200', arret: '200' }),
|
||||
t('qualite.performance.duree', { duree: format.duree(2500) }),
|
||||
t('qualite.performance.coeurs', { coeurs: 2 }),
|
||||
t('qualite.performance.cadence', { cadence: format.nombre(1600) }),
|
||||
t('qualite.performance.propositions', { nombre: 3 }),
|
||||
]);
|
||||
assert.equal(vue.tete[1], 'durée observée 2,5 s');
|
||||
assert.equal(vue.tete[3], 'cadence 1\u{202F}600 mouvements par seconde');
|
||||
assert.deepEqual(vue.phrases, []);
|
||||
});
|
||||
|
||||
test('rouge : une durée nulle ou inconnue écrit « durée inconnue », sans aucune cadence, même portée par le détail', () => {
|
||||
for (const generation of [
|
||||
{ dureeMs: null, coeurs: 1, cadence: 950, nombre: 1 },
|
||||
{ dureeMs: null, coeurs: null, cadence: null, nombre: null },
|
||||
]) {
|
||||
const vue = vuePerformance(detailDe({ course: { generation } }), ecrire);
|
||||
assert.ok(vue.tete.includes(t('qualite.performance.dureeInconnue')), JSON.stringify(generation));
|
||||
assert.equal(vue.tete.filter((morceau) => morceau.includes('cadence')).length, 0, JSON.stringify(generation));
|
||||
assert.equal(vue.tete.filter((morceau) => morceau.startsWith('durée observée')).length, 0);
|
||||
}
|
||||
const inconnue = vuePerformance(detailDe({ course: { generation: { dureeMs: null, coeurs: null, cadence: null, nombre: null } } }), ecrire);
|
||||
assert.deepEqual(inconnue.tete, [t('qualite.performance.mouvements', { evalues: '200', arret: '200' }), 'durée inconnue']);
|
||||
});
|
||||
|
||||
test("rouge : sous le compte, « rien à reprendre » ; la phrase du dernier changement s'arrête aux mouvements évalués, jamais au compte", () => {
|
||||
const releves = [
|
||||
{ mouvements: 1, valeurs: valeurs(1, 6, 9) },
|
||||
{ mouvements: 10, valeurs: valeurs(2, 4, 7) },
|
||||
{ mouvements: 100, valeurs: valeurs(2, 4, 7) },
|
||||
{ mouvements: 1000, valeurs: valeurs(2, 4, 7) },
|
||||
{ mouvements: 3412, valeurs: valeurs(2, 4, 7) },
|
||||
];
|
||||
const vue = vuePerformance(detailDe({ course: { evalues: 3412, arret: 20000, releves, dernierChangement: 10 } }), ecrire);
|
||||
assert.equal(vue.tete[0], 'rien à reprendre après 3\u{202F}412 mouvements sur 20\u{202F}000');
|
||||
assert.equal(vue.dernierChangement, t('qualite.performance.dernierChangement', { de: '10', a: '3\u{202F}412' }));
|
||||
assert.ok(!vue.dernierChangement.includes('20\u{202F}000'));
|
||||
assert.deepEqual(vue.abscisses, [1, 10, 100, 1000, 3412]);
|
||||
});
|
||||
|
||||
// Un arrêt avant le compte, sous les réglages de la génération : le
|
||||
// n-uplet atteint zéro au dernier mouvement évalué, une valeur y passe donc
|
||||
// à zéro, et le dernier changement tombe sur les mouvements évalués.
|
||||
test("rouge : un arrêt avant le compte change au dernier point évalué : « rien à reprendre », la phrase du dernier point, jamais celle d'un intervalle", () => {
|
||||
const releves = [
|
||||
{ mouvements: 1, valeurs: valeurs(1, 6, 9) },
|
||||
{ mouvements: 10, valeurs: valeurs(2, 4, 7) },
|
||||
{ mouvements: 100, valeurs: valeurs(2, 3, 4) },
|
||||
{ mouvements: 1000, valeurs: valeurs(2, 1, 2) },
|
||||
{ mouvements: 3412, valeurs: valeurs(2, 0, 0) },
|
||||
];
|
||||
const vue = vuePerformance(detailDe({ course: { evalues: 3412, arret: 20000, releves, dernierChangement: 3412 } }), ecrire);
|
||||
assert.equal(vue.tete[0], 'rien à reprendre après 3\u{202F}412 mouvements sur 20\u{202F}000');
|
||||
assert.equal(vue.dernierChangement, t('qualite.performance.changeAuDernier', { a: '3\u{202F}412' }));
|
||||
assert.notEqual(vue.dernierChangement, t('qualite.performance.dernierChangement', { de: '3\u{202F}412', a: '3\u{202F}412' }));
|
||||
assert.deepEqual(vue.jumeau.lignes.at(-1), { mouvements: 3412, ...valeurs(2, 0, 0) });
|
||||
});
|
||||
|
||||
test("à zéro mouvement : « le plan initial n'avait rien à reprendre », un seul point, aucun graphique ni phrase de changement", () => {
|
||||
const course = { evalues: 0, releves: [{ mouvements: 0, valeurs: valeurs(2, 4, 7) }], dernierChangement: 0 };
|
||||
const vue = vuePerformance(detailDe({ course }), ecrire);
|
||||
assert.equal(vue.tete[0], "le plan initial n'avait rien à reprendre");
|
||||
assert.deepEqual([vue.multiples, vue.abscisses, vue.dernierChangement, vue.synthese], [[], null, null, null]);
|
||||
assert.deepEqual(vue.jumeau.lignes, [{ mouvements: 0, ...valeurs(2, 4, 7) }]);
|
||||
});
|
||||
|
||||
test('un seul mouvement évalué, au compte 1 : un point, sa phrase, aucun graphique', () => {
|
||||
const course = { evalues: 1, arret: 1, releves: [{ mouvements: 1, valeurs: valeurs(2, 4, 7) }], dernierChangement: 1 };
|
||||
const vue = vuePerformance(detailDe({ course }), ecrire);
|
||||
assert.equal(vue.tete[0], t('qualite.performance.mouvements', { evalues: '1', arret: '1' }));
|
||||
assert.equal(vue.tete[0], '1 mouvement évalué sur 1');
|
||||
assert.deepEqual(vue.phrases, [{ code: 'unPoint', texte: t('qualite.performance.unPoint') }]);
|
||||
assert.deepEqual([vue.multiples, vue.abscisses, vue.dernierChangement], [[], null, null]);
|
||||
assert.equal(vue.jumeau.lignes.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualite/performance : les petits multiples et le jumeau (§ 12.14, D49)', () => {
|
||||
test('un graphique par valeur, le minimum brut d’abord, dans l’ordre du détail, sur les mouvements de chaque point', () => {
|
||||
const vue = vuePerformance(detailDe(), ecrire);
|
||||
assert.deepEqual(vue.abscisses, [1, 10, 100, 200]);
|
||||
assert.deepEqual(
|
||||
vue.multiples.map(({ nom, graphique, libelle, valeurs: v }) => [nom, graphique, libelle, v]),
|
||||
[
|
||||
['minimumBrut', 'performance-minimumBrut', 'minimum brut', [1, 2, 3, 3]],
|
||||
['excedentCollisions', 'performance-excedentCollisions', 'excédent de collisions', [6, 4, 2, 2]],
|
||||
['rencontresRepeteesChoisies', 'performance-rencontresRepeteesChoisies', 'rencontres répétées', [9, 7, 5, 5]],
|
||||
],
|
||||
);
|
||||
const titres = vue.multiples.map(({ titre }) => titre);
|
||||
assert.equal(new Set(titres).size, titres.length, 'chaque graphique a son nom');
|
||||
assert.equal(titres[0], t('qualite.performance.graphique', { indicateur: 'minimum brut' }));
|
||||
assert.equal(vue.multiples[1].synthese, t('qualite.performance.syntheseIndicateur', { indicateur: 'excédent de collisions', premier: '6', dernier: '2' }));
|
||||
assert.equal(vue.synthese, t('qualite.performance.synthese'));
|
||||
});
|
||||
|
||||
test('rouge : le jumeau porte une ligne par point, ses mouvements puis une colonne par valeur, et aucune durée', () => {
|
||||
const vue = vuePerformance(detailDe(), ecrire);
|
||||
assert.deepEqual(clesDuJumeau(vue), ['mouvements', 'minimumBrut', 'excedentCollisions', 'rencontresRepeteesChoisies']);
|
||||
assert.ok(vue.jumeau.colonnes.every(({ genre }) => genre === 'nombre'));
|
||||
assert.deepEqual(
|
||||
vue.jumeau.colonnes.map(({ libelle }) => libelle),
|
||||
[t('qualite.performance.colonne.mouvements'), 'minimum brut', 'excédent de collisions', 'rencontres répétées'],
|
||||
);
|
||||
assert.deepEqual(vue.jumeau.lignes, [
|
||||
{ mouvements: 1, ...valeurs(1, 6, 9) },
|
||||
{ mouvements: 10, ...valeurs(2, 4, 7) },
|
||||
{ mouvements: 100, ...valeurs(3, 2, 5) },
|
||||
{ mouvements: 200, ...valeurs(3, 2, 5) },
|
||||
]);
|
||||
assert.equal(vue.jumeau.nom, t('qualite.performance.jumeau'));
|
||||
for (const ligne of vue.jumeau.lignes) assert.deepEqual(Object.keys(ligne), clesDuJumeau(vue));
|
||||
});
|
||||
|
||||
test('le dernier changement : avant le dernier point, entre lui et les mouvements évalués ; au dernier, sa phrase', () => {
|
||||
assert.equal(vuePerformance(detailDe(), ecrire).dernierChangement, t('qualite.performance.dernierChangement', { de: '100', a: '200' }));
|
||||
const auDernier = vuePerformance(detailDe({ course: { dernierChangement: 200 } }), ecrire);
|
||||
assert.equal(auDernier.dernierChangement, t('qualite.performance.changeAuDernier', { a: '200' }));
|
||||
});
|
||||
|
||||
test('une valeur sans aucun point mesuré — un a priori inconnu — garde sa colonne, « — », sans graphique', () => {
|
||||
const indicateurs = ['minimumBrut', 'ecartAuPlafondAPrioriMax'];
|
||||
const releves = [
|
||||
{ mouvements: 1, valeurs: { minimumBrut: 1, ecartAuPlafondAPrioriMax: null } },
|
||||
{ mouvements: 10, valeurs: { minimumBrut: 2, ecartAuPlafondAPrioriMax: null } },
|
||||
];
|
||||
const vue = vuePerformance(detailDe({ course: { indicateurs, releves, evalues: 10, arret: 10, dernierChangement: 10 } }), ecrire);
|
||||
assert.deepEqual(vue.multiples.map(({ nom }) => nom), ['minimumBrut']);
|
||||
assert.deepEqual(clesDuJumeau(vue), ['mouvements', 'minimumBrut', 'ecartAuPlafondAPrioriMax']);
|
||||
assert.equal(vue.jumeau.lignes[0].ecartAuPlafondAPrioriMax, null);
|
||||
});
|
||||
|
||||
test('la clé du nom de chaque valeur : celle de la page pour le minimum brut, celle du moteur pour une valeur qui classe', () => {
|
||||
assert.equal(cleIndicateur('minimumBrut'), 'qualite.performance.minimumBrut');
|
||||
assert.equal(cleIndicateur('redondance'), 'moteur.valeur.redondance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualite/performance : le retenu et la course absente (§ 12.6, § 12.14, B15)', () => {
|
||||
test('un retenu non ajusté : la vue dit qu’elle mesure une proposition, la nomme et en montre la course', () => {
|
||||
const vue = vuePerformance(detailDe({ id: 'retenu', propositionDuRetenu: 2 }), ecrire);
|
||||
assert.deepEqual(vue.phrases, [{ code: 'retenu', texte: t('qualite.performance.retenu', { proposition: 2 }) }]);
|
||||
assert.equal(vue.multiples.length, 3);
|
||||
assert.notEqual(vue.tete, null);
|
||||
});
|
||||
|
||||
test('un retenu ajusté, ou dont la proposition est effacée : sa phrase, rien d’autre', () => {
|
||||
const vue = vuePerformance(detailDe({ id: 'retenu', propositionDuRetenu: null, course: null }), ecrire);
|
||||
assert.deepEqual(vue.phrases, [{ code: 'retenuSansCourse', texte: t('qualite.performance.retenuSansCourse') }]);
|
||||
assert.deepEqual([vue.tete, vue.multiples, vue.jumeau, vue.dernierChangement], [null, [], null, null]);
|
||||
});
|
||||
|
||||
test('un retenu dont la proposition est dans la liste sans course gardée : il la nomme, et dit sa course non gardée', () => {
|
||||
const vue = vuePerformance(detailDe({ id: 'retenu', propositionDuRetenu: 3, course: null }), ecrire);
|
||||
assert.deepEqual(vue.phrases, [{ code: 'retenuCourseNonGardee', texte: t('qualite.performance.retenuCourseNonGardee', { proposition: 3 }) }]);
|
||||
assert.deepEqual([vue.tete, vue.multiples, vue.jumeau], [null, [], null]);
|
||||
});
|
||||
|
||||
test('une proposition sans course gardée : « Sa course n’est pas gardée. », rien d’autre', () => {
|
||||
const vue = vuePerformance(detailDe({ course: null }), ecrire);
|
||||
assert.deepEqual(vue.phrases, [{ code: 'sansCourse', texte: "Sa course n'est pas gardée." }]);
|
||||
assert.deepEqual([vue.tete, vue.multiples, vue.jumeau, vue.synthese], [null, [], null, null]);
|
||||
});
|
||||
|
||||
test('sans détail, ou un détail qui ne se compare pas : rien', () => {
|
||||
for (const detail of [null, { id: 4, statut: 'derive', raisons: [{ code: 'X' }], course: null, propositionDuRetenu: null }]) {
|
||||
assert.deepEqual(vuePerformance(detail, ecrire), {
|
||||
phrases: [],
|
||||
tete: null,
|
||||
synthese: null,
|
||||
dernierChangement: null,
|
||||
abscisses: null,
|
||||
multiples: [],
|
||||
jumeau: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualite/performance : sur le détail d’une génération du moteur (§ 12.14, D40)', () => {
|
||||
// Six personnes inventées, deux appartenances, deux tables de trois
|
||||
// places, deux tours.
|
||||
function chargeSix() {
|
||||
const charge = creerCharge({ id: 'evt-performance', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
|
||||
const noms = [
|
||||
['Ombrelle', 'Iris', 'Coopérative Aubier'],
|
||||
['Pervenche', 'Théo', 'Coopérative Aubier'],
|
||||
['Lacasse', 'Ondine', 'Atelier Ressac'],
|
||||
['Tourteau', 'Basile', 'Atelier Ressac'],
|
||||
['Quenouille', 'Maëlle', null],
|
||||
['Varech', 'Sidonie', null],
|
||||
];
|
||||
charge.participants = noms.map(([nom, prenom, appartenance], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
test('le jumeau reprend chaque point du détail, le dernier à ses mouvements évalués ; un graphique par valeur', () => {
|
||||
const charge = chargeSix();
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, { graine: 5, nombre: 1, arret: 300 });
|
||||
const rendu = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee });
|
||||
const courses = avecCourses(new Map(), rendu.courses);
|
||||
const detail = detaillerQualite(rendu.charge, 1, { courseDe: (id) => courseDe({ charge: rendu.charge, courses }, id) });
|
||||
const vue = vuePerformance(detail, ecrire);
|
||||
assert.deepEqual(
|
||||
vue.jumeau.lignes,
|
||||
detail.course.releves.map(({ mouvements, valeurs: v }) => ({ mouvements, ...v })),
|
||||
);
|
||||
assert.equal(vue.jumeau.lignes.at(-1).mouvements, detail.course.evalues);
|
||||
assert.deepEqual(clesDuJumeau(vue), ['mouvements', ...detail.course.indicateurs]);
|
||||
assert.ok(vue.multiples.length >= 1);
|
||||
assert.equal(vue.multiples[0].nom, 'minimumBrut');
|
||||
assert.ok(vue.tete.includes('durée inconnue'));
|
||||
});
|
||||
});
|
||||
162
src/interface/qualite/profil.js
Normal file
162
src/interface/qualite/profil.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle du profil trié des rencontres de la page de qualité (§ 12.3,
|
||||
// B12, B15, D28, D30, D34, D41, D51), pur : VueProfil.svelte le câble.
|
||||
//
|
||||
// Le profil se dessine en deux graphiques empilés, au même ordre de rangs,
|
||||
// celui que le détail rend (detail.profil, D28) : rien ne s'y trie ni ne
|
||||
// s'y recompte. Le premier, en comptes de personnes : les rencontres, puis
|
||||
// les appartenances rencontrées, et deux repères, le plafond réalisé et la
|
||||
// borne de diversité. Le second, en pourcentages : le pourcentage de chaque
|
||||
// rang, l'entier que le détail porte, null — sans point — à plafond réalisé
|
||||
// nul, et la règle à 100 %, l'image du plafond réalisé. Chaque rang marqué
|
||||
// porte son signe dans les deux graphiques : au second, celui d'un rang
|
||||
// sans point se pose au pied de l'axe. Une appartenance au plus : une
|
||||
// seule série, sans borne de diversité tracée, et la phrase du cas (B15) ;
|
||||
// une seule table : la phrase seule, pour les deux graphiques.
|
||||
//
|
||||
// L'infobulle des deux graphiques et la première table du jumeau portent la
|
||||
// même ligne entière par rang, le pourcentage suivi du compte de la ligne
|
||||
// (« 24 sur 27 ») ; la seconde table, la répartition des rencontres.
|
||||
|
||||
/** Les graduations de l'axe du second graphique : une valeur, écrite par
|
||||
* format.nombre, suivie du signe sous la clé de l'axe (contrainte 44). */
|
||||
export const graduerPourcent =
|
||||
({ t, format }) =>
|
||||
(valeur) =>
|
||||
t('qualite.pourcentage.axe', { valeur: format.nombre(valeur) });
|
||||
|
||||
// Les colonnes de la ligne d'un rang, dans l'ordre de l'infobulle et du
|
||||
// jumeau : [clé, genre] ; les deux colonnes des appartenances s'écrivent
|
||||
// dès qu'une appartenance est portée (B5), même quand le premier graphique
|
||||
// ne les trace pas ; sans appartenance, elles valent 0 partout et tombent.
|
||||
const COLONNES_LIGNE = [
|
||||
['rang', 'nombre'],
|
||||
['nom', 'texte'],
|
||||
['appartenance', 'texte'],
|
||||
['rencontres', 'nombre'],
|
||||
['plafondRealise', 'nombre'],
|
||||
['pourcent', 'pourcentage'],
|
||||
['plafondAPriori', 'nombre'],
|
||||
['appartenances', 'nombre'],
|
||||
['borneDiversite', 'nombre'],
|
||||
['toursNonAssis', 'nombre'],
|
||||
['marque', 'texte'],
|
||||
];
|
||||
const DES_APPARTENANCES = new Set(['appartenances', 'borneDiversite']);
|
||||
|
||||
// Les colonnes de la répartition des rencontres : la valeur, l'effectif,
|
||||
// l'effectif cumulé, les personnes à leur plafond réalisé.
|
||||
const COLONNES_REPARTITION = ['valeur', 'effectif', 'cumul', 'auPlafond'];
|
||||
|
||||
/**
|
||||
* Le profil d'une source mesurée : null sans détail comparable ou sans
|
||||
* personne mesurée — aucun axe vide ne se dessine ; une seule
|
||||
* table, {cas: 'uneTable', phrase: 'uneTable'} ; sinon les deux graphiques.
|
||||
*
|
||||
* @param {Object|null} detail le détail du placement (detaillerQualite)
|
||||
* @param {{t: Function, format: {nombre: Function}}} outils
|
||||
* @returns {null|{cas: 'uneTable', phrase: 'uneTable'}|{cas: 'profil',
|
||||
* phrase: null|'uneAppartenance'|'aucuneAppartenance',
|
||||
* comptes: {nom, series, reperes, marques, synthese},
|
||||
* pourcentages: {nom, series, reperes, marques,
|
||||
* synthese: {texte, pourcentage: null|{texte, compte}}},
|
||||
* infobulle: {colonnes, lignes}, tableaux: Array<{nom, colonnes, lignes}>}}
|
||||
* phrase : le code du cas limite, sa phrase sous qualite.profil.<code> ;
|
||||
* series et reperes : ceux d'Escalier, libellés traduits, une valeur par
|
||||
* rang ; marques : la marque de chaque rang, null sans, la même dans
|
||||
* les deux graphiques ; synthese : la phrase du premier graphique, et
|
||||
* celle du second, la personne au rang 1 puis son pourcentage et le
|
||||
* compte du pire — à plafond réalisé nul, une phrase entière qui le dit,
|
||||
* et pourcentage null ; infobulle : les colonnes et une ligne par rang ;
|
||||
* tableaux : les tables du jumeau
|
||||
*/
|
||||
export function profilDe(detail, { t }) {
|
||||
if (detail === null || detail.statut !== 'comparable' || detail.profil.length === 0) return null;
|
||||
if (detail.idsTables.length === 1) return { cas: 'uneTable', phrase: 'uneTable' };
|
||||
const { profil, bornes } = detail;
|
||||
const personnes = new Map(detail.personnes.map((personne) => [personne.id, personne]));
|
||||
const avecAppartenances = detail.G > 1;
|
||||
const colonnesAppartenances = detail.G > 0;
|
||||
let phrase = null;
|
||||
if (detail.G === 1) phrase = 'uneAppartenance';
|
||||
else if (detail.G === 0) phrase = 'aucuneAppartenance';
|
||||
const pourcent = (valeur) => (valeur === null ? null : t('qualite.pourcentage.valeur', { pourcent: valeur }));
|
||||
const valeursDe = (champ) => profil.map((rang) => rang[champ]);
|
||||
const rencontres = { libelle: t('qualite.profil.serie.rencontres'), valeurs: valeursDe('rencontres') };
|
||||
|
||||
const colonnes = COLONNES_LIGNE.filter(([cle]) => colonnesAppartenances || !DES_APPARTENANCES.has(cle)).map(([cle, genre]) => ({
|
||||
cle,
|
||||
libelle: t(`qualite.profil.colonne.${cle}`),
|
||||
genre,
|
||||
}));
|
||||
const lignes = profil.map((rang, indice) => {
|
||||
const personne = personnes.get(rang.id);
|
||||
const ligne = {
|
||||
rang: indice + 1,
|
||||
nom: personne.nom,
|
||||
appartenance: personne.appartenance,
|
||||
rencontres: rang.rencontres,
|
||||
plafondRealise: rang.plafondRealise,
|
||||
pourcent: {
|
||||
texte: pourcent(rang.pourcent),
|
||||
compte: t('qualite.profil.ligne', { rencontres: rang.rencontres, plafond: rang.plafondRealise }),
|
||||
},
|
||||
plafondAPriori: rang.plafondAPriori,
|
||||
toursNonAssis: rang.toursNonAssis,
|
||||
marque: rang.marque === null ? null : t(`qualite.profil.marque.${rang.marque}`),
|
||||
};
|
||||
if (colonnesAppartenances) {
|
||||
ligne.appartenances = rang.appartenances;
|
||||
ligne.borneDiversite = rang.borneDiversite;
|
||||
}
|
||||
return ligne;
|
||||
});
|
||||
const infobulle = { colonnes, lignes };
|
||||
|
||||
const { rang1 } = bornes;
|
||||
const nomRang1 = personnes.get(rang1.id).nom;
|
||||
const marques = valeursDe('marque');
|
||||
return {
|
||||
cas: 'profil',
|
||||
phrase,
|
||||
comptes: {
|
||||
nom: t('qualite.profil.graphique.comptes'),
|
||||
series: avecAppartenances
|
||||
? [rencontres, { libelle: t('qualite.profil.serie.appartenances'), valeurs: valeursDe('appartenances') }]
|
||||
: [rencontres],
|
||||
reperes: [
|
||||
{ genre: 'plafond', libelle: t('qualite.profil.repere.plafond'), valeurs: valeursDe('plafondRealise') },
|
||||
...(avecAppartenances ? [{ genre: 'borne', libelle: t('qualite.profil.repere.borne'), valeurs: valeursDe('borneDiversite') }] : []),
|
||||
],
|
||||
marques,
|
||||
synthese: t('qualite.profil.synthese', detail.synthese),
|
||||
},
|
||||
pourcentages: {
|
||||
nom: t('qualite.profil.graphique.pourcentages'),
|
||||
series: [{ libelle: t('qualite.profil.serie.rencontres'), valeurs: valeursDe('pourcent') }],
|
||||
reperes: [{ genre: 'cent', libelle: t('qualite.pourcentage.cent'), valeurs: profil.map(() => 100) }],
|
||||
marques,
|
||||
synthese:
|
||||
rang1.pourcent === null
|
||||
? { texte: t('qualite.profil.plafondNul', { nom: nomRang1 }), pourcentage: null }
|
||||
: {
|
||||
texte: t('qualite.profil.synthesePourcentage', { nom: nomRang1 }),
|
||||
pourcentage: {
|
||||
texte: pourcent(rang1.pourcent),
|
||||
compte: t('qualite.profil.pire', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }),
|
||||
},
|
||||
},
|
||||
},
|
||||
infobulle,
|
||||
tableaux: [
|
||||
{ nom: t('qualite.vue.profil.titre'), colonnes, lignes },
|
||||
{
|
||||
nom: t('qualite.profil.repartition.titre'),
|
||||
colonnes: COLONNES_REPARTITION.map((cle) => ({ cle, libelle: t(`qualite.profil.repartition.${cle}`), genre: 'nombre' })),
|
||||
lignes: detail.repartitionRencontres,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
321
src/interface/qualite/profil.test.js
Normal file
321
src/interface/qualite/profil.test.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle du profil trié de la page de qualité (§ 12.3, B12,
|
||||
// B15, D28, D30, D41, D51) : deux graphiques au même ordre de rangs, celui
|
||||
// du détail — le premier en comptes, deux séries, le plafond réalisé et la
|
||||
// borne de diversité en repères, les rangs marqués ; le second en
|
||||
// pourcentages, une série interrompue aux rangs sans plafond, la règle à
|
||||
// 100 % ; l'infobulle et le jumeau portent la ligne entière, le
|
||||
// pourcentage suivi du compte de la ligne ; une seule appartenance ou
|
||||
// aucune ne trace qu'une série, une seule table cède la place à sa phrase.
|
||||
// Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { detaillerQualite } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { exigerColonnes, ligneInfobulle } from './graphiques/pointage.js';
|
||||
import { graduerPourcent, profilDe } from './profil.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const outils = { t, format };
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
// Six personnes inventées, deux tables de trois places, trois tours ;
|
||||
// appartenances : les deux premières à l'une, les deux suivantes à l'autre
|
||||
// (deux), toutes à la même (une), aucune. tables : 2, ou 1 de six places.
|
||||
function chargeSix({ appartenances = 'deux', tables = 2, reservation = false } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-profil', nom: 'Atelier Ressac', siegesParDefaut: tables === 1 ? 6 : 3, tours: 3 });
|
||||
const noms = [
|
||||
['Ombrelle', 'Iris'],
|
||||
['Pervenche', 'Théo'],
|
||||
['Lacasse', 'Ondine'],
|
||||
['Tourteau', 'Basile'],
|
||||
['Quenouille', 'Maëlle'],
|
||||
['Varech', 'Sidonie'],
|
||||
];
|
||||
const appartenanceDe = (rang) => {
|
||||
if (appartenances === 'une') return 'Coopérative Aubier';
|
||||
if (appartenances === 'aucune' || rang >= 4) return null;
|
||||
return rang < 2 ? 'Coopérative Aubier' : 'Atelier Ressac';
|
||||
};
|
||||
charge.participants = noms.map(([nom, prenom], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: appartenanceDe(rang),
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = Array.from({ length: tables }, (_, rang) => ({
|
||||
id: rang + 1,
|
||||
numero: rang + 1,
|
||||
sieges: null,
|
||||
forme: 'ronde',
|
||||
position: { x: 300 * (rang + 1), y: 0 },
|
||||
}));
|
||||
if (reservation) charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
|
||||
charge.prochainsIds = { participant: 7, table: tables + 1, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Le détail de la première proposition d'une génération de la charge.
|
||||
function detailDe(charge) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, { graine: 3, nombre: 1, arret: 200 });
|
||||
const generee = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
return detaillerQualite(generee, generee.propositions[0].id);
|
||||
}
|
||||
|
||||
// Quatre personnes inventées, une table de trois places et une de deux,
|
||||
// deux tours, et une proposition écrite à la main qui assied Maëlle
|
||||
// Quenouille seule à la petite table aux deux tours : son plafond réalisé
|
||||
// est nul. reservee : une réservation l'y ancre.
|
||||
function chargeSeule({ reservee = false } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-seule', nom: 'Atelier Ressac', siegesParDefaut: 3, tours: 2 });
|
||||
charge.participants = [
|
||||
['Ombrelle', 'Iris'],
|
||||
['Pervenche', 'Théo'],
|
||||
['Lacasse', 'Ondine'],
|
||||
['Quenouille', 'Maëlle'],
|
||||
].map(([nom, prenom], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: rang < 2 ? 'Coopérative Aubier' : 'Atelier Ressac',
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [
|
||||
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } },
|
||||
{ id: 2, numero: 2, sieges: 2, forme: 'ronde', position: { x: 600, y: 0 } },
|
||||
];
|
||||
if (reservee) charge.reservations = [{ participant: 4, table: 2, siege: null, portee: 'tous', tour: null }];
|
||||
charge.prochainsIds = { participant: 5, table: 3, proposition: 2 };
|
||||
charge.propositions = [
|
||||
{
|
||||
id: 1,
|
||||
graine: 1,
|
||||
arret: 40,
|
||||
historique: 1000,
|
||||
produitVersion: V1,
|
||||
siegesAttribues: false,
|
||||
tables: [1, 2],
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: 2,
|
||||
participants: [1, 2, 3, 4],
|
||||
placement: [0, 1].map(() => ({ sieges: [[1, 2, 3], [4]], reserve: [] })),
|
||||
},
|
||||
];
|
||||
charge.evenement.etat = 'propose';
|
||||
return charge;
|
||||
}
|
||||
|
||||
const nomDe = (detail, id) => detail.personnes.find((personne) => personne.id === id).nom;
|
||||
const cles = (colonnes) => colonnes.map(({ cle }) => cle);
|
||||
|
||||
describe('profilDe : le premier graphique, en comptes (§ 12.3, B12, D30, D41)', () => {
|
||||
test('deux séries et deux repères, chacun une valeur par rang dans l’ordre du profil : rencontres, appartenances, plafond réalisé, borne de diversité', () => {
|
||||
const detail = detailDe(chargeSix());
|
||||
const { comptes, cas, phrase } = profilDe(detail, outils);
|
||||
assert.equal(cas, 'profil');
|
||||
assert.equal(phrase, null);
|
||||
assert.deepEqual(comptes.series, [
|
||||
{ libelle: t('qualite.profil.serie.rencontres'), valeurs: detail.profil.map(({ rencontres }) => rencontres) },
|
||||
{ libelle: t('qualite.profil.serie.appartenances'), valeurs: detail.profil.map(({ appartenances }) => appartenances) },
|
||||
]);
|
||||
assert.deepEqual(comptes.reperes, [
|
||||
{ genre: 'plafond', libelle: t('qualite.profil.repere.plafond'), valeurs: detail.profil.map(({ plafondRealise }) => plafondRealise) },
|
||||
{ genre: 'borne', libelle: t('qualite.profil.repere.borne'), valeurs: detail.profil.map(({ borneDiversite }) => borneDiversite) },
|
||||
]);
|
||||
// La borne se nomme « borne de diversité », jamais « plafond ».
|
||||
assert.equal(comptes.reperes[1].libelle, 'borne de diversité');
|
||||
assert.deepEqual(comptes.marques, detail.profil.map(({ marque }) => marque));
|
||||
assert.equal(comptes.synthese, t('qualite.profil.synthese', detail.synthese));
|
||||
assert.equal(comptes.nom, t('qualite.profil.graphique.comptes'));
|
||||
});
|
||||
|
||||
test('l’ordre est celui du détail, jamais retrié : un profil forgé dans un autre ordre se trace tel quel, dans les deux graphiques', () => {
|
||||
const detail = structuredClone(detailDe(chargeSix()));
|
||||
detail.profil.reverse();
|
||||
detail.profil[0].pourcent = 99;
|
||||
detail.profil[1].pourcent = 3;
|
||||
const { comptes, pourcentages, infobulle } = profilDe(detail, outils);
|
||||
assert.deepEqual(comptes.series[0].valeurs, detail.profil.map(({ rencontres }) => rencontres));
|
||||
assert.deepEqual(pourcentages.series[0].valeurs, detail.profil.map(({ pourcent }) => pourcent));
|
||||
assert.deepEqual(infobulle.lignes.map(({ nom }) => nom), detail.profil.map(({ id }) => nomDe(detail, id)));
|
||||
});
|
||||
|
||||
test('une personne réservée : le rang marqué « ancre », le signe à son rang', () => {
|
||||
const detail = detailDe(chargeSix({ reservation: true }));
|
||||
const { comptes } = profilDe(detail, outils);
|
||||
const rang = detail.profil.findIndex(({ id }) => id === 1);
|
||||
assert.equal(comptes.marques[rang], 'ancre');
|
||||
});
|
||||
});
|
||||
|
||||
describe('profilDe : un plafond réalisé nul (§ 12.3, B12, D51)', () => {
|
||||
test('une personne mobile seule à sa table : la marque plafondNul, sans point au second graphique, son compte écrit ; ancrée, la marque ancre', () => {
|
||||
const detail = detaillerQualite(chargeSeule(), 1);
|
||||
assert.equal(detail.statut, 'comparable');
|
||||
const rang = detail.profil.findIndex(({ id }) => id === 4);
|
||||
assert.equal(detail.profil[rang].plafondRealise, 0);
|
||||
const { comptes, pourcentages, infobulle } = profilDe(detail, outils);
|
||||
assert.equal(comptes.marques[rang], 'plafondNul');
|
||||
assert.equal(pourcentages.series[0].valeurs[rang], null);
|
||||
assert.deepEqual(infobulle.lignes[rang].pourcent, { texte: null, compte: t('qualite.profil.ligne', { rencontres: 0, plafond: 0 }) });
|
||||
assert.equal(infobulle.lignes[rang].marque, t('qualite.profil.marque.plafondNul'));
|
||||
// Ancrée, la première marque qui s'applique.
|
||||
const ancree = detaillerQualite(chargeSeule({ reservee: true }), 1);
|
||||
const sonRang = ancree.profil.findIndex(({ id }) => id === 4);
|
||||
assert.equal(ancree.profil[sonRang].plafondRealise, 0);
|
||||
assert.equal(profilDe(ancree, outils).comptes.marques[sonRang], 'ancre');
|
||||
});
|
||||
});
|
||||
|
||||
describe('profilDe : le second graphique, en pourcentages (§ 12.3, B12, D51)', () => {
|
||||
test('une seule série, le pourcentage du détail ; la règle à 100 % en repère, du motif du plafond, libellée « 100 % »', () => {
|
||||
const detail = detailDe(chargeSix());
|
||||
const { pourcentages } = profilDe(detail, outils);
|
||||
assert.deepEqual(pourcentages.series, [{ libelle: t('qualite.profil.serie.rencontres'), valeurs: detail.profil.map(({ pourcent }) => pourcent) }]);
|
||||
assert.deepEqual(pourcentages.reperes, [{ genre: 'cent', libelle: t('qualite.pourcentage.cent'), valeurs: detail.profil.map(() => 100) }]);
|
||||
assert.equal(pourcentages.nom, t('qualite.profil.graphique.pourcentages'));
|
||||
assert.notEqual(pourcentages.nom, profilDe(detail, outils).comptes.nom);
|
||||
// Sa synthèse : la personne au rang 1, son pourcentage, puis le pire en compte.
|
||||
const { rang1 } = detail.bornes;
|
||||
assert.deepEqual(pourcentages.synthese, {
|
||||
texte: t('qualite.profil.synthesePourcentage', { nom: nomDe(detail, rang1.id) }),
|
||||
pourcentage: { texte: t('qualite.pourcentage.valeur', { pourcent: rang1.pourcent }), compte: t('qualite.profil.pire', { rencontres: rang1.rencontres, plafond: rang1.plafondRealise }) },
|
||||
});
|
||||
assert.match(pourcentages.synthese.pourcentage.compte, /^minimum \d+ sur \d+$/);
|
||||
});
|
||||
|
||||
test('les graduations de l’axe s’écrivent en pourcentages', () => {
|
||||
assert.equal(graduerPourcent(outils)(100), t('qualite.pourcentage.axe', { valeur: format.nombre(100) }));
|
||||
assert.equal(graduerPourcent(outils)(40), `40\u{202F}%`);
|
||||
});
|
||||
|
||||
test('un rang au plafond réalisé nul n’a pas de point : sa valeur est null, et le compte de sa ligne reste écrit', () => {
|
||||
const detail = structuredClone(detailDe(chargeSix()));
|
||||
const ligne = detail.profil[2];
|
||||
Object.assign(ligne, { plafondRealise: 0, rencontres: 0, pourcent: null, marque: 'plafondNul' });
|
||||
const { pourcentages, comptes, infobulle } = profilDe(detail, outils);
|
||||
assert.equal(pourcentages.series[0].valeurs[2], null);
|
||||
assert.equal(comptes.marques[2], 'plafondNul');
|
||||
assert.deepEqual(infobulle.lignes[2].pourcent, { texte: null, compte: t('qualite.profil.ligne', { rencontres: 0, plafond: 0 }) });
|
||||
});
|
||||
|
||||
test('la personne au rang 1 au plafond réalisé nul : la synthèse du second graphique le dit en entier, sans pourcentage', () => {
|
||||
const detail = detaillerQualite(chargeSeule(), 1);
|
||||
const { rang1 } = detail.bornes;
|
||||
assert.deepEqual([rang1.id, rang1.plafondRealise, rang1.pourcent], [4, 0, null]);
|
||||
const { synthese } = profilDe(detail, outils).pourcentages;
|
||||
assert.deepEqual(synthese, { texte: t('qualite.profil.plafondNul', { nom: nomDe(detail, 4) }), pourcentage: null });
|
||||
assert.equal(
|
||||
synthese.texte,
|
||||
'La personne au rang 1, Maëlle Quenouille, a un plafond réalisé nul : elle ne rencontre personne, et son rang n’a pas de point en pourcentage.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('profilDe : l’infobulle et le jumeau portent la ligne entière (§ 12.3, B12, D34)', () => {
|
||||
test('une ligne par rang : rang, nom, appartenance, rencontres, plafond réalisé, pourcentage et son compte, a priori, A(p), borne de diversité, tours non assis, marque', () => {
|
||||
const detail = detailDe(chargeSix({ reservation: true }));
|
||||
const { infobulle, tableaux } = profilDe(detail, outils);
|
||||
exigerColonnes(infobulle.colonnes);
|
||||
assert.deepEqual(cles(infobulle.colonnes), [
|
||||
'rang',
|
||||
'nom',
|
||||
'appartenance',
|
||||
'rencontres',
|
||||
'plafondRealise',
|
||||
'pourcent',
|
||||
'plafondAPriori',
|
||||
'appartenances',
|
||||
'borneDiversite',
|
||||
'toursNonAssis',
|
||||
'marque',
|
||||
]);
|
||||
assert.equal(infobulle.colonnes.find(({ cle }) => cle === 'pourcent').genre, 'pourcentage');
|
||||
detail.profil.forEach((rang, i) => {
|
||||
const personne = detail.personnes.find(({ id }) => id === rang.id);
|
||||
assert.deepEqual(infobulle.lignes[i], {
|
||||
rang: i + 1,
|
||||
nom: personne.nom,
|
||||
appartenance: personne.appartenance,
|
||||
rencontres: rang.rencontres,
|
||||
plafondRealise: rang.plafondRealise,
|
||||
pourcent: {
|
||||
texte: rang.pourcent === null ? null : t('qualite.pourcentage.valeur', { pourcent: rang.pourcent }),
|
||||
compte: t('qualite.profil.ligne', { rencontres: rang.rencontres, plafond: rang.plafondRealise }),
|
||||
},
|
||||
plafondAPriori: rang.plafondAPriori,
|
||||
appartenances: rang.appartenances,
|
||||
borneDiversite: rang.borneDiversite,
|
||||
toursNonAssis: rang.toursNonAssis,
|
||||
marque: rang.marque === null ? null : t(`qualite.profil.marque.${rang.marque}`),
|
||||
});
|
||||
});
|
||||
// Chaque ligne s'écrit, son pourcentage suivi du compte « 24 sur 27 ».
|
||||
const ecrite = ligneInfobulle(infobulle.colonnes, infobulle.lignes[0], format.nombre);
|
||||
assert.match(ecrite.find(({ cle }) => cle === 'pourcent').compte, /^\d+ sur \d+$/);
|
||||
// Le jumeau : les mêmes lignes, puis la répartition des rencontres.
|
||||
assert.equal(tableaux.length, 2);
|
||||
assert.deepEqual(tableaux[0], { nom: t('qualite.vue.profil.titre'), colonnes: infobulle.colonnes, lignes: infobulle.lignes });
|
||||
assert.equal(tableaux[1].nom, t('qualite.profil.repartition.titre'));
|
||||
assert.deepEqual(cles(tableaux[1].colonnes), ['valeur', 'effectif', 'cumul', 'auPlafond']);
|
||||
assert.deepEqual(tableaux[1].lignes, detail.repartitionRencontres);
|
||||
});
|
||||
});
|
||||
|
||||
describe('profilDe : les cas limites (§ 12.6, B15)', () => {
|
||||
// Une seule appartenance : A(p) et la borne de diversité valent 1 et
|
||||
// restent écrites dans l'infobulle et le jumeau (B5) ; aucune : elles
|
||||
// valent 0 partout et leurs colonnes tombent avec le tracé.
|
||||
for (const [appartenances, code, colonnesEcrites] of [
|
||||
['une', 'uneAppartenance', true],
|
||||
['aucune', 'aucuneAppartenance', false],
|
||||
]) {
|
||||
test(`${code} : une seule série, ni borne de diversité tracée ; la phrase le dit ; les colonnes des appartenances ${colonnesEcrites ? 'restent écrites' : 'tombent'}`, () => {
|
||||
const detail = detailDe(chargeSix({ appartenances }));
|
||||
const { cas, phrase, comptes, infobulle, tableaux } = profilDe(detail, outils);
|
||||
assert.equal(cas, 'profil');
|
||||
assert.equal(phrase, code);
|
||||
assert.equal(comptes.series.length, 1);
|
||||
assert.deepEqual(comptes.reperes.map(({ genre }) => genre), ['plafond']);
|
||||
for (const colonnes of [infobulle.colonnes, tableaux[0].colonnes]) {
|
||||
assert.equal(cles(colonnes).includes('appartenances'), colonnesEcrites);
|
||||
assert.equal(cles(colonnes).includes('borneDiversite'), colonnesEcrites);
|
||||
}
|
||||
if (colonnesEcrites) {
|
||||
// La phrase ne nie que le tracé : la colonne écrite ne la contredit
|
||||
// pas.
|
||||
assert.match(t(`qualite.profil.${code}`), /pas de borne de diversité tracée/);
|
||||
assert.deepEqual(infobulle.lignes.map(({ appartenances: a }) => a), detail.profil.map(({ appartenances: a }) => a));
|
||||
assert.deepEqual(infobulle.lignes.map(({ borneDiversite }) => borneDiversite), detail.profil.map(({ borneDiversite }) => borneDiversite));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('une seule table : la phrase, pour les deux graphiques', () => {
|
||||
const detail = detailDe(chargeSix({ tables: 1 }));
|
||||
assert.deepEqual(profilDe(detail, outils), { cas: 'uneTable', phrase: 'uneTable' });
|
||||
});
|
||||
|
||||
test('aucun profil sans détail comparable', () => {
|
||||
assert.equal(profilDe(null, outils), null);
|
||||
assert.equal(profilDe({ statut: 'fautif' }, outils), null);
|
||||
});
|
||||
});
|
||||
|
|
@ -771,6 +771,23 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
|
|||
expect(FONDS_GRAPHIQUE.map((f) => f.nom)).toContain('graphiqueGrille');
|
||||
});
|
||||
|
||||
test('marques de graphique : un repère à motif posé sur sa série se mesure contre le fond ; un repère plein, contre la série', () => {
|
||||
const trace = (designation, jeton, marque) =>
|
||||
`<path ${designation} data-marque="${marque}" d="M10 20 H190" style="fill:none;stroke:var(${jeton});stroke-width:2px" />`;
|
||||
const fragment = (motif) =>
|
||||
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +
|
||||
trace('data-serie="serie1"', '--couleur-graphique-serie-1', 'graphiqueSerie1') +
|
||||
trace(`data-repere="borne"${motif}`, '--couleur-graphique-borne', 'graphiqueBorne') +
|
||||
'</svg>';
|
||||
const pointille = avecFragment(fragment(' stroke-dasharray="1 3"'), (c) => releverTheme(c).marques);
|
||||
expect(pointille.vues).toEqual(['graphiqueSerie1', 'graphiqueBorne']);
|
||||
expect(pointille.mesures).toBe(2);
|
||||
expect(pointille.fautes).toEqual([]);
|
||||
const plein = avecFragment(fragment(''), (c) => releverTheme(c).marques);
|
||||
expect(plein.fautes).toHaveLength(1);
|
||||
expect(plein.fautes[0]).toContain('graphiqueBorne');
|
||||
});
|
||||
|
||||
test('marques de graphique : le relevé du parcours photographié (stylesDuTheme) les connaît aussi — une série sur la carte ne porte aucune faute et se range sous son jeton, rôle marque', () => {
|
||||
const { styles, fautes } = avecFragment(
|
||||
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +
|
||||
|
|
|
|||
|
|
@ -447,13 +447,14 @@ export function mixTables(instance, tableDe, occupation, parcours = parcourirTou
|
|||
* Les objectifs des tables tour par tour (§ 12.12, D36, B21), lus dans
|
||||
* parcourirTours et dans les planchers par tour du diagnostic, sans recompte.
|
||||
* Par tour : possibles, Π_r ; servis, S_r ; leur pourcentage ; hors, Π_r −
|
||||
* S_r ; plancher, le plancher du tour ; sansCouple, σ_r, les assis sans table
|
||||
* S_r ; plancher, le plancher du tour ; reste, hors − plancher, le second
|
||||
* segment de la barre du tour (B21) ; sansCouple, σ_r, les assis sans table
|
||||
* possible. Un tour sans couple possible a possibles 0, servis, pourcent,
|
||||
* hors et plancher null, et garde sansCouple.
|
||||
* hors, plancher et reste null, et garde sansCouple.
|
||||
*
|
||||
* pireTour : l'index du tour au plus grand reste, hors − plancher, le
|
||||
* premier à égalité. soiree : les sommes des tours, Σ S_r sur Σ Π_r, la seule
|
||||
* somme d'un pourcentage admise (D52), et la somme des planchers.
|
||||
* pireTour : l'index du tour au plus grand reste, le premier à égalité.
|
||||
* soiree : les sommes des tours, Σ S_r sur Σ Π_r, la seule somme d'un
|
||||
* pourcentage admise (D52), et la somme des planchers.
|
||||
* jamaisServies : les concernées dont s(p) est nul, avec leurs tours
|
||||
* possibles, les plus nombreux d'abord, puis par index. repartition :
|
||||
* P(p) − s(p) sur les concernées, de la plus grande valeur à la plus petite.
|
||||
|
|
@ -478,20 +479,21 @@ export function objectifsParTour(instance, parcours, planchers) {
|
|||
const possibles = possiblesParTour[r];
|
||||
const sansCouple = assisSansCoupleParTour[r];
|
||||
if (possibles === 0) {
|
||||
parTour.push({ possibles, servis: null, pourcent: null, hors: null, plancher: null, sansCouple });
|
||||
parTour.push({ possibles, servis: null, pourcent: null, hors: null, plancher: null, reste: null, sansCouple });
|
||||
continue;
|
||||
}
|
||||
const servis = servisParTour[r];
|
||||
const hors = possibles - servis;
|
||||
const plancher = planchers[r];
|
||||
parTour.push({ possibles, servis, pourcent: pourcentage(servis, possibles), hors, plancher, sansCouple });
|
||||
const reste = hors - plancher;
|
||||
parTour.push({ possibles, servis, pourcent: pourcentage(servis, possibles), hors, plancher, reste, sansCouple });
|
||||
soiree.possibles += possibles;
|
||||
soiree.servis += servis;
|
||||
soiree.hors += hors;
|
||||
soiree.plancher += plancher;
|
||||
if (pireTour === null || hors - plancher > pireReste) {
|
||||
if (pireTour === null || reste > pireReste) {
|
||||
pireTour = r;
|
||||
pireReste = hors - plancher;
|
||||
pireReste = reste;
|
||||
}
|
||||
}
|
||||
if (soiree.possibles === 0) return null;
|
||||
|
|
|
|||
|
|
@ -336,12 +336,12 @@ function objectifsDe(prepare) {
|
|||
}
|
||||
|
||||
describe('objectifsParTour (§ 12.12, D36, B21)', () => {
|
||||
test('chaque tour, compté à la main : possibles, servis, pourcentage, hors objectif, plancher, assis sans table possible', () => {
|
||||
test('chaque tour, compté à la main : possibles, servis, pourcentage, hors objectif, plancher, reste, assis sans table possible', () => {
|
||||
const { objectifs } = objectifsDe(preparer(OBJECTIFS));
|
||||
assert.deepEqual(objectifs.parTour, [
|
||||
{ possibles: 5, servis: 3, pourcent: 60, hors: 2, plancher: 2, sansCouple: 4 },
|
||||
{ possibles: 6, servis: 2, pourcent: 33, hors: 4, plancher: 3, sansCouple: 2 },
|
||||
{ possibles: 0, servis: null, pourcent: null, hors: null, plancher: null, sansCouple: 9 },
|
||||
{ possibles: 5, servis: 3, pourcent: 60, hors: 2, plancher: 2, reste: 0, sansCouple: 4 },
|
||||
{ possibles: 6, servis: 2, pourcent: 33, hors: 4, plancher: 3, reste: 1, sansCouple: 2 },
|
||||
{ possibles: 0, servis: null, pourcent: null, hors: null, plancher: null, reste: null, sansCouple: 9 },
|
||||
]);
|
||||
assert.equal(objectifs.pireTour, 1);
|
||||
assert.deepEqual(objectifs.soiree, { possibles: 11, servis: 5, pourcent: 45, hors: 6, plancher: 5 });
|
||||
|
|
@ -390,7 +390,7 @@ describe('objectifsParTour (§ 12.12, D36, B21)', () => {
|
|||
plan[1] = { 1: [6, 3, 1], 2: [2, 7, 8], 3: [4, 5] };
|
||||
const prepare = preparer({ ...OBJECTIFS, plan });
|
||||
const { objectifs } = objectifsDe(prepare);
|
||||
assert.deepEqual(objectifs.parTour.map(({ hors, plancher }) => [hors, plancher]), [[2, 2], [3, 3], [null, null]]);
|
||||
assert.deepEqual(objectifs.parTour.map(({ hors, plancher, reste }) => [hors, plancher, reste]), [[2, 2, 0], [3, 3, 0], [null, null, null]]);
|
||||
assert.equal(objectifs.pireTour, 0);
|
||||
const { tours } = prepare;
|
||||
const lu = tourLu(prepare.configuration, 1, tours[0]);
|
||||
|
|
|
|||
|
|
@ -290,10 +290,27 @@ function dessousLePlusFrequent(forme, quoi) {
|
|||
return [...comptes.values()].reduce((a, b) => (b.n > a.n ? b : a)).couleurs;
|
||||
}
|
||||
|
||||
// Le frère avant lequel se pose la sonde de ce qui est peint sous une forme :
|
||||
// la forme elle-même, sauf pour un repère de graphique à motif — data-repere
|
||||
// et un stroke-dasharray —, tracé par-dessus les séries qu'il borne et qui
|
||||
// recouvre une série sur toute sa longueur quand chaque rang est à sa borne.
|
||||
// Les vides de son motif laissent alors voir la série : le repère se mesure
|
||||
// contre ce qui est peint sous la première série tracée avant lui, le fond du
|
||||
// graphique et sa grille. Un repère plein, sans vide, se mesure contre la
|
||||
// série qu'il recouvre.
|
||||
function sondeAvant(forme) {
|
||||
if (!forme.matches('[data-repere]') || getComputedStyle(forme).strokeDasharray === 'none') return forme;
|
||||
for (const frere of forme.parentNode.children) {
|
||||
if (frere === forme) break;
|
||||
if (frere.matches('[data-serie]')) return frere;
|
||||
}
|
||||
return forme;
|
||||
}
|
||||
|
||||
// Les couleurs peintes sous une forme SVG au point donné de son repère : une
|
||||
// sonde sans peinture, posée juste avant elle chez son parent, se mesure par
|
||||
// fondPeintSous, qui lit les formes peintes avant elle à ce point, puis ce
|
||||
// qui est sous le dessin.
|
||||
// sonde sans peinture, posée chez son parent juste avant elle (sondeAvant),
|
||||
// se mesure par fondPeintSous, qui lit les formes peintes avant elle à ce
|
||||
// point, puis ce qui est sous le dessin.
|
||||
function dessousAuPoint(forme, point) {
|
||||
const ecran = point.matrixTransform(forme.getScreenCTM());
|
||||
const parent = forme.parentNode;
|
||||
|
|
@ -307,7 +324,7 @@ function dessousAuPoint(forme, point) {
|
|||
sonde.setAttribute('height', String(cote));
|
||||
sonde.style.fill = 'none';
|
||||
sonde.style.stroke = 'none';
|
||||
parent.insertBefore(sonde, forme);
|
||||
parent.insertBefore(sonde, sondeAvant(forme));
|
||||
try {
|
||||
return fondPeintSous(sonde);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,12 @@ const ENTREES = [
|
|||
['data-conversion', 'plan : une conversion offerte par une liste de travail', null],
|
||||
['data-corbeille', 'accueil : la corbeille', ['']],
|
||||
['data-corbeille-dossier', 'accueil : un dossier de la corbeille', null],
|
||||
['data-course', 'qualité : la ligne de tête de la performance du calcul', ['']],
|
||||
['data-critere', 'propositions : un nom de la phrase de l’ordre, par le critère qui classe sa valeur, objectifs et animateurs compris', null],
|
||||
['data-defaut', 'tables : le réglage du défaut de places', ['']],
|
||||
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
|
||||
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
|
||||
['data-dernier-changement', 'qualité : la phrase du dernier changement de la performance du calcul', ['']],
|
||||
['data-devient', 'import : ce que devient une ligne', ['']],
|
||||
['data-donnee', 'diagnostic : une donnée nommée dans une phrase', ['']],
|
||||
['data-dossier', 'accueil : le dossier de travail montré', ['']],
|
||||
|
|
@ -75,6 +77,7 @@ const ENTREES = [
|
|||
['data-enregistre', 'import : le chemin des refus enregistrés', ['']],
|
||||
['data-entete', 'import : la ligne d’en-tête lue', ['']],
|
||||
['data-entete-liste', 'aperçu : l’en-tête d’une liste imprimée — titre et têtes de colonnes —, répété sur chaque feuille', ['']],
|
||||
['data-equite', 'qualité : les trois nombres d’une colonne de la diversité par animateur, par sa grandeur', ['appartenances', 'etiquettes']],
|
||||
['data-estimation', 'génération : l’estimation de la durée', ['']],
|
||||
['data-etat', 'événement : un état du plan dans la tête', null],
|
||||
['data-etat-table', 'tables : l’état d’une table', ['']],
|
||||
|
|
@ -104,6 +107,7 @@ const ENTREES = [
|
|||
['data-impression', 'aperçu : le document imprimé', ['']],
|
||||
['data-hors-classement', 'propositions : les lignes hors classement', ['']],
|
||||
['data-issue', 'génération : l’issue de la dernière génération', ['']],
|
||||
['data-jamais-servies', 'qualité : la liste des personnes jamais servies, ou sa phrase quand elle est vide', ['']],
|
||||
['data-jumeau', 'qualité : un tableau jumeau ouvert, par son rang dans le jumeau', null],
|
||||
['data-legende', 'plan : la légende des conflits', ['']],
|
||||
['data-legende-etiquettes', 'participants : la légende des étiquettes', ['']],
|
||||
|
|
@ -137,6 +141,7 @@ const ENTREES = [
|
|||
['data-ordre', 'propositions : la phrase de l’ordre', ['']],
|
||||
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
|
||||
['data-pages', 'propositions : une nav des pages du classement, haut ou bas', null],
|
||||
['data-paire', 'qualité : une ligne du tableau des paires répétées, par « a:b » des identifiants', null],
|
||||
['data-palier', 'génération : un palier de l’éditeur des priorités, par son index', null],
|
||||
['data-panneau', 'plan : un panneau', ['place', 'table']],
|
||||
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
|
||||
|
|
@ -154,6 +159,7 @@ const ENTREES = [
|
|||
['data-poignee', 'plan : la poignée d’une table', null],
|
||||
['data-point-releve', 'qualité : un point de relevé d’un petit multiple, par ses mouvements évalués', null],
|
||||
['data-portee', 'tables : la portée d’un objectif, tous pour la soirée, tour pour un tour désigné', ['tous', 'tour']],
|
||||
['data-position-tables', 'qualité : un bouton radio du sélecteur du mélange des tables, par sa position', ['sieges', 'assis', 'doublons']],
|
||||
['data-pourcentage', 'qualité : un pourcentage rendu, que suit son compte', ['']],
|
||||
['data-priorite', 'génération : un critère de l’éditeur des priorités, par son nom', null],
|
||||
['data-priorites', 'génération : la liste des paliers de l’éditeur des priorités', ['']],
|
||||
|
|
@ -170,6 +176,7 @@ const ENTREES = [
|
|||
['data-repere', 'qualité : le tracé d’un repère — plafond, borne ou règle à 100 % (cent) —, par son genre', null],
|
||||
['data-reserve', 'plan : la réserve d’un tour', null],
|
||||
['data-retenu', 'propositions : la ligne du retenu', null],
|
||||
['data-rubrique-rencontres', 'participants : la rubrique « Rencontres » de la fiche', ['']],
|
||||
['data-secours', 'récupération : une source de secours', null],
|
||||
['data-segment', 'qualité : un segment de barre, par son genre', null],
|
||||
['data-section', 'une section de l’événement', ['badges', 'diagnostic', 'generation', 'import', 'participants', 'plan', 'propositions', 'qualite', 'tables']],
|
||||
|
|
@ -179,6 +186,7 @@ const ENTREES = [
|
|||
['data-sieges-retires', 'tables : les sièges retirés', ['']],
|
||||
['data-signalement', 'accueil : un signalement d’une ligne', null],
|
||||
['data-socle', 'plan : le socle d’une pastille', ['']],
|
||||
['data-soiree-objectifs', 'qualité : la soirée des objectifs, en texte', ['']],
|
||||
['data-source', 'plan : le placement montré, dans la barre de vue', ['']],
|
||||
['data-source-qualite', 'qualité : la ligne de source, le placement que la page mesure', ['']],
|
||||
['data-support-rompu', 'événement : le message du support rompu', ['']],
|
||||
|
|
@ -191,6 +199,7 @@ const ENTREES = [
|
|||
['data-theme-choix', 'le bandeau : un choix de thème', null],
|
||||
['data-titre', 'plan : un titre non pourvu d’une liste de travail', null],
|
||||
['data-tours', 'tables : le réglage du nombre de tours', ['']],
|
||||
['data-tuile', 'qualité : une tuile de l’en-tête chiffré, par le nom de sa colonne', null],
|
||||
['data-valides', 'import : le compte des lignes valides', ['']],
|
||||
['data-verrou', 'événement : le verrou d’une autre séance', ['']],
|
||||
['data-verrou-depuis', 'événement : l’instant du verrou', ['']],
|
||||
|
|
|
|||
Loading…
Reference in a new issue