[ADD] quality detail: placement, course re-measured, a person's line
The Qualité tab needs one call that gives everything a view draws. The application now details a placement for the proposition the plan shows: the engine's detail, the indicator guard with its four complements whose refusal stops the drawing, the course re-measured under current settings with one value per ranked quantity, the generation's cadence, and a person's meetings for the card. The « retenu ajusté » rule lives in one predicate the propositions table imports. Checked: node 4965, navigateur 1016, node-long 128; 15 mutants caught. --- FR --- [ADD] détail de la qualité : placement, course remesurée, fiche L'onglet Qualité demande un seul appel qui rende tout ce qu'une vue dessine. L'application détaille désormais le placement que montre le plan : le détail du moteur, la garde des indicateurs à quatre compléments dont le refus arrête le dessin, la course remesurée sous les réglages courants avec une valeur par grandeur classée, la cadence de la génération, et les rencontres d'une personne pour la fiche. La règle du retenu ajusté tient en un prédicat que le tableau des propositions importe. Vérifié : node 4965, navigateur 1016, longues 128 ; 15 mutants pris. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
133ae77907
commit
26f70cf8c3
8 changed files with 1669 additions and 46 deletions
|
|
@ -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<string, number|null>}>} 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`<script module>${texte}</script>`, { 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
60
src/interface/qualite/modele.js
Normal file
60
src/interface/qualite/modele.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
154
src/interface/qualite/modele.test.js
Normal file
154
src/interface/qualite/modele.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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: '<p>{liste.length}</p>' }),
|
||||
'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é/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue