[ADD] interface: propositions — ranked table, retain, clear
The operator chooses among the generated plans. The ranked table orders propositions by the criteria of § 5.4 and shows, for encounters, the minimum only — the mean is the same for every proposition at full tables — with redundancy read through its sum. A proposition is retained or the list cleared through named commands; clearing spares the retained one, and a proposition that broke is shown apart, not ranked. Checked: 2400 node and 406 browser tests. --- FR --- [ADD] interface : propositions — tableau classé, retenir, effacer L'opérateur choisit parmi les plans générés. Le tableau classé ordonne les propositions selon les critères du § 5.4 et ne montre, pour les rencontres, que le minimum — la moyenne est la même pour toute proposition à tables pleines —, la redondance se lisant par sa somme. Une proposition se retient, ou la liste s'efface, par des commandes nommées ; effacer épargne la retenue, et une proposition abîmée se montre à part, hors classement. Vérifié : 2400 node et 406 navigateur. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
d19394957f
commit
ad19131954
6 changed files with 1887 additions and 4 deletions
|
|
@ -1,6 +1,107 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les libellés du tableau des propositions (§ 14.6). Chaque clé commence par «
|
||||
// propositions. ».
|
||||
export const TABLE = new Map();
|
||||
// Les libellés du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9,
|
||||
// § 14.6). Chaque clé commence par « propositions. ».
|
||||
//
|
||||
// Chaque en-tête de colonne nomme son unité (§ 5.4) : collisions cumulées,
|
||||
// paires distinctes et excédent ne se confondent jamais. Des rencontres, le
|
||||
// tableau ne porte que le minimum, sur les trois populations du § 5.4 ; la
|
||||
// moyenne n'y paraît pas. La phrase de l'ordre compose les noms des critères
|
||||
// par leurs clés moteur.critere.<nom>, dans l'ordre reçu, puis l'identifiant
|
||||
// croissant, qui départage deux propositions égales sur tous (§ 5.7).
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
|
||||
// Le nom d'un critère du classement, celui de la table du moteur.
|
||||
const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- La section (§ 5.7) ------------------------------------------------------
|
||||
['propositions.tableau.titre', 'Propositions classées'],
|
||||
['propositions.aucune', "Aucune proposition : la génération en produit, et chaque génération s'ajoute aux précédentes."],
|
||||
[
|
||||
'propositions.ordre.criteres',
|
||||
({ criteres }, texte) =>
|
||||
`Ordre du classement, chaque critère au plus faible d'abord : ${criteres
|
||||
.map((nom) => critere(nom, texte))
|
||||
.join(', puis ')}, puis l'identifiant croissant.`,
|
||||
],
|
||||
[
|
||||
'propositions.ordre.saute',
|
||||
({ saute, applique }, texte) =>
|
||||
`Critère sauté, faute de plafond a priori par personne : ${critere(saute, texte)}. ` +
|
||||
`Appliqué à sa place : ${critere(applique, texte)}.`,
|
||||
],
|
||||
|
||||
// --- Les colonnes, chacune avec son unité (§ 5.4, § 5.5) ----------------------
|
||||
['propositions.colonne.rang', 'Rang'],
|
||||
['propositions.colonne.proposition', 'Proposition'],
|
||||
['propositions.colonne.rencontresTous', 'Personnes distinctes rencontrées, minimum : tous'],
|
||||
['propositions.colonne.rencontresMobiles', 'Personnes distinctes rencontrées, minimum : mobiles'],
|
||||
['propositions.colonne.rencontresAncres', 'Personnes distinctes rencontrées, minimum : ancrés'],
|
||||
['propositions.colonne.collisionsCumulees', 'Collisions cumulées (paire et tour)'],
|
||||
['propositions.colonne.pairesDistinctes', 'Paires distinctes de même appartenance'],
|
||||
['propositions.colonne.excedentCollisions', 'Excédent de collisions (retrouvailles au-delà de la première)'],
|
||||
['propositions.colonne.rencontresRepetees', 'Rencontres répétées choisies (paires)'],
|
||||
['propositions.colonne.maxRencontresPaire', "Rencontres d'une même paire, au plus"],
|
||||
['propositions.colonne.retoursChoisis', 'Retours choisis à une table déjà occupée'],
|
||||
['propositions.colonne.appartenancesVues', 'Appartenances rencontrées A(p), minimum'],
|
||||
['propositions.colonne.totalRedondance', "Redondance d'appartenance, somme Σ r(p)"],
|
||||
['propositions.colonne.diversite', 'Taux de diversité d(p), minimum'],
|
||||
['propositions.colonne.manqueMax', 'Plus grand manque (personnes)'],
|
||||
['propositions.colonne.effectifManque', 'Personnes à qui il en manque'],
|
||||
['propositions.colonne.ecartItineraireMax', "Écart d'itinéraire maximal (personnes)"],
|
||||
['propositions.colonne.ecartAuPlafondAPriori', 'Écart au plafond a priori maximal (personnes)'],
|
||||
['propositions.colonne.certificat', 'Certificat'],
|
||||
['propositions.colonne.imposes', 'Imposé par les réservations'],
|
||||
['propositions.colonne.commandes', 'Commandes'],
|
||||
|
||||
// --- Les valeurs écrites en mots (§ 5.5, § 12.6) ------------------------------
|
||||
['propositions.valeur.inconnu', 'inconnu'],
|
||||
['propositions.certificat.atteint', 'minimum atteint'],
|
||||
['propositions.certificat.inconnue', 'atteignabilité inconnue'],
|
||||
|
||||
// --- Ce que les réservations imposent, à part (§ 5.4) --------------------------
|
||||
[
|
||||
'propositions.imposes.retours',
|
||||
({ retours, ancrages }) =>
|
||||
retours === 0
|
||||
? 'Aucun retour imposé.'
|
||||
: `${compte(retours, 'retour imposé', 'retours imposés')} par ${compte(ancrages, 'ancrage', 'ancrages')}.`,
|
||||
],
|
||||
[
|
||||
'propositions.imposes.repetees',
|
||||
({ repetees }) =>
|
||||
repetees === 0
|
||||
? 'Aucune rencontre répétée imposée.'
|
||||
: `${compte(repetees, 'rencontre répétée imposée', 'rencontres répétées imposées')}.`,
|
||||
],
|
||||
|
||||
// --- Les lignes (§ 5.7, § 9, § 12.6) -----------------------------------------
|
||||
['propositions.marque.retenue', 'retenue'],
|
||||
['propositions.retenu.titre', 'Placement retenu'],
|
||||
['propositions.retenu.origine', ({ proposition }) => `Tel que la proposition n° ${proposition}.`],
|
||||
['propositions.retenu.ajuste', ({ proposition }) => `Ajusté à la main depuis la proposition n° ${proposition}.`],
|
||||
['propositions.horsClassement.titre', 'Hors classement'],
|
||||
['propositions.statut.derive', 'Non comparable : la proposition ne décrit plus la configuration courante.'],
|
||||
['propositions.statut.fautif', 'Non comparable : le placement se contredit.'],
|
||||
|
||||
// --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------
|
||||
['propositions.commande.voir', 'Voir sur le plan'],
|
||||
['propositions.commande.retenir', 'Retenir'],
|
||||
['propositions.commande.effacer', 'Effacer les propositions'],
|
||||
|
||||
// --- Retenir sur un retenu ajusté (§ 2.1) ----------------------------------------
|
||||
[
|
||||
'propositions.confirmation.titre',
|
||||
({ proposition }) => `Retenir la proposition n° ${proposition} détruit les ajustements du placement retenu`,
|
||||
],
|
||||
[
|
||||
'propositions.confirmation.retenuAjuste',
|
||||
({ proposition }) => `Le placement retenu, ajusté à la main depuis la proposition n° ${proposition}`,
|
||||
],
|
||||
[
|
||||
'propositions.confirmation.remplacant',
|
||||
({ proposition }) => `remplacé par la proposition n° ${proposition}, telle que générée`,
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
535
src/interface/propositions/Propositions.navigateur.test.js
Normal file
535
src/interface/propositions/Propositions.navigateur.test.js
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La section des propositions dans un vrai moteur de rendu (§ 2.1 étapes 10
|
||||
// et 11, § 5.4, § 5.5, § 5.7, § 8.4, § 9, § 12.6, § 19.3 capture 11) : le
|
||||
// tableau classé et sa phrase d'ordre, les générations qui s'accumulent,
|
||||
// retenir, voir sur le plan, effacer, le retenu ajusté et sa fenêtre, les
|
||||
// refus en lecture et sur un plan bloqué. Chaque épreuve monte App par
|
||||
// monterApp, sur le système de fichiers simulé où un fichier d'état
|
||||
// d'épreuve est posé puis ouvert, au cadre de 1 366 × 700, et lit le rendu :
|
||||
// le texte, le style calculé, les attributs data-… — jamais une classe.
|
||||
//
|
||||
// Une ligne classée porte data-proposition, la ligne du retenu data-retenu,
|
||||
// une cellule data-colonne, la phrase de l'ordre data-ordre. La valeur
|
||||
// attendue vient des fonctions du moteur, sollicitées une seconde fois
|
||||
// (§ 19.1), mises en forme par creerFormat ; le texte, de la même traduction
|
||||
// que celle de l'application. Les noms d'épreuve sont inventés.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { CRITERES, classer } from '../../moteur/classement.js';
|
||||
import { indexerPlan, normaliser } from '../../moteur/configuration.js';
|
||||
import { mesurer } from '../../moteur/indicateurs.js';
|
||||
import { troisChiffres } from '../../moteur/manque.js';
|
||||
import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { serialiser } from '../../stockage/canonique.js';
|
||||
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { planDepuisFichier } from '../../stockage/placements.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const V1 = VERSION.affichee;
|
||||
const BASE = 'soiree';
|
||||
const TIRET = '\u{2014}';
|
||||
|
||||
// --- Les charges d'épreuve -------------------------------------------------------
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// La charge, une génération du moteur de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
// Une proposition écrite à la main, sièges non attribués, sur les tables de
|
||||
// la charge dans leur ordre.
|
||||
function propositionDe(charge, id, tours) {
|
||||
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
|
||||
return {
|
||||
id,
|
||||
graine: id,
|
||||
arret: 40,
|
||||
historique: 1000,
|
||||
produitVersion: V1,
|
||||
siegesAttribues: false,
|
||||
tables: charge.tables.map(({ id: table }) => table),
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: tours.length,
|
||||
participants,
|
||||
placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: [] })),
|
||||
};
|
||||
}
|
||||
|
||||
// La charge, ses propositions remplacées par les plans donnés, numérotés à
|
||||
// partir de 1.
|
||||
function avecPlans(charge, plans) {
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.propositions = plans.map((tours, rang) => propositionDe(suivante, rang + 1, tours));
|
||||
suivante.prochainsIds.proposition = plans.length + 1;
|
||||
suivante.evenement.etat = 'propose';
|
||||
return suivante;
|
||||
}
|
||||
|
||||
// Trois plans de la petite démonstration au conflit inévitable, dans l'ordre
|
||||
// des identifiants : (4, 1), (4, 2), (6, 6) collisions (cumulées,
|
||||
// distinctes), au même plus grand écart au plafond a priori (A6).
|
||||
const PLANS_CONFLIT = [
|
||||
[
|
||||
[[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]],
|
||||
[[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]],
|
||||
[[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]],
|
||||
[[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]],
|
||||
],
|
||||
[
|
||||
[[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]],
|
||||
[[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]],
|
||||
[[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]],
|
||||
[[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]],
|
||||
],
|
||||
[
|
||||
[[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]],
|
||||
[[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]],
|
||||
[[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]],
|
||||
[[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]],
|
||||
],
|
||||
];
|
||||
|
||||
// Six personnes inventées, deux tables de quatre places, quatre tours, et
|
||||
// trois plans à trois par table : une place libre à chaque table.
|
||||
function chargeSix() {
|
||||
const charge = creerCharge({ id: 'evt-propositions', nom: 'Club des Merles', siegesParDefaut: 4, tours: 4 });
|
||||
const noms = [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Grisaille', null], ['Bruyère', 'Anouk'], ['Sarcelle', 'Capucine']];
|
||||
charge.participants = noms.map(([nom, prenom], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: rang < 2 ? 'Club des Merles' : null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
return avecPlans(charge, [
|
||||
[[[1, 2, 3], [4, 5, 6]], [[1, 2, 4], [3, 5, 6]], [[1, 2, 5], [3, 4, 6]], [[1, 2, 6], [3, 4, 5]]],
|
||||
[[[1, 3, 5], [2, 4, 6]], [[1, 4, 6], [2, 3, 5]], [[1, 2, 3], [4, 5, 6]], [[1, 5, 6], [2, 3, 4]]],
|
||||
[[[1, 4, 5], [2, 3, 6]], [[1, 3, 6], [2, 4, 5]], [[1, 2, 6], [3, 4, 5]], [[1, 3, 4], [2, 5, 6]]],
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Le montage --------------------------------------------------------------------
|
||||
|
||||
// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs
|
||||
// n'a rien reçu pendant l'épreuve.
|
||||
let montes = [];
|
||||
beforeEach(async () => {
|
||||
await fixerCadre(1366, 700);
|
||||
});
|
||||
afterEach(() => {
|
||||
const erreurs = montes.flatMap((m) => m.erreurs());
|
||||
for (const m of montes.reverse()) m.demonter();
|
||||
montes = [];
|
||||
window.scrollTo(0, 0);
|
||||
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
|
||||
});
|
||||
|
||||
// Attend que l'assertion passe, le rendu de Svelte à jour.
|
||||
async function attendre(assertion) {
|
||||
for (let essai = 0; ; essai += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
return assertion();
|
||||
} catch (erreur) {
|
||||
if (essai >= 200) throw erreur;
|
||||
await new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La charge posée en fichier d'état, ouverte par la séance — en écriture
|
||||
// sauf mode 'lecture' —, la section des propositions ouverte.
|
||||
async function ouvrir(charge, { mode = 'ecriture' } = {}) {
|
||||
const texte = serialiser(charge, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}${SUFFIXES.etat}`, texte }] });
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
if (mode === 'ecriture') await m.seance.passerEnEcriture();
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- Les lectures du rendu ---------------------------------------------------------
|
||||
|
||||
const section = (m) => m.racine.querySelector('[data-section="propositions"]');
|
||||
const lignes = (m) => [...section(m).querySelectorAll('tr[data-proposition]')];
|
||||
const ids = (m) => lignes(m).map((tr) => Number(tr.dataset.proposition));
|
||||
const ligne = (m, id) => section(m).querySelector(`tr[data-proposition="${id}"]`);
|
||||
const ligneRetenu = (m) => section(m).querySelector('tr[data-retenu]');
|
||||
const commande = (racine, nom) => racine.querySelector(`[data-commande="${nom}"]`);
|
||||
const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
const fenetreOuverte = (m) => m.racine.querySelector('dialog[open]');
|
||||
const charge = (m) => m.seance.etat().charge;
|
||||
const revision = (m) => m.seance.etat().evenement.revision;
|
||||
// Le refus posé à côté d'un bouton de Commande : dans son conteneur.
|
||||
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
|
||||
|
||||
// Ce que le plan dessiné montre au tour affiché : pour chaque [data-table]
|
||||
// rendu, les noms de sa liste [data-liste], triés. Rien n'est lu dans l'état
|
||||
// des places : la valeur vient du rendu même.
|
||||
function occupantsDessines(m) {
|
||||
const plan = m.racine.querySelector('[data-plan]');
|
||||
const tables = [...plan.querySelectorAll('[data-table]')];
|
||||
return new Map(
|
||||
tables.map((groupe) => {
|
||||
const noms = [...plan.querySelectorAll(`[data-liste="${groupe.dataset.table}"] [data-partie="nom"]`)];
|
||||
return [Number(groupe.dataset.table), noms.map(texteDe).sort()];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Les noms attendus à chaque table d'un placement, au tour 1, triés.
|
||||
function occupantsDuPlacement(chargeLue, placement) {
|
||||
const nomDe = new Map(chargeLue.participants.map((personne) => [personne.id, nomAffiche(personne)]));
|
||||
return new Map(placement.tables.map((table, rang) => [table, placement.placement[0].sieges[rang].filter((id) => id !== null).map((id) => nomDe.get(id)).sort()]));
|
||||
}
|
||||
|
||||
// Le plan dessiné montre, au tour 1, les occupants du placement donné.
|
||||
async function exigerPlanMontre(m, placement) {
|
||||
const attendu = occupantsDuPlacement(charge(m), placement);
|
||||
expect(attendu.size).toBeGreaterThan(0);
|
||||
await attendre(() => expect(occupantsDessines(m)).toEqual(attendu));
|
||||
for (const noms of attendu.values()) expect(noms.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Le texte de chaque cellule d'une ligne, par colonne.
|
||||
function cellules(tr) {
|
||||
const lues = [...tr.querySelectorAll('td[data-colonne]')];
|
||||
return new Map(lues.map((td) => [td.dataset.colonne, texteDe(td)]));
|
||||
}
|
||||
|
||||
// Les chiffres que rend le moteur pour un placement de la charge, écrits
|
||||
// comme le tableau les écrit.
|
||||
function attendus(chargeLue, placement) {
|
||||
const instance = normaliser(configurationDepuisCharge(chargeLue));
|
||||
const tableDe = indexerPlan(instance, planDepuisFichier(placement));
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const trois = troisChiffres(instance, mesures, plafondsAPriori(instance), plafondsRealises(instance, tableDe));
|
||||
const ecrit = (valeur, decimales = 0) => (valeur === null ? TIRET : format.nombre(valeur, { decimales }));
|
||||
return new Map([
|
||||
['rencontresTous', ecrit(mesures.aggRencontres.tous.min)],
|
||||
['rencontresMobiles', ecrit(mesures.aggRencontres.mobiles.min)],
|
||||
['rencontresAncres', ecrit(mesures.aggRencontres.ancres.min)],
|
||||
['collisionsCumulees', ecrit(mesures.collisionsCumulees)],
|
||||
['pairesDistinctes', ecrit(mesures.pairesDistinctes)],
|
||||
['excedentCollisions', ecrit(mesures.excedentCollisions)],
|
||||
['rencontresRepetees', ecrit(mesures.rencontresRepetees.choisies)],
|
||||
['maxRencontresPaire', ecrit(mesures.maxRencontresPaire)],
|
||||
['retoursChoisis', ecrit(mesures.totalRetoursChoisis)],
|
||||
['appartenancesVues', ecrit(mesures.aggAppartenancesVues.tous.min)],
|
||||
['totalRedondance', ecrit(mesures.totalRedondance)],
|
||||
['diversite', ecrit(mesures.aggDiversite.tous.min, 2)],
|
||||
['manqueMax', ecrit(trois.manqueMax.tous)],
|
||||
['effectifManque', ecrit(trois.effectifManque.tous)],
|
||||
['ecartItineraireMax', ecrit(trois.ecartItineraireMax.tous)],
|
||||
]);
|
||||
}
|
||||
|
||||
// Chaque chiffre attendu se lit dans sa cellule ; aucune cellule ne porte
|
||||
// le minimum de r(p), qui désignerait la personne la mieux servie.
|
||||
function exigerChiffres(tr, attendue) {
|
||||
const lues = cellules(tr);
|
||||
expect(attendue.size).toBe(15);
|
||||
expect(lues.has('redondance'), 'minimum de r(p)').toBe(false);
|
||||
for (const [colonne, texte] of attendue) expect(lues.get(colonne), colonne).toBe(texte);
|
||||
}
|
||||
|
||||
// --- Le tableau classé (§ 5.4, § 5.7, § 19.3 capture 11) --------------------------
|
||||
|
||||
describe('le tableau classé', () => {
|
||||
test('les trois plans d’A6 : (6, 6), (4, 2), (4, 1) ; la phrase cite les cinq critères puis l’identifiant ; chaque chiffre est celui du moteur', async () => {
|
||||
const chargeLue = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
|
||||
const m = await ouvrir(chargeLue);
|
||||
expect(ids(m)).toEqual([3, 2, 1]);
|
||||
const phrase = texteDe(section(m).querySelector('[data-ordre]'));
|
||||
expect(phrase).toBe(t('propositions.ordre.criteres', { criteres: [...CRITERES] }));
|
||||
const positions = CRITERES.map((nom) => phrase.indexOf(t(`moteur.critere.${nom}`)));
|
||||
expect(positions.every((position, rang) => position >= 0 && (rang === 0 || position > positions[rang - 1]))).toBe(true);
|
||||
expect(phrase.indexOf('identifiant')).toBeGreaterThan(positions[4]);
|
||||
for (const proposition of chargeLue.propositions) exigerChiffres(ligne(m, proposition.id), attendus(chargeLue, proposition));
|
||||
});
|
||||
|
||||
test('le minimum seul : aucun en-tête ni aucune cellule ne porte la moyenne des rencontres ; les unités nommées', async () => {
|
||||
const chargeLue = generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 });
|
||||
const m = await ouvrir(chargeLue);
|
||||
const entetes = [...section(m).querySelectorAll('th')].map(texteDe);
|
||||
expect(entetes.length).toBeGreaterThan(0);
|
||||
for (const entete of entetes) expect(entete).not.toMatch(/moyenne/i);
|
||||
expect(entetes.some((entete) => /collisions cumulées/i.test(entete))).toBe(true);
|
||||
expect(entetes.some((entete) => /paires distinctes/i.test(entete))).toBe(true);
|
||||
expect(entetes.some((entete) => /excédent de collisions/i.test(entete))).toBe(true);
|
||||
const lues = cellules(ligne(m, 1));
|
||||
expect(lues.get('rencontresTous')).toBe('24');
|
||||
expect(lues.get('rencontresAncres')).toBe('24');
|
||||
expect(Number(lues.get('rencontresMobiles'))).toBeGreaterThan(24);
|
||||
const instance = normaliser(configurationDepuisCharge(chargeLue));
|
||||
const { aggRencontres } = mesurer(instance, indexerPlan(instance, planDepuisFichier(chargeLue.propositions[0])));
|
||||
const moyennes = ['tous', 'mobiles'].map((population) => format.nombre(aggRencontres[population].moyenne, { decimales: 2 }));
|
||||
for (const texte of lues.values()) for (const moyenne of moyennes) expect(texte).not.toContain(moyenne);
|
||||
});
|
||||
|
||||
test('la grande : « 99 retours imposés par 33 ancrages » à part, la ligne « ancrés » à 24, et « atteignabilité inconnue »', async () => {
|
||||
const m = await ouvrir(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 }));
|
||||
const lues = cellules(ligne(m, 1));
|
||||
expect(lues.get('imposes')).toContain('99 retours imposés par 33 ancrages.');
|
||||
expect(lues.get('imposes')).toMatch(/rencontres? répétées? imposées?|Aucune rencontre répétée imposée/);
|
||||
expect(lues.get('certificat')).toBe('atteignabilité inconnue');
|
||||
});
|
||||
|
||||
test('la petite sans réservation : la colonne « ancrés » porte « — » à chaque ligne ; le plan parfait porte « minimum atteint »', async () => {
|
||||
const chargeLue = generee(chargeDemo('petite'), { graine: 4, nombre: 2, arret: 300 });
|
||||
const parfaite = propositionDe(chargeLue, 3, [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
|
||||
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
|
||||
]);
|
||||
chargeLue.propositions.push(parfaite);
|
||||
chargeLue.prochainsIds.proposition = 4;
|
||||
const m = await ouvrir(chargeLue);
|
||||
expect(lignes(m).length).toBe(3);
|
||||
for (const tr of lignes(m)) expect(cellules(tr).get('rencontresAncres')).toBe(TIRET);
|
||||
expect(cellules(ligne(m, 3)).get('certificat')).toBe('minimum atteint');
|
||||
});
|
||||
|
||||
test('les générations s’accumulent : 3, puis 2 sous une autre graine, cinq lignes dans l’ordre de classer sur les cinq', async () => {
|
||||
const m = await ouvrir(generee(chargeDemo('petite-conflit'), { graine: 1, nombre: 3, arret: 300 }));
|
||||
expect(lignes(m).length).toBe(3);
|
||||
const configuration = configurationDepuisCharge(charge(m));
|
||||
const propositions = rechercher(configuration, { graine: 2, nombre: 2, arret: 300 });
|
||||
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: V1 });
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const { ordre } = classer(
|
||||
charge(m).propositions.map(({ id, ...p }) => ({ id, mesures: mesurer(instance, indexerPlan(instance, planDepuisFichier({ id, ...p }))), plafondsAPriori: aPriori })),
|
||||
);
|
||||
await attendre(() => expect(ids(m)).toEqual(ordre));
|
||||
expect(ordre.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Retenir, voir sur le plan (§ 2.1 étape 10, § 9) --------------------------------
|
||||
|
||||
describe('retenir, voir sur le plan', () => {
|
||||
test('retenir la 3 : l’état affiché est « Retenu », charge.retenu.proposition vaut 3, les trois lignes restent et la 3 est marquée', async () => {
|
||||
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
const avant = revision(m);
|
||||
commande(ligne(m, 3), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(3));
|
||||
expect(revision(m)).toBe(avant + 1);
|
||||
const etat = m.racine.querySelector('[data-etat="retenu"]');
|
||||
expect(getComputedStyle(etat).fontWeight).toBe('700');
|
||||
expect(texteDe(etat)).toBe(t('evenement.etat.retenu'));
|
||||
await attendre(() => expect(lignes(m).length).toBe(3));
|
||||
const marquees = lignes(m).filter((tr) => texteDe(tr.querySelector('td[data-colonne="proposition"]')).includes(t('propositions.marque.retenue')));
|
||||
expect(marquees.map((tr) => Number(tr.dataset.proposition))).toEqual([3]);
|
||||
expect(Number(ligneRetenu(m).dataset.retenu)).toBe(3);
|
||||
});
|
||||
|
||||
test('retenir la 3, voir la 1 sur le plan : la section du plan s’ouvre sur la proposition 1, sans écriture ; retenir la 2 rend le plan au retenu', async () => {
|
||||
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
commande(ligne(m, 3), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(3));
|
||||
const retenu = structuredClone(charge(m).retenu);
|
||||
const ecritures = m.trace.length;
|
||||
commande(ligne(m, 1), 'voir').click();
|
||||
await attendre(() => expect(m.racine.querySelector('[data-section="plan"]')).not.toBeNull());
|
||||
expect(m.ui.etat().proposition).toBe(1);
|
||||
const proposition = (id) => charge(m).propositions.find((candidate) => candidate.id === id);
|
||||
// Les trois placements diffèrent au tour 1 : le plan montré se distingue
|
||||
// du retenu.
|
||||
expect(occupantsDuPlacement(charge(m), proposition(1))).not.toEqual(occupantsDuPlacement(charge(m), retenu));
|
||||
await exigerPlanMontre(m, proposition(1));
|
||||
expect(occupantsDessines(m).size).toBe(4);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(charge(m).retenu).toEqual(retenu);
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
commande(ligne(m, 2), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(2));
|
||||
expect(m.ui.etat().proposition).toBe(null);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
await exigerPlanMontre(m, charge(m).retenu);
|
||||
expect(occupantsDuPlacement(charge(m), charge(m).retenu)).not.toEqual(occupantsDuPlacement(charge(m), proposition(1)));
|
||||
});
|
||||
|
||||
test('les indicateurs suivent l’ajustement : la ligne du retenu égale sa mesure, celle de la proposition 2 n’a pas changé', async () => {
|
||||
const m = await ouvrir(chargeSix());
|
||||
commande(ligne(m, 2), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(2));
|
||||
const avant = cellules(ligne(m, 2));
|
||||
await m.seance.executer('deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null });
|
||||
const chargeLue = charge(m);
|
||||
await attendre(() => exigerChiffres(ligneRetenu(m), attendus(chargeLue, chargeLue.retenu)));
|
||||
expect(cellules(ligne(m, 2))).toEqual(avant);
|
||||
expect(texteDe(ligneRetenu(m))).toContain(t('propositions.retenu.ajuste', { proposition: 2 }));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le retenu ajusté (§ 2.1, § 5.9) -----------------------------------------------
|
||||
|
||||
describe('retenir sur un retenu ajusté', () => {
|
||||
test('retenir la 3 ouvre une fenêtre qui nomme le retenu ajusté et la 3 ; Annuler n’écrit rien ; Confirmer retient la 3, une entrée', async () => {
|
||||
const m = await ouvrir(chargeSix());
|
||||
commande(ligne(m, 2), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(2));
|
||||
await m.seance.executer('deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null });
|
||||
flushSync();
|
||||
const ajuste = structuredClone(charge(m).retenu);
|
||||
const ecritures = m.trace.length;
|
||||
commande(ligne(m, 3), 'retenir').click();
|
||||
const fenetre = await attendre(() => {
|
||||
const ouverte = fenetreOuverte(m);
|
||||
expect(ouverte).not.toBeNull();
|
||||
return ouverte;
|
||||
});
|
||||
const texte = texteDe(fenetre);
|
||||
expect(texte).toContain(t('propositions.confirmation.retenuAjuste', { proposition: 2 }));
|
||||
expect(texte).toContain(t('propositions.confirmation.remplacant', { proposition: 3 }));
|
||||
commande(fenetre, 'renoncer').click();
|
||||
await attendre(() => expect(fenetreOuverte(m)).toBeNull());
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(charge(m).retenu).toEqual(ajuste);
|
||||
const avant = revision(m);
|
||||
commande(ligne(m, 3), 'retenir').click();
|
||||
const seconde = await attendre(() => {
|
||||
const ouverte = fenetreOuverte(m);
|
||||
expect(ouverte).not.toBeNull();
|
||||
return ouverte;
|
||||
});
|
||||
commande(seconde, 'confirmer').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(3));
|
||||
await attendre(() => expect(fenetreOuverte(m)).toBeNull());
|
||||
expect(revision(m)).toBe(avant + 1);
|
||||
});
|
||||
|
||||
test('sans ajustement, retenir une autre proposition n’ouvre aucune fenêtre', async () => {
|
||||
const m = await ouvrir(chargeSix());
|
||||
commande(ligne(m, 2), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(2));
|
||||
commande(ligne(m, 3), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(3));
|
||||
expect(fenetreOuverte(m)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Effacer (§ 5.7) ----------------------------------------------------------------
|
||||
|
||||
describe('effacer', () => {
|
||||
test('retenir la 2, effacer : seule la 2 reste, charge.retenu intact, une entrée', async () => {
|
||||
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
commande(ligne(m, 2), 'retenir').click();
|
||||
await attendre(() => expect(charge(m).retenu?.proposition).toBe(2));
|
||||
const retenu = structuredClone(charge(m).retenu);
|
||||
const avant = revision(m);
|
||||
commande(section(m), 'effacer').click();
|
||||
await attendre(() => expect(ids(m)).toEqual([2]));
|
||||
expect(charge(m).propositions.map(({ id }) => id)).toEqual([2]);
|
||||
expect(charge(m).retenu).toEqual(retenu);
|
||||
expect(revision(m)).toBe(avant + 1);
|
||||
});
|
||||
|
||||
test('sans retenue, la liste se vide et la section le dit', async () => {
|
||||
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
|
||||
commande(section(m), 'effacer').click();
|
||||
await attendre(() => expect(lignes(m).length).toBe(0));
|
||||
expect(charge(m).propositions).toEqual([]);
|
||||
expect(texteDe(section(m))).toContain(t('propositions.aucune'));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les populations différentes (§ 9, § 12.6) -------------------------------------
|
||||
|
||||
describe('les populations différentes', () => {
|
||||
test('générer, exclure une personne, générer encore : les premières hors classement, « non comparables », nomment la personne exclue', async () => {
|
||||
let chargeLue = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 });
|
||||
chargeLue = executer(chargeLue, 'exclureParticipant', { id: 4 });
|
||||
chargeLue = generee(chargeLue, { graine: 2, nombre: 2, arret: 200 });
|
||||
const m = await ouvrir(chargeLue);
|
||||
const classees = [...section(m).querySelectorAll('[data-classement] tr[data-proposition]')].map((tr) => Number(tr.dataset.proposition));
|
||||
expect(classees.sort()).toEqual([3, 4]);
|
||||
const hors = [...section(m).querySelectorAll('[data-hors-classement] tr[data-proposition]')];
|
||||
expect(hors.map((tr) => Number(tr.dataset.proposition))).toEqual([1, 2]);
|
||||
const nom = nomAffiche(chargeLue.participants.find(({ id }) => id === 4));
|
||||
for (const tr of hors) {
|
||||
const texte = texteDe(tr);
|
||||
expect(texte).toContain(t('propositions.statut.derive'));
|
||||
expect(texte).toContain(t('PARTICIPANT_EXCLU', { participant: 4 }));
|
||||
expect(texte).toContain(nom);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Vivant en lecture (§ 8.4, § 9) -------------------------------------------------
|
||||
|
||||
describe('en lecture et sur un plan bloqué', () => {
|
||||
test('en lecture : le tableau se lit ; « Retenir » et « Effacer » refusés à côté d’eux, avec « Modifier », sans fenêtre ni écriture', async () => {
|
||||
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), { mode: 'lecture' });
|
||||
expect(ids(m)).toEqual([3, 2, 1]);
|
||||
const ecritures = m.trace.length;
|
||||
const retenir = commande(ligne(m, 3), 'retenir');
|
||||
retenir.click();
|
||||
const refus = await attendre(() => {
|
||||
const pose = refusPres(retenir, 'MODE_LECTURE');
|
||||
expect(pose).not.toBeNull();
|
||||
return pose;
|
||||
});
|
||||
expect(commande(refus, 'modifier')).not.toBeNull();
|
||||
const effacer = commande(section(m), 'effacer');
|
||||
effacer.click();
|
||||
await attendre(() => expect(refusPres(effacer, 'MODE_LECTURE')).not.toBeNull());
|
||||
expect(fenetreOuverte(m)).toBeNull();
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(charge(m).retenu).toBe(null);
|
||||
});
|
||||
|
||||
test('sur un plan bloqué, retenu ajusté : « Retenir » refusé avec « Débloquer », sans fenêtre ; « Effacer » aussi', async () => {
|
||||
const chargeLue = executer(chargeSix(), 'retenirProposition', { id: 2 });
|
||||
const bloquee = executer(executer(chargeLue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null }), 'changerEtat', { etat: 'bloque' });
|
||||
const m = await ouvrir(bloquee, { mode: 'lecture' });
|
||||
expect(lignes(m).length).toBe(3);
|
||||
const ecritures = m.trace.length;
|
||||
const retenir = commande(ligne(m, 3), 'retenir');
|
||||
retenir.click();
|
||||
const refus = await attendre(() => {
|
||||
const pose = refusPres(retenir, 'LECTURE_SEULE');
|
||||
expect(pose).not.toBeNull();
|
||||
return pose;
|
||||
});
|
||||
expect(texteDe(refus)).toContain(t('LECTURE_SEULE', { raison: 'BLOQUE' }));
|
||||
expect(commande(refus, 'debloquer')).not.toBeNull();
|
||||
expect(fenetreOuverte(m)).toBeNull();
|
||||
const effacer = commande(section(m), 'effacer');
|
||||
effacer.click();
|
||||
await attendre(() => expect(refusPres(effacer, 'LECTURE_SEULE')).not.toBeNull());
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,137 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section des propositions sous sa forme minimale : son titre, tiré des libellés de la coquille.
|
||||
// La section des propositions (§ 2.1 étape 10, § 5.7, § 9) : la phrase de
|
||||
// l'ordre du classement, le tableau, et la commande qui efface la liste en
|
||||
// épargnant le retenu. Regarder n'écrit jamais (§ 8.4) : le tableau se lit
|
||||
// en lecture et sur un plan bloqué, où la séance refuse elle-même
|
||||
// « Retenir » et « Effacer », le refus posé à côté du bouton.
|
||||
//
|
||||
// La table d'évaluation se lit dans la charge de l'état observable, par
|
||||
// evaluation (modele.js) : une seule mesure, celle d'evaluer (A6), refaite
|
||||
// à chaque charge écrite ; les indicateurs du retenu suivent donc
|
||||
// l'ajustement (§ 2.1 étape 11).
|
||||
//
|
||||
// « Voir sur le plan » demande la proposition au plan par l'état de
|
||||
// navigation, sans écriture, et ouvre la section du plan ; voir(null) y
|
||||
// montre le retenu. « Retenir » exécute retenirProposition, puis remet la
|
||||
// demande à null : le plan montre le retenu. Quand le retenu est ajusté et
|
||||
// que la séance accepterait le geste, retenir détruit ces ajustements : la
|
||||
// fenêtre à confirmer le dit d'abord, nommant le retenu ajusté et la
|
||||
// proposition qui le remplace (§ 2.1). Un geste que la séance refuserait
|
||||
// s'exécute sans fenêtre, et son refus s'affiche. « Effacer » ne détruit
|
||||
// aucune saisie — chaque proposition se régénère de sa graine, et le geste
|
||||
// se défait — : il s'exécute sans fenêtre.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import TableauPropositions from './TableauPropositions.svelte';
|
||||
import { demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau } from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const evaluee = $derived(charge === null ? { table: null, refus: null } : evaluation(charge));
|
||||
const ajuste = $derived(charge !== null && retenuAjuste(charge));
|
||||
const vue = $derived(evaluee.table === null ? null : tableau(evaluee.table, { ajuste }));
|
||||
|
||||
// La retenue en attente de confirmation, telle que demandeConfirmation la
|
||||
// rend : la proposition demandée, le titre et les éléments de la fenêtre ;
|
||||
// null sans fenêtre.
|
||||
let aConfirmer = $state(null);
|
||||
|
||||
function voir(id) {
|
||||
ui.montrerProposition(id);
|
||||
ui.allerA('plan');
|
||||
}
|
||||
|
||||
async function retenirSansQuestion(id) {
|
||||
await seance.executer('retenirProposition', { id });
|
||||
ui.montrerProposition(null);
|
||||
}
|
||||
|
||||
async function retenir(id) {
|
||||
const demande = demandeConfirmation(seance.etat(), id);
|
||||
if (demande !== null) {
|
||||
aConfirmer = demande;
|
||||
return;
|
||||
}
|
||||
await retenirSansQuestion(id);
|
||||
}
|
||||
|
||||
async function confirmer() {
|
||||
await retenirSansQuestion(aConfirmer.id);
|
||||
aConfirmer = null;
|
||||
}
|
||||
|
||||
const effacer = () => seance.executer('effacerPropositions', {});
|
||||
const nommer = (id) => nomDe(charge, id);
|
||||
</script>
|
||||
|
||||
<section data-section="propositions">
|
||||
<h2>{t('coquille.section.propositions')}</h2>
|
||||
|
||||
{#if charge !== null && charge.propositions.length === 0}
|
||||
<p>{t('propositions.aucune')}</p>
|
||||
{/if}
|
||||
|
||||
{#if evaluee.refus !== null}
|
||||
<Refus refus={evaluee.refus} />
|
||||
{/if}
|
||||
|
||||
{#if vue !== null}
|
||||
<div class="ordre" data-ordre>
|
||||
{#each vue.ordre as phrase (phrase.cle)}
|
||||
<p>{t(phrase.cle, phrase.details)}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<TableauPropositions {vue} {nommer} {voir} {retenir} />
|
||||
{/if}
|
||||
|
||||
{#if charge !== null && charge.propositions.length > 0}
|
||||
<div class="effacer">
|
||||
<Commande cle="propositions.commande.effacer" executer={effacer} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if aConfirmer !== null}
|
||||
<Confirmation
|
||||
titre={aConfirmer.titre.cle}
|
||||
details={aConfirmer.titre.details}
|
||||
elements={aConfirmer.elements.map((element) => t(element.cle, element.details))}
|
||||
surConfirmer={confirmer}
|
||||
surAnnuler={() => (aConfirmer = null)}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ordre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.effacer {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
221
src/interface/propositions/TableauPropositions.svelte
Normal file
221
src/interface/propositions/TableauPropositions.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le tableau des propositions (§ 5.4, § 5.7, § 9, § 12.6) : une ligne
|
||||
// d'en-têtes, chacun nommant son unité ; la ligne du retenu, mesurée, au-
|
||||
// dessus du classement ; les propositions classées, dans l'ordre de la
|
||||
// table d'évaluation ; puis celles hors classement, « non comparables »,
|
||||
// avec leurs raisons et les personnes qu'elles nomment. Chaque cellule
|
||||
// s'écrit par texteCellule (modele.js) ; aucune valeur ne se calcule ici.
|
||||
//
|
||||
// vue : ce que rend tableau(), libellés et étendues compris. nommer(id) :
|
||||
// le nom affiché d'une personne, null pour id null ou pour une personne que
|
||||
// la charge n'a plus. voir(id) et
|
||||
// retenir(id) : les gestes d'une ligne, que la section câble ; voir(null)
|
||||
// montre le retenu. Un refus de « Retenir » se pose à côté du bouton, par
|
||||
// la Commande.
|
||||
//
|
||||
// Désignations : data-retenu sur la ligne du retenu, data-proposition sur
|
||||
// chaque autre ligne, data-colonne sur chaque cellule et chaque en-tête ;
|
||||
// data-classement et data-hors-classement sur les deux groupes de lignes.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { texteCellule } from './modele.js';
|
||||
|
||||
let { vue, nommer, voir, retenir } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const outils = { t, format };
|
||||
</script>
|
||||
|
||||
{#snippet raisons(ligne)}
|
||||
<p class="statut">{t(ligne.statut)}</p>
|
||||
{#each ligne.raisons as raison, rang (rang)}
|
||||
{@const personne = nommer(raison.participant)}
|
||||
<p class="raison">
|
||||
<span>{t(raison.cle, raison.details)}</span>
|
||||
{#if personne !== null}
|
||||
<span class="nom">{personne}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet mesures(ligne)}
|
||||
{#if ligne.comparable}
|
||||
{#each ligne.cellules as cellule (cellule.colonne)}
|
||||
<td data-colonne={cellule.colonne} class="nombre">{texteCellule(cellule, outils)}</td>
|
||||
{/each}
|
||||
<td data-colonne="imposes">
|
||||
{#each ligne.imposes as phrase, rang (rang)}
|
||||
<p>{t(phrase.cle, phrase.details)}</p>
|
||||
{/each}
|
||||
</td>
|
||||
{:else}
|
||||
<td colspan={vue.etendueRaisons} class="raisons">{@render raisons(ligne)}</td>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet identite(ligne)}
|
||||
<td data-colonne="proposition">
|
||||
<span class="identifiant">{format.nombre(ligne.id)}</span>
|
||||
{#if ligne.retenue}
|
||||
<span class="marque">{t('propositions.marque.retenue')}</span>
|
||||
{/if}
|
||||
{#if ligne.comparable}
|
||||
{#each ligne.violations as violation, rang (rang)}
|
||||
<p>{t(violation.cle, violation.details)}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
{/snippet}
|
||||
|
||||
{#snippet commandes(ligne)}
|
||||
<td data-colonne="commandes">
|
||||
<div class="commandes">
|
||||
<Commande cle="propositions.commande.voir" nom="voir" executer={() => voir(ligne.id)} />
|
||||
<Commande cle="propositions.commande.retenir" executer={() => retenir(ligne.id)} />
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
|
||||
<div class="defilement">
|
||||
<table>
|
||||
<caption>{t('propositions.tableau.titre')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each vue.colonnes as colonne (colonne.nom)}
|
||||
<th scope="col" data-colonne={colonne.nom}>{t(colonne.cle)}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
{#if vue.retenu !== null}
|
||||
<tbody>
|
||||
<tr data-retenu={vue.retenu.id} class="retenu">
|
||||
<th scope="row" data-colonne="rang">{t('propositions.retenu.titre')}</th>
|
||||
<td data-colonne="proposition">
|
||||
<span class="identifiant">{format.nombre(vue.retenu.id)}</span>
|
||||
<p>{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}</p>
|
||||
</td>
|
||||
{@render mesures(vue.retenu)}
|
||||
<td data-colonne="commandes">
|
||||
<div class="commandes">
|
||||
<Commande cle="propositions.commande.voir" nom="voir" executer={() => voir(null)} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{/if}
|
||||
<tbody data-classement>
|
||||
{#each vue.lignes as ligne (ligne.id)}
|
||||
<tr data-proposition={ligne.id} class:retenue={ligne.retenue}>
|
||||
<td data-colonne="rang" class="nombre">{format.nombre(ligne.rang)}</td>
|
||||
{@render identite(ligne)}
|
||||
{@render mesures(ligne)}
|
||||
{@render commandes(ligne)}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{#if vue.horsClassement.length > 0}
|
||||
<tbody data-hors-classement>
|
||||
<tr>
|
||||
<th scope="rowgroup" colspan={vue.colonnes.length}>{t('propositions.horsClassement.titre')}</th>
|
||||
</tr>
|
||||
{#each vue.horsClassement as ligne (ligne.id)}
|
||||
<tr data-proposition={ligne.id} class:retenue={ligne.retenue}>
|
||||
<td data-colonne="rang">{t('format.vide')}</td>
|
||||
{@render identite(ligne)}
|
||||
{@render mesures(ligne)}
|
||||
{@render commandes(ligne)}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.defilement {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-bottom: var(--espace-2);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
thead th {
|
||||
min-width: 6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nombre {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.raisons {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
.raison {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.statut {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nom {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* La ligne du retenu et la proposition retenue se distinguent par leur
|
||||
forme : texte gras, filet épais à gauche, et le mot « retenue ». */
|
||||
.retenu,
|
||||
.retenue {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.retenu > :first-child,
|
||||
.retenue > :first-child {
|
||||
border-left: 4px solid var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
.marque {
|
||||
margin-left: var(--espace-1);
|
||||
padding: 0 var(--espace-1);
|
||||
border: 1px solid var(--couleur-page-texte);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
</style>
|
||||
295
src/interface/propositions/modele.js
Normal file
295
src/interface/propositions/modele.js
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le modèle du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9, § 12.1,
|
||||
// § 12.6, § 12.9), pur : SectionPropositions.svelte et
|
||||
// TableauPropositions.svelte le câblent.
|
||||
//
|
||||
// Le tableau lit la table d'évaluation d'evaluer (A6), la seule mesure : chaque
|
||||
// cellule porte la valeur que la table rend, telle quelle, et sa clé de mise
|
||||
// en forme ; aucune arithmétique ne s'y fait, aucun plafond ne s'y recompose.
|
||||
// Des rencontres, il ne porte que le minimum, sur les trois populations du
|
||||
// § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de mesure ; la moyenne
|
||||
// n'y entre pas. Le plafond réalisé, individuel, se lit par le manque, dont
|
||||
// les cellules sont celles de troisChiffres. Les imposés se disent à part, sur
|
||||
// chaque ligne, jamais ajoutés aux choisis.
|
||||
//
|
||||
// Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) :
|
||||
// le moins de rencontres, d'appartenances vues, de diversité. La redondance
|
||||
// r(p) va dans l'autre sens — plus elle est grande, pire est le sort —, et
|
||||
// son minimum désignerait la personne la mieux servie : elle se lit par sa
|
||||
// somme Σ r(p), la grandeur que la recherche et le classement lisent.
|
||||
//
|
||||
// Une cellule vaut { colonne, valeur, cle, decimales } : cle null, la valeur
|
||||
// est un nombre que format.nombre écrit avec decimales chiffres après la
|
||||
// virgule ; sinon le texte est t(cle) — « — » pour une population vide,
|
||||
// « inconnu » pour une grandeur que l'a priori inconnu ne permet pas, le
|
||||
// certificat en mots.
|
||||
import { evaluer } from '../../application/evaluation.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
|
||||
const VIDE = 'format.vide';
|
||||
const INCONNU = 'propositions.valeur.inconnu';
|
||||
|
||||
// Chaque colonne de mesure et la lecture de sa valeur dans une ligne
|
||||
// comparable de la table d'évaluation, dans l'ordre d'affichage. La Map ne
|
||||
// sert qu'à retrouver la lecture d'une colonne ; COLONNES fixe l'ordre.
|
||||
const LECTURES = new Map([
|
||||
['rencontresTous', (l) => l.mesures.aggRencontres.tous.min],
|
||||
['rencontresMobiles', (l) => l.mesures.aggRencontres.mobiles.min],
|
||||
['rencontresAncres', (l) => l.mesures.aggRencontres.ancres.min],
|
||||
['collisionsCumulees', (l) => l.mesures.collisionsCumulees],
|
||||
['pairesDistinctes', (l) => l.mesures.pairesDistinctes],
|
||||
['excedentCollisions', (l) => l.mesures.excedentCollisions],
|
||||
['rencontresRepetees', (l) => l.mesures.rencontresRepetees.choisies],
|
||||
['maxRencontresPaire', (l) => l.mesures.maxRencontresPaire],
|
||||
['retoursChoisis', (l) => l.mesures.totalRetoursChoisis],
|
||||
['appartenancesVues', (l) => l.mesures.aggAppartenancesVues.tous.min],
|
||||
['totalRedondance', (l) => l.mesures.totalRedondance],
|
||||
['diversite', (l) => l.mesures.aggDiversite.tous.min],
|
||||
['manqueMax', (l) => l.troisChiffres.manqueMax.tous],
|
||||
['effectifManque', (l) => l.troisChiffres.effectifManque.tous],
|
||||
['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null],
|
||||
['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax],
|
||||
['certificat', (l) => l.minimumAtteint],
|
||||
]);
|
||||
|
||||
// Les colonnes dont la valeur n'existe pas quand le plafond a priori est
|
||||
// inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6).
|
||||
const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']);
|
||||
|
||||
// Le taux de diversité, un rapport, se lit au centième ; les autres colonnes
|
||||
// sont des comptes.
|
||||
const DECIMALES = new Map([['diversite', 2]]);
|
||||
|
||||
/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
|
||||
* l'identifiant, puis les mesures — les trois minimums des rencontres en
|
||||
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom
|
||||
* et la clé de son en-tête, qui nomme son unité. */
|
||||
export const COLONNES = Object.freeze(
|
||||
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
|
||||
Object.freeze({ nom, cle: `propositions.colonne.${nom}` }),
|
||||
),
|
||||
);
|
||||
|
||||
// Une cellule de nombre : null s'écrit « — ».
|
||||
const nombre = (colonne, valeur) => ({
|
||||
colonne,
|
||||
valeur,
|
||||
cle: valeur === null ? VIDE : null,
|
||||
decimales: DECIMALES.get(colonne) ?? 0,
|
||||
});
|
||||
|
||||
// Le certificat en mots (§ 5.5) : « minimum atteint » quand minimumAtteint
|
||||
// est vrai ; « atteignabilité inconnue » quand il est faux, ou null faute de
|
||||
// plafond a priori ; « — » sur une population vide.
|
||||
function certificat(ligne) {
|
||||
const valeur = ligne.minimumAtteint;
|
||||
let cle = 'propositions.certificat.inconnue';
|
||||
if (valeur === true) cle = 'propositions.certificat.atteint';
|
||||
else if (ligne.mesures.rencontres.length === 0) cle = VIDE;
|
||||
return { colonne: 'certificat', valeur, cle, decimales: 0 };
|
||||
}
|
||||
|
||||
// Les cellules de mesure d'une ligne comparable, dans l'ordre de LECTURES.
|
||||
// L'a priori est inconnu quand troisChiffres ne porte pas d'écart
|
||||
// d'itinéraire du tout.
|
||||
function cellules(ligne) {
|
||||
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
|
||||
return [...LECTURES].map(([colonne, lire]) => {
|
||||
if (colonne === 'certificat') return certificat(ligne);
|
||||
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
|
||||
return nombre(colonne, lire(ligne));
|
||||
});
|
||||
}
|
||||
|
||||
// Ce que les réservations imposent, énoncé à part (§ 5.4) : les retours et
|
||||
// les ancrages qui les imposent, les mêmes pour toute ligne ; les rencontres
|
||||
// répétées imposées, propres au plan.
|
||||
const imposes = (ligne, { retours, ancrages }) => [
|
||||
{ cle: 'propositions.imposes.retours', details: { retours, ancrages } },
|
||||
{ cle: 'propositions.imposes.repetees', details: { repetees: ligne.mesures.rencontresRepetees.imposees } },
|
||||
];
|
||||
|
||||
// Un code et ses détails : une raison de dérive, une faute, une violation.
|
||||
const codeDe = (raison) => ({ cle: raison.code, details: raison });
|
||||
|
||||
// Une raison de dérive : son code, ses détails, et la personne qu'elle nomme,
|
||||
// null quand elle n'en nomme aucune.
|
||||
const raisonDe = (raison) => ({ ...codeDe(raison), participant: raison.participant ?? null });
|
||||
|
||||
// Les colonnes que couvre la cellule des raisons d'une ligne hors
|
||||
// classement : celles des mesures et celle des imposés.
|
||||
const ETENDUE_RAISONS = LECTURES.size + 1;
|
||||
|
||||
// La ligne d'un placement mesuré, ou celle d'un placement hors classement.
|
||||
// rang : la place dans le classement, à partir de 1 ; null hors classement
|
||||
// et pour le retenu.
|
||||
function ligneDe(ligne, table, idRetenu, rang = null) {
|
||||
const retenue = ligne.id === idRetenu;
|
||||
if (ligne.statut !== 'comparable') {
|
||||
return {
|
||||
id: ligne.id,
|
||||
rang: null,
|
||||
retenue,
|
||||
comparable: false,
|
||||
statut: `propositions.statut.${ligne.statut}`,
|
||||
raisons: ligne.raisons.map(raisonDe),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: ligne.id,
|
||||
rang,
|
||||
retenue,
|
||||
comparable: true,
|
||||
cellules: cellules(ligne),
|
||||
imposes: imposes(ligne, table.imposes),
|
||||
violations: ligne.violations.map(codeDe),
|
||||
};
|
||||
}
|
||||
|
||||
// La phrase de l'ordre (§ 5.7) : le critère sauté et celui appliqué à sa
|
||||
// place quand le plafond a priori manque, puis les critères appliqués.
|
||||
function ordre({ criteresAppliques, critereSaute }) {
|
||||
const phrases = [];
|
||||
if (critereSaute !== null) {
|
||||
phrases.push({ cle: 'propositions.ordre.saute', details: { saute: critereSaute, applique: criteresAppliques[0] } });
|
||||
}
|
||||
phrases.push({ cle: 'propositions.ordre.criteres', details: { criteres: [...criteresAppliques] } });
|
||||
return phrases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le tableau d'une table d'évaluation (A6).
|
||||
*
|
||||
* @param {import('../../application/evaluation.js').TableEvaluation} tableEvaluation
|
||||
* @param {{ajuste?: boolean}} [options] ajuste : le retenu ne porte plus le
|
||||
* placement de sa proposition d'origine (retenuAjuste) ; il choisit le
|
||||
* libellé de la ligne du retenu
|
||||
* @returns {{ordre: Array<{cle: string, details: Object}>, colonnes: typeof COLONNES,
|
||||
* etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}}
|
||||
* lignes : les comparables dans l'ordre du classement, chacune
|
||||
* { id, rang, retenue, comparable: true, cellules, imposes, violations } ;
|
||||
* horsClassement : les autres par identifiant, { id, rang: null, retenue,
|
||||
* comparable: false, statut, raisons } — statut la clé de « non comparable »,
|
||||
* raisons leurs codes, chacune { cle, details, participant }, participant
|
||||
* l'identifiant de la personne en cause ou null ; etendueRaisons : le nombre
|
||||
* de colonnes que couvre la cellule des raisons ; retenu : la ligne du
|
||||
* retenu sous l'une ou l'autre forme, plus libelle { cle, details } — « tel
|
||||
* que » ou « ajusté » —, null sans retenu. retenue marque la proposition
|
||||
* d'origine du retenu.
|
||||
*/
|
||||
export function tableau(tableEvaluation, { ajuste = false } = {}) {
|
||||
const idRetenu = tableEvaluation.retenu?.id ?? null;
|
||||
// Les comparables ouvrent la liste, dans l'ordre du classement : leur
|
||||
// place y est leur rang.
|
||||
const toutes = tableEvaluation.propositions.map((ligne, place) =>
|
||||
ligneDe(ligne, tableEvaluation, idRetenu, ligne.statut === 'comparable' ? place + 1 : null),
|
||||
);
|
||||
return {
|
||||
ordre: ordre(tableEvaluation.classement),
|
||||
colonnes: COLONNES,
|
||||
etendueRaisons: ETENDUE_RAISONS,
|
||||
lignes: toutes.filter(({ comparable }) => comparable),
|
||||
horsClassement: toutes.filter(({ comparable }) => !comparable),
|
||||
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, ajuste),
|
||||
};
|
||||
}
|
||||
|
||||
// La ligne du retenu, et son libellé : « tel que » la proposition d'origine,
|
||||
// ou « ajusté » depuis elle.
|
||||
function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) {
|
||||
const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine';
|
||||
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu), libelle: { cle, details: { proposition: idRetenu } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le texte d'une cellule : t(cle) quand elle porte une clé, sinon son nombre
|
||||
* mis en forme.
|
||||
*
|
||||
* @param {{valeur: number|boolean|null, cle: string|null, decimales: number}} cellule
|
||||
* @param {{t: Function, format: {nombre: Function}}} outils
|
||||
* @returns {string}
|
||||
*/
|
||||
export function texteCellule({ valeur, cle, decimales }, { t, format }) {
|
||||
return cle === null ? format.nombre(valeur, { decimales }) : t(cle);
|
||||
}
|
||||
|
||||
// Égalité en profondeur de deux valeurs du fichier d'état : nombres,
|
||||
// chaînes, booléens, null, listes et objets simples.
|
||||
function egaux(a, b) {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
const clesA = Object.keys(a);
|
||||
if (clesA.length !== Object.keys(b).length) return false;
|
||||
return clesA.every((cle) => Object.hasOwn(b, cle) && egaux(a[cle], b[cle]));
|
||||
}
|
||||
|
||||
// Ce que le retenu copie de sa proposition d'origine, et que retenirProposition
|
||||
// remplacerait.
|
||||
const CHAMPS_DU_PLACEMENT = ['siegesAttribues', 'tables', 'capacites', 'tours', 'participants', 'placement'];
|
||||
|
||||
/** Vrai quand un retenu existe et ne porte plus le placement de sa proposition d'origine :
|
||||
* tables, capacités, tours, participants ou placement diffèrent, en profondeur, de ceux
|
||||
* de la proposition retenu.proposition, ou celle-ci n'est plus dans la liste. */
|
||||
export function retenuAjuste(charge) {
|
||||
const { retenu } = charge;
|
||||
if (retenu === null) return false;
|
||||
const origine = charge.propositions.find(({ id }) => id === retenu.proposition);
|
||||
if (origine === undefined) return true;
|
||||
return !CHAMPS_DU_PLACEMENT.every((champ) => egaux(retenu[champ], origine[champ]));
|
||||
}
|
||||
|
||||
/**
|
||||
* La fenêtre à confirmer avant de retenir la proposition id (§ 2.1) : retenir
|
||||
* remplace le retenu, et détruit ses ajustements quand il en porte. Elle
|
||||
* s'ouvre quand la séance accepterait le geste — etat.refus null — et que le
|
||||
* retenu est ajusté, y compris pour re-retenir sa proposition d'origine ; un
|
||||
* geste que la séance refuserait s'exécute sans fenêtre, et son refus
|
||||
* s'affiche.
|
||||
*
|
||||
* @param {{refus: Object|null, charge: Object|null}} etat l'état observable de la séance
|
||||
* @param {number} id la proposition à retenir
|
||||
* @returns {null|{id: number, titre: {cle: string, details: Object},
|
||||
* elements: Array<{cle: string, details: Object}>}} null sans fenêtre ;
|
||||
* sinon la proposition demandée, le titre, puis les éléments qui nomment le
|
||||
* retenu ajusté et la proposition qui le remplace
|
||||
*/
|
||||
export function demandeConfirmation({ refus, charge }, id) {
|
||||
if (refus !== null || charge === null || !retenuAjuste(charge)) return null;
|
||||
return {
|
||||
id,
|
||||
titre: { cle: 'propositions.confirmation.titre', details: { proposition: id } },
|
||||
elements: [
|
||||
{ cle: 'propositions.confirmation.retenuAjuste', details: { proposition: charge.retenu.proposition } },
|
||||
{ cle: 'propositions.confirmation.remplacant', details: { proposition: id } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La table d'évaluation d'une charge, ou ce qui l'empêche : null sans
|
||||
* proposition ni retenu, rien n'étant à mesurer ; le refus de normaliser —
|
||||
* une erreur qui porte un code — quand la configuration ne se tient pas.
|
||||
* Toute autre erreur remonte.
|
||||
*
|
||||
* @param {Object} charge
|
||||
* @returns {{table: Object|null, refus: null|{code: string, details: Object}}}
|
||||
*/
|
||||
export function evaluation(charge) {
|
||||
if (charge.propositions.length === 0 && charge.retenu === null) return { table: null, refus: null };
|
||||
try {
|
||||
return { table: evaluer(charge), refus: null };
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
return { table: null, refus: { code: erreur.code, details: erreur.details ?? {} } };
|
||||
}
|
||||
}
|
||||
|
||||
/** Le nom affiché de la personne id de la charge, exclue comprise ; null
|
||||
* pour une personne que la charge n'a plus, et pour id null. */
|
||||
export function nomDe(charge, id) {
|
||||
const personne = charge.participants.find((candidate) => candidate.id === id);
|
||||
return personne === undefined ? null : nomAffiche(personne);
|
||||
}
|
||||
606
src/interface/propositions/modele.test.js
Normal file
606
src/interface/propositions/modele.test.js
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du modèle du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9,
|
||||
// § 12.1, § 12.6, § 12.9) : chaque cellule porte la valeur de la table
|
||||
// d'évaluation et sa clé de mise en forme ; la phrase de l'ordre ; le
|
||||
// minimum seul des rencontres ; les imposés à part ; le retenu ajusté. Les
|
||||
// charges partent des démonstrations livrées ou s'écrivent ici ; leurs
|
||||
// propositions s'enregistrent par la commande. La valeur attendue vient des
|
||||
// fonctions du moteur, sollicitées une seconde fois (§ 19.1). Les noms
|
||||
// d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { appliquerCommande } from '../../application/commandes.js';
|
||||
import { evaluer } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { nomAffiche } from '../../application/personnes.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js';
|
||||
import { texteLivre } from '../../demo/livrees.js';
|
||||
import { CRITERES, classer } from '../../moteur/classement.js';
|
||||
import { indexerPlan, normaliser } from '../../moteur/configuration.js';
|
||||
import { mesurer } from '../../moteur/indicateurs.js';
|
||||
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../../moteur/manque.js';
|
||||
import { plafondsAPriori, plafondsRealises } from '../../moteur/plafond.js';
|
||||
import { rechercher } from '../../moteur/recherche.js';
|
||||
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
|
||||
import { planDepuisFichier } from '../../stockage/placements.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { COLONNES, demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau, texteCellule } from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
const V1 = VERSION.affichee;
|
||||
|
||||
const chargeDemo = (cle) => analyser(texteLivre(cle)).charge;
|
||||
|
||||
// La charge, une génération du moteur de plus, enregistrée par la commande.
|
||||
function generee(charge, reglages) {
|
||||
const configuration = configurationDepuisCharge(charge);
|
||||
const propositions = rechercher(configuration, reglages);
|
||||
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
|
||||
}
|
||||
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
|
||||
// Une proposition écrite à la main, sièges non attribués, sur les tables de
|
||||
// la charge dans leur ordre ; ses listes et réserves telles que données.
|
||||
function propositionDe(charge, id, tours, reserves = tours.map(() => [])) {
|
||||
const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p);
|
||||
return {
|
||||
id,
|
||||
graine: id,
|
||||
arret: 40,
|
||||
historique: 1000,
|
||||
produitVersion: V1,
|
||||
siegesAttribues: false,
|
||||
tables: charge.tables.map(({ id: table }) => table),
|
||||
capacites: charge.tables.map((table) => capacite(charge, table)),
|
||||
tours: tours.length,
|
||||
participants,
|
||||
placement: tours.map((listes, rang) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: reserves[rang] })),
|
||||
};
|
||||
}
|
||||
|
||||
// La charge, ses propositions remplacées par celles des plans donnés,
|
||||
// numérotées à partir de 1.
|
||||
function avecPlans(charge, plans) {
|
||||
const suivante = structuredClone(charge);
|
||||
suivante.propositions = plans.map(({ tours, reserves }, rang) => propositionDe(suivante, rang + 1, tours, reserves));
|
||||
suivante.prochainsIds.proposition = plans.length + 1;
|
||||
suivante.evenement.etat = 'propose';
|
||||
return suivante;
|
||||
}
|
||||
|
||||
// Six personnes inventées, deux tables de places, quatre tours ; les deux
|
||||
// premières partagent une appartenance quand collegues est vrai, les autres
|
||||
// n'en ont aucune.
|
||||
function chargeSix({ places = 3, collegues = true } = {}) {
|
||||
const charge = creerCharge({ id: 'evt-propositions', nom: 'Club des Merles', siegesParDefaut: places, tours: 4 });
|
||||
const noms = [['Ombrelle', 'Iris'], ['Pervenche', 'Théo'], ['Lacasse', 'Ondine'], ['Grisaille', null], ['Bruyère', 'Anouk'], ['Sarcelle', 'Capucine']];
|
||||
charge.participants = noms.map(([nom, prenom], rang) => ({
|
||||
id: rang + 1,
|
||||
nom,
|
||||
prenom,
|
||||
appartenance: collegues && rang < 2 ? 'Club des Merles' : null,
|
||||
courriel: null,
|
||||
titrePressenti: null,
|
||||
notes: null,
|
||||
exclu: false,
|
||||
}));
|
||||
charge.tables = [1, 2].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } }));
|
||||
charge.prochainsIds = { participant: 7, table: 3, proposition: 1 };
|
||||
return charge;
|
||||
}
|
||||
|
||||
// Les personnes 1 et 2 ensemble aux quatre tours, les quatre autres tournent.
|
||||
const DEUX_COLLEGUES = [
|
||||
[[1, 2, 3], [4, 5, 6]],
|
||||
[[1, 2, 4], [3, 5, 6]],
|
||||
[[1, 2, 5], [3, 4, 6]],
|
||||
[[1, 2, 6], [3, 4, 5]],
|
||||
];
|
||||
|
||||
// Trois plans de la petite démonstration au conflit inévitable, nommés par
|
||||
// leurs collisions (cumulées, distinctes), au même plus grand écart au
|
||||
// plafond a priori : ceux de l'épreuve de la table d'évaluation (A6).
|
||||
const PLANS_CONFLIT = [
|
||||
{
|
||||
nom: '(4, 1)',
|
||||
tours: [
|
||||
[[2, 3, 10], [6, 8, 9], [4, 7, 12], [1, 5, 11]],
|
||||
[[2, 7, 10], [4, 6, 8], [3, 11, 12], [1, 5, 9]],
|
||||
[[7, 9, 12], [3, 8, 11], [4, 6, 10], [1, 2, 5]],
|
||||
[[2, 7, 10], [3, 8, 9], [6, 11, 12], [1, 4, 5]],
|
||||
],
|
||||
},
|
||||
{
|
||||
nom: '(4, 2)',
|
||||
tours: [
|
||||
[[2, 6, 10], [3, 8, 9], [7, 11, 12], [1, 4, 5]],
|
||||
[[4, 7, 10], [2, 6, 8], [1, 5, 11], [3, 9, 12]],
|
||||
[[2, 6, 12], [4, 8, 10], [1, 3, 11], [5, 7, 9]],
|
||||
[[2, 3, 12], [4, 8, 10], [1, 6, 9], [5, 7, 11]],
|
||||
],
|
||||
},
|
||||
{
|
||||
nom: '(6, 6)',
|
||||
tours: [
|
||||
[[3, 4, 12], [7, 8, 9], [2, 6, 10], [1, 5, 11]],
|
||||
[[4, 6, 12], [2, 8, 10], [1, 7, 11], [3, 5, 9]],
|
||||
[[2, 4, 10], [6, 8, 11], [1, 3, 12], [5, 7, 9]],
|
||||
[[2, 7, 10], [5, 6, 8], [9, 11, 12], [1, 3, 4]],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// La valeur attendue de chaque colonne de mesure, lue dans une ligne
|
||||
// comparable de la table d'évaluation : le tableau n'en calcule aucune.
|
||||
const ATTENDUE = new Map([
|
||||
['rencontresTous', (l) => l.mesures.aggRencontres.tous.min],
|
||||
['rencontresMobiles', (l) => l.mesures.aggRencontres.mobiles.min],
|
||||
['rencontresAncres', (l) => l.mesures.aggRencontres.ancres.min],
|
||||
['collisionsCumulees', (l) => l.mesures.collisionsCumulees],
|
||||
['pairesDistinctes', (l) => l.mesures.pairesDistinctes],
|
||||
['excedentCollisions', (l) => l.mesures.excedentCollisions],
|
||||
['rencontresRepetees', (l) => l.mesures.rencontresRepetees.choisies],
|
||||
['maxRencontresPaire', (l) => l.mesures.maxRencontresPaire],
|
||||
['retoursChoisis', (l) => l.mesures.totalRetoursChoisis],
|
||||
['appartenancesVues', (l) => l.mesures.aggAppartenancesVues.tous.min],
|
||||
['totalRedondance', (l) => l.mesures.totalRedondance],
|
||||
['diversite', (l) => l.mesures.aggDiversite.tous.min],
|
||||
['manqueMax', (l) => l.troisChiffres.manqueMax.tous],
|
||||
['effectifManque', (l) => l.troisChiffres.effectifManque.tous],
|
||||
['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax.tous],
|
||||
['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax],
|
||||
['certificat', (l) => l.minimumAtteint],
|
||||
]);
|
||||
|
||||
// Les douze chiffres du § 5.4, chacun et la colonne qui le porte.
|
||||
const DOUZE = [
|
||||
['personnes distinctes rencontrées', 'rencontresTous'],
|
||||
['collisions cumulées', 'collisionsCumulees'],
|
||||
['paires de même appartenance', 'pairesDistinctes'],
|
||||
['excédent de collisions', 'excedentCollisions'],
|
||||
['rencontres répétées', 'rencontresRepetees'],
|
||||
['maximum par paire', 'maxRencontresPaire'],
|
||||
['retours', 'retoursChoisis'],
|
||||
['A(p)', 'appartenancesVues'],
|
||||
['r(p)', 'totalRedondance'],
|
||||
['d(p)', 'diversite'],
|
||||
['manque', 'manqueMax'],
|
||||
["écart d'itinéraire", 'ecartItineraireMax'],
|
||||
];
|
||||
|
||||
const cellule = (ligne, nom) => {
|
||||
const trouvee = ligne.cellules.find(({ colonne }) => colonne === nom);
|
||||
assert.ok(trouvee !== undefined, `colonne ${nom} absente`);
|
||||
return trouvee;
|
||||
};
|
||||
const texte = (c) => texteCellule(c, { t, format });
|
||||
const enTete = (nom) => t(COLONNES.find((colonne) => colonne.nom === nom).cle);
|
||||
|
||||
// Les mesures du moteur pour chaque proposition de la charge, et leur
|
||||
// classement : la valeur attendue, calculée sans la table d'évaluation.
|
||||
function parLeMoteur(charge) {
|
||||
const instance = normaliser(configurationDepuisCharge(charge));
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const parId = new Map();
|
||||
for (const proposition of charge.propositions) {
|
||||
const tableDe = indexerPlan(instance, planDepuisFichier(proposition));
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const realises = plafondsRealises(instance, tableDe);
|
||||
parId.set(proposition.id, {
|
||||
mesures,
|
||||
troisChiffres: troisChiffres(instance, mesures, aPriori, realises),
|
||||
ecarts: ecartsAuPlafondAPriori(mesures, aPriori),
|
||||
minimumAtteint: minimumAtteint(mesures, aPriori),
|
||||
});
|
||||
}
|
||||
const classement = classer(charge.propositions.map(({ id }) => ({ id, mesures: parId.get(id).mesures, plafondsAPriori: aPriori })));
|
||||
return { parId, classement };
|
||||
}
|
||||
|
||||
describe('tableau : chaque cellule porte la valeur de la table d’évaluation (§ 5.4, § 12.9)', () => {
|
||||
test('chaque cellule égale la valeur de sa ligne ; un nombre se met en forme, null porte la clé de « — »', () => {
|
||||
const table = evaluer(generee(chargeDemo('petite-conflit'), { graine: 5, nombre: 3, arret: 400 }));
|
||||
const vue = tableau(table);
|
||||
assert.deepEqual(vue.colonnes, COLONNES);
|
||||
assert.equal(vue.lignes.length, 3);
|
||||
assert.deepEqual(vue.lignes.map(({ id }) => id), table.propositions.map(({ id }) => id));
|
||||
let vues = 0;
|
||||
vue.lignes.forEach((ligne, rang) => {
|
||||
const source = table.propositions[rang];
|
||||
assert.equal(ligne.cellules.length, ATTENDUE.size);
|
||||
for (const [nom, attendue] of ATTENDUE) {
|
||||
const c = cellule(ligne, nom);
|
||||
assert.deepEqual(c.valeur, attendue(source), nom);
|
||||
if (nom === 'certificat') continue;
|
||||
assert.equal(c.cle, c.valeur === null ? 'format.vide' : null, nom);
|
||||
assert.equal(c.decimales, nom === 'diversite' ? 2 : 0, nom);
|
||||
vues += 1;
|
||||
}
|
||||
});
|
||||
assert.equal(vues, 3 * (ATTENDUE.size - 1));
|
||||
});
|
||||
|
||||
test('une population vide porte la clé de « — », jamais 0 ; un a priori inconnu, « inconnu »', () => {
|
||||
const charge = generee(chargeDemo('petite'), { graine: 2, nombre: 2, arret: 300 });
|
||||
const [ligne] = tableau(evaluer(charge)).lignes;
|
||||
const ancres = cellule(ligne, 'rencontresAncres');
|
||||
assert.deepEqual([ancres.valeur, ancres.cle], [null, 'format.vide']);
|
||||
assert.equal(texte(ancres), '\u{2014}');
|
||||
const [inconnue] = tableau(evaluer(charge, { plafondsAPriori: null })).lignes;
|
||||
for (const nom of ['ecartItineraireMax', 'ecartAuPlafondAPriori']) {
|
||||
assert.equal(cellule(inconnue, nom).cle, 'propositions.valeur.inconnu', nom);
|
||||
assert.equal(texte(cellule(inconnue, nom)), 'inconnu', nom);
|
||||
}
|
||||
assert.equal(texte(cellule(inconnue, 'certificat')), 'atteignabilité inconnue');
|
||||
});
|
||||
|
||||
test('les générations s’accumulent : 3, puis 2 sous une autre graine, cinq lignes dans l’ordre de classer sur les cinq (§ 5.7)', () => {
|
||||
let charge = generee(chargeDemo('petite-conflit'), { graine: 1, nombre: 3, arret: 300 });
|
||||
charge = generee(charge, { graine: 2, nombre: 2, arret: 300 });
|
||||
const vue = tableau(evaluer(charge));
|
||||
assert.equal(vue.lignes.length, 5);
|
||||
assert.deepEqual(vue.lignes.map(({ id }) => id), parLeMoteur(charge).classement.ordre);
|
||||
assert.deepEqual(vue.lignes.map(({ rang }) => rang), [1, 2, 3, 4, 5]);
|
||||
assert.deepEqual(vue.horsClassement, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : les douze chiffres, les chiffres du moteur (§ 5.4, § 12.9, § 19.1)', () => {
|
||||
test('trois propositions : chaque ligne porte les douze, nombre ou « — », égaux à ce que rend le moteur', () => {
|
||||
const charge = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 500 });
|
||||
const vue = tableau(evaluer(charge));
|
||||
const { parId } = parLeMoteur(charge);
|
||||
assert.equal(vue.lignes.length, 3);
|
||||
let lus = 0;
|
||||
for (const ligne of vue.lignes) {
|
||||
const moteur = parId.get(ligne.id);
|
||||
const valeurs = new Map([
|
||||
['rencontresTous', moteur.mesures.aggRencontres.tous.min],
|
||||
['collisionsCumulees', moteur.mesures.collisionsCumulees],
|
||||
['pairesDistinctes', moteur.mesures.pairesDistinctes],
|
||||
['excedentCollisions', moteur.mesures.excedentCollisions],
|
||||
['rencontresRepetees', moteur.mesures.rencontresRepetees.choisies],
|
||||
['maxRencontresPaire', moteur.mesures.maxRencontresPaire],
|
||||
['retoursChoisis', moteur.mesures.totalRetoursChoisis],
|
||||
['appartenancesVues', moteur.mesures.aggAppartenancesVues.tous.min],
|
||||
['totalRedondance', moteur.mesures.totalRedondance],
|
||||
['diversite', moteur.mesures.aggDiversite.tous.min],
|
||||
['manqueMax', moteur.troisChiffres.manqueMax.tous],
|
||||
['ecartItineraireMax', moteur.troisChiffres.ecartItineraireMax.tous],
|
||||
]);
|
||||
for (const [chiffre, nom] of DOUZE) {
|
||||
const ecrit = texte(cellule(ligne, nom));
|
||||
assert.notEqual(ecrit, '', chiffre);
|
||||
const attendue = valeurs.get(nom);
|
||||
assert.equal(ecrit, attendue === null ? '\u{2014}' : format.nombre(attendue, { decimales: nom === 'diversite' ? 2 : 0 }), chiffre);
|
||||
lus += 1;
|
||||
}
|
||||
assert.equal(cellule(ligne, 'ecartAuPlafondAPriori').valeur, Math.max(...moteur.ecarts));
|
||||
assert.equal(cellule(ligne, 'certificat').valeur, moteur.minimumAtteint);
|
||||
}
|
||||
assert.equal(lus, 36);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : les populations (§ 5.4)', () => {
|
||||
test('petite sans réservation : « ancrés » porte « — » à chaque ligne ; aucun 0, NaN ni infini pour une population vide', () => {
|
||||
const vue = tableau(evaluer(generee(chargeDemo('petite'), { graine: 4, nombre: 3, arret: 300 })));
|
||||
assert.equal(vue.lignes.length, 3);
|
||||
for (const ligne of vue.lignes) {
|
||||
assert.equal(texte(cellule(ligne, 'rencontresAncres')), '\u{2014}');
|
||||
assert.equal(cellule(ligne, 'rencontresAncres').valeur, null);
|
||||
assert.ok(cellule(ligne, 'rencontresMobiles').valeur > 0);
|
||||
for (const c of ligne.cellules) {
|
||||
if (typeof c.valeur === 'number') assert.ok(Number.isFinite(c.valeur), c.colonne);
|
||||
if (c.valeur === null) assert.notEqual(c.cle, null, c.colonne);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('grande : minimum global 24, ancrés 24, mobiles au-dessus', () => {
|
||||
const vue = tableau(evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 })));
|
||||
const [ligne] = vue.lignes;
|
||||
assert.equal(cellule(ligne, 'rencontresTous').valeur, 24);
|
||||
assert.equal(cellule(ligne, 'rencontresAncres').valeur, 24);
|
||||
assert.ok(cellule(ligne, 'rencontresMobiles').valeur > 24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : le minimum seul des rencontres (§ 5.4, § 12.1)', () => {
|
||||
test('les trois minimums ouvrent les colonnes de mesure ; aucune colonne, aucun en-tête, aucune cellule ne porte la moyenne', () => {
|
||||
assert.deepEqual(COLONNES.slice(2, 5).map(({ nom }) => nom), ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']);
|
||||
for (const nom of ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']) assert.match(enTete(nom), /minimum/);
|
||||
assert.ok(COLONNES.length > 0);
|
||||
for (const { nom, cle } of COLONNES) {
|
||||
assert.doesNotMatch(nom, /moyenne/i, nom);
|
||||
assert.doesNotMatch(t(cle), /moyenne/i, cle);
|
||||
}
|
||||
const table = evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 }));
|
||||
const [source] = table.propositions;
|
||||
// Les moyennes qu'aucun compte entier ne peut égaler par hasard.
|
||||
const moyennes = ['tous', 'mobiles', 'ancres']
|
||||
.map((population) => source.mesures.aggRencontres[population].moyenne)
|
||||
.filter((moyenne) => !Number.isInteger(moyenne));
|
||||
assert.ok(moyennes.length >= 2, 'des moyennes discernables');
|
||||
const [ligne] = tableau(table).lignes;
|
||||
for (const c of ligne.cellules) assert.ok(!moyennes.includes(c.valeur), c.colonne);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : r(p) se lit par sa somme (§ 5.4, § 12.1)', () => {
|
||||
test('aucune colonne ne porte le minimum de r(p), qui désigne la personne la mieux servie ; Σ r(p) reste', () => {
|
||||
const noms = COLONNES.map(({ nom }) => nom);
|
||||
assert.ok(noms.length > 0);
|
||||
assert.ok(noms.includes('totalRedondance'));
|
||||
assert.ok(!noms.includes('redondance'));
|
||||
for (const { cle } of COLONNES) assert.doesNotMatch(t(cle), /r\(p\), minimum/, cle);
|
||||
const table = evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 2000 }));
|
||||
const [source] = table.propositions;
|
||||
const [ligne] = tableau(table).lignes;
|
||||
assert.equal(cellule(ligne, 'totalRedondance').valeur, source.mesures.totalRedondance);
|
||||
// Le minimum de r(p) n'occupe aucune cellule de redondance.
|
||||
assert.ok(ligne.cellules.length > 0);
|
||||
assert.ok(ligne.cellules.every(({ colonne }) => colonne !== 'redondance'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : les unités (§ 5.4)', () => {
|
||||
test('deux mêmes collègues ensemble aux quatre tours : 4 collisions cumulées, 1 paire distincte, excédent 3, chaque en-tête nomme son unité', () => {
|
||||
const charge = avecPlans(chargeSix(), [{ tours: DEUX_COLLEGUES }]);
|
||||
const [ligne] = tableau(evaluer(charge)).lignes;
|
||||
assert.equal(cellule(ligne, 'collisionsCumulees').valeur, 4);
|
||||
assert.equal(cellule(ligne, 'pairesDistinctes').valeur, 1);
|
||||
assert.equal(cellule(ligne, 'excedentCollisions').valeur, 3);
|
||||
assert.match(enTete('collisionsCumulees'), /collisions cumulées/i);
|
||||
assert.match(enTete('pairesDistinctes'), /paires distinctes/i);
|
||||
assert.match(enTete('excedentCollisions'), /excédent de collisions/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : l’ordre écrit (§ 5.7, § 17 point 4)', () => {
|
||||
test('la phrase cite les cinq critères dans l’ordre, puis l’identifiant ; les trois plans d’A6 : (6, 6), (4, 2), (4, 1)', () => {
|
||||
const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
|
||||
const vue = tableau(evaluer(charge));
|
||||
assert.deepEqual(vue.lignes.map(({ id }) => id), [3, 2, 1]);
|
||||
assert.deepEqual(vue.ordre, [{ cle: 'propositions.ordre.criteres', details: { criteres: [...CRITERES] } }]);
|
||||
const phrase = t(vue.ordre[0].cle, vue.ordre[0].details);
|
||||
const positions = CRITERES.map((nom) => phrase.indexOf(t(`moteur.critere.${nom}`)));
|
||||
assert.equal(positions.length, 5);
|
||||
assert.ok(positions.every((position, rang) => position >= 0 && (rang === 0 || position > positions[rang - 1])), phrase);
|
||||
assert.ok(phrase.indexOf('identifiant') > positions[4], phrase);
|
||||
});
|
||||
|
||||
test('a priori inconnu : la phrase nomme le critère sauté et « excédent de collisions » appliqué à sa place', () => {
|
||||
const charge = avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
|
||||
const vue = tableau(evaluer(charge, { plafondsAPriori: null }));
|
||||
assert.equal(vue.ordre.length, 2);
|
||||
assert.deepEqual(vue.ordre[0], {
|
||||
cle: 'propositions.ordre.saute',
|
||||
details: { saute: 'ecartAuPlafondAPrioriMax', applique: 'excedentCollisions' },
|
||||
});
|
||||
const saute = t(vue.ordre[0].cle, vue.ordre[0].details);
|
||||
assert.ok(saute.indexOf('écart au plafond a priori') >= 0 && saute.indexOf('excédent de collisions') > saute.indexOf('écart au plafond a priori'), saute);
|
||||
assert.equal(vue.ordre[1].details.criteres[0], 'excedentCollisions');
|
||||
for (const ligne of vue.lignes) assert.equal(texte(cellule(ligne, 'ecartItineraireMax')), 'inconnu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : la diversité en colonne (§ 5.7)', () => {
|
||||
test('chaque ligne porte A(p), Σ r(p), d(p) et les paires distinctes ; sans affilié rencontré, d(p) s’écrit « — »', () => {
|
||||
const vue = tableau(evaluer(generee(chargeDemo('petite'), { graine: 6, nombre: 2, arret: 300 })));
|
||||
assert.equal(vue.lignes.length, 2);
|
||||
for (const ligne of vue.lignes) {
|
||||
for (const nom of ['appartenancesVues', 'totalRedondance', 'diversite', 'pairesDistinctes']) {
|
||||
assert.equal(typeof cellule(ligne, nom).valeur, 'number', nom);
|
||||
}
|
||||
}
|
||||
const sans = avecPlans(chargeSix({ collegues: false }), [{ tours: DEUX_COLLEGUES }]);
|
||||
const [ligne] = tableau(evaluer(sans)).lignes;
|
||||
assert.equal(cellule(ligne, 'diversite').valeur, null);
|
||||
assert.equal(texte(cellule(ligne, 'diversite')), '\u{2014}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : le certificat (§ 5.5, § 15.3)', () => {
|
||||
test('le plan parfait de la petite, enregistré comme proposition : « minimum atteint »', () => {
|
||||
const charge = avecPlans(chargeDemo('petite'), [{ tours: PLAN_PARFAIT_PETITE.tours }]);
|
||||
const [ligne] = tableau(evaluer(charge)).lignes;
|
||||
assert.equal(cellule(ligne, 'certificat').valeur, true);
|
||||
assert.equal(texte(cellule(ligne, 'certificat')), 'minimum atteint');
|
||||
});
|
||||
|
||||
test('grande : jamais ; l’écart au plafond a priori et « atteignabilité inconnue »', () => {
|
||||
const [ligne] = tableau(evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 1, arret: 50_000 }))).lignes;
|
||||
assert.equal(texte(cellule(ligne, 'certificat')), 'atteignabilité inconnue');
|
||||
assert.ok(cellule(ligne, 'ecartAuPlafondAPriori').valeur >= 1);
|
||||
});
|
||||
|
||||
test('manque nul sur des itinéraires bas, une personne en réserve trois tours sur quatre : pas de certificat', () => {
|
||||
const tours = [
|
||||
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||
[[6, 9, 12], [3, 8, 11], [2, 5], [1, 4, 7]],
|
||||
[[4, 8], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
|
||||
[[5, 7, 11], [1, 9], [3, 4, 12], [2, 6, 8]],
|
||||
];
|
||||
const charge = avecPlans(chargeDemo('petite'), [{ tours, reserves: [[], [10], [10], [10]] }]);
|
||||
const [ligne] = tableau(evaluer(charge)).lignes;
|
||||
assert.equal(cellule(ligne, 'manqueMax').valeur, 0);
|
||||
assert.equal(cellule(ligne, 'certificat').valeur, false);
|
||||
assert.equal(texte(cellule(ligne, 'certificat')), 'atteignabilité inconnue');
|
||||
assert.ok(cellule(ligne, 'ecartItineraireMax').valeur > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : le manque contre le plafond réalisé (§ 5.5)', () => {
|
||||
test('le plus grand manque et son effectif égalent troisChiffres sur plafondsRealises ; aucune colonne ne recompose un plafond', () => {
|
||||
const charge = generee(chargeDemo('petite-conflit'), { graine: 8, nombre: 3, arret: 300 });
|
||||
const vue = tableau(evaluer(charge));
|
||||
const instance = normaliser(configurationDepuisCharge(charge));
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
assert.equal(vue.lignes.length, 3);
|
||||
for (const ligne of vue.lignes) {
|
||||
const tableDe = indexerPlan(instance, planDepuisFichier(charge.propositions.find(({ id }) => id === ligne.id)));
|
||||
const mesures = mesurer(instance, tableDe);
|
||||
const trois = troisChiffres(instance, mesures, aPriori, plafondsRealises(instance, tableDe));
|
||||
assert.equal(cellule(ligne, 'manqueMax').valeur, trois.manqueMax.tous);
|
||||
assert.equal(cellule(ligne, 'effectifManque').valeur, trois.effectifManque.tous);
|
||||
assert.equal(cellule(ligne, 'rencontresTous').valeur, mesures.aggRencontres.tous.min);
|
||||
}
|
||||
for (const { nom } of COLONNES) assert.doesNotMatch(nom, /plafond(?!APriori)/i, nom);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : le retenu et les placements hors classement (§ 9, § 12.6)', () => {
|
||||
test('le retenu se mesure sur sa propre ligne ; la proposition d’origine est marquée', () => {
|
||||
let charge = generee(chargeDemo('petite'), { graine: 3, nombre: 3, arret: 300 });
|
||||
charge = executer(charge, 'retenirProposition', { id: 2 });
|
||||
const table = evaluer(charge);
|
||||
const vue = tableau(table);
|
||||
assert.equal(vue.retenu.id, 2);
|
||||
assert.equal(vue.retenu.comparable, true);
|
||||
assert.deepEqual(vue.retenu.libelle, { cle: 'propositions.retenu.origine', details: { proposition: 2 } });
|
||||
assert.deepEqual(tableau(table, { ajuste: true }).retenu.libelle, { cle: 'propositions.retenu.ajuste', details: { proposition: 2 } });
|
||||
assert.equal(vue.lignes.filter(({ libelle }) => libelle !== undefined).length, 0);
|
||||
for (const [nom, attendue] of ATTENDUE) assert.deepEqual(cellule(vue.retenu, nom).valeur, attendue(table.retenu), nom);
|
||||
assert.deepEqual(vue.lignes.filter(({ retenue }) => retenue).map(({ id }) => id), [2]);
|
||||
});
|
||||
|
||||
test('générer, exclure une personne, générer encore : les premières hors classement, non comparables, leurs raisons nomment la personne', () => {
|
||||
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 });
|
||||
charge = executer(charge, 'exclureParticipant', { id: 4 });
|
||||
charge = generee(charge, { graine: 2, nombre: 2, arret: 200 });
|
||||
const vue = tableau(evaluer(charge));
|
||||
assert.deepEqual(vue.lignes.map(({ id }) => id).sort(), [3, 4]);
|
||||
assert.deepEqual(vue.horsClassement.map(({ id }) => id), [1, 2]);
|
||||
for (const ligne of vue.horsClassement) {
|
||||
assert.equal(ligne.rang, null);
|
||||
assert.equal(ligne.statut, 'propositions.statut.derive');
|
||||
assert.deepEqual(ligne.raisons, [{ cle: 'PARTICIPANT_EXCLU', details: { code: 'PARTICIPANT_EXCLU', participant: 4 }, participant: 4 }]);
|
||||
assert.match(t(ligne.raisons[0].cle, ligne.raisons[0].details), /4/);
|
||||
}
|
||||
assert.equal(nomDe(charge, 4), nomAffiche(charge.participants.find(({ id }) => id === 4)));
|
||||
assert.equal(nomDe(charge, 999), null);
|
||||
assert.equal(nomDe(charge, null), null);
|
||||
// La cellule des raisons couvre les colonnes de mesure et celle des
|
||||
// imposés d'une ligne comparable.
|
||||
const [comparable] = vue.lignes;
|
||||
assert.equal(vue.etendueRaisons, comparable.cellules.length + 1);
|
||||
assert.equal(vue.etendueRaisons, COLONNES.length - 3);
|
||||
});
|
||||
|
||||
test('une exclusion et un ajout, à effectif égal : hors classement aussi', () => {
|
||||
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 200 });
|
||||
charge = executer(charge, 'exclureParticipant', { id: 4 });
|
||||
charge = executer(charge, 'ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Club des Merles' } });
|
||||
const vue = tableau(evaluer(charge));
|
||||
assert.deepEqual(vue.lignes, []);
|
||||
assert.deepEqual(vue.horsClassement.map(({ id }) => id), [1]);
|
||||
assert.deepEqual(vue.horsClassement[0].raisons.map(({ cle }) => cle), ['PARTICIPANT_EXCLU', 'PARTICIPANT_NON_PLACE']);
|
||||
// Chaque raison porte la personne qu'elle nomme, null quand elle n'en
|
||||
// nomme aucune.
|
||||
assert.deepEqual(
|
||||
vue.horsClassement[0].raisons.map(({ participant }) => participant),
|
||||
vue.horsClassement[0].raisons.map(({ details }) => details.participant ?? null),
|
||||
);
|
||||
assert.ok(vue.horsClassement[0].raisons.every(({ participant }) => participant !== undefined));
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableau : les imposés à part (§ 5.4)', () => {
|
||||
test('grande : chaque ligne énonce « 99 retours imposés par 33 ancrages », à part des retours choisis', () => {
|
||||
const table = evaluer(generee(chargeDemo('grande'), { graine: 1, nombre: 2, arret: 20_000 }));
|
||||
const vue = tableau(table);
|
||||
assert.equal(vue.lignes.length, 2);
|
||||
vue.lignes.forEach((ligne, rang) => {
|
||||
const [retours] = ligne.imposes;
|
||||
assert.deepEqual(retours, { cle: 'propositions.imposes.retours', details: { retours: 99, ancrages: 33 } });
|
||||
assert.equal(t(retours.cle, retours.details), '99 retours imposés par 33 ancrages.');
|
||||
assert.equal(cellule(ligne, 'retoursChoisis').valeur, table.propositions[rang].mesures.totalRetoursChoisis);
|
||||
});
|
||||
});
|
||||
|
||||
test('deux personnes réservées ensemble : les rencontres répétées imposées à part des choisies, jamais additionnées', () => {
|
||||
const charge = structuredClone(chargeDemo('petite'));
|
||||
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
const table = evaluer(generee(charge, { graine: 6, nombre: 3, arret: 500 }));
|
||||
const vue = tableau(table);
|
||||
assert.equal(vue.lignes.length, 3);
|
||||
vue.lignes.forEach((ligne, rang) => {
|
||||
const { choisies, imposees } = table.propositions[rang].mesures.rencontresRepetees;
|
||||
assert.equal(imposees, 1);
|
||||
assert.equal(cellule(ligne, 'rencontresRepetees').valeur, choisies);
|
||||
assert.deepEqual(ligne.imposes[1], { cle: 'propositions.imposes.repetees', details: { repetees: 1 } });
|
||||
assert.equal(t(ligne.imposes[1].cle, ligne.imposes[1].details), '1 rencontre répétée imposée.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('retenuAjuste (§ 2.1, § 5.9)', () => {
|
||||
// La charge à six personnes, deux tables de quatre, une proposition.
|
||||
const deTables = () => avecPlans(chargeSix({ places: 4 }), [{ tours: DEUX_COLLEGUES }]);
|
||||
|
||||
test('faux sans retenu, et pour un retenu tel que retenu', () => {
|
||||
const charge = deTables();
|
||||
assert.equal(retenuAjuste(charge), false);
|
||||
assert.equal(retenuAjuste(executer(charge, 'retenirProposition', { id: 1 })), false);
|
||||
});
|
||||
|
||||
test('vrai après un deplacerDansRetenu, vrai quand la proposition d’origine n’est plus dans la liste', () => {
|
||||
const retenue = executer(deTables(), 'retenirProposition', { id: 1 });
|
||||
const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null });
|
||||
assert.equal(retenuAjuste(deplacee), true);
|
||||
const sansOrigine = structuredClone(retenue);
|
||||
sansOrigine.propositions = [];
|
||||
assert.equal(retenuAjuste(sansOrigine), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('demandeConfirmation (§ 2.1)', () => {
|
||||
const deTables = () => avecPlans(chargeSix({ places: 4 }), [{ tours: DEUX_COLLEGUES }, { tours: DEUX_COLLEGUES }]);
|
||||
|
||||
test('null sans retenu ajusté, sans charge, ou quand la séance refuserait le geste', () => {
|
||||
const charge = deTables();
|
||||
assert.equal(demandeConfirmation({ refus: null, charge }, 2), null);
|
||||
assert.equal(demandeConfirmation({ refus: null, charge: null }, 2), null);
|
||||
const retenue = executer(charge, 'retenirProposition', { id: 1 });
|
||||
assert.equal(demandeConfirmation({ refus: null, charge: retenue }, 2), null);
|
||||
const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null });
|
||||
const refus = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE' } };
|
||||
assert.equal(demandeConfirmation({ refus, charge: deplacee }, 2), null);
|
||||
});
|
||||
|
||||
test('un retenu ajusté que la séance laisserait remplacer : la fenêtre nomme le retenu ajusté et la proposition qui le remplace', () => {
|
||||
const retenue = executer(deTables(), 'retenirProposition', { id: 1 });
|
||||
const deplacee = executer(retenue, 'deplacerDansRetenu', { tour: 1, participant: 1, table: 2, siege: null });
|
||||
assert.deepEqual(demandeConfirmation({ refus: null, charge: deplacee }, 2), {
|
||||
id: 2,
|
||||
titre: { cle: 'propositions.confirmation.titre', details: { proposition: 2 } },
|
||||
elements: [
|
||||
{ cle: 'propositions.confirmation.retenuAjuste', details: { proposition: 1 } },
|
||||
{ cle: 'propositions.confirmation.remplacant', details: { proposition: 2 } },
|
||||
],
|
||||
});
|
||||
// Re-retenir l'origine détruit aussi les ajustements.
|
||||
assert.equal(demandeConfirmation({ refus: null, charge: deplacee }, 1).id, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluation : la table d’une charge, ou ce qui l’empêche', () => {
|
||||
test('sans proposition ni retenu, rien à évaluer ; une configuration qui ne se normalise pas rend son refus', () => {
|
||||
assert.deepEqual(evaluation(chargeDemo('petite')), { table: null, refus: null });
|
||||
const avec = generee(chargeDemo('petite'), { graine: 1, nombre: 1, arret: 100 });
|
||||
assert.deepEqual(evaluation(avec), { table: evaluer(avec), refus: null });
|
||||
const charge = structuredClone(avec);
|
||||
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
|
||||
const { table, refus } = evaluation(charge);
|
||||
assert.equal(table, null);
|
||||
assert.equal(refus.code, 'SURRESERVATION');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue