The Qualité tab now draws its five views from the placement's detail. A numeric header with each figure's percentage and worst case; the profile as counts with the diversity bound dotted, and as percentages on a second chart; membership collisions and a table grid behind a three-position selector, seats by default; repeated pairs with matrix and a card section; goals per round on servable couples and facilitators over the evening and per round; the course of the search. Each chart has its twin table and keyboard access. Checked: node 5083, navigateur 1102, node-long 128; captures stable. --- FR --- [ADD] vues de la qualité : en-tête, profils, appartenances, paires L'onglet Qualité dessine désormais ses cinq vues à partir du détail du placement : un en-tête chiffré avec le pourcentage et le pire cas de chaque chiffre ; le profil en comptes, borne de diversité en pointillé, et en pourcentages sur un second graphique ; les collisions et la grille des tables derrière un sélecteur à trois positions, les sièges par défaut ; les paires répétées, leur matrice et la fiche ; les objectifs par tour et les animateurs sur la soirée et par tour ; la course. Chaque graphique a son tableau jumeau et se parcourt au clavier. Vérifié : node 5083, navigateur 1102, longues 128 ; captures stables. Assisted-by: Claude Opus 5.5
967 lines
48 KiB
JavaScript
967 lines
48 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Épreuves du détail d'un placement (§ 12, D27 à D37, D51, D52) : le
|
||
// pourcentage et ses bords, le profil trié et ses marques, la répartition,
|
||
// la synthèse, les bornes des tuiles, les barres des collisions, le mélange
|
||
// des appartenances à chaque table et ses trois ordres, les objectifs tour
|
||
// par tour, le profil des animateurs, et detailler qui les réunit. Chaque
|
||
// configuration est inventée et petite ; ses valeurs sont comptées à la main
|
||
// dans le commentaire qui la précède, et une lecture directe du plan, qui
|
||
// n'emprunte rien au module éprouvé, recoupe les comptes par table et par
|
||
// tour. Identifiants et index diffèrent quand l'ordre en dépend.
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { describe, test } from '../../test/lanceur.js';
|
||
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
|
||
import { LIBRE, indexerPlan, normaliser } from './configuration.js';
|
||
import { diagnostiquer } from './diagnostic.js';
|
||
import { diversiteAnimateurs } from './diversite.js';
|
||
import { mesurer, parcourirTours } from './indicateurs.js';
|
||
import { matriceRencontres, pairesRepetees } from './paires.js';
|
||
import { borneDiversite, occupations, plafondsAPriori, plafondsRealises } from './plafond.js';
|
||
import {
|
||
barresCollisions,
|
||
bornesDesTuiles,
|
||
detailler,
|
||
mixTables,
|
||
objectifsParTour,
|
||
planchersParGroupe,
|
||
pourcentage,
|
||
profilAnimateurs,
|
||
profilRencontres,
|
||
repartition,
|
||
synthese,
|
||
} from './qualite.js';
|
||
|
||
const AUBIER = 'Coopérative Aubier';
|
||
const RESSAC = 'Atelier Ressac';
|
||
|
||
const SANS_CONTRAINTE = {
|
||
separerAppartenances: false,
|
||
nouveauxVoisins: false,
|
||
nouvelleTable: false,
|
||
varierAppartenances: false,
|
||
};
|
||
|
||
// La clé normalisée d'une appartenance, telle que l'appelant la passe : le
|
||
// moteur n'importe pas le CSV, et l'épreuve n'en a pas besoin.
|
||
const cle = (libelle) => libelle.toLowerCase();
|
||
|
||
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
|
||
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
|
||
|
||
// Participant présent : [id, appartenance, étiquettes, tours animés], les
|
||
// trois derniers facultatifs.
|
||
const participant = ([id, appartenance = null, etiquettes, anime]) => ({
|
||
id,
|
||
nom: `P${id}`,
|
||
appartenance,
|
||
...(etiquettes === undefined ? {} : { etiquettes }),
|
||
...(anime === undefined ? {} : { anime }),
|
||
});
|
||
|
||
// Configuration, instance et plan indexé. tables : [id, capacité], dans
|
||
// l'ordre de la configuration ; plan[r] associe à un identifiant de table
|
||
// les identifiants qui s'y assoient au tour r + 1 ; un présent absent d'un
|
||
// tour est en réserve.
|
||
function preparer({ personnes, tables, plan, reservations = [], objectifs = [], contraintes = SANS_CONTRAINTE }) {
|
||
const configuration = {
|
||
participants: personnes.map(participant),
|
||
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
|
||
tours: plan.length,
|
||
reservations,
|
||
contraintes,
|
||
...(objectifs.length === 0 ? {} : { objectifs }),
|
||
};
|
||
const instance = normaliser(configuration);
|
||
const ids = configuration.tables.map(({ id }) => id);
|
||
const tours = plan.map((tour) => ids.map((id) => tour[id] ?? []));
|
||
const reserves = tours.map((listes) => {
|
||
const assis = new Set(listes.flat());
|
||
return instance.ids.filter((id) => !assis.has(id));
|
||
});
|
||
const tableDe = indexerPlan(instance, { tables: ids, tours, reserves });
|
||
return { configuration, instance, tableDe, tours };
|
||
}
|
||
|
||
// Les mesures d'un plan préparé, ses plafonds et sa borne de diversité.
|
||
function mesurerTout(prepare) {
|
||
const { instance, tableDe } = prepare;
|
||
return {
|
||
mesures: mesurer(instance, tableDe),
|
||
realises: plafondsRealises(instance, tableDe),
|
||
aPriori: plafondsAPriori(instance),
|
||
aMax: borneDiversite(instance, tableDe).aMax,
|
||
};
|
||
}
|
||
|
||
const index = (instance, id) => instance.indexDe.get(id);
|
||
const idsDe = (instance, lignes) => lignes.map(({ p }) => instance.ids[p]);
|
||
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
|
||
|
||
describe('pourcentage (D52, § 5.4)', () => {
|
||
test('les bords : 0 à numérateur nul, 100 au numérateur égal, null à dénominateur nul', () => {
|
||
assert.equal(pourcentage(0, 27), 0);
|
||
assert.equal(pourcentage(27, 27), 100);
|
||
assert.equal(pourcentage(5, 0), null);
|
||
assert.equal(pourcentage(0, 0), null);
|
||
});
|
||
|
||
test('entre les bords, la partie entière, jamais 0 ni 100 : 259 sur 260 donne 99, 1 sur 300 donne 1', () => {
|
||
assert.equal(pourcentage(259, 260), 99);
|
||
assert.equal(pourcentage(1, 300), 1);
|
||
assert.equal(pourcentage(24, 27), 88);
|
||
assert.equal(pourcentage(2, 3), 66);
|
||
assert.equal(pourcentage(1, 2), 50);
|
||
assert.equal(pourcentage(5, 8), 62);
|
||
// Le centième exact n'est pas arrondi à la baisse d'un cran.
|
||
assert.equal(pourcentage(29, 100), 29);
|
||
assert.equal(pourcentage(57, 100), 57);
|
||
});
|
||
|
||
test('au-delà du dénominateur, que la garde refuse, le quotient entier : rien ne le ramène à 100', () => {
|
||
assert.equal(pourcentage(28, 27), 103);
|
||
});
|
||
|
||
test('lève RangeError sur un compte qui n’est pas un entier positif ou nul', () => {
|
||
for (const [n, d] of [[-1, 4], [1.5, 4], [1, -4], [1, 2.5], [Number.NaN, 4], ['3', 4], [1, null]]) {
|
||
assert.throws(() => pourcentage(n, d), RangeError, `${String(n)} sur ${String(d)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Six personnes, deux tables de trois, deux tours ; 1 en réserve au tour 2.
|
||
// Rencontres et plafonds réalisés, comptés à la main (aucun ancré, n_p = 5) :
|
||
// 1 : {2, 3}, 2 sur 2 ; 2 : {1, 3, 4, 5}, 4 sur 4 ; 3 : {1, 2, 6}, 3 sur 3 ;
|
||
// 4 : {5, 6, 2}, 3 sur 4 ; 5 : {4, 6, 2}, 3 sur 4 ; 6 : {4, 5, 3}, 3 sur 3.
|
||
// À trois rencontres, 4 et 5, au plafond le plus haut, passent devant 3 et
|
||
// 6 ; trié sur le pourcentage, 4 et 5, à 75, viendraient en tête.
|
||
// Appartenances : 1, 3 Aubier ; 2, 4 Ressac ; 5, 6 sans. A(p) vaut 1 pour 4
|
||
// et 5 seulement ; A_max(4) = min(Φ_aff = 1, G = 2, 4 − 2 + 1) = 1,
|
||
// A_max(5) = min(Φ_aff = 3, 2) = 2.
|
||
const PROFIL = {
|
||
personnes: [[1, AUBIER], [2, RESSAC], [3, AUBIER], [4, RESSAC], [5], [6]],
|
||
tables: [[10, 3], [20, 3]],
|
||
plan: [
|
||
{ 10: [1, 2, 3], 20: [4, 5, 6] },
|
||
{ 10: [2, 4, 5], 20: [3, 6] },
|
||
],
|
||
};
|
||
|
||
describe('profilRencontres (§ 12.3, D28, D51)', () => {
|
||
test('rencontres croissantes, puis plafond réalisé décroissant, puis identifiant : jamais le pourcentage', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { instance } = prepare;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||
assert.deepEqual(idsDe(instance, profil), [1, 4, 5, 3, 6, 2]);
|
||
assert.deepEqual(profil.map(({ rencontres, plafondRealise }) => [rencontres, plafondRealise]), [[2, 2], [3, 4], [3, 4], [3, 3], [3, 3], [4, 4]]);
|
||
assert.deepEqual(profil.map(({ pourcent }) => pourcent), [100, 75, 75, 100, 100, 100]);
|
||
});
|
||
|
||
test('chaque ligne porte ce que les mesures donnent pour sa personne, lu sans recompte', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { instance } = prepare;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
for (const ligne of profilRencontres(instance, mesures, realises, aPriori, aMax)) {
|
||
const { p } = ligne;
|
||
assert.deepEqual(ligne, {
|
||
p,
|
||
rencontres: mesures.rencontres[p],
|
||
plafondRealise: realises[p],
|
||
pourcent: pourcentage(mesures.rencontres[p], realises[p]),
|
||
plafondAPriori: aPriori[p],
|
||
appartenances: mesures.appartenancesVues[p],
|
||
affilies: mesures.affilies[p],
|
||
borneDiversite: aMax[p],
|
||
toursNonAssis: 2 - mesures.toursAssis[p],
|
||
marque: p === index(instance, 1) ? 'reserve' : null,
|
||
});
|
||
}
|
||
// A(p) et A_max, comptés à la main.
|
||
assert.deepEqual(mesures.appartenancesVues, [2, 2, 2, 1, 1, 2]);
|
||
assert.deepEqual([aMax[3], aMax[4]], [1, 2]);
|
||
});
|
||
|
||
test('plafond a priori inconnu : null sur chaque ligne', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { mesures, realises, aMax } = mesurerTout(prepare);
|
||
const profil = profilRencontres(prepare.instance, mesures, realises, null, aMax);
|
||
assert.ok(profil.every(({ plafondAPriori }) => plafondAPriori === null));
|
||
});
|
||
|
||
// Tables 10, 20, 30, 40 de deux places, deux tours. 1 ancré à la table
|
||
// 10 ; 2 réservé à la table 20 au tour 1 seulement, en réserve au tour 2 ;
|
||
// 5 en réserve au tour 1 ; 6 seul à la table 30 aux deux tours, plafond
|
||
// réalisé nul ; 7 seul à la table 40 au tour 1 et en réserve au tour 2,
|
||
// plafond réalisé nul aussi : la réserve passe avant le plafond nul ; 3 et
|
||
// 4 assis aux deux tours, chacun à son plafond.
|
||
test('les marques, la première qui s’applique : ancré, partiellement fixé, en réserve, plafond nul ; le pourcentage null à plafond nul', () => {
|
||
const prepare = preparer({
|
||
personnes: [[1], [2], [3], [4], [5], [6], [7]],
|
||
tables: [[10, 2], [20, 2], [30, 2], [40, 2]],
|
||
plan: [
|
||
{ 10: [1, 3], 20: [2, 4], 30: [6], 40: [7] },
|
||
{ 10: [1, 5], 20: [3, 4], 30: [6] },
|
||
],
|
||
reservations: [tous(1, 10), auTour(2, 20, 1)],
|
||
});
|
||
const { instance } = prepare;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||
const deId = (id) => profil.find(({ p }) => p === index(instance, id));
|
||
assert.deepEqual([1, 2, 3, 4, 5, 6, 7].map((id) => deId(id).marque), ['ancre', 'partiel', null, null, 'reserve', 'plafondNul', 'reserve']);
|
||
assert.equal(deId(2).toursNonAssis, 1);
|
||
assert.deepEqual([deId(7).toursNonAssis, deId(7).plafondRealise], [1, 0]);
|
||
assert.deepEqual([deId(6).plafondRealise, deId(6).pourcent], [0, null]);
|
||
assert.deepEqual(idsDe(instance, profil).slice(0, 1), [6]);
|
||
});
|
||
|
||
test('lève RangeError sur une liste qui n’a pas une case par personne', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
const { instance } = prepare;
|
||
assert.throws(() => profilRencontres(instance, mesures, realises.slice(1), aPriori, aMax), RangeError);
|
||
assert.throws(() => profilRencontres(instance, mesures, realises, aPriori.slice(1), aMax), RangeError);
|
||
assert.throws(() => profilRencontres(instance, mesures, realises, aPriori, aMax.slice(1)), RangeError);
|
||
});
|
||
});
|
||
|
||
describe('repartition et synthese (§ 12.3)', () => {
|
||
test('une ligne par valeur présente, croissante, avec l’effectif, le cumul et les personnes à leur plafond, contre une lecture directe', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { mesures, realises } = mesurerTout(prepare);
|
||
const lignes = repartition(mesures.rencontres, realises);
|
||
assert.deepEqual(lignes, [
|
||
{ valeur: 2, effectif: 1, cumul: 1, auPlafond: 1 },
|
||
{ valeur: 3, effectif: 4, cumul: 5, auPlafond: 2 },
|
||
{ valeur: 4, effectif: 1, cumul: 6, auPlafond: 1 },
|
||
]);
|
||
const valeurs = [...new Set(mesures.rencontres)].sort((a, b) => a - b);
|
||
assert.deepEqual(lignes.map(({ valeur, effectif, auPlafond }) => ({ valeur, effectif, auPlafond })), valeurs.map((valeur) => ({
|
||
valeur,
|
||
effectif: mesures.rencontres.filter((v) => v === valeur).length,
|
||
auPlafond: mesures.rencontres.filter((v, p) => v === valeur && v === realises[p]).length,
|
||
})));
|
||
});
|
||
|
||
test('sans plafonds, auPlafond vaut null ; sans valeur, aucune ligne', () => {
|
||
assert.deepEqual(repartition([3, 1, 3], null), [
|
||
{ valeur: 1, effectif: 1, cumul: 1, auPlafond: null },
|
||
{ valeur: 3, effectif: 2, cumul: 3, auPlafond: null },
|
||
]);
|
||
assert.deepEqual(repartition([], []), []);
|
||
assert.throws(() => repartition([1, 2], [1]), RangeError);
|
||
});
|
||
|
||
test('la synthèse : le minimum, son effectif, et les personnes sous leur plafond réalisé', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { instance } = prepare;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
const resume = synthese(profilRencontres(instance, mesures, realises, aPriori, aMax));
|
||
assert.deepEqual(resume, { minimum: 2, effectifAuMinimum: 1, effectifSousPlafond: 2 });
|
||
assert.equal(resume.minimum, mesures.aggRencontres.tous.min);
|
||
assert.deepEqual(synthese([]), { minimum: null, effectifAuMinimum: 0, effectifSousPlafond: 0 });
|
||
});
|
||
});
|
||
|
||
// Dix « finance », 1 à 5 libres, 4 réservé à la table 2 au tour 1, 6 ancré à
|
||
// la table 1 ; 7 à 9 sans étiquette. Table 1, trois places, « finance » aux
|
||
// tours 1 et 2 ; tables 2 (trois places) et 3 (quatre places), sans objectif ;
|
||
// trois tours, le troisième sans objectif. 9 est en réserve au tour 2.
|
||
//
|
||
// Couples possibles : tour 1, 1, 2, 3, 5 et 6 (4 est fixé à une table sans
|
||
// objectif) ; tour 2, 1 à 6 ; tour 3, aucun. P_r, les non fixés, et L_r, les
|
||
// deux sièges libres de la table 1 : planchers 4 − 2 = 2, 5 − 2 = 3, 0.
|
||
// Tour 1 : 6, 1, 2 servis ; hors 2, plancher 2, reste 0 ; 4, 7, 8, 9 sans
|
||
// table possible. Tour 2 : 6 et 3 servis ; hors 4, plancher 3, reste 1 ; 7
|
||
// et 8 sans table possible, 9 en réserve. Tour 3 : rien de possible, les neuf
|
||
// assis sans table possible.
|
||
// s(p) : 1, 1, 1, 0, 0, 2 ; P(p) : 2, 2, 2, 1, 2, 2. Jamais servies : 5, puis
|
||
// 4 ; au minimum de s(p), 5, aux tours possibles les plus nombreux.
|
||
const OBJECTIFS = {
|
||
personnes: [[1, null, ['finance']], [2, null, ['finance']], [3, null, ['finance']], [4, null, ['finance']], [5, null, ['finance']], [6, null, ['finance']], [7], [8], [9]],
|
||
tables: [[1, 3], [2, 3], [3, 4]],
|
||
reservations: [tous(6, 1), auTour(4, 2, 1)],
|
||
objectifs: [
|
||
{ table: 1, tour: 1, regle: 'ou', etiquettes: ['finance'] },
|
||
{ table: 1, tour: 2, regle: 'ou', etiquettes: ['finance'] },
|
||
],
|
||
plan: [
|
||
{ 1: [6, 1, 2], 2: [3, 4, 7], 3: [5, 8, 9] },
|
||
{ 1: [6, 3, 7], 2: [1, 2, 8], 3: [4, 5] },
|
||
{ 1: [6, 1, 2], 2: [3, 4, 5], 3: [7, 8, 9] },
|
||
],
|
||
};
|
||
|
||
// Lecture directe des objectifs d'un tour (§ 4.5, § 5.6), sans rien du
|
||
// module éprouvé : P_r, les couples possibles dont la personne n'est pas
|
||
// fixée ; S^nf_r, les servis non fixés ; σ_r, les assis sans couple possible.
|
||
// tour, de 1 à R ; listes, celles du plan préparé à ce tour.
|
||
function tourLu(configuration, tour, listes) {
|
||
const { participants, tables, reservations, objectifs = [] } = configuration;
|
||
const couvre = (res) => res.portee === 'tous' || res.tour === tour;
|
||
const fixee = (id) => reservations.find((res) => res.participant === id && couvre(res))?.table;
|
||
const libre = (table) => reservations.filter((res) => res.table === table.id && couvre(res)).length < table.capacite;
|
||
const objectif = (table) => objectifs.find((o) => o.table === table && o.tour === tour) ?? objectifs.find((o) => o.table === table && o.tour === null);
|
||
const correspond = (personne, table) => {
|
||
const o = objectif(table);
|
||
return o !== undefined && o.etiquettes.some((e) => (personne.etiquettes ?? []).includes(e));
|
||
};
|
||
const possible = (personne) => {
|
||
const t = fixee(personne.id);
|
||
return t === undefined ? tables.some((table) => libre(table) && correspond(personne, table.id)) : correspond(personne, t);
|
||
};
|
||
let nonFixes = 0;
|
||
let servisNonFixes = 0;
|
||
let sansCouple = 0;
|
||
for (const personne of participants) {
|
||
const i = listes.findIndex((occupants) => occupants.includes(personne.id));
|
||
if (possible(personne) && fixee(personne.id) === undefined) nonFixes += 1;
|
||
if (i === -1) continue;
|
||
if (!possible(personne)) sansCouple += 1;
|
||
const table = tables[i];
|
||
if (fixee(personne.id) === undefined && libre(table) && correspond(personne, table.id)) servisNonFixes += 1;
|
||
}
|
||
return { nonFixes, servisNonFixes, sansCouple };
|
||
}
|
||
|
||
// Le parcours, le diagnostic et les objectifs par tour d'un plan préparé.
|
||
function objectifsDe(prepare) {
|
||
const { configuration, instance, tableDe } = prepare;
|
||
const parcours = parcourirTours(instance, tableDe);
|
||
const diagnostic = diagnostiquer(configuration);
|
||
return { parcours, diagnostic, objectifs: objectifsParTour(instance, parcours, diagnostic.objectifsInevitables.parTour) };
|
||
}
|
||
|
||
describe('objectifsParTour (§ 12.12, D36, B21)', () => {
|
||
test('chaque tour, compté à la main : possibles, servis, pourcentage, hors objectif, plancher, reste, assis sans table possible', () => {
|
||
const { objectifs } = objectifsDe(preparer(OBJECTIFS));
|
||
assert.deepEqual(objectifs.parTour, [
|
||
{ possibles: 5, servis: 3, pourcent: 60, hors: 2, plancher: 2, reste: 0, sansCouple: 4 },
|
||
{ possibles: 6, servis: 2, pourcent: 33, hors: 4, plancher: 3, reste: 1, sansCouple: 2 },
|
||
{ possibles: 0, servis: null, pourcent: null, hors: null, plancher: null, reste: null, sansCouple: 9 },
|
||
]);
|
||
assert.equal(objectifs.pireTour, 1);
|
||
assert.deepEqual(objectifs.soiree, { possibles: 11, servis: 5, pourcent: 45, hors: 6, plancher: 5 });
|
||
});
|
||
|
||
test('les servis des tours somment s(p) ; Π_r − S_r = P_r − S^nf_r ; chaque plancher borne son tour ; σ_r + Π_r ne dépasse pas les présents', () => {
|
||
const prepare = preparer(OBJECTIFS);
|
||
const { configuration, instance, tableDe, tours } = prepare;
|
||
const { objectifs, diagnostic } = objectifsDe(prepare);
|
||
const mesures = mesurer(instance, tableDe);
|
||
assert.equal(somme(objectifs.parTour.map(({ servis }) => servis ?? 0)), somme(mesures.objectifs.tours));
|
||
assert.equal(objectifs.soiree.servis, somme(mesures.objectifs.tours));
|
||
assert.equal(objectifs.soiree.possibles, somme(mesures.objectifs.possibles));
|
||
objectifs.parTour.forEach(({ possibles, servis, hors, plancher, sansCouple }, r) => {
|
||
const lu = tourLu(configuration, r + 1, tours[r]);
|
||
assert.equal(sansCouple, lu.sansCouple, `tour ${r + 1}`);
|
||
assert.ok(sansCouple + possibles <= instance.N, `tour ${r + 1}`);
|
||
if (possibles === 0) return;
|
||
assert.equal(possibles - servis, lu.nonFixes - lu.servisNonFixes, `tour ${r + 1}`);
|
||
assert.ok(plancher <= hors, `tour ${r + 1}`);
|
||
});
|
||
// La somme des planchers des tours est le plancher que le diagnostic écrit.
|
||
assert.equal(objectifs.soiree.plancher, diagnostic.objectifsInevitables.horsObjectif);
|
||
});
|
||
|
||
test('les personnes jamais servies : celles de la mesure, aux tours possibles les plus nombreux d’abord ; la répartition de P(p) − s(p), de la plus grande valeur à la plus petite', () => {
|
||
const prepare = preparer(OBJECTIFS);
|
||
const { instance, tableDe } = prepare;
|
||
const { objectifs } = objectifsDe(prepare);
|
||
assert.deepEqual(objectifs.jamaisServies, [{ p: index(instance, 5), possibles: 2 }, { p: index(instance, 4), possibles: 1 }]);
|
||
assert.equal(objectifs.jamaisServies.length, mesurer(instance, tableDe).objectifs.jamaisServies);
|
||
assert.deepEqual(objectifs.repartition, [{ valeur: 2, effectif: 1 }, { valeur: 1, effectif: 4 }, { valeur: 0, effectif: 1 }]);
|
||
// 2 passe le tour 1 à la table 2 : jamais servi, deux tours possibles
|
||
// comme 5 ; à égalité, l'identifiant.
|
||
const plan = OBJECTIFS.plan.map((tour) => ({ ...tour }));
|
||
plan[0] = { 1: [6, 1, 3], 2: [2, 4, 7], 3: [5, 8, 9] };
|
||
const autre = preparer({ ...OBJECTIFS, plan });
|
||
assert.deepEqual(idsDe(autre.instance, objectifsDe(autre).objectifs.jamaisServies), [2, 5, 4]);
|
||
});
|
||
|
||
test('à reste égal, le premier tour est le pire ; la personne fixée à une table à objectif est servie hors du plancher', () => {
|
||
// Au tour 2, 1 rejoint 6 et 3 à la table 1 : trois servis, hors 3,
|
||
// plancher 3, reste 0, comme au tour 1. 6, ancré, est servi à chaque
|
||
// tour où la table porte un objectif, sans compter dans P_r.
|
||
const plan = OBJECTIFS.plan.map((tour) => ({ ...tour }));
|
||
plan[1] = { 1: [6, 3, 1], 2: [2, 7, 8], 3: [4, 5] };
|
||
const prepare = preparer({ ...OBJECTIFS, plan });
|
||
const { objectifs } = objectifsDe(prepare);
|
||
assert.deepEqual(objectifs.parTour.map(({ hors, plancher, reste }) => [hors, plancher, reste]), [[2, 2, 0], [3, 3, 0], [null, null, null]]);
|
||
assert.equal(objectifs.pireTour, 0);
|
||
const { tours } = prepare;
|
||
const lu = tourLu(prepare.configuration, 1, tours[0]);
|
||
assert.equal(lu.nonFixes, 4);
|
||
assert.equal(objectifs.parTour[0].possibles, lu.nonFixes + 1);
|
||
});
|
||
|
||
test('critère décoché : les planchers des tours sont ceux du diagnostic, non nuls', () => {
|
||
const contraintes = { ...SANS_CONTRAINTE, respecterObjectifs: false };
|
||
const { objectifs } = objectifsDe(preparer({ ...OBJECTIFS, contraintes }));
|
||
assert.deepEqual(objectifs.parTour.map(({ plancher }) => plancher), [2, 3, null]);
|
||
});
|
||
|
||
test('sans personne concernée, null ; une liste de planchers d’une autre longueur lève RangeError', () => {
|
||
const prepare = preparer({ ...OBJECTIFS, objectifs: [] });
|
||
const { objectifs, parcours } = objectifsDe(prepare);
|
||
assert.equal(objectifs, null);
|
||
const avec = preparer(OBJECTIFS);
|
||
assert.throws(() => objectifsParTour(avec.instance, parcourirTours(avec.instance, avec.tableDe), [2, 3]), RangeError);
|
||
assert.ok(parcours.possiblesParTour.every((n) => n === 0));
|
||
});
|
||
});
|
||
|
||
// Onze personnes, cinq tables de trois, deux tours, sans réservation.
|
||
// « atelier ressac » (1, 2), « Atelier Ressac » (3, 4), « Zinc » (5, 6),
|
||
// « Grès » (7, 8, 9), « Coopérative Aubier » (10, 11), dans cet ordre
|
||
// d'apparition. Tour 1 : chaque appartenance à sa table, Grès à trois. Tour 2 :
|
||
// 1, 3, 5 ; 2, 4, 6 ; 7 ; 8 ; 9, 10, 11. Aubier : 2 collisions, 1 paire,
|
||
// excédent 1 ; Grès : 3, 3, 0 ; les trois autres : 1, 1, 0.
|
||
const COLLISIONS = {
|
||
personnes: [[1, 'atelier ressac'], [2, 'atelier ressac'], [3, RESSAC], [4, RESSAC], [5, 'Zinc'], [6, 'Zinc'], [7, 'Grès'], [8, 'Grès'], [9, 'Grès'], [10, AUBIER], [11, AUBIER]],
|
||
tables: [[1, 3], [2, 3], [3, 3], [4, 3], [5, 3]],
|
||
plan: [
|
||
{ 1: [1, 2], 2: [3, 4], 3: [5, 6], 4: [7, 8, 9], 5: [10, 11] },
|
||
{ 1: [1, 3, 5], 2: [2, 4, 6], 3: [7], 4: [8], 5: [9, 10, 11] },
|
||
],
|
||
};
|
||
|
||
describe('barresCollisions et planchersParGroupe (§ 12.4, D31, B13)', () => {
|
||
test('l’excédent décroissant, puis les collisions cumulées, puis la clé normalisée, puis la chaîne ; jamais la longueur de la barre', () => {
|
||
const prepare = preparer(COLLISIONS);
|
||
const { configuration, instance, tableDe } = prepare;
|
||
const mesures = mesurer(instance, tableDe);
|
||
const planchers = planchersParGroupe(instance, diagnostiquer(configuration).collisions);
|
||
const lignes = barresCollisions(instance, mesures, planchers, true, cle);
|
||
assert.deepEqual(lignes.map(({ groupe }) => groupe), [AUBIER, 'Grès', RESSAC, 'atelier ressac', 'Zinc']);
|
||
assert.deepEqual(lignes[0], { g: 4, groupe: AUBIER, effectif: 2, cumulees: 2, prouve: 0, reste: 2, pairesDistinctes: 1, excedent: 1 });
|
||
assert.deepEqual(lignes.map(({ cumulees, pairesDistinctes, excedent }) => [cumulees, pairesDistinctes, excedent]), [[2, 1, 1], [3, 3, 0], [1, 1, 0], [1, 1, 0], [1, 1, 0]]);
|
||
// Sans réservation, le diagnostic se tait : « prouvé inévitable » vaut 0.
|
||
assert.deepEqual(planchers, [0, 0, 0, 0, 0]);
|
||
});
|
||
|
||
test('hors du domaine des planchers, prouvé et reste valent null ; le compte reste', () => {
|
||
const prepare = preparer(COLLISIONS);
|
||
const { instance, tableDe } = prepare;
|
||
const lignes = barresCollisions(instance, mesurer(instance, tableDe), [0, 0, 0, 0, 0], false, cle);
|
||
assert.ok(lignes.every(({ prouve, reste }) => prouve === null && reste === null));
|
||
assert.equal(somme(lignes.map(({ cumulees }) => cumulees)), 8);
|
||
});
|
||
|
||
test('la variante conflit : le plancher du diagnostic, 4, est prouvé inévitable pour l’appartenance de cinq', () => {
|
||
const configuration = CATALOGUE.find(({ cle: nom }) => nom === 'petite-conflit').construire();
|
||
const instance = normaliser(configuration);
|
||
const tableDe = indexerPlan(instance, PLAN_PARFAIT_PETITE);
|
||
const planchers = planchersParGroupe(instance, diagnostiquer(configuration).collisions);
|
||
const lignes = barresCollisions(instance, mesurer(instance, tableDe), planchers, true, cle);
|
||
const conflit = lignes.find(({ effectif }) => effectif === 5);
|
||
assert.equal(conflit.prouve, 4);
|
||
assert.equal(conflit.reste, conflit.cumulees - 4);
|
||
assert.ok(lignes.filter((ligne) => ligne !== conflit).every(({ prouve }) => prouve === 0));
|
||
});
|
||
|
||
test('un plancher d’une appartenance absente de l’instance, ou une liste d’une autre longueur, lève RangeError', () => {
|
||
const { instance, tableDe } = preparer(COLLISIONS);
|
||
assert.throws(() => planchersParGroupe(instance, [{ groupe: 'Ardoise', plancherCumulees: 2 }]), RangeError);
|
||
assert.throws(() => barresCollisions(instance, mesurer(instance, tableDe), [0, 0], true, cle), RangeError);
|
||
});
|
||
});
|
||
|
||
// Lecture directe d'une case (§ 5.4, B4) : capacité, occupants, affiliés et
|
||
// appartenances distinctes de la table i au tour r, sur le plan préparé.
|
||
function caseLue({ configuration, tours }, r, i) {
|
||
const appartenance = new Map(configuration.participants.map(({ id, appartenance: a }) => [id, a]));
|
||
const occupants = tours[r][i];
|
||
const affilies = occupants.filter((id) => appartenance.get(id) !== null);
|
||
return {
|
||
capacite: configuration.tables[i].capacite,
|
||
occupants: occupants.length,
|
||
affilies: affilies.length,
|
||
appartenances: new Set(affilies.map((id) => appartenance.get(id))).size,
|
||
};
|
||
}
|
||
|
||
// Le mélange d'un plan préparé.
|
||
function melange({ instance, tableDe }) {
|
||
return mixTables(instance, tableDe, occupations(instance, tableDe, { affilies: true }));
|
||
}
|
||
|
||
// Une table par cas, un seul tour, quatre appartenances : W, en tête de
|
||
// l'ordre des tables, quatre places et deux sans appartenance ; X, huit
|
||
// places, quatre affiliés de quatre appartenances et un sans ; Y, quatre
|
||
// places, deux Aubier, un Ressac, un Grès ; Z, quatre places, un Aubier et
|
||
// trois sans ; V, quatre places, pleine de quatre affiliés de quatre
|
||
// appartenances. Sièges : —, 4, 1, 3, 0 ; assis : —, 1, 1, 3, 0 ;
|
||
// doublons : —, 0, 1, 0, 0. W, à l'index 0, ne finit chaque ordre que par
|
||
// la règle des tables toutes « — », non par l'ordre des tables ; V, sans
|
||
// rien à compter, finit les tables mesurées.
|
||
const TROIS_ORDRES = {
|
||
personnes: [
|
||
[1, AUBIER], [2, RESSAC], [3, 'Grès'], [4, 'Zinc'], [5], [6, AUBIER], [7, AUBIER], [8, RESSAC], [9, 'Grès'], [10, AUBIER], [11], [12], [13], [14], [15],
|
||
[16, AUBIER], [17, RESSAC], [18, 'Grès'], [19, 'Zinc'],
|
||
],
|
||
tables: [[10, 4], [40, 8], [30, 4], [20, 4], [50, 4]],
|
||
plan: [{ 40: [1, 2, 3, 4, 5], 30: [6, 7, 8, 9], 20: [10, 11, 12, 13], 10: [14, 15], 50: [16, 17, 18, 19] }],
|
||
};
|
||
|
||
describe('mixTables (§ 12.11, D35, B20)', () => {
|
||
test('chaque case, contre une lecture directe : les trois comptes, emboîtés, et leurs trois pourcentages', () => {
|
||
const prepare = preparer(TROIS_ORDRES);
|
||
const { lignes } = melange(prepare);
|
||
assert.deepEqual(lignes.map(({ t, capacite }) => [t, capacite]), [[0, 4], [1, 8], [2, 4], [3, 4], [4, 4]]);
|
||
lignes.forEach(({ t, tours }) => {
|
||
const lu = caseLue(prepare, 0, t);
|
||
const [cas] = tours;
|
||
if (lu.affilies === 0) {
|
||
assert.equal(cas, null);
|
||
return;
|
||
}
|
||
const { capacite: c, occupants: o, affilies: a, appartenances: d } = lu;
|
||
assert.deepEqual(cas, {
|
||
occupants: o,
|
||
affilies: a,
|
||
appartenances: d,
|
||
comptes: { sieges: c - d, assis: o - d, doublons: a - d },
|
||
pourcents: { sieges: pourcentage(d, c), assis: pourcentage(d, o), doublons: pourcentage(d, a) },
|
||
});
|
||
assert.ok(cas.comptes.sieges >= cas.comptes.assis && cas.comptes.assis >= cas.comptes.doublons && cas.comptes.doublons >= 0);
|
||
});
|
||
// La table incomplète, avec un assis sans appartenance : 4 ≥ 1 ≥ 0,
|
||
// quatre appartenances pour huit sièges, cinq assis, quatre affiliés.
|
||
assert.deepEqual(lignes[1].tours[0].comptes, { sieges: 4, assis: 1, doublons: 0 });
|
||
assert.deepEqual(lignes[1].tours[0].pourcents, { sieges: 50, assis: 80, doublons: 100 });
|
||
// Le sans-appartenance de Z n'est pas un doublon.
|
||
assert.deepEqual(lignes[3].tours[0].comptes, { sieges: 3, assis: 3, doublons: 0 });
|
||
// La table pleine d'affiliés tous distincts : rien à compter.
|
||
assert.deepEqual(lignes[4].tours[0].comptes, { sieges: 0, assis: 0, doublons: 0 });
|
||
});
|
||
|
||
test('trois ordres, un par position, qui diffèrent : la pire table d’abord, celle dont toutes les cases sont vides en dernier', () => {
|
||
const { ordres, lignes } = melange(preparer(TROIS_ORDRES));
|
||
// La table toute « — » est la première des tables : seul son rang à part
|
||
// la met en dernier, jamais l'ordre des tables.
|
||
assert.deepEqual(ordres, { sieges: [1, 3, 2, 4, 0], assis: [3, 1, 2, 4, 0], doublons: [2, 1, 3, 4, 0] });
|
||
assert.deepEqual(lignes.map(({ pire }) => pire), [
|
||
null,
|
||
{ sieges: 4, assis: 1, doublons: 0 },
|
||
{ sieges: 1, assis: 1, doublons: 1 },
|
||
{ sieges: 3, assis: 3, doublons: 0 },
|
||
{ sieges: 0, assis: 0, doublons: 0 },
|
||
]);
|
||
assert.equal(lignes[0].somme, null);
|
||
});
|
||
|
||
// Deux tours, cinq tables de trois ; 1 à 4 Aubier, les autres sans. A :
|
||
// tour 1, 1, 2 et 5, un doublon ; tour 2, 1, 5, 6, aucun. B : 3, 4, 6, un
|
||
// doublon ; puis 2, 3, 7, un doublon. C : sans affilié au tour 1 ; 4, 8, 9
|
||
// au tour 2. D et E, vides aux deux tours, dans l'ordre des tables.
|
||
test('à pire égal, la somme sur les tours départage, puis l’ordre des tables ; une case vide ou sans affilié est ignorée', () => {
|
||
const prepare = preparer({
|
||
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, AUBIER], [5], [6], [7], [8], [9]],
|
||
tables: [[1, 3], [2, 3], [3, 3], [4, 3], [5, 3]],
|
||
plan: [
|
||
{ 1: [1, 2, 5], 2: [3, 4, 6], 3: [7, 8, 9] },
|
||
{ 1: [1, 5, 6], 2: [2, 3, 7], 3: [4, 8, 9] },
|
||
],
|
||
});
|
||
const { ordres, lignes } = melange(prepare);
|
||
assert.deepEqual(lignes.map(({ tours }) => tours.map((cas) => (cas === null ? null : cas.comptes.doublons))), [[1, 0], [1, 1], [null, 0], [null, null], [null, null]]);
|
||
assert.deepEqual(lignes.map(({ somme: s }) => s), [
|
||
{ sieges: 4, assis: 4, doublons: 1 },
|
||
{ sieges: 4, assis: 4, doublons: 2 },
|
||
{ sieges: 2, assis: 2, doublons: 0 },
|
||
null,
|
||
null,
|
||
]);
|
||
assert.deepEqual(ordres, { sieges: [0, 1, 2, 3, 4], assis: [0, 1, 2, 3, 4], doublons: [1, 0, 2, 3, 4] });
|
||
});
|
||
|
||
// Deux tables de trois, trois tours ; 1 à 5 Aubier, les autres sans. La
|
||
// table 1 reçoit trois Aubier au tour 1, un seul ensuite : doublons 2, 0,
|
||
// 0, pire 2, somme 2. La table 2 en reçoit deux à chaque tour : doublons
|
||
// 1, 1, 1, pire 1, somme 3. Le plus grand compte d'une case passe avant la
|
||
// somme : la table 1 vient d'abord.
|
||
test('le plus grand compte d’une case, puis la somme : une seule case lourde l’emporte sur trois cases légères', () => {
|
||
const { ordres, lignes } = melange(
|
||
preparer({
|
||
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, AUBIER], [5, AUBIER], [6], [7], [8]],
|
||
tables: [[1, 3], [2, 3]],
|
||
plan: [
|
||
{ 1: [1, 2, 3], 2: [4, 5, 6] },
|
||
{ 1: [1, 6, 7], 2: [2, 3, 8] },
|
||
{ 1: [4, 6, 7], 2: [1, 5, 8] },
|
||
],
|
||
}),
|
||
);
|
||
assert.deepEqual(lignes.map(({ tours }) => tours.map(({ comptes }) => comptes.doublons)), [[2, 0, 0], [1, 1, 1]]);
|
||
assert.deepEqual(lignes.map(({ pire, somme: s }) => [pire.doublons, s.doublons]), [[2, 2], [1, 3]]);
|
||
assert.deepEqual(ordres.doublons, [0, 1]);
|
||
});
|
||
|
||
test('un doublon d’appartenance est non nul exactement là où parcourirTours compte des collègues', () => {
|
||
for (const description of [COLLISIONS, TROIS_ORDRES, PROFIL]) {
|
||
const prepare = preparer(description);
|
||
const { parTour } = parcourirTours(prepare.instance, prepare.tableDe);
|
||
const { lignes } = melange(prepare);
|
||
lignes.forEach(({ t, tours }) => {
|
||
tours.forEach((cas, r) => {
|
||
const doublons = cas === null ? 0 : cas.comptes.doublons;
|
||
assert.equal(doublons > 0, parTour[r][t].collegues.length > 0, `tour ${r + 1}, table ${t}`);
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
test('sans les affiliés de l’occupation, lève TypeError', () => {
|
||
const { instance, tableDe } = preparer(TROIS_ORDRES);
|
||
assert.throws(() => mixTables(instance, tableDe, occupations(instance, tableDe)), TypeError);
|
||
});
|
||
});
|
||
|
||
// Trois tables de deux, deux tours. 1 anime au tour 1 à la table 1, 2 au
|
||
// tour 2 à la table 2 ; 3, ancré à la table 3 où personne n'anime, n'est pas
|
||
// concerné (B(3) = 0). B(1) = B(2) = 1, l'autre animateur ; B(4) = B(5) =
|
||
// B(6) = 2. 4 rencontre les deux ; 5, 6, 1, 2 aucun. Ordre : 5 et 6, à la
|
||
// borne la plus haute, avant 1 et 2, puis 4.
|
||
const ANIMATEURS = {
|
||
personnes: [[1, null, undefined, [1]], [2, null, undefined, [2]], [3], [4], [5], [6]],
|
||
tables: [[1, 2], [2, 2], [3, 2]],
|
||
reservations: [auTour(1, 1, 1), auTour(2, 2, 2), tous(3, 3)],
|
||
plan: [
|
||
{ 1: [1, 4], 2: [2, 5], 3: [3, 6] },
|
||
{ 1: [1, 5], 2: [2, 4], 3: [3, 6] },
|
||
],
|
||
};
|
||
|
||
describe('profilAnimateurs (§ 12.13, D37)', () => {
|
||
test('les seules personnes concernées, m(p) croissant, puis la borne décroissante, puis l’identifiant, avec leur statut', () => {
|
||
const prepare = preparer(ANIMATEURS);
|
||
const { instance, tableDe } = prepare;
|
||
const mesures = mesurer(instance, tableDe);
|
||
const profil = profilAnimateurs(instance, mesures);
|
||
assert.deepEqual(idsDe(instance, profil), [5, 6, 1, 2, 4]);
|
||
assert.deepEqual(profil.map(({ rencontres, borne, statut }) => [rencontres, borne, statut]), [
|
||
[0, 2, 'mobile'],
|
||
[0, 2, 'mobile'],
|
||
[0, 1, 'partiel'],
|
||
[0, 1, 'partiel'],
|
||
[2, 2, 'mobile'],
|
||
]);
|
||
assert.deepEqual(Object.keys(profil[0]), ['p', 'rencontres', 'borne', 'statut']);
|
||
});
|
||
|
||
test('sans personne concernée, null', () => {
|
||
const prepare = preparer({ ...ANIMATEURS, personnes: [[1], [2], [3], [4], [5], [6]], reservations: [] });
|
||
assert.equal(profilAnimateurs(prepare.instance, mesurer(prepare.instance, prepare.tableDe)), null);
|
||
});
|
||
});
|
||
|
||
describe('bornesDesTuiles (§ 12.2, D29, D34, D52, B11)', () => {
|
||
test('le rang 1 du profil, la tête du profil de manque, le minimum de A(p) au même tri : chacun une personne nommée', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { instance } = prepare;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||
const bornes = bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs: null, objectifs: null });
|
||
const un = index(instance, 1);
|
||
assert.deepEqual(bornes.rang1, { p: un, rencontres: 2, plafondRealise: 2, plafondAPriori: aPriori[un], pourcent: 100 });
|
||
// Manque 1 pour 4 et 5, au même plafond 4 : l'identifiant le plus petit.
|
||
assert.deepEqual(bornes.manque, { p: index(instance, 4), plafondRealise: 4 });
|
||
// A(p) = 1 pour 4 et 5 : 5, à la borne de diversité la plus haute.
|
||
assert.deepEqual(bornes.diversite, { p: index(instance, 5), aMax: 2 });
|
||
assert.equal(bornes.animateurs, null);
|
||
assert.equal(bornes.objectifs, null);
|
||
assert.equal(bornes.soiree, null);
|
||
});
|
||
|
||
test('aucun pourcentage pour le manque, la diversité ni les animateurs ; celui du minimum de s(p) et de la soirée', () => {
|
||
const avec = preparer(OBJECTIFS);
|
||
const { instance, tableDe } = avec;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(avec);
|
||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||
const { objectifs } = objectifsDe(avec);
|
||
const bornes = bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs: null, objectifs });
|
||
// s(p) = 0 pour 4 et 5 : 5, aux tours possibles les plus nombreux.
|
||
assert.deepEqual(bornes.objectifs, { p: index(instance, 5), servis: 0, possibles: 2, pourcent: 0 });
|
||
assert.deepEqual(bornes.soiree, { servis: 5, possibles: 11, pourcent: 45, jamaisServies: 2 });
|
||
assert.equal(bornes.soiree.jamaisServies, mesurer(instance, tableDe).objectifs.jamaisServies);
|
||
assert.deepEqual(Object.keys(bornes.manque), ['p', 'plafondRealise']);
|
||
assert.equal(bornes.diversite, null);
|
||
|
||
// La table 1 à quatre places : chaque « finance » libre est servi une
|
||
// fois, 6 deux fois. Le minimum de s(p) vaut 1 chez les concernées ; 7,
|
||
// 8 et 9, à s(p) = P(p) = 0, n'en sont pas et ne le portent jamais.
|
||
const servis = preparer({
|
||
...OBJECTIFS,
|
||
tables: [[1, 4], [2, 3], [3, 4]],
|
||
plan: [
|
||
{ 1: [6, 1, 2, 3], 2: [4, 7, 8], 3: [5, 9] },
|
||
{ 1: [6, 4, 5, 7], 2: [1, 2, 8], 3: [3, 9] },
|
||
{ 1: [6, 1, 2], 2: [3, 4, 5], 3: [7, 8, 9] },
|
||
],
|
||
});
|
||
const lu = mesurerTout(servis);
|
||
const parServis = bornesDesTuiles(servis.instance, {
|
||
mesures: lu.mesures,
|
||
plafondsRealises: lu.realises,
|
||
aMax: lu.aMax,
|
||
profil: profilRencontres(servis.instance, lu.mesures, lu.realises, lu.aPriori, lu.aMax),
|
||
animateurs: null,
|
||
objectifs: objectifsDe(servis).objectifs,
|
||
});
|
||
assert.deepEqual(lu.mesures.objectifs.tours, [1, 1, 1, 1, 1, 2, 0, 0, 0]);
|
||
// s(p) = 1 pour 1, 2, 3, 5 à P(p) = 2 et pour 4 à P(p) = 1 : 1, le
|
||
// premier aux tours possibles les plus nombreux.
|
||
assert.deepEqual(parServis.objectifs, { p: index(servis.instance, 1), servis: 1, possibles: 2, pourcent: 50 });
|
||
|
||
const animes = preparer(ANIMATEURS);
|
||
const m = mesurer(animes.instance, animes.tableDe);
|
||
const tout = mesurerTout(animes);
|
||
const profilA = profilRencontres(animes.instance, m, tout.realises, tout.aPriori, tout.aMax);
|
||
const deAnimateurs = bornesDesTuiles(animes.instance, {
|
||
mesures: m,
|
||
plafondsRealises: tout.realises,
|
||
aMax: tout.aMax,
|
||
profil: profilA,
|
||
animateurs: profilAnimateurs(animes.instance, m),
|
||
objectifs: null,
|
||
});
|
||
assert.deepEqual(deAnimateurs.animateurs, { p: index(animes.instance, 5), borne: 2 });
|
||
});
|
||
|
||
test('sans personne présente : ni rang 1, ni tête de manque', () => {
|
||
const vide = normaliser({
|
||
participants: [{ id: 1, nom: 'P1', appartenance: AUBIER, exclu: true }],
|
||
tables: [{ id: 1, numero: 1, capacite: 2 }],
|
||
tours: 1,
|
||
reservations: [],
|
||
contraintes: SANS_CONTRAINTE,
|
||
});
|
||
const plan = new Int32Array(0);
|
||
const mesures = mesurer(vide, plan);
|
||
const bornes = bornesDesTuiles(vide, { mesures, plafondsRealises: [], aMax: [], profil: [], animateurs: null, objectifs: null });
|
||
assert.deepEqual(bornes, { rang1: null, manque: null, diversite: null, animateurs: null, objectifs: null, soiree: null });
|
||
});
|
||
});
|
||
|
||
// Tous les plans indexés d'une instance qui assoient chacun à chaque tour,
|
||
// honorent les réservations et tiennent les capacités ; le même tableau est
|
||
// rendu, rempli, à chaque plan.
|
||
function* tousLesPlans({ N, T, R, capacite, fixe }) {
|
||
const tableDe = new Int32Array(N * R);
|
||
const occupation = new Int32Array(T * R);
|
||
function* placer(i) {
|
||
if (i === N * R) {
|
||
yield tableDe;
|
||
return;
|
||
}
|
||
const r = i % R;
|
||
for (let t = 0; t < T; t += 1) {
|
||
if ((fixe[i] !== LIBRE && fixe[i] !== t) || occupation[t * R + r] === capacite[t]) continue;
|
||
occupation[t * R + r] += 1;
|
||
tableDe[i] = t;
|
||
yield* placer(i + 1);
|
||
occupation[t * R + r] -= 1;
|
||
}
|
||
}
|
||
yield* placer(0);
|
||
}
|
||
|
||
describe('le détail sur chaque plan d’une petite configuration (§ 14.10, B25)', () => {
|
||
// Six personnes, trois tables de deux, deux tours ; 1 ancré à la table 1,
|
||
// « finance » pour la soirée ; la table 2, « finance » au seul tour 2 ; la
|
||
// table 3 sans objectif. Trente plans par tour, neuf cents en tout.
|
||
test('les comptes par table et par tour tiennent leurs identités sur chacun des neuf cents plans', () => {
|
||
const configuration = {
|
||
participants: [
|
||
participant([1, AUBIER, ['finance']]),
|
||
participant([2, AUBIER, ['finance']]),
|
||
participant([3, RESSAC, ['finance']]),
|
||
participant([4, RESSAC]),
|
||
participant([5, null, ['finance']]),
|
||
participant([6]),
|
||
],
|
||
tables: [{ id: 1, numero: 1, capacite: 2 }, { id: 2, numero: 2, capacite: 2 }, { id: 3, numero: 3, capacite: 2 }],
|
||
tours: 2,
|
||
reservations: [tous(1, 1)],
|
||
contraintes: SANS_CONTRAINTE,
|
||
objectifs: [
|
||
{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] },
|
||
{ table: 2, tour: 2, regle: 'ou', etiquettes: ['finance'] },
|
||
],
|
||
};
|
||
const instance = normaliser(configuration);
|
||
const { N, R, T, ids } = instance;
|
||
const diagnostic = diagnostiquer(configuration);
|
||
const appartenance = new Map(configuration.participants.map(({ id, appartenance: a }) => [id, a]));
|
||
let plans = 0;
|
||
for (const tableDe of tousLesPlans(instance)) {
|
||
plans += 1;
|
||
const tours = Array.from({ length: R }, (_, r) => Array.from({ length: T }, (__, t) => ids.filter((___, p) => tableDe[p * R + r] === t)));
|
||
const parcours = parcourirTours(instance, tableDe);
|
||
const { lignes } = mixTables(instance, tableDe, occupations(instance, tableDe, { affilies: true }), parcours);
|
||
for (const { t, tours: cases } of lignes) {
|
||
cases.forEach((cas, r) => {
|
||
const affilies = tours[r][t].filter((id) => appartenance.get(id) !== null);
|
||
if (affilies.length === 0) {
|
||
assert.equal(cas, null);
|
||
return;
|
||
}
|
||
const d = new Set(affilies.map((id) => appartenance.get(id))).size;
|
||
assert.equal(cas.appartenances, d);
|
||
assert.equal(cas.comptes.doublons, affilies.length - d);
|
||
assert.equal(cas.comptes.doublons > 0, parcours.parTour[r][t].collegues.length > 0);
|
||
assert.ok(cas.comptes.sieges >= cas.comptes.assis && cas.comptes.assis >= cas.comptes.doublons);
|
||
});
|
||
}
|
||
const objectifs = objectifsParTour(instance, parcours, diagnostic.objectifsInevitables.parTour);
|
||
assert.equal(objectifs.soiree.servis, somme(parcours.servis));
|
||
assert.equal(objectifs.soiree.plancher, diagnostic.objectifsInevitables.horsObjectif);
|
||
objectifs.parTour.forEach(({ possibles, servis, hors, plancher, sansCouple }, r) => {
|
||
const lu = tourLu(configuration, r + 1, tours[r]);
|
||
assert.equal(sansCouple, lu.sansCouple);
|
||
assert.ok(sansCouple + possibles <= N);
|
||
assert.equal(possibles - servis, lu.nonFixes - lu.servisNonFixes);
|
||
assert.ok(plancher <= hors);
|
||
});
|
||
}
|
||
assert.equal(plans, 900);
|
||
});
|
||
});
|
||
|
||
// Un objet simple ou une liste, à toute profondeur, sans tableau typé.
|
||
function sansTableauType(valeur, chemin = 'détail') {
|
||
if (valeur === null || typeof valeur !== 'object') return;
|
||
assert.ok(!ArrayBuffer.isView(valeur), `${chemin} est un tableau typé`);
|
||
assert.ok(Array.isArray(valeur) || Object.getPrototypeOf(valeur) === Object.prototype, `${chemin} n'est ni une liste ni un objet simple`);
|
||
for (const [cle2, element] of Object.entries(valeur)) sansTableauType(element, `${chemin}.${cle2}`);
|
||
}
|
||
|
||
describe('detailler (§ 12.9, D27)', () => {
|
||
// Les deux configurations réunies : appartenances, objectifs et animateurs.
|
||
function complet() {
|
||
const prepare = preparer({
|
||
...OBJECTIFS,
|
||
personnes: OBJECTIFS.personnes.map(([id, , etiquettes], i) => [id, [AUBIER, RESSAC, null][i % 3], etiquettes, id === 7 ? [2] : undefined]),
|
||
reservations: [...OBJECTIFS.reservations, auTour(7, 1, 2)],
|
||
});
|
||
const { configuration, instance, tableDe } = prepare;
|
||
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
|
||
const options = {
|
||
diagnostic: diagnostiquer(configuration),
|
||
mesures,
|
||
plafondsRealises: realises,
|
||
plafondsAPriori: aPriori,
|
||
planchersValides: true,
|
||
cle,
|
||
ordreMatrice: null,
|
||
};
|
||
return { prepare, instance, tableDe, mesures, realises, aPriori, aMax, options };
|
||
}
|
||
|
||
test('réunit chaque fonction du détail sur le même plan, et ne rend aucun tableau typé', () => {
|
||
const { instance, tableDe, mesures, realises, aPriori, aMax, options } = complet();
|
||
const detail = detailler(instance, tableDe, options);
|
||
sansTableauType(detail);
|
||
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
|
||
assert.deepEqual(detail.profil, profil);
|
||
assert.deepEqual(detail.synthese, synthese(profil));
|
||
assert.deepEqual(detail.repartitionRencontres, repartition(mesures.rencontres, realises));
|
||
assert.deepEqual(detail.collisions, {
|
||
planchersValides: true,
|
||
lignes: barresCollisions(instance, mesures, planchersParGroupe(instance, options.diagnostic.collisions), true, cle),
|
||
});
|
||
assert.deepEqual(detail.tables, mixTables(instance, tableDe, occupations(instance, tableDe, { affilies: true })));
|
||
assert.deepEqual(detail.paires, pairesRepetees(instance, tableDe));
|
||
assert.equal(detail.paires.choisies.length, mesures.rencontresRepetees.choisies);
|
||
assert.equal(detail.paires.imposees.length, mesures.rencontresRepetees.imposees);
|
||
const parcours = parcourirTours(instance, tableDe);
|
||
const objectifs = objectifsParTour(instance, parcours, options.diagnostic.objectifsInevitables.parTour);
|
||
assert.deepEqual(detail.objectifs, objectifs);
|
||
const animateurs = profilAnimateurs(instance, mesures);
|
||
assert.deepEqual(detail.animateurs, {
|
||
profil: animateurs,
|
||
repartition: repartition(animateurs.map(({ rencontres }) => rencontres), null),
|
||
diversite: diversiteAnimateurs(instance, tableDe),
|
||
});
|
||
assert.deepEqual(detail.bornes, bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs, objectifs }));
|
||
assert.equal(detail.matrice, null);
|
||
});
|
||
|
||
test('la borne de diversité reçue sert telle quelle ; la matrice suit l’ordre demandé', () => {
|
||
const { instance, tableDe, options } = complet();
|
||
const aMax = new Array(instance.N).fill(1);
|
||
const ordre = [4, 0, 2];
|
||
const detail = detailler(instance, tableDe, { ...options, aMax, ordreMatrice: ordre });
|
||
assert.ok(detail.profil.every(({ borneDiversite: b }) => b === 1));
|
||
assert.deepEqual(detail.matrice, { ordre, comptes: matriceRencontres(instance, tableDe, ordre) });
|
||
assert.notEqual(detail.matrice.ordre, ordre);
|
||
sansTableauType(detail);
|
||
});
|
||
|
||
test('sans animateur ni personne concernée, les animateurs valent null ; hors du domaine, les barres n’ont qu’un segment', () => {
|
||
const prepare = preparer(PROFIL);
|
||
const { configuration, instance, tableDe } = prepare;
|
||
const { mesures, realises, aPriori } = mesurerTout(prepare);
|
||
const detail = detailler(instance, tableDe, {
|
||
diagnostic: diagnostiquer(configuration),
|
||
mesures,
|
||
plafondsRealises: realises,
|
||
plafondsAPriori: aPriori,
|
||
planchersValides: false,
|
||
cle,
|
||
ordreMatrice: null,
|
||
});
|
||
assert.equal(detail.animateurs, null);
|
||
assert.equal(detail.objectifs, null);
|
||
assert.ok(detail.collisions.lignes.every(({ prouve, reste }) => prouve === null && reste === null));
|
||
assert.equal(detail.collisions.planchersValides, false);
|
||
});
|
||
|
||
test('des animateurs sans personne concernée : le profil et sa répartition valent null, la diversité reste', () => {
|
||
// 1 anime, seul présent : il ne se rencontre pas lui-même, et personne
|
||
// d'autre ne peut le rejoindre.
|
||
const prepare = preparer({
|
||
personnes: [[1, null, undefined, [1]]],
|
||
tables: [[1, 2]],
|
||
reservations: [auTour(1, 1, 1)],
|
||
plan: [{ 1: [1] }],
|
||
});
|
||
const { configuration, instance, tableDe } = prepare;
|
||
const { mesures, realises, aPriori } = mesurerTout(prepare);
|
||
assert.equal(mesures.animateurs, null);
|
||
const detail = detailler(instance, tableDe, {
|
||
diagnostic: diagnostiquer(configuration),
|
||
mesures,
|
||
plafondsRealises: realises,
|
||
plafondsAPriori: aPriori,
|
||
planchersValides: true,
|
||
cle,
|
||
ordreMatrice: null,
|
||
});
|
||
assert.equal(detail.animateurs.profil, null);
|
||
assert.equal(detail.animateurs.repartition, null);
|
||
assert.equal(detail.animateurs.diversite.lignes.length, 1);
|
||
});
|
||
});
|
||
|
||
describe('qualite : la source du module', () => {
|
||
const source = readFileSync(new URL('./qualite.js', import.meta.url), 'utf8');
|
||
|
||
test('aucun couple possible ni correspondance relus : les comptes par tour viennent de parcourirTours', () => {
|
||
assert.doesNotMatch(source, /\bpossible\[/);
|
||
assert.doesNotMatch(source, /\bcorrespond\[/);
|
||
assert.doesNotMatch(source, /\bobjectifDe\[/);
|
||
});
|
||
|
||
test('une seule multiplication par cent : celle de pourcentage', () => {
|
||
assert.equal(source.match(/\b100\s*\*/g).length, 1);
|
||
});
|
||
|
||
test('aucun plan relu case à case : le détail lit le parcours et l’occupation', () => {
|
||
assert.doesNotMatch(source, /\btableDe\[/);
|
||
});
|
||
});
|