diff --git a/src/application/evaluation.js b/src/application/evaluation.js index efe086d..9413249 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -38,18 +38,35 @@ // ordre des priorités, son mode des objectifs, ses cases. La table porte // aussi l'état de chaque critère tel que normaliser le donne — coché, sans // objet, ses valeurs —, que l'écran lit au lieu de le recalculer. +// +// Le détail d'un placement (detaillerQualite, § 12.9, D27) se calcule pour +// lui seul, à la demande de la page de qualité, jamais dans evaluer : la +// table se recalcule pour chaque proposition à chaque écriture, le détail +// n'en sert qu'une. Il lit les fonctions du moteur — detailler, la borne de +// diversité, la garde à quatre paramètres — et rend des identifiants et des +// noms, jamais un index. La course d'une proposition ne vient ni de la +// charge ni du moteur : la séance la lit dans le compagnon des courses et +// la passe (courseDe) ; chacun de ses points de relevé se mesure ici, sous +// la configuration de la charge reçue. La génération d'où vient la +// proposition, durée et cœurs, vient de la séance aussi (generationDe) ; +// rien ici ne lit l'horloge. +import { cleNormalisee } from '../csv/normalisation.js'; +import { constante } from '../geometrie/constantes.js'; import { classer } from '../moteur/classement.js'; -import { figerConfiguration, indexerPlan, normaliser } from '../moteur/configuration.js'; +import { SANS_GROUPE, STATUT, figerConfiguration, indexerPlan, normaliser } from '../moteur/configuration.js'; import { compilationDe } from '../moteur/criteres.js'; import { diagnostiquer } from '../moteur/diagnostic.js'; import { mesurer } from '../moteur/indicateurs.js'; import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur/manque.js'; -import { plafondsRealises } from '../moteur/plafond.js'; +import { rencontresDe } from '../moteur/paires.js'; +import { borneDiversite, plafondsRealises } from '../moteur/plafond.js'; +import { detailler, planchersParGroupe, profilRencontres } from '../moteur/qualite.js'; import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js'; import { ErreurConfiguration } from '../moteur/erreurs.js'; import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance } from '../stockage/document.js'; import { derive, deriveSansRevalider, fautes, planDepuisFichier, planSansRevalider } from '../stockage/placements.js'; import { configurationDe } from './configuration.js'; +import { nomAffiche } from './personnes.js'; export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance }; @@ -98,6 +115,14 @@ function plusGrand(valeurs) { return plus; } +// Lève TypeError quand l'option plafondsAPriori de la fonction nommée ne +// vaut ni 'calcul' ni null. +function exigerAPriori(fonction, plafondsAPriori) { + if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) { + throw new TypeError(`${fonction} : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`); + } +} + // Vrai quand le placement se compare à la configuration courante : sans // faute, puis sans dérive — derive lève sur une faute, et ne la reçoit // jamais. @@ -133,9 +158,7 @@ const nonMesuree = (id, statut, raisons) => ({ * @throws {TypeError} une autre valeur de plafondsAPriori */ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) { - if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) { - throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`); - } + exigerAPriori('evaluer', plafondsAPriori); const configuration = figerConfiguration(configurationDe(charge)); const diagnostic = diagnostiquer(configuration); const instance = normaliser(configuration); @@ -249,3 +272,495 @@ export function resumeDuMeilleur(charge) { const premiere = premiereDuClassement(table); return premiere === null ? null : { id: premiere.id }; } + +// --- Le détail d'un placement (§ 12, § 12.9, D27, D29, D40, D42) ------------ + +/** + * @typedef {Object} CourseDetaillee + * @property {'proposition'|'retenu'} source ce que la page mesure + * @property {number|null} propositionDuRetenu la proposition d'où vient le + * retenu ; null pour une proposition + * @property {number} evalues mouvements évalués de la recherche + * @property {number} arret son compte d'arrêt + * @property {string[]} indicateurs les noms des valeurs de chaque point, dans + * leur ordre : minimumBrut, puis chaque grandeur que les critères + * appliqués classent, palier après palier + * @property {Array<{mouvements: number, valeurs: Object}>} releves + * chaque point de relevé de la course, puis le dernier, à evalues, + * le plan de la proposition ; aucune durée + * @property {number} dernierChangement les mouvements du dernier point dont + * une valeur diffère du point précédent ; ceux du premier point + * quand aucune ne change + * @property {{dureeMs: number|null, coeurs: number|null, cadence: number|null, + * nombre: number|null}} generation + * la génération de la séance d'où vient la proposition ; dureeMs et + * cadence null pour une durée nulle ou inconnue, les quatre null + * sans génération connue + * + * @typedef {Object} DetailQualite + * @property {number|'retenu'} id + * @property {'comparable'|'derive'|'fautif'} statut + * @property {object[]} raisons fautif : ses fautes ; derive : ses raisons + * Les champs qui suivent valent null quand le statut n'est pas comparable. + * @property {{mesuree: number, exclue: number}|null} population effectifs + * @property {number|null} T + * @property {number|null} R + * @property {number|null} G appartenances des présents + * @property {number[]|null} idsTables ordre des tables de l'instance + * @property {number[]|null} numerosTables le numéro de chacune + * @property {Array<{id: number, nom: string, appartenance: string|null, + * statut: 'mobile'|'partiel'|'ancre'}>|null} personnes + * ordre canonique, identifiants croissants + * @property {number|null} propositionDuRetenu pour le retenu, la + * proposition dont il porte encore le placement ; null sinon + * @property {Object|null} bornes, profil, synthese, repartitionRencontres, + * collisions, tables, paires, matrice, objectifs, animateurs + * ceux de detailler (qualite.js), chaque personne et chaque table + * par son identifiant (id, a, b, table), chaque tour par son index + * à partir de 0 ; objectifs porte en plus critere, {coche, + * sansObjet} du critère des objectifs + * @property {CourseDetaillee|null} course + * @property {object[]} refusDessin verifierIndicateurs à quatre paramètres, + * chaque violation complétée de participant (index) et + * d'appartenance (groupe) + */ + +// Le nom du statut d'une personne (§ 4.2), par valeur de STATUT. La Map ne +// sert qu'à retrouver un nom. +const NOM_DU_STATUT = new Map([ + [STATUT.MOBILE, 'mobile'], + [STATUT.PARTIELLEMENT_FIXE, 'partiel'], + [STATUT.ANCRE, 'ancre'], +]); + +// Le critère dont la vue des objectifs lit l'état. +const CRITERE_OBJECTIFS = 'toursHorsObjectif'; + +// Le seuil de la matrice, nommé dans le registre des constantes mesurées +// (§ 14.1, § 19.10) : un nom de constante, que la table des libellés ne +// connaît pas. +const SEUIL_MATRICE = Object.freeze({ nom: 'SEUIL_MATRICE_PARTICIPANTS' }); + +// Les champs de mesure d'un détail, null quand la source ne se compare pas. +const CHAMPS_DE_MESURE = [ + 'population', + 'T', + 'R', + 'G', + 'idsTables', + 'numerosTables', + 'personnes', + 'propositionDuRetenu', + 'bornes', + 'profil', + 'synthese', + 'repartitionRencontres', + 'collisions', + 'tables', + 'paires', + 'matrice', + 'objectifs', + 'animateurs', + 'course', +]; + +// La lecture de chaque valeur d'un point de relevé dans mesurer : le +// minimum brut, puis les grandeurs que les critères classent, sous les noms +// que le classement leur donne (valeursQuiClassent, criteres.js), chacune +// lue comme classer la lit. L'écart au plafond a priori se lit comme +// ligneDe le lit, null sous un a priori inconnu ; une grandeur que les +// mesures ne portent pas — objectifs ou animateurs sans personne +// concernée — vaut null. La Map ne sert qu'à retrouver une lecture. +const LECTURES_DES_VALEURS = new Map([ + ['minimumBrut', (mesures) => mesures.aggRencontres.tous.min], + ['ecartAuPlafondAPrioriMax', (mesures, aPriori) => plusGrand(ecartsAuPlafondAPriori(mesures, aPriori))], + ['excedentCollisions', (mesures) => mesures.excedentCollisions], + ['collisionsCumulees', (mesures) => mesures.collisionsCumulees], + ['toursHorsObjectif', (mesures) => mesures.objectifs?.horsObjectif ?? null], + ['jamaisServies', (mesures) => mesures.objectifs?.jamaisServies ?? null], + ['horsObjectifMax', (mesures) => mesures.objectifs?.horsObjectifMax ?? null], + ['rencontresRepeteesChoisies', (mesures) => mesures.rencontresRepetees.choisies], + ['retoursChoisis', (mesures) => mesures.totalRetoursChoisis], + ['animateursNonRencontres', (mesures) => mesures.animateurs?.nonRencontres ?? null], + ['redondance', (mesures) => mesures.totalRedondance], +]); + +// Les noms des valeurs d'un point de relevé : le minimum brut, puis chaque +// grandeur que les critères appliqués classent, palier après palier — une +// par grandeur, jamais la clé pondérée d'un palier. RangeError pour une +// grandeur qu'aucune lecture ne porte. +function indicateursDe(criteresAppliques) { + const noms = ['minimumBrut', ...criteresAppliques.flatMap(({ criteres }) => criteres.flatMap(({ valeurs }) => valeurs))]; + for (const nom of noms) { + if (!LECTURES_DES_VALEURS.has(nom)) throw new RangeError(`valeur d'un point de relevé inconnue : ${nom}`); + } + return noms; +} + +// Les valeurs d'un point, lues dans ses mesures, dans l'ordre des noms. +function valeursDe(indicateurs, mesures, aPriori) { + const valeurs = {}; + for (const nom of indicateurs) valeurs[nom] = LECTURES_DES_VALEURS.get(nom)(mesures, aPriori); + return valeurs; +} + +// Égalité en profondeur de deux valeurs d'un placement de fichier : +// 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 : un des champs que retenirProposition copie — + * sièges attribués, tables, capacités, tours, participants, placement — + * diffère, en profondeur, de celui de la proposition retenu.proposition, ou + * celle-ci n'est plus dans la liste. Faux sans retenu. La seule définition + * du retenu ajusté : le tableau des propositions la lit pour demander + * confirmation, la page de qualité pour taire la course du retenu. Ne + * modifie pas la charge. + * + * @param {import('../stockage/types.js').Charge} charge + * @returns {boolean} + */ +export function retenuAjuste({ retenu, propositions }) { + if (retenu === null) return false; + const origine = propositions.find(({ id }) => id === retenu.proposition); + if (origine === undefined) return true; + return !CHAMPS_DU_PLACEMENT.every((champ) => egaux(retenu[champ], origine[champ])); +} + +// La proposition d'où vient le retenu de la charge quand il n'est pas +// ajusté (retenuAjuste) ; null pour un retenu ajusté, ou dont la +// proposition est effacée. +function origineDuRetenu(charge) { + if (retenuAjuste(charge)) return null; + return charge.propositions.find(({ id }) => id === charge.retenu.proposition); +} + +// La génération d'où vient la proposition, telle que la page la lit : +// resume est ce que generationDe rend, null hors de la séance qui l'a +// produite. La cadence est la somme des mouvements évalués de la génération +// par seconde de sa durée, arrondie à l'entier ; une durée nulle, ou qui +// n'est pas un nombre positif, n'a ni durée ni cadence. +function generationDuDetail(resume) { + if (resume === null) return { dureeMs: null, coeurs: null, cadence: null, nombre: null }; + const { dureeMs, coeurs, evalues, nombre } = resume; + const connue = typeof dureeMs === 'number' && dureeMs > 0; + return { + dureeMs: connue ? dureeMs : null, + coeurs, + cadence: connue ? Math.round((evalues * 1000) / dureeMs) : null, + nombre, + }; +} + +// La course mesurée : chaque point de relevé de course, son plan mesuré +// par mesurer sous l'instance de la charge reçue, puis le dernier point, à +// course.evalues, qui reçoit les valeurs du plan de la proposition, déjà +// mesurées (dernieres). Aucune valeur ne vient du compagnon ni d'une charge +// antérieure. +function courseMesuree({ source, origine, course, instance, aPriori, indicateurs, dernieres, resume }) { + const releves = course.releves.map(({ mouvements, plan }) => ({ + mouvements, + valeurs: valeursDe(indicateurs, mesurer(instance, indexerPlan(instance, plan)), aPriori), + })); + releves.push({ mouvements: course.evalues, valeurs: dernieres }); + let dernierChangement = releves[0].mouvements; + for (let i = 1; i < releves.length; i += 1) { + const avant = releves[i - 1].valeurs; + const apres = releves[i].valeurs; + if (indicateurs.some((nom) => avant[nom] !== apres[nom])) dernierChangement = releves[i].mouvements; + } + return { + source, + propositionDuRetenu: source === 'retenu' ? origine.id : null, + evalues: course.evalues, + arret: origine.arret, + indicateurs: [...indicateurs], + releves, + dernierChangement, + generation: generationDuDetail(resume), + }; +} + +// Une violation de la garde complétée de ce qu'elle désigne : participant, +// l'identifiant de son index de personne ; appartenance, le libellé de son +// index de groupe. +function violationNommee(instance, violation) { + const nommee = { ...violation }; + if (Object.hasOwn(violation, 'index')) nommee.participant = instance.ids[violation.index]; + if (Object.hasOwn(violation, 'groupe')) nommee.appartenance = instance.groupes[violation.groupe]; + return nommee; +} + +// Le détail du moteur (detailler) rendu en identifiants : chaque index de +// personne p, q, a, b devient l'identifiant de la personne, sous id quand +// il désignait la ligne, chaque index de table t devient table, +// l'identifiant de la table ; l'index de groupe g des collisions, que le +// libellé groupe double, se retire. Les tours restent des index, à partir +// de 0. +function enIdentifiants(instance, brut, critere) { + const { ids, idsTables } = instance; + const avecId = (element) => { + if (element === null) return null; + const { p, ...reste } = element; + return { id: ids[p], ...reste }; + }; + const toursEnTables = (tours) => tours.map(({ tour, t, ...reste }) => ({ tour, table: idsTables[t], ...reste })); + const paire = ({ a, b, fois, tours }) => ({ a: ids[a], b: ids[b], fois, tours: toursEnTables(tours) }); + const { bornes, objectifs, animateurs, matrice } = brut; + return { + bornes: { + rang1: avecId(bornes.rang1), + manque: avecId(bornes.manque), + diversite: avecId(bornes.diversite), + animateurs: avecId(bornes.animateurs), + objectifs: avecId(bornes.objectifs), + soiree: bornes.soiree, + }, + profil: brut.profil.map(avecId), + synthese: brut.synthese, + repartitionRencontres: brut.repartitionRencontres, + collisions: { + planchersValides: brut.collisions.planchersValides, + lignes: brut.collisions.lignes.map(({ g, ...reste }) => reste), + }, + tables: { + lignes: brut.tables.lignes.map(({ t, ...reste }) => ({ table: idsTables[t], ...reste })), + ordres: brut.tables.ordres, + }, + paires: { choisies: brut.paires.choisies.map(paire), imposees: brut.paires.imposees.map(paire) }, + matrice: matrice === null ? null : { ordre: matrice.ordre.map((p) => ids[p]), comptes: matrice.comptes }, + objectifs: + objectifs === null + ? null + : { + critere: { coche: critere.coche, sansObjet: critere.sansObjet }, + parTour: objectifs.parTour, + pireTour: objectifs.pireTour, + soiree: objectifs.soiree, + jamaisServies: objectifs.jamaisServies.map(avecId), + repartition: objectifs.repartition, + }, + animateurs: + animateurs === null + ? null + : { + profil: animateurs.profil === null ? null : animateurs.profil.map(avecId), + repartition: animateurs.repartition, + diversite: { + colonnes: animateurs.diversite.colonnes, + lignes: animateurs.diversite.lignes.map(({ p, parTour, ...reste }) => ({ + id: ids[p], + ...reste, + parTour: toursEnTables(parTour), + })), + equite: animateurs.diversite.equite, + }, + }, + }; +} + +// Le détail d'une source qui ne se compare pas : ses raisons, chaque champ +// de mesure à null. +function nonDetaille(id, statut, raisons) { + const detail = { id, statut, raisons }; + for (const champ of CHAMPS_DE_MESURE) detail[champ] = null; + detail.refusDessin = []; + return detail; +} + +// Le placement id de la charge : le retenu pour 'retenu', sinon la +// proposition de cet identifiant ; null sans lui. +function placementDe(charge, id) { + if (id === 'retenu') return charge.retenu; + return charge.propositions.find((proposition) => proposition.id === id) ?? null; +} + +/** + * Le détail d'un placement pour la page de qualité (§ 12, § 12.9, D27) : la + * proposition id, ou le retenu pour 'retenu', mesuré pour lui seul. + * + * La configuration figée et l'instance sont celles de la charge, comme dans + * evaluer ; le placement se valide une fois (fautes), puis se lit sans + * seconde validation. Fautif ou en dérive, il rend ses raisons et chaque + * champ de mesure à null. Sinon : le diagnostic une fois ; les planchers + * valent sur un plan qui assoit chacun à chaque tour et ne laisse aucune + * réservation non tenue (D31) ; la borne de diversité ; detailler, en une + * lecture du plan, la matrice seulement jusqu'à SEUIL_MATRICE_PARTICIPANTS + * personnes, dans l'ordre du profil ; puis la garde à quatre paramètres, + * planchers des collisions et des objectifs par tour compris (D42). + * + * La course (§ 12.14, D40) : celle de la proposition, ou, pour un retenu, + * celle de la proposition d'où il vient quand il en porte encore le + * placement et qu'elle est dans la liste ; aucune pour un retenu ajusté ou + * dont la proposition est effacée. courseDe(n) la rend pour la proposition + * n, contrôlée contre elle (seance.courseDe), ou null quand elle n'est pas + * gardée ; sans courseDe, aucune course. Chaque point de relevé se mesure + * par mesurer sous l'instance de la charge reçue, le dernier étant le plan + * de la proposition, au compte de ses mouvements évalués : ses valeurs sont + * celles de sa ligne d'évaluation. generationDe(n) rend la génération de la + * séance d'où vient la proposition n (generationDe du contrôleur), null + * hors d'elle. Ne lit jamais l'horloge, ne modifie pas la charge. + * + * borneDiversite et diagnostiquer remplacent, pour une épreuve, la borne + * de diversité et le diagnostic du moteur, comme diagnostiquer admet ses + * planchers : ce qu'ils rendent est ce que detailler et la garde lisent. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {number|'retenu'} id + * @param {{courseDe?: ((id: number) => ({evalues: number, releves: Array<{mouvements: number, + * plan: import('../moteur/types.js').Plan}>}|null))|null, + * generationDe?: ((id: number) => ({dureeMs: number, coeurs: number, evalues: number, + * nombre: number}|null))|null, + * plafondsAPriori?: 'calcul'|null, + * borneDiversite?: typeof borneDiversite, + * diagnostiquer?: typeof diagnostiquer}} [options] + * @returns {DetailQualite|null} null quand la charge n'a pas ce placement + * @throws {ErreurConfiguration} la configuration ne se normalise pas + * @throws {TypeError} une autre valeur de plafondsAPriori + */ +export function detaillerQualite(charge, id, options = {}) { + const { courseDe = null, generationDe = null, plafondsAPriori = 'calcul' } = options; + const { borneDiversite: borner = borneDiversite, diagnostiquer: diagnostiquerLa = diagnostiquer } = options; + exigerAPriori('detaillerQualite', plafondsAPriori); + const placement = placementDe(charge, id); + if (placement === null) return null; + const configuration = figerConfiguration(configurationDe(charge)); + const instance = normaliser(configuration); + const sesFautes = fautes(placement); + if (sesFautes.length > 0) return nonDetaille(id, 'fautif', sesFautes); + const raisons = deriveSansRevalider(placement, charge); + if (raisons.length > 0) return nonDetaille(id, 'derive', raisons); + + const diagnostic = diagnostiquerLa(configuration); + const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null; + const plan = planSansRevalider(placement); + const tableDe = indexerPlan(instance, plan); + const mesures = mesurer(instance, tableDe); + const realises = plafondsRealises(instance, tableDe); + const nonTenues = verifierInvariants(instance, plan, { reserveAutorisee: true }).filter(({ code }) => code === 'RESERVATION'); + const planchersValides = nonTenues.length === 0 && mesures.toursAssis.every((assis) => assis === instance.R); + const { phiAffilies, aMax } = borner(instance, tableDe); + const ordreMatrice = + instance.N <= constante(SEUIL_MATRICE.nom) + ? profilRencontres(instance, mesures, realises, aPriori, aMax).map(({ p }) => p) + : null; + const brut = detailler(instance, tableDe, { + diagnostic, + mesures, + plafondsRealises: realises, + plafondsAPriori: aPriori, + planchersValides, + cle: cleNormalisee, + ordreMatrice, + aMax, + }); + const refusDessin = verifierIndicateurs(mesures, aPriori, realises, { + planchersValides, + planchersCollisions: planchersParGroupe(instance, diagnostic.collisions), + objectifsParTour: brut.objectifs === null ? null : brut.objectifs.parTour.map(({ hors, plancher }) => ({ hors, plancher })), + phiAffilies, + aMax, + }).map((violation) => violationNommee(instance, violation)); + + const { criteresAppliques } = classer([{ id: 1, mesures, plafondsAPriori: aPriori }], compilationDe(instance)); + const indicateurs = indicateursDe(criteresAppliques); + const origine = id === 'retenu' ? origineDuRetenu(charge) : placement; + const course = origine === null || courseDe === null ? null : courseDe(origine.id); + const parId = new Map(charge.participants.map((personne) => [personne.id, personne])); + const numeroDe = new Map(charge.tables.map((table) => [table.id, table.numero])); + const critere = instance.etatCriteres.find(({ nom }) => nom === CRITERE_OBJECTIFS); + + return { + id, + statut: 'comparable', + raisons: [], + population: { mesuree: instance.N, exclue: charge.participants.filter(({ exclu }) => exclu).length }, + T: instance.T, + R: instance.R, + G: instance.groupes.length, + idsTables: [...instance.idsTables], + numerosTables: instance.idsTables.map((table) => numeroDe.get(table)), + personnes: instance.ids.map((personne, p) => ({ + id: personne, + nom: nomAffiche(parId.get(personne)), + appartenance: instance.groupe[p] === SANS_GROUPE ? null : instance.groupes[instance.groupe[p]], + statut: NOM_DU_STATUT.get(instance.statut[p]), + })), + propositionDuRetenu: id === 'retenu' && origine !== null ? origine.id : null, + ...enIdentifiants(instance, brut, critere), + course: + course === null + ? null + : courseMesuree({ + source: id === 'retenu' ? 'retenu' : 'proposition', + origine, + course, + instance, + aPriori, + indicateurs, + dernieres: valeursDe(indicateurs, mesures, aPriori), + resume: generationDe === null ? null : generationDe(origine.id), + }), + refusDessin, + }; +} + +/** + * La ligne d'une personne pour la rubrique « Rencontres » de sa fiche + * (§ 12.5, D33) : chaque personne qu'elle rencontre dans le placement id — + * une proposition, ou le retenu pour 'retenu', la source de la page de + * qualité —, avec le nombre de fois et les tours, les plus revues d'abord, + * puis par identifiant (rencontresDe). source est id ; statut : + * - sansPlacement : id null, ou la charge n'a pas ce placement ; + * - exclue : la personne est exclue (§ 4.4) ; + * - nonMesurable : le placement est fautif ou en dérive, ou la + * configuration ne se normalise pas ; + * - mesuree : lignes porte chaque rencontre, {id, nom, fois, tours: [{tour, + * table}]}, tour l'index du tour à partir de 0, table l'identifiant de la + * table. + * Hors de mesuree, lignes est vide. Ne modifie pas la charge. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {number|'retenu'|null} id + * @param {number} personne l'identifiant d'une personne de la charge + * @returns {{source: number|'retenu'|null, statut: 'mesuree'|'nonMesurable'|'exclue'|'sansPlacement', + * lignes: Array<{id: number, nom: string, fois: number, tours: Array<{tour: number, table: number}>}>}} + * @throws {RangeError} une personne que la charge n'a pas + */ +export function rencontresDePersonne(charge, id, personne) { + const fiche = charge.participants.find((candidate) => candidate.id === personne); + if (fiche === undefined) throw new RangeError(`rencontresDePersonne : personne ${personne} absente de la charge`); + const rendu = (statut, lignes = []) => ({ source: id, statut, lignes }); + const placement = id === null ? null : placementDe(charge, id); + if (placement === null) return rendu('sansPlacement'); + if (fiche.exclu) return rendu('exclue'); + if (fautes(placement).length > 0 || deriveSansRevalider(placement, charge).length > 0) return rendu('nonMesurable'); + let instance; + try { + instance = normaliser(configurationDe(charge)); + } catch (erreur) { + if (erreur instanceof ErreurConfiguration) return rendu('nonMesurable'); + throw erreur; + } + const tableDe = indexerPlan(instance, planSansRevalider(placement)); + const parId = new Map(charge.participants.map((element) => [element.id, element])); + const lignes = rencontresDe(instance, tableDe, instance.indexDe.get(personne)).map(({ q, fois, tours }) => ({ + id: instance.ids[q], + nom: nomAffiche(parId.get(instance.ids[q])), + fois, + tours: tours.map(({ tour, t }) => ({ tour, table: instance.idsTables[t] })), + })); + return rendu('mesuree', lignes); +} diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js index 9b8ba02..9e73f9b 100644 --- a/src/application/evaluation.test.js +++ b/src/application/evaluation.test.js @@ -13,6 +13,8 @@ // clé de référence privée des critères que les cases décochées retirent, // relue sur les grandeurs du fichier des empreintes (contrainte 33). import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { parse } from 'svelte/compiler'; import { CASES, CLASSEMENT, @@ -21,30 +23,44 @@ import { exigerVerification, lireEmpreintes, } from '../../test/empreintes_moteur.js'; +import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; import { texteLivre } from '../demo/livrees.js'; +import { constante } from '../geometrie/constantes.js'; import { classer } from '../moteur/classement.js'; -import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { SANS_GROUPE, STATUT, indexerPlan, normaliser } from '../moteur/configuration.js'; import { compilationDe } from '../moteur/criteres.js'; import { diagnostiquer } from '../moteur/diagnostic.js'; +import * as DIVERSITE from '../moteur/diversite.js'; import { ErreurConfiguration } from '../moteur/erreurs.js'; import { mesurer } from '../moteur/indicateurs.js'; import { ecartsAuPlafondAPriori } from '../moteur/manque.js'; -import { plafondsAPriori } from '../moteur/plafond.js'; +import * as PAIRES from '../moteur/paires.js'; +import { borneDiversite, plafondsAPriori } from '../moteur/plafond.js'; +import * as QUALITE from '../moteur/qualite.js'; import { rechercher } from '../moteur/recherche.js'; -import { analyser, capacite } from '../stockage/document.js'; -import { fautes, planDepuisFichier } from '../stockage/placements.js'; +import { avecCourses, courseDe } from '../stockage/courses.js'; +import { creer as creerDansLeDepot } from '../stockage/depot.js'; +import { analyser, capacite, creerCharge } from '../stockage/document.js'; +import { deriveSansRevalider, fautes, planDepuisFichier } from '../stockage/placements.js'; import { VERSION } from '../version.genere.js'; import { appliquerCommande } from './commandes.js'; import { configurationDe } from './configuration.js'; import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance, + detaillerQualite, evaluer, premiereDuClassement, + rencontresDePersonne, resumeDuMeilleur, resumeDuRetenu, } from './evaluation.js'; +import { creerControleurGeneration, creerExecuteurDirect } from './generation.js'; +import { creerHorlogePilotee } from './horloge.js'; +import { libelle } from './libelles.js'; +import { nomAffiche } from './personnes.js'; +import { creerSeance } from './seance.js'; const V1 = VERSION.affichee; @@ -750,3 +766,840 @@ describe('évaluation : le mode ne touche pas au classement de référence, sans }); } }); + +// --- Le détail d'un placement (§ 12.9, D27, D29, D40, D42, B23) --------- + +// La génération du moteur, enregistrée par la commande, et ses courses en +// mémoire sous la clé de leur proposition, comme la séance les garde. +function genereeAvecCourses(charge, reglages) { + const configuration = configurationDe(charge); + const propositions = rechercher(configuration, reglages); + const rendu = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }); + return { charge: rendu.charge, courses: avecCourses(new Map(), rendu.courses) }; +} + +// La lecture des courses d'une charge, telle que seance.courseDe la fait. +const lecteurDe = (charge, courses) => (id) => courseDe({ charge, courses }, id); + +// L'ordre réglé de l'étape 1 : l'excédent et les objectifs, pondérés à 3, +// partagent un palier. +const ORDRE_REGLE = [ + [e('ecartAuPlafondAPrioriMax')], + [e('excedentCollisions'), e('toursHorsObjectif', 3)], + [e('collisionsCumulees')], + [e('rencontresRepetees')], + [e('animateursNonRencontres')], + [e('redondance')], +]; + +// Les noms des valeurs d'un point de relevé : le minimum brut, puis chaque +// grandeur que les critères appliqués de la table classent, dans leur ordre. +const indicateursDe = (table) => [ + 'minimumBrut', + ...table.classement.criteresAppliques.flatMap(({ criteres }) => criteres.flatMap(({ valeurs }) => valeurs)), +]; + +// Les valeurs attendues d'un point, lues une à une dans mesurer. +function valeursAttendues(table, mesures, aPriori) { + const ecarts = ecartsAuPlafondAPriori(mesures, aPriori); + const lire = { + minimumBrut: () => mesures.aggRencontres.tous.min, + ecartAuPlafondAPrioriMax: () => (ecarts === null || ecarts.length === 0 ? null : Math.max(...ecarts)), + excedentCollisions: () => mesures.excedentCollisions, + collisionsCumulees: () => mesures.collisionsCumulees, + toursHorsObjectif: () => mesures.objectifs.horsObjectif, + jamaisServies: () => mesures.objectifs.jamaisServies, + horsObjectifMax: () => mesures.objectifs.horsObjectifMax, + rencontresRepeteesChoisies: () => mesures.rencontresRepetees.choisies, + retoursChoisis: () => mesures.totalRetoursChoisis, + animateursNonRencontres: () => mesures.animateurs.nonRencontres, + redondance: () => mesures.totalRedondance, + }; + return Object.fromEntries(indicateursDe(table).map((nom) => [nom, lire[nom]()])); +} + +// Lève sur un tableau typé atteint depuis la valeur, à toute profondeur. +function sansTableauType(valeur, chemin = 'detail') { + assert.equal(ArrayBuffer.isView(valeur), false, `${chemin} : tableau typé`); + if (valeur !== null && typeof valeur === 'object') { + for (const [cle, element] of Object.entries(valeur)) sansTableauType(element, `${chemin}.${cle}`); + } +} + +// Les champs de mesure d'un détail, null quand la source ne se mesure pas. +const CHAMPS_DE_MESURE = [ + 'population', 'T', 'R', 'G', 'idsTables', 'numerosTables', 'personnes', 'bornes', 'profil', 'synthese', + 'repartitionRencontres', 'collisions', 'tables', 'paires', 'matrice', 'objectifs', 'animateurs', 'course', +]; + +// Une charge de n personnes inventées, deux appartenances, des tables de +// cinq places, deux tours. +function chargeDeN(n) { + const charge = creerCharge({ id: `evt-matrice-${n}`, nom: 'Atelier Ressac', siegesParDefaut: 5, tours: 2 }); + charge.participants = Array.from({ length: n }, (_, rang) => ({ + id: rang + 1, + nom: `Ombrelle ${rang + 1}`, + prenom: 'Iris', + appartenance: rang % 3 === 0 ? 'Coopérative Aubier' : 'Atelier Ressac', + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, + })); + const tables = Math.ceil(n / 5); + charge.tables = Array.from({ length: tables }, (_, rang) => ({ + id: rang + 1, + numero: rang + 1, + sieges: null, + forme: 'ronde', + position: { x: 300 * rang, y: 0 }, + })); + charge.prochainsIds = { participant: n + 1, table: tables + 1, proposition: 1 }; + return charge; +} + +// Les noms que lit un nœud d'analyse : chaque identifiant, hors de la clé +// nommée d'une propriété et du membre nommé d'un accès, qui ne lisent +// aucune liaison. Le membre nommé d'un identifiant s'ajoute sous +// « objet.membre », pour les imports d'espace de noms. +function nomsLus(noeud, lus = new Set()) { + if (noeud === null || typeof noeud !== 'object') return lus; + if (Array.isArray(noeud)) { + for (const enfant of noeud) nomsLus(enfant, lus); + return lus; + } + if (noeud.type === 'Identifier') lus.add(noeud.name); + for (const [champ, valeur] of Object.entries(noeud)) { + if (champ === 'loc' || champ === 'metadata' || champ === 'parent') continue; + if (noeud.type === 'MemberExpression' && champ === 'property' && !noeud.computed) { + if (noeud.object.type === 'Identifier') lus.add(`${noeud.object.name}.${valeur.name}`); + } else if (!(noeud.type === 'Property' && champ === 'key' && !noeud.computed)) { + nomsLus(valeur, lus); + } + } + return lus; +} + +// Ce qu'atteint, dans le texte d'un module, la liaison de premier niveau +// depart : chaque liaison de premier niveau qu'elle lit, de proche en +// proche — fonction, classe ou variable, exportée ou non —, et le nom +// importé de chaque import qu'une d'elles lit, directement ou par le membre +// d'un espace de noms. Un nom local qui en masque un de premier niveau +// compte comme lu : l'approximation ne laisse passer aucun appel. +function atteintDepuis(texte, depart) { + const programme = parse(``, { modern: true }).module.content; + const lit = new Map(); + const importe = new Map(); + for (const instruction of programme.body) { + const declaration = instruction.type.startsWith('Export') && instruction.declaration ? instruction.declaration : instruction; + if (declaration.type === 'ImportDeclaration') { + for (const { type, local, imported } of declaration.specifiers) importe.set(local.name, type === 'ImportSpecifier' ? imported.name : '*'); + } else if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') { + lit.set(declaration.id.name, nomsLus(declaration)); + } else if (declaration.type === 'VariableDeclaration') { + for (const { id, init } of declaration.declarations) for (const nom of nomsLus(id)) lit.set(nom, nomsLus(init)); + } + } + assert.ok(lit.has(depart), depart); + const liaisons = new Set([depart]); + const importes = new Set(); + const aVoir = [depart]; + while (aVoir.length > 0) { + for (const nom of lit.get(aVoir.pop())) { + const [objet, membre] = nom.split('.'); + if (membre !== undefined) { + if (importe.get(objet) === '*') importes.add(membre); + } else if (importe.has(nom)) { + importes.add(importe.get(nom)); + } else if (lit.has(nom) && !liaisons.has(nom)) { + liaisons.add(nom); + aVoir.push(nom); + } + } + } + return { liaisons, importes }; +} + +// Les fonctions du moteur propres au détail (contrainte 41) : chaque export +// de qualite.js, paires.js et diversite.js, la borne de diversité et les +// occupations. +const FONCTIONS_DU_DETAIL = new Set([ + ...Object.keys(QUALITE), + ...Object.keys(PAIRES), + ...Object.keys(DIVERSITE), + 'borneDiversite', + 'occupations', +]); + +describe('detaillerQualite : une seule arithmétique (§ 12.9, § 13.2, contrainte 41)', () => { + test("la ligne d'évaluation et le détail d'une même proposition portent les mêmes rencontres, collisions, excédent et tours hors objectif", () => { + const { charge } = genereeAvecCourses(chargeAObjectifs(), { graine: 3, nombre: 3, arret: 300 }); + figer(charge); + const table = evaluer(charge); + const instance = normaliser(configurationDe(charge)); + assert.equal(table.propositions.length, 3); + for (const ligne of table.propositions) { + const detail = detaillerQualite(charge, ligne.id); + assert.equal(detail.statut, 'comparable'); + assert.equal(detail.id, ligne.id); + const rang = new Map(detail.profil.map((element) => [element.id, element])); + instance.ids.forEach((id, p) => { + assert.equal(rang.get(id).rencontres, ligne.mesures.rencontres[p], `${ligne.id}, ${id}`); + assert.equal(rang.get(id).plafondRealise, ligne.plafondsRealises[p], `${ligne.id}, ${id}`); + }); + for (const { groupe, cumulees, excedent, pairesDistinctes } of detail.collisions.lignes) { + const mesure = ligne.mesures.parGroupe.find((element) => element.groupe === groupe); + assert.deepEqual([cumulees, excedent, pairesDistinctes], [mesure.collisionsCumulees, mesure.excedent, mesure.pairesDistinctes]); + } + const somme = (cle) => detail.collisions.lignes.reduce((total, element) => total + element[cle], 0); + assert.equal(somme('excedent'), ligne.mesures.excedentCollisions); + assert.equal(somme('cumulees'), ligne.mesures.collisionsCumulees); + assert.equal(detail.objectifs.soiree.hors, ligne.mesures.objectifs.horsObjectif); + assert.equal(detail.bornes.soiree.jamaisServies, ligne.mesures.objectifs.jamaisServies); + assert.deepEqual(detail.objectifs.critere, { coche: true, sansObjet: false }); + assert.deepEqual(detail.refusDessin, []); + assert.equal(detail.synthese.minimum, ligne.mesures.aggRencontres.tous.min); + } + }); + + test("identifiants et noms : personnes dans l'ordre canonique, tables par identifiant, aucun index", () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 200 }); + const instance = normaliser(configurationDe(charge)); + const detail = detaillerQualite(figer(charge), 1); + const nommee = new Map(charge.participants.map((personne) => [personne.id, personne])); + assert.deepEqual( + detail.personnes.map(({ id, nom }) => [id, nom]), + instance.ids.map((id) => [id, nomAffiche(nommee.get(id))]), + ); + assert.deepEqual(detail.idsTables, [...instance.idsTables]); + assert.deepEqual(detail.numerosTables, detail.idsTables.map((id) => charge.tables.find((table) => table.id === id).numero)); + assert.deepEqual([detail.T, detail.R, detail.G], [instance.T, instance.R, instance.groupes.length]); + assert.deepEqual(detail.population, { mesuree: instance.N, exclue: 0 }); + const ids = new Set(instance.ids); + for (const { id } of detail.profil) assert.ok(ids.has(id)); + assert.ok(detail.tables.lignes.every(({ table }) => detail.idsTables.includes(table))); + for (const { a, b, tours } of [...detail.paires.choisies, ...detail.paires.imposees]) { + assert.ok(ids.has(a) && ids.has(b)); + assert.ok(tours.every(({ table }) => detail.idsTables.includes(table))); + } + assert.ok(ids.has(detail.bornes.rang1.id) && ids.has(detail.bornes.manque.id) && ids.has(detail.bornes.diversite.id)); + for (const element of [detail.profil[0], detail.bornes.rang1]) assert.equal(Object.hasOwn(element, 'p'), false); + assert.ok(detail.collisions.lignes.every((ligne) => !Object.hasOwn(ligne, 'g'))); + }); + + test("chaque personne en entier — appartenance ou null, statut ancré, partiel ou mobile — et le numéro de chaque table, distinct de son identifiant", () => { + const charge = structuredClone(chargeDemo('petite-conflit')); + const [premiere, seconde] = [...charge.tables].sort((a, b) => a.id - b.id); + // Les numéros dans l'ordre inverse des identifiants, décalés de dix : + // aucun numéro n'égale l'identifiant de sa table. + charge.tables.forEach((table, rang) => { + table.numero = 10 + charge.tables.length - rang; + }); + charge.participants.find(({ id }) => id === 1).appartenance = null; + charge.reservations.push({ participant: 2, table: premiere.id, siege: null, portee: 'tous', tour: null }); + charge.reservations.push({ participant: 3, table: seconde.id, siege: null, portee: 'tour', tour: 1 }); + const generation = figer(generee(charge, { graine: 2, nombre: 1, arret: 200 })); + const instance = normaliser(configurationDe(generation)); + const detail = detaillerQualite(generation, 1); + assert.equal(detail.statut, 'comparable'); + const nomDuStatut = new Map([ + [STATUT.MOBILE, 'mobile'], + [STATUT.PARTIELLEMENT_FIXE, 'partiel'], + [STATUT.ANCRE, 'ancre'], + ]); + const nommee = new Map(generation.participants.map((personne) => [personne.id, personne])); + assert.deepEqual( + detail.personnes, + instance.ids.map((id, p) => ({ + id, + nom: nomAffiche(nommee.get(id)), + appartenance: instance.groupe[p] === SANS_GROUPE ? null : instance.groupes[instance.groupe[p]], + statut: nomDuStatut.get(instance.statut[p]), + })), + ); + const parId = new Map(detail.personnes.map((personne) => [personne.id, personne])); + assert.deepEqual( + [1, 2, 3, 4].map((id) => [parId.get(id).appartenance, parId.get(id).statut]), + [ + [null, 'mobile'], + [nommee.get(2).appartenance, 'ancre'], + [nommee.get(3).appartenance, 'partiel'], + [nommee.get(4).appartenance, 'mobile'], + ], + ); + assert.deepEqual(detail.idsTables, [...instance.idsTables]); + assert.deepEqual(detail.numerosTables, detail.idsTables.map((id) => generation.tables.find((table) => table.id === id).numero)); + assert.ok(detail.numerosTables.every((numero, rang) => numero !== detail.idsTables[rang])); + }); + + test("le détail ne rend que des listes et des objets simples : figer ne lève pas, aucun tableau typé", () => { + const { charge, courses } = genereeAvecCourses(chargeAObjectifs(), { graine: 3, nombre: 1, arret: 300 }); + const detail = detaillerQualite(figer(charge), 1, { courseDe: lecteurDe(charge, courses) }); + assert.notEqual(detail.course, null); + sansTableauType(detail); + assert.doesNotThrow(() => figer(detail)); + }); + + test("le détail ne se calcule jamais dans evaluer : ses lignes gardent leurs champs, et rien de ce qu'evaluer atteint n'appelle une fonction du détail", () => { + const { charge } = genereeAvecCourses(chargeAObjectifs(), { graine: 3, nombre: 2, arret: 200 }); + const retenue = figer(executer(charge, 'retenirProposition', { id: 1 })); + const table = evaluer(retenue); + const CHAMPS = ['id', 'statut', 'raisons', 'violations', 'mesures', 'plafondsRealises', 'troisChiffres', 'ecartAuPlafondAPrioriMax', 'minimumAtteint', 'refusDessin']; + for (const ligne of [...table.propositions, table.retenu]) assert.deepEqual(Object.keys(ligne), CHAMPS); + // Le comptage des appels se fait sur le texte du module : la fermeture + // de ce qu'evaluer atteint, ligneDe comprise et chaque fonction du + // module qu'elle appelle, n'importe aucune fonction du détail et + // n'atteint ni detaillerQualite ni rencontresDePersonne. + const texte = readFileSync(new URL('./evaluation.js', import.meta.url), 'utf8'); + const deEvaluer = atteintDepuis(texte, 'evaluer'); + assert.ok(deEvaluer.importes.has('mesurer') && deEvaluer.liaisons.has('plusGrand'), 'la fermeture voit les appels de ligneDe'); + assert.deepEqual([...deEvaluer.importes].filter((nom) => FONCTIONS_DU_DETAIL.has(nom)), []); + assert.deepEqual([...deEvaluer.liaisons].filter((nom) => nom === 'detaillerQualite' || nom === 'rencontresDePersonne'), []); + const duDetail = atteintDepuis(texte, 'detaillerQualite'); + for (const nom of ['detailler', 'borneDiversite', 'profilRencontres', 'planchersParGroupe']) assert.ok(duDetail.importes.has(nom), nom); + }); +}); + +// Le diagnostic du moteur dont chaque plancher de collisions et chaque +// plancher des objectifs par tour vaut plancher, pour une épreuve de la +// garde. +const planchersRelevesA = (plancher) => (configuration) => { + const diagnostic = diagnostiquer(configuration); + for (const ligne of diagnostic.collisions) ligne.plancherCumulees = plancher; + diagnostic.objectifsInevitables.parTour.fill(plancher); + return diagnostic; +}; + +describe('detaillerQualite : la garde à quatre paramètres (§ 12.3, D31, D42)', () => { + test('un plan qui assoit chacun à chaque tour et tient ses réservations : planchers valides, segments prouvés', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 300 }); + const detail = detaillerQualite(figer(charge), 1); + assert.equal(detail.collisions.planchersValides, true); + assert.ok(detail.collisions.lignes.every(({ prouve, reste }) => prouve !== null && reste !== null && reste >= 0)); + }); + + test("un retenu ajusté qui laisse une personne en réserve : planchers non valides, aucun RESTE_NEGATIF ni RESTE_OBJECTIFS_NEGATIF", () => { + const charge = chargeAObjectifs(); + // Le plan parfait de la petite démonstration, la personne 10 en réserve + // aux tours 2 à 4 : une collision, sous le plancher de quatre. + 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 proposition = propositionDe(charge, 1, tours, [[], [10], [10], [10]]); + charge.propositions = [proposition]; + charge.retenu = retenuDe(proposition); + charge.prochainsIds.proposition = 2; + charge.evenement.etat = 'retenu'; + figer(charge); + for (const id of [1, 'retenu']) { + // Les planchers relevés au-delà de toute mesure : sur ce plan, où ils + // ne valent pas, la garde ne les lit pas. + const detail = detaillerQualite(charge, id, { diagnostiquer: planchersRelevesA(1000) }); + assert.equal(detail.statut, 'comparable', `${id}`); + assert.equal(detail.collisions.planchersValides, false, `${id}`); + assert.ok(detail.collisions.lignes.every(({ prouve, reste }) => prouve === null && reste === null), `${id}`); + const codes = detail.refusDessin.map(({ code }) => code); + assert.equal(codes.includes('RESTE_NEGATIF'), false, `${id}`); + assert.equal(codes.includes('RESTE_OBJECTIFS_NEGATIF'), false, `${id}`); + } + assert.equal(detaillerQualite(charge, 'retenu').course, null); + }); + + test('une réservation que le plan ne tient pas : planchers non valides', () => { + const avant = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + const [proposition] = avant.propositions; + const tableDuPremier = proposition.tables.find((_, rang) => !proposition.placement[0].sieges[rang].includes(1)); + const apres = structuredClone(avant); + apres.reservations.push({ participant: 1, table: tableDuPremier, siege: null, portee: 'tour', tour: 1 }); + assert.equal(evaluer(apres).propositions[0].violations.length, 1); + const detail = detaillerQualite(figer(apres), proposition.id); + assert.equal(detail.statut, 'comparable'); + assert.equal(detail.collisions.planchersValides, false); + }); + + test('une borne de diversité dépassée nomme la personne : BORNE_DIVERSITE, son index et son identifiant', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 300 }); + const instance = normaliser(configurationDe(charge)); + let visee = null; + // Une borne falsifiée : celle du calcul, moins une pour la première + // personne qui rencontre au moins une appartenance. + const falsifiee = (inst, tableDe) => { + const borne = borneDiversite(inst, tableDe); + const vues = mesurer(inst, tableDe).appartenancesVues; + visee = vues.findIndex((a) => a > 0); + borne.aMax[visee] = vues[visee] - 1; + return borne; + }; + const detail = detaillerQualite(figer(charge), 1, { borneDiversite: falsifiee }); + assert.notEqual(visee, null); + assert.deepEqual( + detail.refusDessin.filter(({ code }) => code === 'BORNE_DIVERSITE'), + [{ code: 'BORNE_DIVERSITE', index: visee, participant: instance.ids[visee] }], + ); + assert.deepEqual(detaillerQualite(charge, 1).refusDessin, []); + }); + + test("un plafond des affiliés dépassé nomme la personne : AFFILIES_PLAFOND, son index et son identifiant", () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 300 }); + const instance = normaliser(configurationDe(charge)); + let visee = null; + // Une borne falsifiée : Φ_aff de la première personne qui rencontre un + // affilié au moins, un de moins que ses affiliés rencontrés ; A_max + // celle du calcul. + const falsifiee = (inst, tableDe) => { + const borne = borneDiversite(inst, tableDe); + const { affilies } = mesurer(inst, tableDe); + visee = affilies.findIndex((f) => f > 0); + borne.phiAffilies[visee] = affilies[visee] - 1; + return borne; + }; + const detail = detaillerQualite(figer(charge), 1, { borneDiversite: falsifiee }); + assert.notEqual(visee, null); + assert.deepEqual(detail.refusDessin, [{ code: 'AFFILIES_PLAFOND', index: visee, participant: instance.ids[visee] }]); + }); + + test("sur un plan où les planchers valent, un plancher au-dessus de la mesure : RESTE_NEGATIF nomme l'appartenance, RESTE_OBJECTIFS_NEGATIF le tour", () => { + const { charge } = genereeAvecCourses(chargeAObjectifs(), { graine: 3, nombre: 1, arret: 300 }); + figer(charge); + const instance = normaliser(configurationDe(charge)); + const juste = detaillerQualite(charge, 1); + assert.equal(juste.collisions.planchersValides, true); + assert.deepEqual(juste.refusDessin, []); + // Le plancher de la première appartenance que le diagnostic chiffre, et + // celui du premier tour qui a des places à servir, un au-dessus de ce + // que le plan mesure : le reste vaut −1, la plus petite violation. + const [{ groupe }] = diagnostiquer(configurationDe(charge)).collisions; + const { cumulees } = juste.collisions.lignes.find((ligne) => ligne.groupe === groupe); + const tour = juste.objectifs.parTour.findIndex(({ possibles }) => possibles > 0); + const { hors } = juste.objectifs.parTour[tour]; + const releves = (configuration) => { + const diagnostic = diagnostiquer(configuration); + diagnostic.collisions.find((ligne) => ligne.groupe === groupe).plancherCumulees = cumulees + 1; + diagnostic.objectifsInevitables.parTour[tour] = hors + 1; + return diagnostic; + }; + const detail = detaillerQualite(charge, 1, { diagnostiquer: releves }); + assert.equal(detail.collisions.planchersValides, true); + assert.deepEqual(detail.refusDessin, [ + { code: 'RESTE_NEGATIF', groupe: instance.groupes.indexOf(groupe), appartenance: groupe }, + { code: 'RESTE_OBJECTIFS_NEGATIF', tour: tour + 1 }, + ]); + // Au plancher même, le reste est nul et rien ne se lève. + const atteints = (configuration) => { + const diagnostic = diagnostiquer(configuration); + diagnostic.collisions.find((ligne) => ligne.groupe === groupe).plancherCumulees = cumulees; + diagnostic.objectifsInevitables.parTour[tour] = hors; + return diagnostic; + }; + assert.deepEqual(detaillerQualite(charge, 1, { diagnostiquer: atteints }).refusDessin, []); + }); +}); + +describe('detaillerQualite : les statuts et les sources (§ 9, § 12, B9)', () => { + test('une proposition en dérive rend ses raisons, et chaque champ de mesure à null', () => { + const { charge, courses } = genereeAvecCourses(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + const exclue = figer(executer(charge, 'exclureParticipant', { id: charge.participants[0].id })); + const detail = detaillerQualite(exclue, 1, { courseDe: lecteurDe(exclue, courses) }); + assert.equal(detail.statut, 'derive'); + assert.deepEqual(detail.raisons, evaluer(exclue).propositions[0].raisons); + for (const champ of CHAMPS_DE_MESURE) assert.equal(detail[champ], null, champ); + assert.deepEqual(detail.refusDessin, []); + }); + + test('une proposition fautive rend ses fautes', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 }); + const fautive = structuredClone(charge); + fautive.propositions[0].placement[0].reserve.push(fautive.propositions[0].participants[0]); + const detail = detaillerQualite(figer(fautive), 1); + assert.equal(detail.statut, 'fautif'); + assert.deepEqual(detail.raisons, fautes(fautive.propositions[0])); + assert.equal(detail.profil, null); + }); + + test("une personne exclue avant la génération : la population mesurée sans elle, l'exclue comptée à part", () => { + const exclue = executer(chargeDemo('petite-conflit'), 'exclureParticipant', { id: 5 }); + const detail = detaillerQualite(figer(generee(exclue, { graine: 2, nombre: 1, arret: 200 })), 1); + assert.equal(detail.statut, 'comparable'); + assert.deepEqual(detail.population, { mesuree: exclue.participants.length - 1, exclue: 1 }); + assert.equal(detail.personnes.some(({ id }) => id === 5), false); + }); + + test("le critère des objectifs décoché : objectifs.critere est l'état que l'instance en donne", () => { + const depart = chargeAObjectifs(); + depart.reglages.respecterObjectifs = false; + const charge = figer(generee(depart, { graine: 3, nombre: 1, arret: 200 })); + const { coche, sansObjet } = normaliser(configurationDe(charge)).etatCriteres.find(({ nom }) => nom === 'toursHorsObjectif'); + const detail = detaillerQualite(charge, 1); + assert.deepEqual(detail.objectifs.critere, { coche, sansObjet }); + assert.deepEqual(detail.objectifs.critere, { coche: false, sansObjet: false }); + }); + + test('null pour une proposition absente ou sans retenu ; plafondsAPriori ni « calcul » ni null lève', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 50 }); + assert.equal(detaillerQualite(charge, 99), null); + assert.equal(detaillerQualite(charge, 'retenu'), null); + assert.throws(() => detaillerQualite(charge, 1, { plafondsAPriori: [] }), TypeError); + }); + + test("plafond a priori inconnu : le profil sans a priori, et l'écart des points de relevé absent", () => { + const { charge, courses } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 5, nombre: 1, arret: 300 }); + const detail = detaillerQualite(figer(charge), 1, { courseDe: lecteurDe(charge, courses), plafondsAPriori: null }); + assert.ok(detail.profil.every(({ plafondAPriori }) => plafondAPriori === null)); + assert.equal(detail.course.indicateurs.includes('ecartAuPlafondAPrioriMax'), false); + assert.deepEqual(detail.course.indicateurs, indicateursDe(evaluer(charge, { plafondsAPriori: null }))); + }); + + test(`la matrice seulement jusqu'au seuil : ${constante('SEUIL_MATRICE_PARTICIPANTS')} personnes dans l'ordre du profil, une de plus aucune`, () => { + const seuil = constante('SEUIL_MATRICE_PARTICIPANTS'); + const auSeuil = detaillerQualite(figer(generee(chargeDeN(seuil), { graine: 1, nombre: 1, arret: 20 })), 1); + assert.deepEqual(auSeuil.matrice.ordre, auSeuil.profil.map(({ id }) => id)); + assert.equal(auSeuil.matrice.comptes.length, (seuil * (seuil - 1)) / 2); + const auDela = detaillerQualite(figer(generee(chargeDeN(seuil + 1), { graine: 1, nombre: 1, arret: 20 })), 1); + assert.equal(auDela.statut, 'comparable'); + assert.equal(auDela.matrice, null); + }); +}); + +// La petite démonstration au conflit inévitable où 1 et 2, réservés sans +// siège aux tables titrées 1 et 2 pour la soirée, animent à chaque tour, et +// la case des animateurs cochée : le critère des animateurs compte. +function chargeAAnimateurs() { + const charge = structuredClone(chargeDemo('petite-conflit')); + charge.titres = [1, 2].map((table) => ({ table, siege: 1, libelle: 'Animation' })); + charge.reservations = [1, 2].map((participant) => ({ participant, table: participant, siege: null, portee: 'tous', tour: null })); + charge.reglages.rencontrerAnimateurs = true; + return charge; +} + +// Lève quand un point de relevé de la course de chaque proposition ne porte +// pas, nom pour nom, les valeurs que valeursAttendues lit dans mesurer sur +// son plan, sous la charge reçue ; le dernier point est le plan de la +// proposition. Rend les valeurs de chaque point, proposition après +// proposition. +function pointsMesures(charge, courses, nom) { + const table = evaluer(charge); + const instance = normaliser(configurationDe(charge)); + const aPriori = diagnostiquer(configurationDe(charge)).plafondAPriori.parPersonne; + const points = []; + for (const proposition of charge.propositions) { + const course = courseDe({ charge, courses }, proposition.id); + const detail = detaillerQualite(charge, proposition.id, { courseDe: lecteurDe(charge, courses) }); + assert.deepEqual(detail.course.indicateurs, indicateursDe(table), nom); + const plans = [...course.releves.map(({ plan }) => plan), planDepuisFichier(proposition)]; + assert.equal(detail.course.releves.length, plans.length, nom); + plans.forEach((plan, i) => { + const mesures = mesurer(instance, indexerPlan(instance, plan)); + assert.deepEqual(detail.course.releves[i].valeurs, valeursAttendues(table, mesures, aPriori), `${nom}, ${proposition.id}, point ${i}`); + points.push({ valeurs: detail.course.releves[i].valeurs, mesures }); + }); + } + return { indicateurs: indicateursDe(table), points }; +} + +describe('detaillerQualite : la course de la recherche (§ 5.10, § 12.14, D40, B23)', () => { + test("chaque point de relevé est mesuré par mesurer sur son plan, sous la charge reçue ; le dernier, aux mouvements évalués, est la ligne de la proposition", () => { + const { charge, courses } = genereeAvecCourses(chargeAObjectifs(), { graine: 4, nombre: 2, arret: 400 }); + figer(charge); + const table = evaluer(charge); + const instance = normaliser(configurationDe(charge)); + const aPriori = diagnostiquer(configurationDe(charge)).plafondAPriori.parPersonne; + for (const proposition of charge.propositions) { + const course = courseDe({ charge, courses }, proposition.id); + const detail = detaillerQualite(charge, proposition.id, { courseDe: lecteurDe(charge, courses) }); + assert.equal(detail.course.source, 'proposition'); + assert.equal(detail.course.propositionDuRetenu, null); + assert.deepEqual([detail.course.evalues, detail.course.arret], [course.evalues, proposition.arret]); + assert.deepEqual(detail.course.indicateurs, indicateursDe(table)); + assert.deepEqual( + detail.course.releves.map(({ mouvements }) => mouvements), + [...course.releves.map(({ mouvements }) => mouvements), course.evalues], + ); + const plans = [...course.releves.map(({ plan }) => plan), planDepuisFichier(proposition)]; + plans.forEach((plan, i) => { + const attendu = valeursAttendues(table, mesurer(instance, indexerPlan(instance, plan)), aPriori); + assert.deepEqual(detail.course.releves[i].valeurs, attendu, `${proposition.id}, point ${i}`); + }); + const ligne = table.propositions.find(({ id }) => id === proposition.id); + assert.deepEqual(detail.course.releves.at(-1).valeurs, valeursAttendues(table, ligne.mesures, aPriori)); + assert.equal(detail.course.releves.at(-1).valeurs.excedentCollisions, ligne.mesures.excedentCollisions); + assert.ok(detail.course.releves.every((point) => Object.keys(point).join() === 'mouvements,valeurs'), 'aucune durée par point'); + } + }); + + test("sous l'ordre réglé de l'étape 1, paliers et poids, chaque valeur est la grandeur brute, jamais la clé pondérée d'un palier", () => { + const { charge, courses } = genereeAvecCourses(chargeAObjectifs({ priorites: ORDRE_REGLE }), { graine: 4, nombre: 1, arret: 400 }); + figer(charge); + const table = evaluer(charge); + const [ligne] = table.propositions; + const detail = detaillerQualite(charge, 1, { courseDe: lecteurDe(charge, courses) }); + assert.deepEqual(detail.course.indicateurs, [ + 'minimumBrut', + 'ecartAuPlafondAPrioriMax', + 'excedentCollisions', + 'toursHorsObjectif', + 'collisionsCumulees', + 'rencontresRepeteesChoisies', + 'redondance', + ]); + for (const { valeurs } of detail.course.releves) assert.deepEqual(Object.keys(valeurs), detail.course.indicateurs); + const dernier = detail.course.releves.at(-1).valeurs; + assert.equal(dernier.toursHorsObjectif, ligne.mesures.objectifs.horsObjectif); + assert.equal(dernier.excedentCollisions, ligne.mesures.excedentCollisions); + assert.ok(ligne.mesures.objectifs.horsObjectif > 0); + }); + + test("chaque lecture des valeurs, sous les modes chacun et minimum, sans « nouveaux voisins » et les animateurs comptés : chaque point est mesuré par mesurer", () => { + const TETE = ['minimumBrut', 'ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees']; + const sansVoisins = structuredClone(chargeDemo('petite-conflit')); + sansVoisins.reglages.nouveauxVoisins = false; + const cas = [ + ['chacun', chargeAObjectifs({ mode: 'chacun' }), [...TETE, 'jamaisServies', 'toursHorsObjectif', 'rencontresRepeteesChoisies', 'redondance']], + ['minimum', chargeAObjectifs({ mode: 'minimum' }), [...TETE, 'horsObjectifMax', 'toursHorsObjectif', 'rencontresRepeteesChoisies', 'redondance']], + ['sans nouveaux voisins', sansVoisins, [...TETE, 'retoursChoisis', 'redondance']], + ['animateurs', chargeAAnimateurs(), [...TETE, 'rencontresRepeteesChoisies', 'animateursNonRencontres', 'redondance']], + ]; + // La grandeur que chaque cas lit seul, et une voisine qu'une lecture + // fautive pourrait rendre à sa place : elles diffèrent à un point au + // moins, sans quoi la comparaison ne départagerait pas les deux. + const temoins = { + chacun: ({ valeurs }) => valeurs.jamaisServies !== valeurs.toursHorsObjectif, + minimum: ({ valeurs }) => valeurs.horsObjectifMax !== valeurs.toursHorsObjectif, + 'sans nouveaux voisins': ({ valeurs, mesures }) => valeurs.retoursChoisis !== mesures.rencontresRepetees.choisies, + animateurs: ({ valeurs }) => valeurs.animateursNonRencontres !== null, + }; + for (const [nom, depart, attendus] of cas) { + const { charge, courses } = genereeAvecCourses(depart, { graine: 4, nombre: 2, arret: 400 }); + const { indicateurs, points } = pointsMesures(figer(charge), courses, nom); + assert.deepEqual(indicateurs, attendus, nom); + assert.ok(points.length > charge.propositions.length, `${nom} : des points avant le dernier`); + assert.ok(points.some(temoins[nom]), `${nom} : le témoin départage`); + } + }); + + test('le dernier changement : le dernier point dont une valeur diffère du précédent, le premier quand rien ne change', () => { + const charge = structuredClone(chargeDemo('petite-conflit')); + charge.propositions = PLANS_CONFLIT.slice(0, 2).map(({ tours }, rang) => propositionDe(charge, rang + 1, tours)); + charge.prochainsIds.proposition = 3; + charge.evenement.etat = 'propose'; + figer(charge); + const [final, autre] = charge.propositions.map(planDepuisFichier); + const avec = (course) => detaillerQualite(charge, 1, { courseDe: () => course }).course; + const pointsDe = (plans, evalues) => ({ evalues, releves: plans.map((plan, i) => ({ mouvements: 10 ** i, plan })) }); + assert.equal(avec(pointsDe([autre, final, final], 400)).dernierChangement, 10); + assert.equal(avec(pointsDe([autre, autre, autre], 400)).dernierChangement, 400); + assert.equal(avec(pointsDe([final, final], 50)).dernierChangement, 1); + const sansMouvement = avec({ evalues: 0, releves: [] }); + assert.deepEqual(sansMouvement.releves.map(({ mouvements }) => mouvements), [0]); + assert.equal(sansMouvement.dernierChangement, 0); + }); + + test('la génération : durée, cœurs, cadence et nombre ; une durée nulle ou absente rend null partout où elle sert', () => { + const { charge, courses } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 2, arret: 300 }); + figer(charge); + const demandes = []; + const generationDe = (resume) => (id) => { + demandes.push(id); + return resume; + }; + const avec = (resume) => detaillerQualite(charge, 2, { courseDe: lecteurDe(charge, courses), generationDe: generationDe(resume) }).course.generation; + assert.deepEqual(avec({ dureeMs: 2000, coeurs: 2, evalues: 5000, nombre: 3 }), { dureeMs: 2000, coeurs: 2, cadence: 2500, nombre: 3 }); + // 2 mouvements en 3 ms : 666,67 par seconde, arrondi à l'entier le plus + // proche, et non tronqué. + assert.deepEqual(avec({ dureeMs: 3, coeurs: 1, evalues: 2, nombre: 1 }), { dureeMs: 3, coeurs: 1, cadence: 667, nombre: 1 }); + assert.deepEqual(avec({ dureeMs: 0, coeurs: 2, evalues: 5000, nombre: 3 }), { dureeMs: null, coeurs: 2, cadence: null, nombre: 3 }); + assert.deepEqual(avec(null), { dureeMs: null, coeurs: null, cadence: null, nombre: null }); + assert.deepEqual(demandes, [2, 2, 2, 2]); + const sansSeance = detaillerQualite(charge, 2, { courseDe: lecteurDe(charge, courses) }).course.generation; + assert.deepEqual(sansSeance, { dureeMs: null, coeurs: null, cadence: null, nombre: null }); + }); + + test('une proposition dont la course n’est pas gardée : course null', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 300 }); + figer(charge); + assert.equal(detaillerQualite(charge, 1).course, null); + assert.equal(detaillerQualite(charge, 1, { courseDe: () => null }).course, null); + assert.equal(detaillerQualite(charge, 1, { courseDe: lecteurDe(charge, new Map()) }).course, null); + }); + + test("le retenu non ajusté montre la course de sa proposition ; ajusté, ou sa proposition effacée, aucune", () => { + const { charge, courses } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 2, arret: 300 }); + const retenue = figer(executer(charge, 'retenirProposition', { id: 2 })); + const demandes = []; + const options = (sur) => ({ + courseDe: (id) => { + demandes.push(id); + return lecteurDe(sur, courses)(id); + }, + generationDe: () => ({ dureeMs: 1000, coeurs: 1, evalues: 700, nombre: 2 }), + }); + const proposition = detaillerQualite(retenue, 2, options(retenue)); + const duRetenu = detaillerQualite(retenue, 'retenu', options(retenue)); + assert.equal(duRetenu.id, 'retenu'); + assert.equal(duRetenu.propositionDuRetenu, 2); + assert.equal(duRetenu.course.source, 'retenu'); + assert.equal(duRetenu.course.propositionDuRetenu, 2); + assert.deepEqual(duRetenu.course.releves, proposition.course.releves); + assert.deepEqual(duRetenu.course.generation, { dureeMs: 1000, coeurs: 1, cadence: 700, nombre: 2 }); + assert.deepEqual(demandes, [2, 2]); + + const ajustee = structuredClone(retenue); + const [premiere, seconde] = ajustee.retenu.placement[0].sieges; + [premiere[0], seconde[0]] = [seconde[0], premiere[0]]; + premiere.sort((a, b) => a - b); + seconde.sort((a, b) => a - b); + figer(ajustee); + const detailAjuste = detaillerQualite(ajustee, 'retenu', options(ajustee)); + assert.equal(detailAjuste.statut, 'comparable'); + assert.deepEqual([detailAjuste.propositionDuRetenu, detailAjuste.course], [null, null]); + + const effacee = structuredClone(retenue); + effacee.propositions = []; + figer(effacee); + const detailEfface = detaillerQualite(effacee, 'retenu', options(effacee)); + assert.equal(detailEfface.statut, 'comparable'); + assert.deepEqual([detailEfface.propositionDuRetenu, detailEfface.course], [null, null]); + assert.deepEqual(demandes, [2, 2]); + assert.equal(detaillerQualite(retenue, 2, options(retenue)).propositionDuRetenu, null); + }); + + test("remesure : un objectif ou l'ordre des priorités changés après la génération ; chaque point se mesure sous la nouvelle charge, le dernier égale sa nouvelle ligne", () => { + const { charge, courses } = genereeAvecCourses(chargeAObjectifs(), { graine: 4, nombre: 1, arret: 400 }); + figer(charge); + const avant = detaillerQualite(charge, 1, { courseDe: lecteurDe(charge, courses) }); + const [, seconde] = [...charge.tables].sort((a, b) => a.id - b.id); + const changees = [ + ['priorités', executer(charge, 'changerPriorites', { paliers: ORDRE_REGLE })], + ['objectif', executer(charge, 'poserObjectif', { table: seconde.id, tour: null, etiquettes: 'finance', regle: null })], + ]; + for (const [nom, nouvelle] of changees) { + figer(nouvelle); + const table = evaluer(nouvelle); + const [ligne] = table.propositions; + assert.equal(ligne.statut, 'comparable', nom); + const instance = normaliser(configurationDe(nouvelle)); + const aPriori = diagnostiquer(configurationDe(nouvelle)).plafondAPriori.parPersonne; + const course = courseDe({ charge: nouvelle, courses }, 1); + const detail = detaillerQualite(nouvelle, 1, { courseDe: lecteurDe(nouvelle, courses) }); + assert.deepEqual(detail.course.indicateurs, indicateursDe(table), nom); + assert.deepEqual(detail.course.releves.at(-1).valeurs, valeursAttendues(table, ligne.mesures, aPriori), nom); + const plans = [...course.releves.map(({ plan }) => plan), planDepuisFichier(nouvelle.propositions[0])]; + plans.forEach((plan, i) => { + const attendu = valeursAttendues(table, mesurer(instance, indexerPlan(instance, plan)), aPriori); + assert.deepEqual(detail.course.releves[i].valeurs, attendu, `${nom}, point ${i}`); + }); + } + const [[, reglee], [, objectif]] = changees; + assert.notDeepEqual(detaillerQualite(reglee, 1, { courseDe: lecteurDe(reglee, courses) }).course.indicateurs, avant.course.indicateurs); + const horsAvant = avant.course.releves.map(({ valeurs }) => valeurs.toursHorsObjectif); + const horsApres = detaillerQualite(objectif, 1, { courseDe: lecteurDe(objectif, courses) }).course.releves.map(({ valeurs }) => valeurs.toursHorsObjectif); + assert.notDeepEqual(horsApres, horsAvant); + }); +}); + +// Le dossier des épreuves de la séance, et le jour de la création. +const DOSSIER = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' }; + +describe('detaillerQualite : aller-retour par le compagnon des courses (§ 8.6, B31, D53)', () => { + test("une génération de la séance, puis l'événement rouvert par une autre séance : les mêmes points, la durée inconnue", async () => { + const charge = structuredClone(chargeDemo('petite-conflit')); + charge.evenement.nom = 'Soirée des Aubiers'; + charge.evenement.id = 'evt-aubiers'; + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + await creerDansLeDepot(fs, DOSSIER, charge, { + libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }), + nomJalon: libelle('JALON_CREATION'), + horodatage: '2031-05-17T07:00:00-04:00', + produitVersion: V1, + }); + const horloge = creerHorlogePilotee(); + const seanceSur = (prefixe) => { + let tirage = 0; + return creerSeance({ + fs, + racine: DOSSIER, + horloge, + identifiant: () => `${prefixe}-${(tirage += 1)}`, + produitVersion: V1, + planifier: horloge.planifier, + annuler: horloge.annuler, + }); + }; + const seance = seanceSur('premiere'); + await seance.ouvrir(charge.evenement.nom); + await seance.passerEnEcriture(); + const controleur = creerControleurGeneration({ seance, executeur: creerExecuteurDirect(), horloge, produitVersion: V1 }); + controleur.subscribe(({ fait }) => { + if (fait > 0) horloge.avancer(7); + }); + await controleur.generer({ graine: 2, nombre: 2, arret: 2_000, historique: 1000 }); + const generation = controleur.generationDe(1); + assert.ok(generation.dureeMs > 0); + const dansLaSeance = detaillerQualite(seance.etat().charge, 1, { courseDe: seance.courseDe, generationDe: controleur.generationDe }); + assert.deepEqual(dansLaSeance.course.generation, { + dureeMs: generation.dureeMs, + coeurs: generation.coeurs, + cadence: Math.round((generation.evalues * 1000) / generation.dureeMs), + nombre: 2, + }); + + const autre = seanceSur('seconde'); + await autre.ouvrir(charge.evenement.nom); + const relue = autre.etat().charge; + assert.notEqual(relue, seance.etat().charge); + const autreControleur = creerControleurGeneration({ seance: autre, executeur: creerExecuteurDirect(), horloge, produitVersion: V1 }); + const rouverte = detaillerQualite(relue, 1, { courseDe: autre.courseDe, generationDe: autreControleur.generationDe }); + assert.deepEqual(rouverte.course.releves, dansLaSeance.course.releves); + assert.ok(rouverte.course.releves.length > 1); + assert.deepEqual(rouverte.course.generation, { dureeMs: null, coeurs: null, cadence: null, nombre: null }); + assert.deepEqual(rouverte.profil, dansLaSeance.profil); + }); +}); + +describe('rencontresDePersonne : la ligne de la fiche (§ 12.5, D33)', () => { + test('mesurée : chaque personne rencontrée, en identifiants et noms, comme rencontresDe ; du retenu comme de sa proposition', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 300 }); + const retenue = figer(executer(charge, 'retenirProposition', { id: 1 })); + const instance = normaliser(configurationDe(retenue)); + const tableDe = indexerPlan(instance, planDepuisFichier(retenue.propositions[0])); + const nommee = new Map(retenue.participants.map((personne) => [personne.id, personne])); + for (const personne of [1, 5, 12]) { + const attendues = PAIRES.rencontresDe(instance, tableDe, instance.indexDe.get(personne)).map(({ q, fois, tours }) => ({ + id: instance.ids[q], + nom: nomAffiche(nommee.get(instance.ids[q])), + fois, + tours: tours.map(({ tour, t }) => ({ tour, table: instance.idsTables[t] })), + })); + assert.ok(attendues.length > 0); + assert.deepEqual(rencontresDePersonne(retenue, 1, personne), { source: 1, statut: 'mesuree', lignes: attendues }); + assert.deepEqual(rencontresDePersonne(retenue, 'retenu', personne), { source: 'retenu', statut: 'mesuree', lignes: attendues }); + } + }); + + test('exclue, source non mesurable, sans placement ; une personne inconnue lève', () => { + const { charge } = genereeAvecCourses(chargeDemo('petite-conflit'), { graine: 2, nombre: 1, arret: 300 }); + const exclue = figer(executer(charge, 'exclureParticipant', { id: 5 })); + assert.deepEqual(rencontresDePersonne(exclue, 1, 5), { source: 1, statut: 'exclue', lignes: [] }); + assert.deepEqual(rencontresDePersonne(exclue, 1, 6), { source: 1, statut: 'nonMesurable', lignes: [] }); + assert.deepEqual(rencontresDePersonne(charge, null, 6), { source: null, statut: 'sansPlacement', lignes: [] }); + assert.deepEqual(rencontresDePersonne(charge, 99, 6), { source: 99, statut: 'sansPlacement', lignes: [] }); + assert.deepEqual(rencontresDePersonne(charge, 'retenu', 6), { source: 'retenu', statut: 'sansPlacement', lignes: [] }); + const fautive = structuredClone(charge); + fautive.propositions[0].placement[0].reserve.push(fautive.propositions[0].participants[0]); + assert.equal(rencontresDePersonne(figer(fautive), 1, 6).statut, 'nonMesurable'); + // Quatre réservations à une table de trois places, posées après la + // génération : le placement ne dérive pas, la configuration ne se + // normalise plus. + const surreservee = structuredClone(charge); + surreservee.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: charge.tables[0].id, siege: null, portee: 'tous', tour: null })); + figer(surreservee); + assert.deepEqual(fautes(surreservee.propositions[0]), []); + assert.deepEqual(deriveSansRevalider(surreservee.propositions[0], surreservee), []); + assert.throws(() => normaliser(configurationDe(surreservee)), (erreur) => erreur instanceof ErreurConfiguration); + assert.deepEqual(rencontresDePersonne(surreservee, 1, 6), { source: 1, statut: 'nonMesurable', lignes: [] }); + assert.throws(() => rencontresDePersonne(charge, 1, 999), RangeError); + }); +}); diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js index 36afd85..5ed9034 100644 --- a/src/interface/propositions/modele.js +++ b/src/interface/propositions/modele.js @@ -31,7 +31,7 @@ // 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 { evaluer, retenuAjuste } from '../../application/evaluation.js'; import { nomAffiche } from '../../application/personnes.js'; const VIDE = 'format.vide'; @@ -444,32 +444,9 @@ 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])); -} +// Le retenu ajusté se définit une fois, dans l'évaluation, que la page de +// qualité lit aussi : le tableau et la page s'accordent sur le même retenu. +export { retenuAjuste }; /** * La fenêtre à confirmer avant de retenir la proposition id (§ 2.1) : retenir diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js index 0a7ac93..dc31908 100644 --- a/src/interface/propositions/modele.test.js +++ b/src/interface/propositions/modele.test.js @@ -12,7 +12,7 @@ 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 { detaillerQualite, evaluer, retenuAjuste as retenuAjusteDeLEvaluation } from '../../application/evaluation.js'; import { creerFormat } from '../../application/format.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { nomAffiche } from '../../application/personnes.js'; @@ -1034,6 +1034,23 @@ describe('retenuAjuste (§ 2.1, § 5.9)', () => { sansOrigine.propositions = []; assert.equal(retenuAjuste(sansOrigine), true); }); + + test("le tableau et la page de qualité lisent la même définition : tel que retenu, ajusté, sa proposition effacée, la page ne montre la course que d'un retenu non ajusté", () => { + const retenue = executer(deTables(), 'retenirProposition', { id: 1 }); + const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }); + const sansOrigine = structuredClone(retenue); + sansOrigine.propositions = []; + assert.equal(retenuAjuste, retenuAjusteDeLEvaluation); + const lus = [retenue, deplacee, sansOrigine].map((charge) => { + const detail = detaillerQualite(charge, 'retenu', { courseDe: () => ({ evalues: 0, releves: [] }) }); + return [detail.statut, retenuAjuste(charge), detail.propositionDuRetenu === null, detail.course === null]; + }); + assert.deepEqual(lus, [ + ['comparable', false, false, false], + ['comparable', true, true, true], + ['comparable', true, true, true], + ]); + }); }); describe('demandeConfirmation (§ 2.1)', () => { diff --git a/src/interface/qualite/SectionQualite.svelte b/src/interface/qualite/SectionQualite.svelte index 2b2b38a..b7057a4 100644 --- a/src/interface/qualite/SectionQualite.svelte +++ b/src/interface/qualite/SectionQualite.svelte @@ -33,15 +33,18 @@ // Sinon, la liste des vues, dans leur ordre à l'écran, mène à chacune : // un lien porte le focus sur la vue, qui se fait voir. Puis les neuf vues, // chacune sous le contrat de propriétés {detail, ligne, table} — le - // détail du placement, la ligne d'évaluation de la source, la table - // d'évaluation de la charge. Le détail n'est pas encore calculé : detail - // vaut null. + // détail du placement (detailDe, modele.js), la ligne d'évaluation de la + // source, la table d'évaluation de la charge. Le détail se mesure pour la + // seule source mesurable, avec la course que la séance garde et la + // génération qui l'a produite ; sa garde à quatre paramètres donne les + // contradictions qui retirent le dessin. import { getContext } from 'svelte'; import Diagnostic from '../diagnostic/Diagnostic.svelte'; import Carte from '../primitives/Carte.svelte'; import Commande from '../primitives/Commande.svelte'; import Refus from '../primitives/Refus.svelte'; import { evaluation, nomDe } from '../propositions/modele.js'; + import { detailDe } from './modele.js'; import { sourceQualite } from './source.js'; import VueAnimateurs from './VueAnimateurs.svelte'; import VueCollisions from './VueCollisions.svelte'; @@ -56,6 +59,7 @@ const t = getContext('t'); const ui = getContext('ui'); const seance = getContext('seance'); + const generation = getContext('generation'); const uid = $props.id(); // Les vues, dans leur ordre à l'écran (§ 12, B9), chacune sous le nom de @@ -76,10 +80,14 @@ const source = $derived(charge === null ? null : sourceQualite(charge, { demandee: $ui.proposition })); const table = $derived(charge === null ? null : evaluation(charge).table); const ligne = $derived(source?.ligne ?? null); - const detail = null; - // Les contradictions qui retirent le dessin : celles de la ligne - // d'évaluation de la source, une liste vide sans source mesurable. - const refusDessin = $derived(ligne?.refusDessin ?? []); + const detail = $derived( + source?.cas === 'mesurable' + ? detailDe(charge, source.id, { courseDe: (id) => seance.courseDe(id), generationDe: (id) => generation.generationDe(id) }) + : null, + ); + // Les contradictions qui retirent le dessin : celles de la garde du + // détail, une liste vide sans source mesurable. + const refusDessin = $derived(detail?.refusDessin ?? []); const nommer = (id) => (id === null || id === undefined ? null : nomDe(charge, id)); // L'identifiant de l'élément qui porte une vue. diff --git a/src/interface/qualite/modele.js b/src/interface/qualite/modele.js new file mode 100644 index 0000000..78a4454 --- /dev/null +++ b/src/interface/qualite/modele.js @@ -0,0 +1,60 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de la page de qualité (§ 12.9, D27, D40), pur : +// SectionQualite.svelte le câble. +// +// Le détail d'un placement se calcule pour lui seul, par detaillerQualite +// (evaluation.js), et se garde sous l'identité de la charge, puis sous +// l'identifiant du placement : une proposition, ou 'retenu'. La charge de +// l'état observable de la séance est figée en profondeur et remplacée par +// une copie neuve à chaque changement — un réglage, un objectif, l'ordre +// des priorités : la charge changée n'est pas dans le cache, et son détail +// se mesure à sa première demande, pour le seul placement que la page +// montre, ses points de relevé remesurés sous ses réglages. Le détail d'une +// charge n'est jamais rendu pour une autre. Une charge qui n'est pas figée +// partout se mesure à chaque demande, son contenu pouvant changer sous la +// même identité. Le cache suit celui de la table d'évaluation +// (propositions/modele.js) : l'exception voulue à la réactivité de Svelte +// (§ 13.3), tenue par l'immuabilité de son entrée. +// +// La séance fournit ce que la charge ne porte pas : la course de la +// recherche, lue dans le compagnon des courses (seance.courseDe), et la +// génération qui a produit la proposition (generationDe du contrôleur de la +// génération). Le détail les lit à sa mesure, une fois par charge. +import { detaillerQualite } from '../../application/evaluation.js'; +import { figeePartout } from '../propositions/modele.js'; + +// Le détail de chaque placement de chaque charge figée partout : charge → +// Map(identifiant → détail). +const details = new WeakMap(); + +// Fige une valeur neuve et tout ce qu'elle atteint. +function figer(valeur) { + if (valeur === null || typeof valeur !== 'object' || Object.isFrozen(valeur)) return valeur; + for (const cle of Object.keys(valeur)) figer(valeur[cle]); + return Object.freeze(valeur); +} + +/** + * Le détail du placement id de la charge (detaillerQualite), figé en + * profondeur ; null quand la charge n'a pas ce placement. Pour une charge + * figée partout, le même objet à chaque demande, mesuré à la première : la + * course et la génération sont lues à ce moment. + * + * @param {Object} charge la charge de l'état observable de la séance + * @param {number|'retenu'} id le placement que la page mesure + * @param {{courseDe?: ((id: number) => Object|null)|null, + * generationDe?: ((id: number) => Object|null)|null}} [seance] + * courseDe : seance.courseDe ; generationDe : celui du contrôleur + * de la génération ; absents, aucune course ni génération + * @returns {Object|null} + */ +export function detailDe(charge, id, { courseDe = null, generationDe = null } = {}) { + const connus = details.get(charge); + if (connus?.has(id)) return connus.get(id); + const detail = figer(detaillerQualite(charge, id, { courseDe, generationDe })); + if (connus !== undefined) connus.set(id, detail); + else if (figeePartout(charge)) details.set(charge, new Map([[id, detail]])); + return detail; +} diff --git a/src/interface/qualite/modele.test.js b/src/interface/qualite/modele.test.js new file mode 100644 index 0000000..224c7d1 --- /dev/null +++ b/src/interface/qualite/modele.test.js @@ -0,0 +1,154 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du modèle de la page de qualité (§ 12.9, D27, D40) : le détail +// d'un placement, gardé par charge puis par identifiant, tant que la charge +// ne change pas ; une charge changée a le sien, mesuré à sa première +// demande ; la séance fournit la course et la génération. 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 { 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 { detailDe } from './modele.js'; + +const V1 = VERSION.affichee; + +// Six personnes inventées, deux appartenances, deux tables de trois places, +// deux tours, sans proposition ni retenu. +function chargeSix() { + const charge = creerCharge({ id: 'evt-modele-qualite', 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; +} + +// Fige une valeur et tout ce qu'elle atteint, comme la séance fige la +// charge de son état observable. +function figer(valeur) { + if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) { + for (const element of Object.values(valeur)) figer(element); + Object.freeze(valeur); + } + return valeur; +} + +// Vrai quand chaque objet atteint depuis la valeur est figé. +const figeePartout = (valeur) => + valeur === null || typeof valeur !== 'object' || (Object.isFrozen(valeur) && Object.values(valeur).every(figeePartout)); + +// La charge figée d'une génération, et la lecture de ses courses telle que +// la séance la fait. +function generee(charge = chargeSix()) { + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 3, nombre: 2, arret: 200 }); + const rendu = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }); + return { charge: figer(rendu.charge), courses: avecCourses(new Map(), rendu.courses) }; +} + +// La séance d'épreuve : courseDe et generationDe, qui comptent leurs appels. +function seanceSur(charge, courses, resume = { dureeMs: 4000, coeurs: 2, evalues: 6000, nombre: 2 }) { + const appels = { course: [], generation: [] }; + return { + appels, + options: { + courseDe: (id) => { + appels.course.push(id); + return courseDe({ charge, courses }, id); + }, + generationDe: (id) => { + appels.generation.push(id); + return resume; + }, + }, + }; +} + +describe('qualite/modele : le détail gardé par charge et par identifiant (§ 12.9, D27, D40)', () => { + test('une charge figée : le même détail, figé, à chaque demande, mesuré une fois ; la course et la génération de la séance passées', () => { + const { charge, courses } = generee(); + const { appels, options } = seanceSur(charge, courses); + const detail = detailDe(charge, 1, options); + assert.equal(detailDe(charge, 1, options), detail); + assert.ok(figeePartout(detail)); + assert.deepEqual(appels, { course: [1], generation: [1] }); + assert.deepEqual(detail.course.generation, { dureeMs: 4000, coeurs: 2, cadence: 1500, nombre: 2 }); + assert.deepEqual(detail, detaillerQualite(charge, 1, options)); + }); + + test('chaque identifiant a son détail : une proposition, une autre, le retenu', () => { + const { charge, courses } = generee(); + const retenue = figer(appliquerCommande('retenirProposition', charge, { id: 2 }).charge); + const { options } = seanceSur(retenue, courses); + const [un, deux, retenu] = [1, 2, 'retenu'].map((id) => detailDe(retenue, id, options)); + assert.deepEqual([un.id, deux.id, retenu.id], [1, 2, 'retenu']); + assert.notEqual(un, deux); + assert.equal(retenu.propositionDuRetenu, 2); + assert.equal(detailDe(retenue, 'retenu', options), retenu); + assert.equal(detailDe(retenue, 99, options), null); + }); + + test("une charge changée — l'ordre des priorités — a son propre détail, remesuré ; jamais celui de l'ancienne charge", () => { + const { charge, courses } = generee(); + const avant = detailDe(charge, 1, seanceSur(charge, courses).options); + 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 }], + ]; + const reglee = figer(appliquerCommande('changerPriorites', charge, { paliers }).charge); + const { appels, options } = seanceSur(reglee, courses); + const apres = detailDe(reglee, 1, options); + assert.notEqual(apres, avant); + assert.deepEqual(appels.course, [1]); + assert.deepEqual(apres, detaillerQualite(reglee, 1, options)); + assert.notDeepEqual(apres.course.indicateurs, avant.course.indicateurs); + assert.equal(apres.course.indicateurs[1], 'ecartAuPlafondAPrioriMax'); + assert.equal(apres.course.indicateurs[2], 'redondance'); + assert.equal(detailDe(charge, 1, seanceSur(charge, courses).options), avant); + }); + + test("une charge qui n'est pas figée partout se mesure à chaque demande", () => { + const { charge, courses } = generee(); + const libre = structuredClone(charge); + const { appels, options } = seanceSur(libre, courses); + const premier = detailDe(libre, 1, options); + const second = detailDe(libre, 1, options); + assert.notEqual(premier, second); + assert.deepEqual(premier, second); + assert.deepEqual(appels.generation, [1, 1]); + }); + + test('sans séance : ni course ni génération', () => { + const { charge } = generee(); + const detail = detailDe(charge, 2); + assert.equal(detail.course, null); + assert.equal(detail.statut, 'comparable'); + }); +}); diff --git a/test/sources_interface.test.js b/test/sources_interface.test.js index dc8ca32..1aa2968 100644 --- a/test/sources_interface.test.js +++ b/test/sources_interface.test.js @@ -34,6 +34,9 @@ import { TABLES_APPLICATION } from '../src/application/libelles.js'; import { creerTraduction } from '../src/application/traduction.js'; import { TABLES_INTERFACE } from '../src/interface/libelles.js'; import { JETONS_CONFLIT } from '../src/interface/marques.js'; +import * as DIVERSITE from '../src/moteur/diversite.js'; +import * as PAIRES from '../src/moteur/paires.js'; +import * as QUALITE from '../src/moteur/qualite.js'; import { DESIGNATIONS } from './navigateur/designations.js'; import { describe, test } from './lanceur.js'; @@ -959,8 +962,12 @@ function releverTexteEtAnimation(racine, exceptions = ANIMATIONS_ADMISES) { // --- Garde 7 : le câblage (§ 12.9, § 13.3) ------------------------------------ const TRIS_ET_REDUCTIONS = new Set(['sort', 'toSorted', 'reduce', 'reduceRight']); -// Les mesures de la table d'évaluation, que src/application/evaluation.js -// seul importe (§ 12.9). +// Les mesures de la table d'évaluation et du détail d'un placement, que +// src/application/evaluation.js seul importe (§ 12.9, contrainte 41) : +// celles de la table, nommées, puis chaque export de qualite.js, de +// paires.js et de diversite.js, lu dans l'espace de noms du module — un +// export ajouté y entre sans qu'on le nomme —, la borne de diversité et les +// occupations. const MESURES = new Set([ 'mesurer', 'plafondsRealises', @@ -969,6 +976,11 @@ const MESURES = new Set([ 'manques', 'ecartsAuPlafondAPriori', 'minimumAtteint', + ...Object.keys(QUALITE), + ...Object.keys(PAIRES), + ...Object.keys(DIVERSITE), + 'borneDiversite', + 'occupations', ]); const EVALUATION = 'src/application/evaluation.js'; @@ -1930,6 +1942,33 @@ describe('sources de l’interface : le câblage (§ 12.9, § 13.3)', () => { ]); }); + test("la garde relève l'import d'une fonction du détail hors d'evaluation.js : detailler, qualite.js, paires.js, diversite.js, la borne et les occupations", () => { + // Chaque export de qualite.js, lu dans l'espace de noms du module. + const qualite = Object.keys(QUALITE); + const fichiers = { + 'src/interface/plan/A.svelte': composant({ script: ' let liste = $state([]);', gabarit: '

{liste.length}

' }), + 'src/application/evaluation.js': "import { detailler } from '../moteur/qualite.js';\nexport { detailler };\n", + 'src/application/generation.js': [ + `import { ${qualite.join(', ')} } from '../moteur/qualite.js';`, + "import { pairesRepetees, rencontresDe } from '../moteur/paires.js';", + "export { matriceRencontres } from '../moteur/paires.js';", + "import * as diversite from '../moteur/diversite.js';", + 'export const f = (x) => diversite.diversiteAnimateurs(x);', + "import { borneDiversite as borne, occupations } from '../moteur/plafond.js';", + '', + ].join('\n'), + }; + assert.deepEqual(avecArbre(fichiers, releverCablage), [ + ...qualite.map((nom) => `src/application/generation.js:1 import de ${nom}`).sort(), + 'src/application/generation.js:2 import de pairesRepetees', + 'src/application/generation.js:2 import de rencontresDe', + 'src/application/generation.js:3 import de matriceRencontres', + 'src/application/generation.js:5 import de diversiteAnimateurs', + 'src/application/generation.js:6 import de borneDiversite', + 'src/application/generation.js:6 import de occupations', + ]); + }); + test('un arbre sans composant fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverCablage), /câblage des composants : aucun fichier examiné/); });