[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:
Mathieu Benoit 2026-10-07 09:10:39 -04:00
parent d19394957f
commit ad19131954
6 changed files with 1887 additions and 4 deletions

View file

@ -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`,
],
]);

View 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);
});
});

View file

@ -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>

View 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>

View 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);
}

View 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');
});
});