diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js index 98ce176..81781dc 100644 --- a/src/interface/libelles/propositions.js +++ b/src/interface/libelles/propositions.js @@ -1,6 +1,107 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par « -// propositions. ». -export const TABLE = new Map(); +// Les libellés du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9, +// § 14.6). Chaque clé commence par « propositions. ». +// +// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées, +// paires distinctes et excédent ne se confondent jamais. Des rencontres, le +// tableau ne porte que le minimum, sur les trois populations du § 5.4 ; la +// moyenne n'y paraît pas. La phrase de l'ordre compose les noms des critères +// par leurs clés moteur.critere., dans l'ordre reçu, puis l'identifiant +// croissant, qui départage deux propositions égales sur tous (§ 5.7). +import { compte } from '../../application/libelles/aides.js'; + +// Le nom d'un critère du classement, celui de la table du moteur. +const critere = (nom, texte) => texte(`moteur.critere.${nom}`); + +export const TABLE = new Map([ + // --- La section (§ 5.7) ------------------------------------------------------ + ['propositions.tableau.titre', 'Propositions classées'], + ['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."], + [ + 'propositions.ordre.criteres', + ({ criteres }, texte) => + `Ordre du classement, chaque critère au plus faible d'abord : ${criteres + .map((nom) => critere(nom, texte)) + .join(', puis ')}, puis l'identifiant croissant.`, + ], + [ + 'propositions.ordre.saute', + ({ saute, applique }, texte) => + `Critère sauté, faute de plafond a priori par personne : ${critere(saute, texte)}. ` + + `Appliqué à sa place : ${critere(applique, texte)}.`, + ], + + // --- Les colonnes, chacune avec son unité (§ 5.4, § 5.5) ---------------------- + ['propositions.colonne.rang', 'Rang'], + ['propositions.colonne.proposition', 'Proposition'], + ['propositions.colonne.rencontresTous', 'Personnes distinctes rencontrées, minimum : tous'], + ['propositions.colonne.rencontresMobiles', 'Personnes distinctes rencontrées, minimum : mobiles'], + ['propositions.colonne.rencontresAncres', 'Personnes distinctes rencontrées, minimum : ancrés'], + ['propositions.colonne.collisionsCumulees', 'Collisions cumulées (paire et tour)'], + ['propositions.colonne.pairesDistinctes', 'Paires distinctes de même appartenance'], + ['propositions.colonne.excedentCollisions', 'Excédent de collisions (retrouvailles au-delà de la première)'], + ['propositions.colonne.rencontresRepetees', 'Rencontres répétées choisies (paires)'], + ['propositions.colonne.maxRencontresPaire', "Rencontres d'une même paire, au plus"], + ['propositions.colonne.retoursChoisis', 'Retours choisis à une table déjà occupée'], + ['propositions.colonne.appartenancesVues', 'Appartenances rencontrées A(p), minimum'], + ['propositions.colonne.totalRedondance', "Redondance d'appartenance, somme Σ r(p)"], + ['propositions.colonne.diversite', 'Taux de diversité d(p), minimum'], + ['propositions.colonne.manqueMax', 'Plus grand manque (personnes)'], + ['propositions.colonne.effectifManque', 'Personnes à qui il en manque'], + ['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"], + ['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori maximal (personnes)'], + ['propositions.colonne.certificat', 'Certificat'], + ['propositions.colonne.imposes', 'Imposé par les réservations'], + ['propositions.colonne.commandes', 'Commandes'], + + // --- Les valeurs écrites en mots (§ 5.5, § 12.6) ------------------------------ + ['propositions.valeur.inconnu', 'inconnu'], + ['propositions.certificat.atteint', 'minimum atteint'], + ['propositions.certificat.inconnue', 'atteignabilité inconnue'], + + // --- Ce que les réservations imposent, à part (§ 5.4) -------------------------- + [ + 'propositions.imposes.retours', + ({ retours, ancrages }) => + retours === 0 + ? 'Aucun retour imposé.' + : `${compte(retours, 'retour imposé', 'retours imposés')} par ${compte(ancrages, 'ancrage', 'ancrages')}.`, + ], + [ + 'propositions.imposes.repetees', + ({ repetees }) => + repetees === 0 + ? 'Aucune rencontre répétée imposée.' + : `${compte(repetees, 'rencontre répétée imposée', 'rencontres répétées imposées')}.`, + ], + + // --- Les lignes (§ 5.7, § 9, § 12.6) ----------------------------------------- + ['propositions.marque.retenue', 'retenue'], + ['propositions.retenu.titre', 'Placement retenu'], + ['propositions.retenu.origine', ({ proposition }) => `Tel que la proposition n° ${proposition}.`], + ['propositions.retenu.ajuste', ({ proposition }) => `Ajusté à la main depuis la proposition n° ${proposition}.`], + ['propositions.horsClassement.titre', 'Hors classement'], + ['propositions.statut.derive', 'Non comparable : la proposition ne décrit plus la configuration courante.'], + ['propositions.statut.fautif', 'Non comparable : le placement se contredit.'], + + // --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------ + ['propositions.commande.voir', 'Voir sur le plan'], + ['propositions.commande.retenir', 'Retenir'], + ['propositions.commande.effacer', 'Effacer les propositions'], + + // --- Retenir sur un retenu ajusté (§ 2.1) ---------------------------------------- + [ + 'propositions.confirmation.titre', + ({ proposition }) => `Retenir la proposition n° ${proposition} détruit les ajustements du placement retenu`, + ], + [ + 'propositions.confirmation.retenuAjuste', + ({ proposition }) => `Le placement retenu, ajusté à la main depuis la proposition n° ${proposition}`, + ], + [ + 'propositions.confirmation.remplacant', + ({ proposition }) => `remplacé par la proposition n° ${proposition}, telle que générée`, + ], +]); diff --git a/src/interface/propositions/Propositions.navigateur.test.js b/src/interface/propositions/Propositions.navigateur.test.js new file mode 100644 index 0000000..2fd4972 --- /dev/null +++ b/src/interface/propositions/Propositions.navigateur.test.js @@ -0,0 +1,535 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La section des propositions dans un vrai moteur de rendu (§ 2.1 étapes 10 +// et 11, § 5.4, § 5.5, § 5.7, § 8.4, § 9, § 12.6, § 19.3 capture 11) : le +// tableau classé et sa phrase d'ordre, les générations qui s'accumulent, +// retenir, voir sur le plan, effacer, le retenu ajusté et sa fenêtre, les +// refus en lecture et sur un plan bloqué. 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, le style calculé, les attributs data-… — jamais une classe. +// +// Une ligne classée porte data-proposition, la ligne du retenu data-retenu, +// une cellule data-colonne, la phrase de l'ordre data-ordre. La valeur +// attendue vient des fonctions du moteur, sollicitées une seconde fois +// (§ 19.1), mises en forme par creerFormat ; le texte, de la même traduction +// que celle de l'application. 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 { TABLES_APPLICATION } from '../../application/libelles.js'; +import { nomAffiche } from '../../application/personnes.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { texteLivre } from '../../demo/livrees.js'; +import { CRITERES, classer } from '../../moteur/classement.js'; +import { indexerPlan, normaliser } from '../../moteur/configuration.js'; +import { mesurer } from '../../moteur/indicateurs.js'; +import { troisChiffres } from '../../moteur/manque.js'; +import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.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 { planDepuisFichier } from '../../stockage/placements.js'; +import { VERSION } from '../../version.genere.js'; +import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; +import { TABLES_INTERFACE } from '../libelles.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); +const V1 = VERSION.affichee; +const BASE = 'soiree'; +const TIRET = '\u{2014}'; + +// --- Les charges d'épreuve ------------------------------------------------------- + +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; + +// Une proposition écrite à la main, sièges non attribués, sur les tables de +// la charge dans leur ordre. +function propositionDe(charge, id, tours) { + const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p); + return { + id, + graine: id, + arret: 40, + historique: 1000, + produitVersion: V1, + siegesAttribues: false, + tables: charge.tables.map(({ id: table }) => table), + capacites: charge.tables.map((table) => capacite(charge, table)), + tours: tours.length, + participants, + placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: [] })), + }; +} + +// La charge, ses propositions remplacées par les plans donnés, numérotés à +// partir de 1. +function avecPlans(charge, plans) { + const suivante = structuredClone(charge); + suivante.propositions = plans.map((tours, rang) => propositionDe(suivante, rang + 1, tours)); + suivante.prochainsIds.proposition = plans.length + 1; + suivante.evenement.etat = 'propose'; + return suivante; +} + +// Trois plans de la petite démonstration au conflit inévitable, dans l'ordre +// des identifiants : (4, 1), (4, 2), (6, 6) collisions (cumulées, +// distinctes), au même plus grand écart au plafond a priori (A6). +const PLANS_CONFLIT = [ + [ + [[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]], + [[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]], + [[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]], + [[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]], + ], + [ + [[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]], + [[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]], + [[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]], + [[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]], + ], + [ + [[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]], + [[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]], + [[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]], + [[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]], + ], +]; + +// Six personnes inventées, deux tables de quatre places, quatre tours, et +// trois plans à trois par table : une place libre à chaque table. +function chargeSix() { + const charge = creerCharge({ id: 'evt-propositions', nom: 'Club des Merles', siegesParDefaut: 4, tours: 4 }); + const noms = [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Grisaille', null], ['Bruyère', 'Anouk'], ['Sarcelle', 'Capucine']]; + charge.participants = noms.map(([nom, prenom], rang) => ({ + id: rang + 1, + nom, + prenom, + appartenance: rang < 2 ? 'Club des Merles' : null, + 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 avecPlans(charge, [ + [[[1, 2, 3], [4, 5, 6]], [[1, 2, 4], [3, 5, 6]], [[1, 2, 5], [3, 4, 6]], [[1, 2, 6], [3, 4, 5]]], + [[[1, 3, 5], [2, 4, 6]], [[1, 4, 6], [2, 3, 5]], [[1, 2, 3], [4, 5, 6]], [[1, 5, 6], [2, 3, 4]]], + [[[1, 4, 5], [2, 3, 6]], [[1, 3, 6], [2, 4, 5]], [[1, 2, 6], [3, 4, 5]], [[1, 3, 4], [2, 5, 6]]], + ]); +} + +// --- Le montage -------------------------------------------------------------------- + +// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs +// n'a rien reçu pendant l'épreuve. +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 section des propositions ouverte. +async function ouvrir(charge, { mode = 'ecriture' } = {}) { + 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(); + m.ui.allerA('propositions'); + flushSync(); + return m; +} + +// --- Les lectures du rendu --------------------------------------------------------- + +const section = (m) => m.racine.querySelector('[data-section="propositions"]'); +const lignes = (m) => [...section(m).querySelectorAll('tr[data-proposition]')]; +const ids = (m) => lignes(m).map((tr) => Number(tr.dataset.proposition)); +const ligne = (m, id) => section(m).querySelector(`tr[data-proposition="${id}"]`); +const ligneRetenu = (m) => section(m).querySelector('tr[data-retenu]'); +const commande = (racine, nom) => racine.querySelector(`[data-commande="${nom}"]`); +const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); +const fenetreOuverte = (m) => m.racine.querySelector('dialog[open]'); +const charge = (m) => m.seance.etat().charge; +const revision = (m) => m.seance.etat().evenement.revision; +// Le refus posé à côté d'un bouton de Commande : dans son conteneur. +const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`); + +// Ce que le plan dessiné montre au tour affiché : pour chaque [data-table] +// rendu, les noms de sa liste [data-liste], triés. Rien n'est lu dans l'état +// des places : la valeur vient du rendu même. +function occupantsDessines(m) { + const plan = m.racine.querySelector('[data-plan]'); + const tables = [...plan.querySelectorAll('[data-table]')]; + return new Map( + tables.map((groupe) => { + const noms = [...plan.querySelectorAll(`[data-liste="${groupe.dataset.table}"] [data-partie="nom"]`)]; + return [Number(groupe.dataset.table), noms.map(texteDe).sort()]; + }), + ); +} + +// Les noms attendus à chaque table d'un placement, au tour 1, triés. +function occupantsDuPlacement(chargeLue, placement) { + const nomDe = new Map(chargeLue.participants.map((personne) => [personne.id, nomAffiche(personne)])); + return new Map(placement.tables.map((table, rang) => [table, placement.placement[0].sieges[rang].filter((id) => id !== null).map((id) => nomDe.get(id)).sort()])); +} + +// Le plan dessiné montre, au tour 1, les occupants du placement donné. +async function exigerPlanMontre(m, placement) { + const attendu = occupantsDuPlacement(charge(m), placement); + expect(attendu.size).toBeGreaterThan(0); + await attendre(() => expect(occupantsDessines(m)).toEqual(attendu)); + for (const noms of attendu.values()) expect(noms.length).toBeGreaterThan(0); +} + +// Le texte de chaque cellule d'une ligne, par colonne. +function cellules(tr) { + const lues = [...tr.querySelectorAll('td[data-colonne]')]; + return new Map(lues.map((td) => [td.dataset.colonne, texteDe(td)])); +} + +// Les chiffres que rend le moteur pour un placement de la charge, écrits +// comme le tableau les écrit. +function attendus(chargeLue, placement) { + const instance = normaliser(configurationDepuisCharge(chargeLue)); + const tableDe = indexerPlan(instance, planDepuisFichier(placement)); + const mesures = mesurer(instance, tableDe); + const trois = troisChiffres(instance, mesures, plafondsAPriori(instance), plafondsRealises(instance, tableDe)); + const ecrit = (valeur, decimales = 0) => (valeur === null ? TIRET : format.nombre(valeur, { decimales })); + return new Map([ + ['rencontresTous', ecrit(mesures.aggRencontres.tous.min)], + ['rencontresMobiles', ecrit(mesures.aggRencontres.mobiles.min)], + ['rencontresAncres', ecrit(mesures.aggRencontres.ancres.min)], + ['collisionsCumulees', ecrit(mesures.collisionsCumulees)], + ['pairesDistinctes', ecrit(mesures.pairesDistinctes)], + ['excedentCollisions', ecrit(mesures.excedentCollisions)], + ['rencontresRepetees', ecrit(mesures.rencontresRepetees.choisies)], + ['maxRencontresPaire', ecrit(mesures.maxRencontresPaire)], + ['retoursChoisis', ecrit(mesures.totalRetoursChoisis)], + ['appartenancesVues', ecrit(mesures.aggAppartenancesVues.tous.min)], + ['totalRedondance', ecrit(mesures.totalRedondance)], + ['diversite', ecrit(mesures.aggDiversite.tous.min, 2)], + ['manqueMax', ecrit(trois.manqueMax.tous)], + ['effectifManque', ecrit(trois.effectifManque.tous)], + ['ecartItineraireMax', ecrit(trois.ecartItineraireMax.tous)], + ]); +} + +// Chaque chiffre attendu se lit dans sa cellule ; aucune cellule ne porte +// le minimum de r(p), qui désignerait la personne la mieux servie. +function exigerChiffres(tr, attendue) { + const lues = cellules(tr); + expect(attendue.size).toBe(15); + expect(lues.has('redondance'), 'minimum de r(p)').toBe(false); + for (const [colonne, texte] of attendue) expect(lues.get(colonne), colonne).toBe(texte); +} + +// --- Le tableau classé (§ 5.4, § 5.7, § 19.3 capture 11) -------------------------- + +describe('le tableau classé', () => { + test('les trois plans d’A6 : (6, 6), (4, 2), (4, 1) ; la phrase cite les cinq critères puis l’identifiant ; chaque chiffre est celui du moteur', async () => { + const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT); + const m = await ouvrir(chargeLue); + expect(ids(m)).toEqual([3, 2, 1]); + const phrase = texteDe(section(m).querySelector('[data-ordre]')); + expect(phrase).toBe(t('propositions.ordre.criteres', { criteres: [...CRITERES] })); + const positions = CRITERES.map((nom) => phrase.indexOf(t(`moteur.critere.${nom}`))); + expect(positions.every((position, rang) => position >= 0 && (rang === 0 || position > positions[rang - 1]))).toBe(true); + expect(phrase.indexOf('identifiant')).toBeGreaterThan(positions[4]); + for (const proposition of chargeLue.propositions) exigerChiffres(ligne(m, proposition.id), attendus(chargeLue, proposition)); + }); + + test('le minimum seul : aucun en-tête ni aucune cellule ne porte la moyenne des rencontres ; les unités nommées', async () => { + const chargeLue = generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 }); + const m = await ouvrir(chargeLue); + const entetes = [...section(m).querySelectorAll('th')].map(texteDe); + expect(entetes.length).toBeGreaterThan(0); + for (const entete of entetes) expect(entete).not.toMatch(/moyenne/i); + expect(entetes.some((entete) => /collisions cumulées/i.test(entete))).toBe(true); + expect(entetes.some((entete) => /paires distinctes/i.test(entete))).toBe(true); + expect(entetes.some((entete) => /excédent de collisions/i.test(entete))).toBe(true); + const lues = cellules(ligne(m, 1)); + expect(lues.get('rencontresTous')).toBe('24'); + expect(lues.get('rencontresAncres')).toBe('24'); + expect(Number(lues.get('rencontresMobiles'))).toBeGreaterThan(24); + const instance = normaliser(configurationDepuisCharge(chargeLue)); + const { aggRencontres } = mesurer(instance, indexerPlan(instance, planDepuisFichier(chargeLue.propositions[0]))); + const moyennes = ['tous', 'mobiles'].map((population) => format.nombre(aggRencontres[population].moyenne, { decimales: 2 })); + for (const texte of lues.values()) for (const moyenne of moyennes) expect(texte).not.toContain(moyenne); + }); + + test('la grande : « 99 retours imposés par 33 ancrages » à part, la ligne « ancrés » à 24, et « atteignabilité inconnue »', async () => { + const m = await ouvrir(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 })); + const lues = cellules(ligne(m, 1)); + expect(lues.get('imposes')).toContain('99 retours imposés par 33 ancrages.'); + expect(lues.get('imposes')).toMatch(/rencontres? répétées? imposées?|Aucune rencontre répétée imposée/); + expect(lues.get('certificat')).toBe('atteignabilité inconnue'); + }); + + test('la petite sans réservation : la colonne « ancrés » porte « — » à chaque ligne ; le plan parfait porte « minimum atteint »', async () => { + const chargeLue = generee(chargeDemo('petite'), { graine: 4, nombre: 2, arret: 300 }); + const parfaite = propositionDe(chargeLue, 3, [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], + [[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]], + [[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]], + [[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]], + ]); + chargeLue.propositions.push(parfaite); + chargeLue.prochainsIds.proposition = 4; + const m = await ouvrir(chargeLue); + expect(lignes(m).length).toBe(3); + for (const tr of lignes(m)) expect(cellules(tr).get('rencontresAncres')).toBe(TIRET); + expect(cellules(ligne(m, 3)).get('certificat')).toBe('minimum atteint'); + }); + + test('les générations s’accumulent : 3, puis 2 sous une autre graine, cinq lignes dans l’ordre de classer sur les cinq', async () => { + const m = await ouvrir(generee(chargeDemo('petite-conflit'), { graine: 1, nombre: 3, arret: 300 })); + expect(lignes(m).length).toBe(3); + const configuration = configurationDepuisCharge(charge(m)); + const propositions = rechercher(configuration, { graine: 2, nombre: 2, arret: 300 }); + await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: V1 }); + const instance = normaliser(configuration); + const aPriori = plafondsAPriori(instance); + const { ordre } = classer( + charge(m).propositions.map(({ id, ...p }) => ({ id, mesures: mesurer(instance, indexerPlan(instance, planDepuisFichier({ id, ...p }))), plafondsAPriori: aPriori })), + ); + await attendre(() => expect(ids(m)).toEqual(ordre)); + expect(ordre.length).toBe(5); + }); +}); + +// --- Retenir, voir sur le plan (§ 2.1 étape 10, § 9) -------------------------------- + +describe('retenir, voir sur le plan', () => { + test('retenir la 3 : l’état affiché est « Retenu », charge.retenu.proposition vaut 3, les trois lignes restent et la 3 est marquée', async () => { + const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT)); + const avant = revision(m); + commande(ligne(m, 3), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(3)); + expect(revision(m)).toBe(avant + 1); + const etat = m.racine.querySelector('[data-etat="retenu"]'); + expect(getComputedStyle(etat).fontWeight).toBe('700'); + expect(texteDe(etat)).toBe(t('evenement.etat.retenu')); + await attendre(() => expect(lignes(m).length).toBe(3)); + const marquees = lignes(m).filter((tr) => texteDe(tr.querySelector('td[data-colonne="proposition"]')).includes(t('propositions.marque.retenue'))); + expect(marquees.map((tr) => Number(tr.dataset.proposition))).toEqual([3]); + expect(Number(ligneRetenu(m).dataset.retenu)).toBe(3); + }); + + test('retenir la 3, voir la 1 sur le plan : la section du plan s’ouvre sur la proposition 1, sans écriture ; retenir la 2 rend le plan au retenu', async () => { + const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT)); + commande(ligne(m, 3), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(3)); + const retenu = structuredClone(charge(m).retenu); + const ecritures = m.trace.length; + commande(ligne(m, 1), 'voir').click(); + await attendre(() => expect(m.racine.querySelector('[data-section="plan"]')).not.toBeNull()); + expect(m.ui.etat().proposition).toBe(1); + const proposition = (id) => charge(m).propositions.find((candidate) => candidate.id === id); + // Les trois placements diffèrent au tour 1 : le plan montré se distingue + // du retenu. + expect(occupantsDuPlacement(charge(m), proposition(1))).not.toEqual(occupantsDuPlacement(charge(m), retenu)); + await exigerPlanMontre(m, proposition(1)); + expect(occupantsDessines(m).size).toBe(4); + expect(m.trace.length).toBe(ecritures); + expect(charge(m).retenu).toEqual(retenu); + m.ui.allerA('propositions'); + flushSync(); + commande(ligne(m, 2), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(2)); + expect(m.ui.etat().proposition).toBe(null); + m.ui.allerA('plan'); + flushSync(); + await exigerPlanMontre(m, charge(m).retenu); + expect(occupantsDuPlacement(charge(m), charge(m).retenu)).not.toEqual(occupantsDuPlacement(charge(m), proposition(1))); + }); + + test('les indicateurs suivent l’ajustement : la ligne du retenu égale sa mesure, celle de la proposition 2 n’a pas changé', async () => { + const m = await ouvrir(chargeSix()); + commande(ligne(m, 2), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(2)); + const avant = cellules(ligne(m, 2)); + await m.seance.executer('deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }); + const chargeLue = charge(m); + await attendre(() => exigerChiffres(ligneRetenu(m), attendus(chargeLue, chargeLue.retenu))); + expect(cellules(ligne(m, 2))).toEqual(avant); + expect(texteDe(ligneRetenu(m))).toContain(t('propositions.retenu.ajuste', { proposition: 2 })); + }); +}); + +// --- Le retenu ajusté (§ 2.1, § 5.9) ----------------------------------------------- + +describe('retenir sur un retenu ajusté', () => { + test('retenir la 3 ouvre une fenêtre qui nomme le retenu ajusté et la 3 ; Annuler n’écrit rien ; Confirmer retient la 3, une entrée', async () => { + const m = await ouvrir(chargeSix()); + commande(ligne(m, 2), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(2)); + await m.seance.executer('deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }); + flushSync(); + const ajuste = structuredClone(charge(m).retenu); + const ecritures = m.trace.length; + commande(ligne(m, 3), 'retenir').click(); + const fenetre = await attendre(() => { + const ouverte = fenetreOuverte(m); + expect(ouverte).not.toBeNull(); + return ouverte; + }); + const texte = texteDe(fenetre); + expect(texte).toContain(t('propositions.confirmation.retenuAjuste', { proposition: 2 })); + expect(texte).toContain(t('propositions.confirmation.remplacant', { proposition: 3 })); + commande(fenetre, 'renoncer').click(); + await attendre(() => expect(fenetreOuverte(m)).toBeNull()); + expect(m.trace.length).toBe(ecritures); + expect(charge(m).retenu).toEqual(ajuste); + const avant = revision(m); + commande(ligne(m, 3), 'retenir').click(); + const seconde = await attendre(() => { + const ouverte = fenetreOuverte(m); + expect(ouverte).not.toBeNull(); + return ouverte; + }); + commande(seconde, 'confirmer').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(3)); + await attendre(() => expect(fenetreOuverte(m)).toBeNull()); + expect(revision(m)).toBe(avant + 1); + }); + + test('sans ajustement, retenir une autre proposition n’ouvre aucune fenêtre', async () => { + const m = await ouvrir(chargeSix()); + commande(ligne(m, 2), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(2)); + commande(ligne(m, 3), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(3)); + expect(fenetreOuverte(m)).toBeNull(); + }); +}); + +// --- Effacer (§ 5.7) ---------------------------------------------------------------- + +describe('effacer', () => { + test('retenir la 2, effacer : seule la 2 reste, charge.retenu intact, une entrée', async () => { + const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT)); + commande(ligne(m, 2), 'retenir').click(); + await attendre(() => expect(charge(m).retenu?.proposition).toBe(2)); + const retenu = structuredClone(charge(m).retenu); + const avant = revision(m); + commande(section(m), 'effacer').click(); + await attendre(() => expect(ids(m)).toEqual([2])); + expect(charge(m).propositions.map(({ id }) => id)).toEqual([2]); + expect(charge(m).retenu).toEqual(retenu); + expect(revision(m)).toBe(avant + 1); + }); + + test('sans retenue, la liste se vide et la section le dit', async () => { + const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT)); + commande(section(m), 'effacer').click(); + await attendre(() => expect(lignes(m).length).toBe(0)); + expect(charge(m).propositions).toEqual([]); + expect(texteDe(section(m))).toContain(t('propositions.aucune')); + }); +}); + +// --- Les populations différentes (§ 9, § 12.6) ------------------------------------- + +describe('les populations différentes', () => { + test('générer, exclure une personne, générer encore : les premières hors classement, « non comparables », nomment la personne exclue', async () => { + let chargeLue = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 }); + chargeLue = executer(chargeLue, 'exclureParticipant', { id: 4 }); + chargeLue = generee(chargeLue, { graine: 2, nombre: 2, arret: 200 }); + const m = await ouvrir(chargeLue); + const classees = [...section(m).querySelectorAll('[data-classement] tr[data-proposition]')].map((tr) => Number(tr.dataset.proposition)); + expect(classees.sort()).toEqual([3, 4]); + const hors = [...section(m).querySelectorAll('[data-hors-classement] tr[data-proposition]')]; + expect(hors.map((tr) => Number(tr.dataset.proposition))).toEqual([1, 2]); + const nom = nomAffiche(chargeLue.participants.find(({ id }) => id === 4)); + for (const tr of hors) { + const texte = texteDe(tr); + expect(texte).toContain(t('propositions.statut.derive')); + expect(texte).toContain(t('PARTICIPANT_EXCLU', { participant: 4 })); + expect(texte).toContain(nom); + } + }); +}); + +// --- Vivant en lecture (§ 8.4, § 9) ------------------------------------------------- + +describe('en lecture et sur un plan bloqué', () => { + test('en lecture : le tableau se lit ; « Retenir » et « Effacer » refusés à côté d’eux, avec « Modifier », sans fenêtre ni écriture', async () => { + const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), { mode: 'lecture' }); + expect(ids(m)).toEqual([3, 2, 1]); + const ecritures = m.trace.length; + const retenir = commande(ligne(m, 3), 'retenir'); + retenir.click(); + const refus = await attendre(() => { + const pose = refusPres(retenir, 'MODE_LECTURE'); + expect(pose).not.toBeNull(); + return pose; + }); + expect(commande(refus, 'modifier')).not.toBeNull(); + const effacer = commande(section(m), 'effacer'); + effacer.click(); + await attendre(() => expect(refusPres(effacer, 'MODE_LECTURE')).not.toBeNull()); + expect(fenetreOuverte(m)).toBeNull(); + expect(m.trace.length).toBe(ecritures); + expect(charge(m).retenu).toBe(null); + }); + + test('sur un plan bloqué, retenu ajusté : « Retenir » refusé avec « Débloquer », sans fenêtre ; « Effacer » aussi', async () => { + const chargeLue = executer(chargeSix(), 'retenirProposition', { id: 2 }); + const bloquee = executer(executer(chargeLue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }), 'changerEtat', { etat: 'bloque' }); + const m = await ouvrir(bloquee, { mode: 'lecture' }); + expect(lignes(m).length).toBe(3); + const ecritures = m.trace.length; + const retenir = commande(ligne(m, 3), 'retenir'); + retenir.click(); + const refus = await attendre(() => { + const pose = refusPres(retenir, 'LECTURE_SEULE'); + expect(pose).not.toBeNull(); + return pose; + }); + expect(texteDe(refus)).toContain(t('LECTURE_SEULE', { raison: 'BLOQUE' })); + expect(commande(refus, 'debloquer')).not.toBeNull(); + expect(fenetreOuverte(m)).toBeNull(); + const effacer = commande(section(m), 'effacer'); + effacer.click(); + await attendre(() => expect(refusPres(effacer, 'LECTURE_SEULE')).not.toBeNull()); + expect(m.trace.length).toBe(ecritures); + }); +}); diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte index a1642ea..00b0425 100644 --- a/src/interface/propositions/SectionPropositions.svelte +++ b/src/interface/propositions/SectionPropositions.svelte @@ -2,12 +2,137 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->

{t('coquille.section.propositions')}

+ + {#if charge !== null && charge.propositions.length === 0} +

{t('propositions.aucune')}

+ {/if} + + {#if evaluee.refus !== null} + + {/if} + + {#if vue !== null} +
+ {#each vue.ordre as phrase (phrase.cle)} +

{t(phrase.cle, phrase.details)}

+ {/each} +
+ + {/if} + + {#if charge !== null && charge.propositions.length > 0} +
+ +
+ {/if} + + {#if aConfirmer !== null} + t(element.cle, element.details))} + surConfirmer={confirmer} + surAnnuler={() => (aConfirmer = null)} + /> + {/if}
+ + diff --git a/src/interface/propositions/TableauPropositions.svelte b/src/interface/propositions/TableauPropositions.svelte new file mode 100644 index 0000000..558e32f --- /dev/null +++ b/src/interface/propositions/TableauPropositions.svelte @@ -0,0 +1,221 @@ + + + + +{#snippet raisons(ligne)} +

{t(ligne.statut)}

+ {#each ligne.raisons as raison, rang (rang)} + {@const personne = nommer(raison.participant)} +

+ {t(raison.cle, raison.details)} + {#if personne !== null} + {personne} + {/if} +

+ {/each} +{/snippet} + +{#snippet mesures(ligne)} + {#if ligne.comparable} + {#each ligne.cellules as cellule (cellule.colonne)} + {texteCellule(cellule, outils)} + {/each} + + {#each ligne.imposes as phrase, rang (rang)} +

{t(phrase.cle, phrase.details)}

+ {/each} + + {:else} + {@render raisons(ligne)} + {/if} +{/snippet} + +{#snippet identite(ligne)} + + {format.nombre(ligne.id)} + {#if ligne.retenue} + {t('propositions.marque.retenue')} + {/if} + {#if ligne.comparable} + {#each ligne.violations as violation, rang (rang)} +

{t(violation.cle, violation.details)}

+ {/each} + {/if} + +{/snippet} + +{#snippet commandes(ligne)} + +
+ voir(ligne.id)} /> + retenir(ligne.id)} /> +
+ +{/snippet} + +
+ + + + + {#each vue.colonnes as colonne (colonne.nom)} + + {/each} + + + {#if vue.retenu !== null} + + + + + {@render mesures(vue.retenu)} + + + + {/if} + + {#each vue.lignes as ligne (ligne.id)} + + + {@render identite(ligne)} + {@render mesures(ligne)} + {@render commandes(ligne)} + + {/each} + + {#if vue.horsClassement.length > 0} + + + + + {#each vue.horsClassement as ligne (ligne.id)} + + + {@render identite(ligne)} + {@render mesures(ligne)} + {@render commandes(ligne)} + + {/each} + + {/if} +
{t('propositions.tableau.titre')}
{t(colonne.cle)}
{t('propositions.retenu.titre')} + {format.nombre(vue.retenu.id)} +

{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}

+
+
+ voir(null)} /> +
+
{format.nombre(ligne.rang)}
{t('propositions.horsClassement.titre')}
{t('format.vide')}
+
+ + diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js new file mode 100644 index 0000000..6cbfd2f --- /dev/null +++ b/src/interface/propositions/modele.js @@ -0,0 +1,295 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9, § 12.1, +// § 12.6, § 12.9), pur : SectionPropositions.svelte et +// TableauPropositions.svelte le câblent. +// +// Le tableau lit la table d'évaluation d'evaluer (A6), la seule mesure : chaque +// cellule porte la valeur que la table rend, telle quelle, et sa clé de mise +// en forme ; aucune arithmétique ne s'y fait, aucun plafond ne s'y recompose. +// Des rencontres, il ne porte que le minimum, sur les trois populations du +// § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de mesure ; la moyenne +// n'y entre pas. Le plafond réalisé, individuel, se lit par le manque, dont +// les cellules sont celles de troisChiffres. Les imposés se disent à part, sur +// chaque ligne, jamais ajoutés aux choisis. +// +// Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) : +// le moins de rencontres, d'appartenances vues, de diversité. La redondance +// r(p) va dans l'autre sens — plus elle est grande, pire est le sort —, et +// son minimum désignerait la personne la mieux servie : elle se lit par sa +// somme Σ r(p), la grandeur que la recherche et le classement lisent. +// +// Une cellule vaut { colonne, valeur, cle, decimales } : cle null, la valeur +// est un nombre que format.nombre écrit avec decimales chiffres après la +// virgule ; sinon le texte est t(cle) — « — » pour une population vide, +// « inconnu » pour une grandeur que l'a priori inconnu ne permet pas, le +// certificat en mots. +import { evaluer } from '../../application/evaluation.js'; +import { nomAffiche } from '../../application/personnes.js'; + +const VIDE = 'format.vide'; +const INCONNU = 'propositions.valeur.inconnu'; + +// Chaque colonne de mesure et la lecture de sa valeur dans une ligne +// comparable de la table d'évaluation, dans l'ordre d'affichage. La Map ne +// sert qu'à retrouver la lecture d'une colonne ; COLONNES fixe l'ordre. +const LECTURES = new Map([ + ['rencontresTous', (l) => l.mesures.aggRencontres.tous.min], + ['rencontresMobiles', (l) => l.mesures.aggRencontres.mobiles.min], + ['rencontresAncres', (l) => l.mesures.aggRencontres.ancres.min], + ['collisionsCumulees', (l) => l.mesures.collisionsCumulees], + ['pairesDistinctes', (l) => l.mesures.pairesDistinctes], + ['excedentCollisions', (l) => l.mesures.excedentCollisions], + ['rencontresRepetees', (l) => l.mesures.rencontresRepetees.choisies], + ['maxRencontresPaire', (l) => l.mesures.maxRencontresPaire], + ['retoursChoisis', (l) => l.mesures.totalRetoursChoisis], + ['appartenancesVues', (l) => l.mesures.aggAppartenancesVues.tous.min], + ['totalRedondance', (l) => l.mesures.totalRedondance], + ['diversite', (l) => l.mesures.aggDiversite.tous.min], + ['manqueMax', (l) => l.troisChiffres.manqueMax.tous], + ['effectifManque', (l) => l.troisChiffres.effectifManque.tous], + ['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null], + ['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax], + ['certificat', (l) => l.minimumAtteint], +]); + +// Les colonnes dont la valeur n'existe pas quand le plafond a priori est +// inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6). +const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']); + +// Le taux de diversité, un rapport, se lit au centième ; les autres colonnes +// sont des comptes. +const DECIMALES = new Map([['diversite', 2]]); + +/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et + * l'identifiant, puis les mesures — les trois minimums des rencontres en + * tête —, le certificat, les imposés et les commandes. Chacune porte son nom + * et la clé de son en-tête, qui nomme son unité. */ +export const COLONNES = Object.freeze( + ['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) => + Object.freeze({ nom, cle: `propositions.colonne.${nom}` }), + ), +); + +// Une cellule de nombre : null s'écrit « — ». +const nombre = (colonne, valeur) => ({ + colonne, + valeur, + cle: valeur === null ? VIDE : null, + decimales: DECIMALES.get(colonne) ?? 0, +}); + +// Le certificat en mots (§ 5.5) : « minimum atteint » quand minimumAtteint +// est vrai ; « atteignabilité inconnue » quand il est faux, ou null faute de +// plafond a priori ; « — » sur une population vide. +function certificat(ligne) { + const valeur = ligne.minimumAtteint; + let cle = 'propositions.certificat.inconnue'; + if (valeur === true) cle = 'propositions.certificat.atteint'; + else if (ligne.mesures.rencontres.length === 0) cle = VIDE; + return { colonne: 'certificat', valeur, cle, decimales: 0 }; +} + +// Les cellules de mesure d'une ligne comparable, dans l'ordre de LECTURES. +// L'a priori est inconnu quand troisChiffres ne porte pas d'écart +// d'itinéraire du tout. +function cellules(ligne) { + const inconnu = ligne.troisChiffres.ecartItineraireMax === null; + return [...LECTURES].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)); + }); +} + +// Ce que les réservations imposent, énoncé à part (§ 5.4) : les retours et +// les ancrages qui les imposent, les mêmes pour toute ligne ; les rencontres +// répétées imposées, propres au plan. +const imposes = (ligne, { retours, ancrages }) => [ + { cle: 'propositions.imposes.retours', details: { retours, ancrages } }, + { cle: 'propositions.imposes.repetees', details: { repetees: ligne.mesures.rencontresRepetees.imposees } }, +]; + +// Un code et ses détails : une raison de dérive, une faute, une violation. +const codeDe = (raison) => ({ cle: raison.code, details: raison }); + +// Une raison de dérive : son code, ses détails, et la personne qu'elle nomme, +// null quand elle n'en nomme aucune. +const raisonDe = (raison) => ({ ...codeDe(raison), participant: raison.participant ?? null }); + +// Les colonnes que couvre la cellule des raisons d'une ligne hors +// classement : celles des mesures et celle des imposés. +const ETENDUE_RAISONS = LECTURES.size + 1; + +// La ligne d'un placement mesuré, ou celle d'un placement hors classement. +// rang : la place dans le classement, à partir de 1 ; null hors classement +// et pour le retenu. +function ligneDe(ligne, table, idRetenu, rang = null) { + const retenue = ligne.id === idRetenu; + if (ligne.statut !== 'comparable') { + return { + id: ligne.id, + rang: null, + retenue, + comparable: false, + statut: `propositions.statut.${ligne.statut}`, + raisons: ligne.raisons.map(raisonDe), + }; + } + return { + id: ligne.id, + rang, + retenue, + comparable: true, + cellules: cellules(ligne), + imposes: imposes(ligne, table.imposes), + violations: ligne.violations.map(codeDe), + }; +} + +// La phrase de l'ordre (§ 5.7) : le critère sauté et celui appliqué à sa +// place quand le plafond a priori manque, puis les critères appliqués. +function ordre({ criteresAppliques, critereSaute }) { + const phrases = []; + if (critereSaute !== null) { + phrases.push({ cle: 'propositions.ordre.saute', details: { saute: critereSaute, applique: criteresAppliques[0] } }); + } + phrases.push({ cle: 'propositions.ordre.criteres', details: { criteres: [...criteresAppliques] } }); + return phrases; +} + +/** + * Le tableau d'une table d'évaluation (A6). + * + * @param {import('../../application/evaluation.js').TableEvaluation} tableEvaluation + * @param {{ajuste?: boolean}} [options] ajuste : le retenu ne porte plus le + * placement de sa proposition d'origine (retenuAjuste) ; il choisit le + * libellé de la ligne du retenu + * @returns {{ordre: Array<{cle: string, details: Object}>, colonnes: typeof COLONNES, + * etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}} + * lignes : les comparables dans l'ordre du classement, chacune + * { id, rang, retenue, comparable: true, cellules, imposes, violations } ; + * horsClassement : les autres par identifiant, { id, rang: null, retenue, + * comparable: false, statut, raisons } — statut la clé de « non comparable », + * raisons leurs codes, chacune { cle, details, participant }, participant + * l'identifiant de la personne en cause ou null ; etendueRaisons : le nombre + * de colonnes que couvre la cellule des raisons ; retenu : la ligne du + * retenu sous l'une ou l'autre forme, plus libelle { cle, details } — « tel + * que » ou « ajusté » —, null sans retenu. retenue marque la proposition + * d'origine du retenu. + */ +export function tableau(tableEvaluation, { ajuste = false } = {}) { + const idRetenu = tableEvaluation.retenu?.id ?? null; + // Les comparables ouvrent la liste, dans l'ordre du classement : leur + // place y est leur rang. + const toutes = tableEvaluation.propositions.map((ligne, place) => + ligneDe(ligne, tableEvaluation, idRetenu, ligne.statut === 'comparable' ? place + 1 : null), + ); + return { + ordre: ordre(tableEvaluation.classement), + colonnes: COLONNES, + etendueRaisons: ETENDUE_RAISONS, + lignes: toutes.filter(({ comparable }) => comparable), + horsClassement: toutes.filter(({ comparable }) => !comparable), + retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, ajuste), + }; +} + +// La ligne du retenu, et son libellé : « tel que » la proposition d'origine, +// ou « ajusté » depuis elle. +function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) { + const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine'; + return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu), libelle: { cle, details: { proposition: idRetenu } } }; +} + +/** + * Le texte d'une cellule : t(cle) quand elle porte une clé, sinon son nombre + * mis en forme. + * + * @param {{valeur: number|boolean|null, cle: string|null, decimales: number}} cellule + * @param {{t: Function, format: {nombre: Function}}} outils + * @returns {string} + */ +export function texteCellule({ valeur, cle, decimales }, { t, format }) { + return cle === null ? format.nombre(valeur, { decimales }) : t(cle); +} + +// Égalité en profondeur de deux valeurs du fichier d'état : nombres, +// chaînes, booléens, null, listes et objets simples. +function egaux(a, b) { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const clesA = Object.keys(a); + if (clesA.length !== Object.keys(b).length) return false; + return clesA.every((cle) => Object.hasOwn(b, cle) && egaux(a[cle], b[cle])); +} + +// Ce que le retenu copie de sa proposition d'origine, et que retenirProposition +// remplacerait. +const CHAMPS_DU_PLACEMENT = ['siegesAttribues', 'tables', 'capacites', 'tours', 'participants', 'placement']; + +/** Vrai quand un retenu existe et ne porte plus le placement de sa proposition d'origine : + * tables, capacités, tours, participants ou placement diffèrent, en profondeur, de ceux + * de la proposition retenu.proposition, ou celle-ci n'est plus dans la liste. */ +export function retenuAjuste(charge) { + const { retenu } = charge; + if (retenu === null) return false; + const origine = charge.propositions.find(({ id }) => id === retenu.proposition); + if (origine === undefined) return true; + return !CHAMPS_DU_PLACEMENT.every((champ) => egaux(retenu[champ], origine[champ])); +} + +/** + * La fenêtre à confirmer avant de retenir la proposition id (§ 2.1) : retenir + * remplace le retenu, et détruit ses ajustements quand il en porte. Elle + * s'ouvre quand la séance accepterait le geste — etat.refus null — et que le + * retenu est ajusté, y compris pour re-retenir sa proposition d'origine ; un + * geste que la séance refuserait s'exécute sans fenêtre, et son refus + * s'affiche. + * + * @param {{refus: Object|null, charge: Object|null}} etat l'état observable de la séance + * @param {number} id la proposition à retenir + * @returns {null|{id: number, titre: {cle: string, details: Object}, + * elements: Array<{cle: string, details: Object}>}} null sans fenêtre ; + * sinon la proposition demandée, le titre, puis les éléments qui nomment le + * retenu ajusté et la proposition qui le remplace + */ +export function demandeConfirmation({ refus, charge }, id) { + if (refus !== null || charge === null || !retenuAjuste(charge)) return null; + return { + id, + titre: { cle: 'propositions.confirmation.titre', details: { proposition: id } }, + elements: [ + { cle: 'propositions.confirmation.retenuAjuste', details: { proposition: charge.retenu.proposition } }, + { cle: 'propositions.confirmation.remplacant', details: { proposition: id } }, + ], + }; +} + +/** + * La table d'évaluation d'une charge, ou ce qui l'empêche : null sans + * proposition ni retenu, rien n'étant à mesurer ; le refus de normaliser — + * une erreur qui porte un code — quand la configuration ne se tient pas. + * Toute autre erreur remonte. + * + * @param {Object} charge + * @returns {{table: Object|null, refus: null|{code: string, details: Object}}} + */ +export function evaluation(charge) { + if (charge.propositions.length === 0 && charge.retenu === null) return { table: null, refus: null }; + try { + return { table: evaluer(charge), refus: null }; + } catch (erreur) { + if (typeof erreur?.code !== 'string') throw erreur; + return { table: null, refus: { code: erreur.code, details: erreur.details ?? {} } }; + } +} + +/** Le nom affiché de la personne id de la charge, exclue comprise ; null + * pour une personne que la charge n'a plus, et pour id null. */ +export function nomDe(charge, id) { + const personne = charge.participants.find((candidate) => candidate.id === id); + return personne === undefined ? null : nomAffiche(personne); +} diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js new file mode 100644 index 0000000..3020ae5 --- /dev/null +++ b/src/interface/propositions/modele.test.js @@ -0,0 +1,606 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du modèle du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9, +// § 12.1, § 12.6, § 12.9) : chaque cellule porte la valeur de la table +// d'évaluation et sa clé de mise en forme ; la phrase de l'ordre ; le +// minimum seul des rencontres ; les imposés à part ; le retenu ajusté. Les +// charges partent des démonstrations livrées ou s'écrivent ici ; leurs +// propositions s'enregistrent par la commande. La valeur attendue vient des +// fonctions du moteur, sollicitées une seconde fois (§ 19.1). 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 { evaluer } from '../../application/evaluation.js'; +import { creerFormat } from '../../application/format.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { nomAffiche } from '../../application/personnes.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js'; +import { texteLivre } from '../../demo/livrees.js'; +import { CRITERES, classer } from '../../moteur/classement.js'; +import { indexerPlan, normaliser } from '../../moteur/configuration.js'; +import { mesurer } from '../../moteur/indicateurs.js'; +import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../../moteur/manque.js'; +import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js'; +import { rechercher } from '../../moteur/recherche.js'; +import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; +import { planDepuisFichier } from '../../stockage/placements.js'; +import { VERSION } from '../../version.genere.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { COLONNES, demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau, texteCellule } from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); +const V1 = VERSION.affichee; + +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; + +// Une proposition écrite à la main, sièges non attribués, sur les tables de +// la charge dans leur ordre ; ses listes et réserves telles que données. +function propositionDe(charge, id, tours, reserves = tours.map(() => [])) { + const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p); + return { + id, + graine: id, + arret: 40, + historique: 1000, + produitVersion: V1, + siegesAttribues: false, + tables: charge.tables.map(({ id: table }) => table), + capacites: charge.tables.map((table) => capacite(charge, table)), + tours: tours.length, + participants, + placement: tours.map((listes, rang) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: reserves[rang] })), + }; +} + +// La charge, ses propositions remplacées par celles des plans donnés, +// numérotées à partir de 1. +function avecPlans(charge, plans) { + const suivante = structuredClone(charge); + suivante.propositions = plans.map(({ tours, reserves }, rang) => propositionDe(suivante, rang + 1, tours, reserves)); + suivante.prochainsIds.proposition = plans.length + 1; + suivante.evenement.etat = 'propose'; + return suivante; +} + +// Six personnes inventées, deux tables de places, quatre tours ; les deux +// premières partagent une appartenance quand collegues est vrai, les autres +// n'en ont aucune. +function chargeSix({ places = 3, collegues = true } = {}) { + const charge = creerCharge({ id: 'evt-propositions', nom: 'Club des Merles', siegesParDefaut: places, tours: 4 }); + const noms = [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Grisaille', null], ['Bruyère', 'Anouk'], ['Sarcelle', 'Capucine']]; + charge.participants = noms.map(([nom, prenom], rang) => ({ + id: rang + 1, + nom, + prenom, + appartenance: collegues && rang < 2 ? 'Club des Merles' : null, + 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; +} + +// Les personnes 1 et 2 ensemble aux quatre tours, les quatre autres tournent. +const DEUX_COLLEGUES = [ + [[1, 2, 3], [4, 5, 6]], + [[1, 2, 4], [3, 5, 6]], + [[1, 2, 5], [3, 4, 6]], + [[1, 2, 6], [3, 4, 5]], +]; + +// Trois plans de la petite démonstration au conflit inévitable, nommés par +// leurs collisions (cumulées, distinctes), au même plus grand écart au +// plafond a priori : ceux de l'épreuve de la table d'évaluation (A6). +const PLANS_CONFLIT = [ + { + nom: '(4, 1)', + tours: [ + [[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]], + [[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]], + [[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]], + [[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]], + ], + }, + { + nom: '(4, 2)', + tours: [ + [[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]], + [[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]], + [[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]], + [[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]], + ], + }, + { + nom: '(6, 6)', + tours: [ + [[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]], + [[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]], + [[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]], + [[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]], + ], + }, +]; + +// La valeur attendue de chaque colonne de mesure, lue dans une ligne +// comparable de la table d'évaluation : le tableau n'en calcule aucune. +const ATTENDUE = new Map([ + ['rencontresTous', (l) => l.mesures.aggRencontres.tous.min], + ['rencontresMobiles', (l) => l.mesures.aggRencontres.mobiles.min], + ['rencontresAncres', (l) => l.mesures.aggRencontres.ancres.min], + ['collisionsCumulees', (l) => l.mesures.collisionsCumulees], + ['pairesDistinctes', (l) => l.mesures.pairesDistinctes], + ['excedentCollisions', (l) => l.mesures.excedentCollisions], + ['rencontresRepetees', (l) => l.mesures.rencontresRepetees.choisies], + ['maxRencontresPaire', (l) => l.mesures.maxRencontresPaire], + ['retoursChoisis', (l) => l.mesures.totalRetoursChoisis], + ['appartenancesVues', (l) => l.mesures.aggAppartenancesVues.tous.min], + ['totalRedondance', (l) => l.mesures.totalRedondance], + ['diversite', (l) => l.mesures.aggDiversite.tous.min], + ['manqueMax', (l) => l.troisChiffres.manqueMax.tous], + ['effectifManque', (l) => l.troisChiffres.effectifManque.tous], + ['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax.tous], + ['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax], + ['certificat', (l) => l.minimumAtteint], +]); + +// Les douze chiffres du § 5.4, chacun et la colonne qui le porte. +const DOUZE = [ + ['personnes distinctes rencontrées', 'rencontresTous'], + ['collisions cumulées', 'collisionsCumulees'], + ['paires de même appartenance', 'pairesDistinctes'], + ['excédent de collisions', 'excedentCollisions'], + ['rencontres répétées', 'rencontresRepetees'], + ['maximum par paire', 'maxRencontresPaire'], + ['retours', 'retoursChoisis'], + ['A(p)', 'appartenancesVues'], + ['r(p)', 'totalRedondance'], + ['d(p)', 'diversite'], + ['manque', 'manqueMax'], + ["écart d'itinéraire", 'ecartItineraireMax'], +]; + +const cellule = (ligne, nom) => { + const trouvee = ligne.cellules.find(({ colonne }) => colonne === nom); + assert.ok(trouvee !== undefined, `colonne ${nom} absente`); + return trouvee; +}; +const texte = (c) => texteCellule(c, { t, format }); +const enTete = (nom) => t(COLONNES.find((colonne) => colonne.nom === nom).cle); + +// Les mesures du moteur pour chaque proposition de la charge, et leur +// classement : la valeur attendue, calculée sans la table d'évaluation. +function parLeMoteur(charge) { + const instance = normaliser(configurationDepuisCharge(charge)); + const aPriori = plafondsAPriori(instance); + const parId = new Map(); + for (const proposition of charge.propositions) { + const tableDe = indexerPlan(instance, planDepuisFichier(proposition)); + const mesures = mesurer(instance, tableDe); + const realises = plafondsRealises(instance, tableDe); + parId.set(proposition.id, { + mesures, + troisChiffres: troisChiffres(instance, mesures, aPriori, realises), + ecarts: ecartsAuPlafondAPriori(mesures, aPriori), + minimumAtteint: minimumAtteint(mesures, aPriori), + }); + } + const classement = classer(charge.propositions.map(({ id }) => ({ id, mesures: parId.get(id).mesures, plafondsAPriori: aPriori }))); + return { parId, classement }; +} + +describe('tableau : chaque cellule porte la valeur de la table d’évaluation (§ 5.4, § 12.9)', () => { + test('chaque cellule égale la valeur de sa ligne ; un nombre se met en forme, null porte la clé de « — »', () => { + const table = evaluer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 })); + const vue = tableau(table); + assert.deepEqual(vue.colonnes, COLONNES); + assert.equal(vue.lignes.length, 3); + assert.deepEqual(vue.lignes.map(({ id }) => id), table.propositions.map(({ id }) => id)); + let vues = 0; + vue.lignes.forEach((ligne, rang) => { + const source = table.propositions[rang]; + assert.equal(ligne.cellules.length, ATTENDUE.size); + for (const [nom, attendue] of ATTENDUE) { + const c = cellule(ligne, nom); + assert.deepEqual(c.valeur, attendue(source), nom); + if (nom === 'certificat') continue; + assert.equal(c.cle, c.valeur === null ? 'format.vide' : null, nom); + assert.equal(c.decimales, nom === 'diversite' ? 2 : 0, nom); + vues += 1; + } + }); + assert.equal(vues, 3 * (ATTENDUE.size - 1)); + }); + + test('une population vide porte la clé de « — », jamais 0 ; un a priori inconnu, « inconnu »', () => { + const charge = generee(chargeDemo('petite'), { graine: 2, nombre: 2, arret: 300 }); + const [ligne] = tableau(evaluer(charge)).lignes; + const ancres = cellule(ligne, 'rencontresAncres'); + assert.deepEqual([ancres.valeur, ancres.cle], [null, 'format.vide']); + assert.equal(texte(ancres), '\u{2014}'); + const [inconnue] = tableau(evaluer(charge, { plafondsAPriori: null })).lignes; + for (const nom of ['ecartItineraireMax', 'ecartAuPlafondAPriori']) { + assert.equal(cellule(inconnue, nom).cle, 'propositions.valeur.inconnu', nom); + assert.equal(texte(cellule(inconnue, nom)), 'inconnu', nom); + } + assert.equal(texte(cellule(inconnue, 'certificat')), 'atteignabilité inconnue'); + }); + + test('les générations s’accumulent : 3, puis 2 sous une autre graine, cinq lignes dans l’ordre de classer sur les cinq (§ 5.7)', () => { + let charge = generee(chargeDemo('petite-conflit'), { graine: 1, nombre: 3, arret: 300 }); + charge = generee(charge, { graine: 2, nombre: 2, arret: 300 }); + const vue = tableau(evaluer(charge)); + assert.equal(vue.lignes.length, 5); + assert.deepEqual(vue.lignes.map(({ id }) => id), parLeMoteur(charge).classement.ordre); + assert.deepEqual(vue.lignes.map(({ rang }) => rang), [1, 2, 3, 4, 5]); + assert.deepEqual(vue.horsClassement, []); + }); +}); + +describe('tableau : les douze chiffres, les chiffres du moteur (§ 5.4, § 12.9, § 19.1)', () => { + test('trois propositions : chaque ligne porte les douze, nombre ou « — », égaux à ce que rend le moteur', () => { + const charge = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 500 }); + const vue = tableau(evaluer(charge)); + const { parId } = parLeMoteur(charge); + assert.equal(vue.lignes.length, 3); + let lus = 0; + for (const ligne of vue.lignes) { + const moteur = parId.get(ligne.id); + const valeurs = new Map([ + ['rencontresTous', moteur.mesures.aggRencontres.tous.min], + ['collisionsCumulees', moteur.mesures.collisionsCumulees], + ['pairesDistinctes', moteur.mesures.pairesDistinctes], + ['excedentCollisions', moteur.mesures.excedentCollisions], + ['rencontresRepetees', moteur.mesures.rencontresRepetees.choisies], + ['maxRencontresPaire', moteur.mesures.maxRencontresPaire], + ['retoursChoisis', moteur.mesures.totalRetoursChoisis], + ['appartenancesVues', moteur.mesures.aggAppartenancesVues.tous.min], + ['totalRedondance', moteur.mesures.totalRedondance], + ['diversite', moteur.mesures.aggDiversite.tous.min], + ['manqueMax', moteur.troisChiffres.manqueMax.tous], + ['ecartItineraireMax', moteur.troisChiffres.ecartItineraireMax.tous], + ]); + for (const [chiffre, nom] of DOUZE) { + const ecrit = texte(cellule(ligne, nom)); + assert.notEqual(ecrit, '', chiffre); + const attendue = valeurs.get(nom); + assert.equal(ecrit, attendue === null ? '\u{2014}' : format.nombre(attendue, { decimales: nom === 'diversite' ? 2 : 0 }), chiffre); + lus += 1; + } + assert.equal(cellule(ligne, 'ecartAuPlafondAPriori').valeur, Math.max(...moteur.ecarts)); + assert.equal(cellule(ligne, 'certificat').valeur, moteur.minimumAtteint); + } + assert.equal(lus, 36); + }); +}); + +describe('tableau : les populations (§ 5.4)', () => { + test('petite sans réservation : « ancrés » porte « — » à chaque ligne ; aucun 0, NaN ni infini pour une population vide', () => { + const vue = tableau(evaluer(generee(chargeDemo('petite'), { graine: 4, nombre: 3, arret: 300 }))); + assert.equal(vue.lignes.length, 3); + for (const ligne of vue.lignes) { + assert.equal(texte(cellule(ligne, 'rencontresAncres')), '\u{2014}'); + assert.equal(cellule(ligne, 'rencontresAncres').valeur, null); + assert.ok(cellule(ligne, 'rencontresMobiles').valeur > 0); + for (const c of ligne.cellules) { + if (typeof c.valeur === 'number') assert.ok(Number.isFinite(c.valeur), c.colonne); + if (c.valeur === null) assert.notEqual(c.cle, null, c.colonne); + } + } + }); + + test('grande : minimum global 24, ancrés 24, mobiles au-dessus', () => { + const vue = tableau(evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 }))); + const [ligne] = vue.lignes; + assert.equal(cellule(ligne, 'rencontresTous').valeur, 24); + assert.equal(cellule(ligne, 'rencontresAncres').valeur, 24); + assert.ok(cellule(ligne, 'rencontresMobiles').valeur > 24); + }); +}); + +describe('tableau : le minimum seul des rencontres (§ 5.4, § 12.1)', () => { + test('les trois minimums ouvrent les colonnes de mesure ; aucune colonne, aucun en-tête, aucune cellule ne porte la moyenne', () => { + assert.deepEqual(COLONNES.slice(2, 5).map(({ nom }) => nom), ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']); + for (const nom of ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']) assert.match(enTete(nom), /minimum/); + assert.ok(COLONNES.length > 0); + for (const { nom, cle } of COLONNES) { + assert.doesNotMatch(nom, /moyenne/i, nom); + assert.doesNotMatch(t(cle), /moyenne/i, cle); + } + const table = evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 })); + const [source] = table.propositions; + // Les moyennes qu'aucun compte entier ne peut égaler par hasard. + const moyennes = ['tous', 'mobiles', 'ancres'] + .map((population) => source.mesures.aggRencontres[population].moyenne) + .filter((moyenne) => !Number.isInteger(moyenne)); + assert.ok(moyennes.length >= 2, 'des moyennes discernables'); + const [ligne] = tableau(table).lignes; + for (const c of ligne.cellules) assert.ok(!moyennes.includes(c.valeur), c.colonne); + }); +}); + +describe('tableau : r(p) se lit par sa somme (§ 5.4, § 12.1)', () => { + test('aucune colonne ne porte le minimum de r(p), qui désigne la personne la mieux servie ; Σ r(p) reste', () => { + const noms = COLONNES.map(({ nom }) => nom); + assert.ok(noms.length > 0); + assert.ok(noms.includes('totalRedondance')); + assert.ok(!noms.includes('redondance')); + for (const { cle } of COLONNES) assert.doesNotMatch(t(cle), /r\(p\), minimum/, cle); + const table = evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 2000 })); + const [source] = table.propositions; + const [ligne] = tableau(table).lignes; + assert.equal(cellule(ligne, 'totalRedondance').valeur, source.mesures.totalRedondance); + // Le minimum de r(p) n'occupe aucune cellule de redondance. + assert.ok(ligne.cellules.length > 0); + assert.ok(ligne.cellules.every(({ colonne }) => colonne !== 'redondance')); + }); +}); + +describe('tableau : les unités (§ 5.4)', () => { + test('deux mêmes collègues ensemble aux quatre tours : 4 collisions cumulées, 1 paire distincte, excédent 3, chaque en-tête nomme son unité', () => { + const charge = avecPlans(chargeSix(), [{ tours: DEUX_COLLEGUES }]); + const [ligne] = tableau(evaluer(charge)).lignes; + assert.equal(cellule(ligne, 'collisionsCumulees').valeur, 4); + assert.equal(cellule(ligne, 'pairesDistinctes').valeur, 1); + assert.equal(cellule(ligne, 'excedentCollisions').valeur, 3); + assert.match(enTete('collisionsCumulees'), /collisions cumulées/i); + assert.match(enTete('pairesDistinctes'), /paires distinctes/i); + assert.match(enTete('excedentCollisions'), /excédent de collisions/i); + }); +}); + +describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => { + test('la phrase cite les cinq critères dans l’ordre, puis l’identifiant ; les trois plans d’A6 : (6, 6), (4, 2), (4, 1)', () => { + const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT); + const vue = tableau(evaluer(charge)); + assert.deepEqual(vue.lignes.map(({ id }) => id), [3, 2, 1]); + assert.deepEqual(vue.ordre, [{ cle: 'propositions.ordre.criteres', details: { criteres: [...CRITERES] } }]); + const phrase = t(vue.ordre[0].cle, vue.ordre[0].details); + const positions = CRITERES.map((nom) => phrase.indexOf(t(`moteur.critere.${nom}`))); + assert.equal(positions.length, 5); + assert.ok(positions.every((position, rang) => position >= 0 && (rang === 0 || position > positions[rang - 1])), phrase); + assert.ok(phrase.indexOf('identifiant') > positions[4], phrase); + }); + + test('a priori inconnu : la phrase nomme le critère sauté et « excédent de collisions » appliqué à sa place', () => { + const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT); + const vue = tableau(evaluer(charge, { plafondsAPriori: null })); + assert.equal(vue.ordre.length, 2); + assert.deepEqual(vue.ordre[0], { + cle: 'propositions.ordre.saute', + details: { saute: 'ecartAuPlafondAPrioriMax', applique: 'excedentCollisions' }, + }); + const saute = t(vue.ordre[0].cle, vue.ordre[0].details); + assert.ok(saute.indexOf('écart au plafond a priori') >= 0 && saute.indexOf('excédent de collisions') > saute.indexOf('écart au plafond a priori'), saute); + assert.equal(vue.ordre[1].details.criteres[0], 'excedentCollisions'); + for (const ligne of vue.lignes) assert.equal(texte(cellule(ligne, 'ecartItineraireMax')), 'inconnu'); + }); +}); + +describe('tableau : la diversité en colonne (§ 5.7)', () => { + test('chaque ligne porte A(p), Σ r(p), d(p) et les paires distinctes ; sans affilié rencontré, d(p) s’écrit « — »', () => { + const vue = tableau(evaluer(generee(chargeDemo('petite'), { graine: 6, nombre: 2, arret: 300 }))); + assert.equal(vue.lignes.length, 2); + for (const ligne of vue.lignes) { + for (const nom of ['appartenancesVues', 'totalRedondance', 'diversite', 'pairesDistinctes']) { + assert.equal(typeof cellule(ligne, nom).valeur, 'number', nom); + } + } + const sans = avecPlans(chargeSix({ collegues: false }), [{ tours: DEUX_COLLEGUES }]); + const [ligne] = tableau(evaluer(sans)).lignes; + assert.equal(cellule(ligne, 'diversite').valeur, null); + assert.equal(texte(cellule(ligne, 'diversite')), '\u{2014}'); + }); +}); + +describe('tableau : le certificat (§ 5.5, § 15.3)', () => { + test('le plan parfait de la petite, enregistré comme proposition : « minimum atteint »', () => { + const charge = avecPlans(chargeDemo('petite'), [{ tours: PLAN_PARFAIT_PETITE.tours }]); + const [ligne] = tableau(evaluer(charge)).lignes; + assert.equal(cellule(ligne, 'certificat').valeur, true); + assert.equal(texte(cellule(ligne, 'certificat')), 'minimum atteint'); + }); + + test('grande : jamais ; l’écart au plafond a priori et « atteignabilité inconnue »', () => { + const [ligne] = tableau(evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 }))).lignes; + assert.equal(texte(cellule(ligne, 'certificat')), 'atteignabilité inconnue'); + assert.ok(cellule(ligne, 'ecartAuPlafondAPriori').valeur >= 1); + }); + + test('manque nul sur des itinéraires bas, une personne en réserve trois tours sur quatre : pas de certificat', () => { + const tours = [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], + [[6, 9, 12], [3, 8, 11], [2, 5], [1, 4, 7]], + [[4, 8], [2, 7, 12], [1, 6, 11], [3, 5, 9]], + [[5, 7, 11], [1, 9], [3, 4, 12], [2, 6, 8]], + ]; + const charge = avecPlans(chargeDemo('petite'), [{ tours, reserves: [[], [10], [10], [10]] }]); + const [ligne] = tableau(evaluer(charge)).lignes; + assert.equal(cellule(ligne, 'manqueMax').valeur, 0); + assert.equal(cellule(ligne, 'certificat').valeur, false); + assert.equal(texte(cellule(ligne, 'certificat')), 'atteignabilité inconnue'); + assert.ok(cellule(ligne, 'ecartItineraireMax').valeur > 0); + }); +}); + +describe('tableau : le manque contre le plafond réalisé (§ 5.5)', () => { + test('le plus grand manque et son effectif égalent troisChiffres sur plafondsRealises ; aucune colonne ne recompose un plafond', () => { + const charge = generee(chargeDemo('petite-conflit'), { graine: 8, nombre: 3, arret: 300 }); + const vue = tableau(evaluer(charge)); + const instance = normaliser(configurationDepuisCharge(charge)); + const aPriori = plafondsAPriori(instance); + assert.equal(vue.lignes.length, 3); + for (const ligne of vue.lignes) { + const tableDe = indexerPlan(instance, planDepuisFichier(charge.propositions.find(({ id }) => id === ligne.id))); + const mesures = mesurer(instance, tableDe); + const trois = troisChiffres(instance, mesures, aPriori, plafondsRealises(instance, tableDe)); + assert.equal(cellule(ligne, 'manqueMax').valeur, trois.manqueMax.tous); + assert.equal(cellule(ligne, 'effectifManque').valeur, trois.effectifManque.tous); + assert.equal(cellule(ligne, 'rencontresTous').valeur, mesures.aggRencontres.tous.min); + } + for (const { nom } of COLONNES) assert.doesNotMatch(nom, /plafond(?!APriori)/i, nom); + }); +}); + +describe('tableau : le retenu et les placements hors classement (§ 9, § 12.6)', () => { + test('le retenu se mesure sur sa propre ligne ; la proposition d’origine est marquée', () => { + let charge = generee(chargeDemo('petite'), { graine: 3, nombre: 3, arret: 300 }); + charge = executer(charge, 'retenirProposition', { id: 2 }); + const table = evaluer(charge); + const vue = tableau(table); + assert.equal(vue.retenu.id, 2); + assert.equal(vue.retenu.comparable, true); + assert.deepEqual(vue.retenu.libelle, { cle: 'propositions.retenu.origine', details: { proposition: 2 } }); + assert.deepEqual(tableau(table, { ajuste: true }).retenu.libelle, { cle: 'propositions.retenu.ajuste', details: { proposition: 2 } }); + assert.equal(vue.lignes.filter(({ libelle }) => libelle !== undefined).length, 0); + for (const [nom, attendue] of ATTENDUE) assert.deepEqual(cellule(vue.retenu, nom).valeur, attendue(table.retenu), nom); + assert.deepEqual(vue.lignes.filter(({ retenue }) => retenue).map(({ id }) => id), [2]); + }); + + test('générer, exclure une personne, générer encore : les premières hors classement, non comparables, leurs raisons nomment la personne', () => { + let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 }); + charge = executer(charge, 'exclureParticipant', { id: 4 }); + charge = generee(charge, { graine: 2, nombre: 2, arret: 200 }); + const vue = tableau(evaluer(charge)); + assert.deepEqual(vue.lignes.map(({ id }) => id).sort(), [3, 4]); + assert.deepEqual(vue.horsClassement.map(({ id }) => id), [1, 2]); + for (const ligne of vue.horsClassement) { + assert.equal(ligne.rang, null); + assert.equal(ligne.statut, 'propositions.statut.derive'); + assert.deepEqual(ligne.raisons, [{ cle: 'PARTICIPANT_EXCLU', details: { code: 'PARTICIPANT_EXCLU', participant: 4 }, participant: 4 }]); + assert.match(t(ligne.raisons[0].cle, ligne.raisons[0].details), /4/); + } + assert.equal(nomDe(charge, 4), nomAffiche(charge.participants.find(({ id }) => id === 4))); + assert.equal(nomDe(charge, 999), null); + assert.equal(nomDe(charge, null), null); + // La cellule des raisons couvre les colonnes de mesure et celle des + // imposés d'une ligne comparable. + const [comparable] = vue.lignes; + assert.equal(vue.etendueRaisons, comparable.cellules.length + 1); + assert.equal(vue.etendueRaisons, COLONNES.length - 3); + }); + + test('une exclusion et un ajout, à effectif égal : hors classement aussi', () => { + let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + charge = executer(charge, 'exclureParticipant', { id: 4 }); + charge = executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' } }); + const vue = tableau(evaluer(charge)); + assert.deepEqual(vue.lignes, []); + assert.deepEqual(vue.horsClassement.map(({ id }) => id), [1]); + assert.deepEqual(vue.horsClassement[0].raisons.map(({ cle }) => cle), ['PARTICIPANT_EXCLU', 'PARTICIPANT_NON_PLACE']); + // Chaque raison porte la personne qu'elle nomme, null quand elle n'en + // nomme aucune. + assert.deepEqual( + vue.horsClassement[0].raisons.map(({ participant }) => participant), + vue.horsClassement[0].raisons.map(({ details }) => details.participant ?? null), + ); + assert.ok(vue.horsClassement[0].raisons.every(({ participant }) => participant !== undefined)); + }); +}); + +describe('tableau : les imposés à part (§ 5.4)', () => { + test('grande : chaque ligne énonce « 99 retours imposés par 33 ancrages », à part des retours choisis', () => { + const table = evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 2, arret: 20_000 })); + const vue = tableau(table); + assert.equal(vue.lignes.length, 2); + vue.lignes.forEach((ligne, rang) => { + const [retours] = ligne.imposes; + assert.deepEqual(retours, { cle: 'propositions.imposes.retours', details: { retours: 99, ancrages: 33 } }); + assert.equal(t(retours.cle, retours.details), '99 retours imposés par 33 ancrages.'); + assert.equal(cellule(ligne, 'retoursChoisis').valeur, table.propositions[rang].mesures.totalRetoursChoisis); + }); + }); + + test('deux personnes réservées ensemble : les rencontres répétées imposées à part des choisies, jamais additionnées', () => { + const charge = structuredClone(chargeDemo('petite')); + charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + const table = evaluer(generee(charge, { graine: 6, nombre: 3, arret: 500 })); + const vue = tableau(table); + assert.equal(vue.lignes.length, 3); + vue.lignes.forEach((ligne, rang) => { + const { choisies, imposees } = table.propositions[rang].mesures.rencontresRepetees; + assert.equal(imposees, 1); + assert.equal(cellule(ligne, 'rencontresRepetees').valeur, choisies); + assert.deepEqual(ligne.imposes[1], { cle: 'propositions.imposes.repetees', details: { repetees: 1 } }); + assert.equal(t(ligne.imposes[1].cle, ligne.imposes[1].details), '1 rencontre répétée imposée.'); + }); + }); +}); + +describe('retenuAjuste (§ 2.1, § 5.9)', () => { + // La charge à six personnes, deux tables de quatre, une proposition. + const deTables = () => avecPlans(chargeSix({ places: 4 }), [{ tours: DEUX_COLLEGUES }]); + + test('faux sans retenu, et pour un retenu tel que retenu', () => { + const charge = deTables(); + assert.equal(retenuAjuste(charge), false); + assert.equal(retenuAjuste(executer(charge, 'retenirProposition', { id: 1 })), false); + }); + + test('vrai après un deplacerDansRetenu, vrai quand la proposition d’origine n’est plus dans la liste', () => { + const retenue = executer(deTables(), 'retenirProposition', { id: 1 }); + const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }); + assert.equal(retenuAjuste(deplacee), true); + const sansOrigine = structuredClone(retenue); + sansOrigine.propositions = []; + assert.equal(retenuAjuste(sansOrigine), true); + }); +}); + +describe('demandeConfirmation (§ 2.1)', () => { + const deTables = () => avecPlans(chargeSix({ places: 4 }), [{ tours: DEUX_COLLEGUES }, { tours: DEUX_COLLEGUES }]); + + test('null sans retenu ajusté, sans charge, ou quand la séance refuserait le geste', () => { + const charge = deTables(); + assert.equal(demandeConfirmation({ refus: null, charge }, 2), null); + assert.equal(demandeConfirmation({ refus: null, charge: null }, 2), null); + const retenue = executer(charge, 'retenirProposition', { id: 1 }); + assert.equal(demandeConfirmation({ refus: null, charge: retenue }, 2), null); + const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }); + const refus = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE' } }; + assert.equal(demandeConfirmation({ refus, charge: deplacee }, 2), null); + }); + + test('un retenu ajusté que la séance laisserait remplacer : la fenêtre nomme le retenu ajusté et la proposition qui le remplace', () => { + const retenue = executer(deTables(), 'retenirProposition', { id: 1 }); + const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }); + assert.deepEqual(demandeConfirmation({ refus: null, charge: deplacee }, 2), { + id: 2, + titre: { cle: 'propositions.confirmation.titre', details: { proposition: 2 } }, + elements: [ + { cle: 'propositions.confirmation.retenuAjuste', details: { proposition: 1 } }, + { cle: 'propositions.confirmation.remplacant', details: { proposition: 2 } }, + ], + }); + // Re-retenir l'origine détruit aussi les ajustements. + assert.equal(demandeConfirmation({ refus: null, charge: deplacee }, 1).id, 1); + }); +}); + +describe('evaluation : la table d’une charge, ou ce qui l’empêche', () => { + test('sans proposition ni retenu, rien à évaluer ; une configuration qui ne se normalise pas rend son refus', () => { + assert.deepEqual(evaluation(chargeDemo('petite')), { table: null, refus: null }); + const avec = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 100 }); + assert.deepEqual(evaluation(avec), { table: evaluer(avec), refus: null }); + const charge = structuredClone(avec); + charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null })); + const { table, refus } = evaluation(charge); + assert.equal(table, null); + assert.equal(refus.code, 'SURRESERVATION'); + }); +});