[ADD] engine for quality: A_max, facilitator diversity, run trace, pairs

The quality page needs figures the engine did not yet produce. It now
bounds each person's distinct memberships (A_max) and checks it with
the indicator guard; reads facilitator diversity over the evening and
per round; records the search's moves and record points; lists repeated
and imposed pairs; and lays out charts through one geometry module with
two bench thresholds. Chart colour tokens carry their own validation.
Default plans still equal the reference fingerprints; nothing new is
written to the file yet.
Checked: node 4768, navigateur 967, node-long 128; new modules at 100 %.

--- FR ---

[ADD] moteur de la qualité : A_max, diversité, course, paires

La page de qualité demande des chiffres que le moteur ne produisait pas.
Il borne désormais les appartenances distinctes de chacun (A_max) et la
garde des indicateurs la vérifie ; il lit la diversité des animateurs sur
la soirée et par tour ; il relève les mouvements et les points de la
recherche ; il liste les paires répétées et imposées ; un seul module de
géométrie dispose les graphiques, avec deux seuils au banc. Les jetons
des graphiques portent leur propre validation. Les plans par défaut
égalent les empreintes ; rien de neuf ne s'écrit encore au fichier.
Vérifié : node 4768, navigateur 967, longues 128 ; nouveaux modules à 100 %.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-12 04:29:27 -04:00
parent 426eb911c9
commit ff31a6e1ca
39 changed files with 4526 additions and 100 deletions

View file

@ -140,6 +140,8 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a
| `HAUTEUR_BANDE_IMPRESSION` | hauteur de ce que porte une feuille imprimée hors de sa zone de contenu : pied, mentions, barre, cartouche | relevée à l’exécution | mesurée | exécution | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre | — | rendu de l'aperçu, relevé à chaque ouverture |
| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,011 rapport | mesurée | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures, chaîne témoin à 16 px, crénage et ligatures coupés ; arrondi par excès à deux chiffres significatifs, écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `RALENTISSEMENT_PLEINE_CHARGE` | ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1) | 2 × | défaut de travail | banc | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | 2,79 × | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `SEUIL_MATRICE_PARTICIPANTS` | nombre de participants au-delà duquel une case de la matrice « qui a rencontré qui » tombe sous la cible de 24 px dans la largeur de la carte, au cadre fixé | 40 participants | défaut de travail | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de participants dont la case de la matrice tient 24 px dans la largeur de la carte de la vue, noms en tête compris, relevé sur le rendu ; la valeur proposée, adoptée à la main dans ce registre | non relevée | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur ralenti ×4, police Noto Sans |
| `LARGEUR_TRONCATURE_NOM_PX` | largeur à laquelle un nom se tronque dans une étiquette de graphique | 180 px | défaut de travail | banc | banc de mesure, police Noto Sans : la largeur rendue d'un nom de 14 caractères, chacun le glyphe le plus large de l'alphabet visé, au corps des étiquettes de graphique, dans la marge gauche d'une vue à barres — jamais un nom de démonstration ; arrondie au pixel supérieur, adoptée à la main dans ce registre | non relevée | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur ralenti ×4, police Noto Sans |
<!-- engendre:fin constantes -->

View file

@ -50,7 +50,7 @@ const DOSSIER_SORTIES = 'scripts/banc/sorties';
/** Les sorties que les épreuves du banc écrivent, par nom de fichier sans
* extension : le harnais, puis chaque mesure. */
export const SORTIES_NAVIGATEUR = Object.freeze(['harnais', 'temoin', 'sieges_lisibles', 'temps_image', 'parallele']);
export const SORTIES_NAVIGATEUR = Object.freeze(['harnais', 'temoin', 'sieges_lisibles', 'temps_image', 'parallele', 'qualite']);
/** Les gardes du banc : des mesures hors du registre, qui ne proposent pas
* de constante mais disent si une hypothèse tient, par ordre de nom. */

View file

@ -89,6 +89,16 @@ const sorties = () =>
mesures: [{ nom: 'TOLERANCE_CHAINE_TEMOIN', unite: 'rapport', valeurProposee: 0.02, critere: 'quart du repli', police: 'Noto Sans', ralentissementCpu: 1 }],
},
],
[
'qualite',
{
format: 1,
mesures: [
{ nom: 'LARGEUR_TRONCATURE_NOM_PX', unite: 'px', valeurProposee: 190, critere: 'nom de référence', police: 'Noto Sans', ralentissementCpu: 1 },
{ nom: 'SEUIL_MATRICE_PARTICIPANTS', unite: 'participants', valeurProposee: 38, critere: 'case de 24 px', ralentissementCpu: 1 },
],
},
],
[
'sieges_lisibles',
{
@ -119,6 +129,8 @@ describe('lancer : assembler (§ 19.10)', () => {
'SEUIL_SIEGES_LISIBLES',
'TOLERANCE_CHAINE_TEMOIN',
'RALENTISSEMENT_PLEINE_CHARGE',
'SEUIL_MATRICE_PARTICIPANTS',
'LARGEUR_TRONCATURE_NOM_PX',
'DUREE_GENERATION_PAR_DEFAUT',
'TEMPS_IMAGE_LISTES',
]);
@ -150,6 +162,8 @@ describe('lancer : assembler (§ 19.10)', () => {
['SEUIL_SIEGES_LISIBLES', 1],
['TOLERANCE_CHAINE_TEMOIN', 1],
['RALENTISSEMENT_PLEINE_CHARGE', 1],
['SEUIL_MATRICE_PARTICIPANTS', 1],
['LARGEUR_TRONCATURE_NOM_PX', 1],
['DUREE_GENERATION_PAR_DEFAUT', 1],
['TEMPS_IMAGE_LISTES', 4],
],
@ -240,7 +254,7 @@ describe('lancer : le jugement qui rend la sortie non nulle', () => {
test('un verdict non tenu : motif non bloquant, qui nomme la mesure et son critère', () => {
const r = releve();
r.mesures[4].verdict = 'nonTenu';
r.mesures.find(({ nom }) => nom === 'TEMPS_IMAGE_LISTES').verdict = 'nonTenu';
assert.deepEqual(jugement(r), { bloquants: [], nonTenus: ['TEMPS_IMAGE_LISTES : verdict non tenu — p95 sous 33 ms'] });
});
});
@ -351,7 +365,7 @@ describe('lancer : executer, sur des effets factices', () => {
fichiers.get('temps_image').mesures[0].verdict = 'nonTenu';
const p = passage({ fichiers });
assert.equal(await executer(p.effets), 1);
assert.equal(JSON.parse(p.ecrits.get(CHEMIN_RELEVE)).mesures[4].verdict, 'nonTenu');
assert.equal(JSON.parse(p.ecrits.get(CHEMIN_RELEVE)).mesures.find(({ nom }) => nom === 'TEMPS_IMAGE_LISTES').verdict, 'nonTenu');
assert.match(p.lignes.join('\n'), /TEMPS_IMAGE_LISTES : verdict non tenu/);
});

View file

@ -347,6 +347,20 @@
"bruite": false,
"ralentissementCpu": 1
},
{
"nom": "SEUIL_MATRICE_PARTICIPANTS",
"unite": "participants",
"valeurProposee": null,
"critere": "non relevée : la case de la matrice dans la largeur de la carte",
"ralentissementCpu": 4
},
{
"nom": "LARGEUR_TRONCATURE_NOM_PX",
"unite": "px",
"valeurProposee": null,
"critere": "non relevée : la largeur du nom de référence",
"ralentissementCpu": 4
},
{
"nom": "DUREE_GENERATION_PAR_DEFAUT",
"unite": "ms",

View file

@ -14,7 +14,10 @@
// recevoir est terminé aussitôt. Chaque proposition se calcule de sa graine
// dérivée, la même quel que soit le travailleur qui la calcule (proposerUne) :
// le résultat, rangé par identifiant, est octet pour octet celui de
// rechercher, quel que soit W et quel que soit l'ordre d'achèvement (§ 5.7).
// rechercher, course de chaque proposition comprise, quel que soit W et quel
// que soit l'ordre d'achèvement (§ 5.7). Aucun travailleur ne date une
// proposition : la durée est celle que le contrôleur prend autour du calcul
// entier.
//
// → { type: 'proposer', configuration, reglages, rang } un rang à la fois
// ← { type: 'avancement', fait, total } fait dans la proposition en

View file

@ -15,7 +15,7 @@ import { texteLivre } from '../demo/livrees.js';
import { rechercher } from '../moteur/recherche.js';
import { analyser, configurationDepuisCharge } from '../stockage/document.js';
import { creerExecuteurTravailleur } from './executeur_travailleur.js';
import { ErreurAnnulee, ErreurGeneration } from './generation.js';
import { ErreurAnnulee, ErreurGeneration, creerExecuteurDirect } from './generation.js';
import { brancherTravailleur } from './generation_travailleur.js';
const CONFIGURATION = Object.freeze({ marque: 'configuration d’épreuve', tours: 3 });
@ -224,6 +224,24 @@ describe('exécuteur du travailleur : le groupe (§ 5.7, § 5.10)', () => {
assert.equal(comparaisons, 9);
});
test("la course de chaque proposition traverse le groupe telle quelle, sans date : l'exécuteur direct rend la même, quel que soit l'ordre d'achèvement", async () => {
const configuration = configurationDe('petite-conflit');
const reglages = { graine: 23, nombre: 4, arret: 3_000, historique: 100 };
const direct = await creerExecuteurDirect().lancer(configuration, reglages).resultat;
assert.ok(direct.every(({ evalues, releves }) => evalues === 3_000 && releves.length === 4));
for (const choisir of [() => 0, (n) => n - 1]) {
const { fabrique, servir } = fabriqueCalculante();
const execution = creerExecuteurTravailleur(fabrique).lancer(configuration, reglages, { coeurs: 3 });
servir(choisir);
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'resolue');
assert.deepStrictEqual(lue.valeur, direct);
for (const proposition of lue.valeur) {
assert.deepEqual(Object.keys(proposition), ['id', 'graine', 'arret', 'historique', 'plan', 'evalues', 'releves']);
}
}
});
test('l’avancement est la somme des avancements de chaque proposition, sur nombre × arret ; une proposition rendue compte pour son arret entier', () => {
const { fabrique, travailleurs } = fabriqueFactice();
const reglages = { ...REGLAGES, nombre: 3, arret: 100 };

View file

@ -35,6 +35,18 @@
// commande enregistrerGeneration, une seule entrée du journal. Le nombre de
// propositions d'une génération est un réglage, jamais une constante d'ici.
//
// La course (§ 5.10). Chaque proposition du moteur porte ses mouvements
// évalués et ses points de relevé ; le contrôleur la confie telle quelle à
// enregistrerGeneration. Ce qui dépend de la machine ne vaut que pour la
// séance et ne s'écrit nulle part : le résumé de chaque génération — la
// durée prise autour du calcul entier, les cœurs employés, la somme des
// mouvements évalués de toutes les propositions calculées, identiques
// écartées comprises, et leur nombre —, rattaché à chaque proposition
// qu'elle a ajoutée, sous la graine, le compte d'arrêt et l'historique que
// celle-ci portait. Aucune proposition n'est datée seule. Ce qui est gardé
// s'oublie quand l'événement se ferme ou qu'un autre s'ouvre ; un
// rechargement de l'application l'oublie avec le contrôleur.
//
// Le compte d'inactivité de la séance est suspendu pendant toute la
// génération (debuterCalcul), et repart à sa fin, quelle qu'elle soit
// (finirCalcul, dans un finally). Passer en lecture, ou fermer l'événement,
@ -259,8 +271,8 @@ function planRange(proposition, configuration, produitVersion) {
* le contrôleur y lit RALENTISSEMENT_PLEINE_CHARGE à chaque
* estimation, sans en garder la valeur
* @returns {Object} le contrôleur : grainePropose, reglagesProposes, valider,
* estimer, derniereGeneration, generer, annuler, coeurs,
* reglerCoeurs, subscribe
* estimer, derniereGeneration, generationDe, generer, annuler,
* coeurs, reglerCoeurs, subscribe
*/
export function creerControleurGeneration({ seance, executeur, horloge, produitVersion, constante: lireConstante = constante }) {
// graine : la graine proposée ; base : celle de l'événement ouvert, null
@ -270,10 +282,14 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV
// coeurs : le réglage des cœurs de calcul, que l'observable porte —
// automatique, le nombre saisi (manuel), les disponibles, et nombre, les
// cœurs réglés.
// generations : par identifiant d'une proposition ajoutée ici, { graine,
// arret, historique, resume }, ce qu'elle portait à l'ajout et le résumé
// de sa génération ; vidé quand base change.
let graine = 1;
let base = null;
let mesure = null;
let execution = null;
const generations = new Map();
const disponibles = coeursDe(executeur.coeursDisponibles);
let reglageCoeurs = { automatique: true, manuel: disponibles };
const coeurs = () => ({
@ -291,9 +307,12 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV
// Une ouverture : l'événement passe de null, ou d'une autre base, à une
// base ; la graine proposée repart de 1.
// Une fermeture, ou l'ouverture d'un autre événement, oublie les
// générations gardées.
seance.subscribe((etat) => {
const suivante = etat.evenement?.base ?? null;
if (suivante !== null && suivante !== base) graine = 1;
if (suivante !== base) generations.clear();
base = suivante;
});
@ -443,6 +462,15 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV
.sort((a, b) => a - b);
}
// Rattache le résumé d'une génération à chaque proposition ajoutée de la
// charge, sous la graine, le compte d'arrêt et l'historique qu'elle porte.
function garder(charge, ajoutees, resume) {
for (const id of ajoutees) {
const { graine: g, arret, historique } = charge.propositions.find((proposition) => proposition.id === id);
generations.set(id, { graine: g, arret, historique, resume });
}
}
async function generer(reglages, { confirmee = false } = {}) {
const { configuration, instance } = exigerLancement(reglages, confirmee);
graine = (reglages.graine + 1) % GRAINES;
@ -462,7 +490,12 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV
if (gardees.length === 0) return { ajoutees: [], identiques, dureeMs };
const avant = new Set(seance.etat().charge.propositions.map(({ id }) => id));
await seance.executer('enregistrerGeneration', { propositions: gardees, configuration, produitVersion });
return { ajoutees: ajouteesDans(seance.etat().charge, avant, gardees), identiques, dureeMs };
const charge = seance.etat().charge;
const ajoutees = ajouteesDans(charge, avant, gardees);
let evalues = 0;
for (const proposition of propositions) evalues += proposition.evalues;
garder(charge, ajoutees, Object.freeze({ dureeMs, coeurs: employes, evalues, nombre: propositions.length }));
return { ajoutees, identiques, dureeMs };
} finally {
seance.finirCalcul();
publier({ enCours: false });
@ -520,6 +553,30 @@ export function creerControleurGeneration({ seance, executeur, horloge, produitV
* coeurs }, coeurs étant ceux que l'exécution a employés.
*/
derniereGeneration: () => (mesure === null ? null : { ...mesure }),
/**
* La génération de cette séance d'où vient la proposition id de la
* charge ouverte : { dureeMs, coeurs, evalues, nombre }, une copie —
* la durée prise autour du calcul entier, les cœurs employés, la somme
* des mouvements évalués de toutes les propositions calculées, identiques
* écartées comprises, leur nombre. null quand aucune génération de la
* séance ne l'a ajoutée, quand la charge n'a pas de proposition id, ou
* quand celle-ci ne porte plus la graine, le compte d'arrêt et
* l'historique gardés pour elle.
*/
generationDe(id) {
const gardee = generations.get(id);
if (gardee === undefined) return null;
const proposition = seance.etat().charge?.propositions.find((candidate) => candidate.id === id);
if (
proposition === undefined
|| proposition.graine !== gardee.graine
|| proposition.arret !== gardee.arret
|| proposition.historique !== gardee.historique
) {
return null;
}
return { ...gardee.resume };
},
/**
* Lance une génération et l'enregistre : { ajoutees, identiques, dureeMs }.
* Refuse avant tout lancement, par ErreurGeneration, dans cet ordre :

View file

@ -1546,3 +1546,192 @@ describe('génération : objectifs, mode et ordre réglé, par la séance (§ 5.
assert.notDeepEqual(regles, plans(chargeAObjectifs({ ordre: false })), 'sans l’ordre');
});
});
// --- La course de la recherche dans la séance (§ 5.10, B6, D40) -------------------------
// La somme des mouvements évalués d'une liste de propositions du moteur.
const sommeEvalues = (propositions) => propositions.reduce((somme, { evalues }) => somme + evalues, 0);
describe('génération : la course, et la génération gardée pour la séance (§ 5.10, B6, D40)', () => {
const PREMIERE = { graine: 1, nombre: 3, arret: 200, historique: 1000 };
test("generationDe : durée, cœurs, somme des mouvements évalués de toutes les propositions calculées, identiques comprises, et nombre", async () => {
const montage = await monter({ charges: [chargeDemo('petite-conflit')] });
const { controleur } = montage;
assert.equal(controleur.generationDe(1), null);
const configuration = configurationDe(montage.charge());
assert.deepEqual((await controleur.generer(PREMIERE)).ajoutees, [1, 2, 3]);
const premieres = rechercher(configuration, PREMIERE);
const resume = { dureeMs: 0, coeurs: 1, evalues: sommeEvalues(premieres), nombre: 3 };
for (const id of [1, 2, 3]) assert.deepEqual(controleur.generationDe(id), resume, `proposition ${id}`);
// La seconde génération recalcule les trois premières, identiques et
// écartées, et ajoute la quatrième, sous l'identifiant 3 + 4 que le
// compteur lui donne : ses évalués comptent les quatre.
const seconde = { ...PREMIERE, nombre: 4 };
const bilan = await controleur.generer(seconde);
assert.deepEqual([bilan.ajoutees, bilan.identiques], [[7], 3]);
const toutes = rechercher(configuration, seconde);
assert.ok(toutes.every(({ evalues }) => evalues > 0));
assert.deepEqual(controleur.generationDe(7), { dureeMs: 0, coeurs: 1, evalues: sommeEvalues(toutes), nombre: 4 });
assert.ok(sommeEvalues(toutes) > toutes[3].evalues);
assert.deepEqual(controleur.generationDe(1), resume);
for (const id of [4, 5, 6, 8]) assert.equal(controleur.generationDe(id), null, `identifiant ${id}`);
// Une copie : la modifier ne change pas ce que la séance garde.
controleur.generationDe(1).dureeMs = 99;
assert.deepEqual(controleur.generationDe(1), resume);
});
test("la proposition passe à enregistrerGeneration avec sa course, telle que le moteur la rend ; ni durée ni cadence n'atteignent le fichier", async () => {
const montage = await monter({ charges: [chargeDemo('petite-conflit')] });
const { seance, controleur, horloge } = montage;
const executer = seance.executer;
const recus = [];
seance.executer = (nom, args) => {
recus.push({ nom, args });
return executer(nom, args);
};
controleur.subscribe(({ fait }) => {
if (fait > 0) horloge.avancer(7);
});
await controleur.generer({ ...PREMIERE, arret: 2_000 });
const configuration = configurationDe(montage.charge());
const attendues = rechercher(configuration, { ...PREMIERE, arret: 2_000 });
assert.deepEqual(recus.map(({ nom }) => nom), ['enregistrerGeneration']);
assert.deepStrictEqual(recus[0].args.propositions, attendues);
assert.ok(attendues.every(({ releves }) => releves.length === 4));
assert.ok(controleur.generationDe(1).dureeMs > 0);
const etat = montage.etatDisque();
for (const mot of ['dureeMs', 'cadence', 'coeurs']) {
assert.equal(etat.includes(mot), false, mot);
assert.equal(JSON.stringify(montage.journal()).includes(mot), false, mot);
}
});
test("horloge pilotée : la durée gardée est exactement l'avance donnée pendant le calcul, la même pour chaque proposition de la génération, et 0 sans avance", async () => {
for (const pas of [0, 1, 1000]) {
const montage = await monter({ charges: [chargeDemo('petite-conflit')] });
let dernier = 0;
montage.controleur.subscribe(({ fait }) => {
if (fait > dernier) {
dernier = fait;
montage.horloge.avancer(pas);
}
});
await montage.controleur.generer({ graine: 11, nombre: 2, arret: 5000, historique: 1000 });
assert.equal(montage.controleur.generationDe(1).dureeMs, 10 * pas, `pas ${pas}`);
assert.equal(montage.controleur.generationDe(2).dureeMs, 10 * pas, `pas ${pas}`);
}
});
test("annulée, une génération ne garde rien : la suivante, aux mêmes réglages, garde sa seule durée", async () => {
const manuel = executeurManuel({ differer: true });
const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: manuel });
const enCours = montage.controleur.generer(PREMIERE);
await Promise.resolve();
montage.horloge.avancer(500);
montage.controleur.annuler();
manuel.executions[0].resoudreParLeMoteur();
assert.ok((await rejet(enCours)) instanceof ErreurAnnulee);
for (const id of [1, 2, 3]) assert.equal(montage.controleur.generationDe(id), null);
const suivante = montage.controleur.generer(PREMIERE);
await Promise.resolve();
manuel.executions[1].resoudreParLeMoteur();
assert.deepEqual((await suivante).ajoutees, [1, 2, 3]);
const configuration = configurationDe(montage.charge());
assert.deepEqual(montage.controleur.generationDe(1), {
dureeMs: 0,
coeurs: 1,
evalues: sommeEvalues(rechercher(configuration, PREMIERE)),
nombre: 3,
});
});
test("les exécuteurs direct et à fils rendent la même course à la séance, quel que soit l'ordre d'achèvement des fils", async () => {
const executeurs = [
['direct', () => creerExecuteurDirect(), null],
['quatre cœurs', () => creerExecuteurTravailleur(travailleursCalculants(), { coeursDisponibles: 4 }), 4],
];
const recus = [];
for (const [nom, creer, coeurs] of executeurs) {
const montage = await monter({ charges: [chargeDemo('petite-conflit')], executeur: creer() });
if (coeurs !== null) {
montage.controleur.reglerCoeurs({ automatique: false });
montage.controleur.reglerCoeurs({ saisie: String(coeurs) });
}
const executer = montage.seance.executer;
montage.seance.executer = (commande, args) => {
recus.push(args.propositions);
return executer(commande, args);
};
await montage.controleur.generer({ graine: 4, nombre: 4, arret: 1_500, historique: 100 });
const { evalues, nombre } = montage.controleur.generationDe(1);
assert.deepEqual([evalues, nombre], [sommeEvalues(recus.at(-1)), 4], nom);
}
assert.equal(recus.length, 2);
assert.deepStrictEqual(recus[1], recus[0]);
assert.ok(recus[0].every(({ releves }) => releves.length === 4));
});
test("fermer l'événement, puis le rouvrir, ou en ouvrir un autre, vide ce que la séance garde", async () => {
const montage = await monter({ charges: [chargeDemo('petite-conflit'), chargeDemo('petite-conflit', AUTRE)] });
const { seance, controleur } = montage;
await controleur.generer(PREMIERE);
assert.notEqual(controleur.generationDe(1), null);
await seance.fermer();
assert.equal(controleur.generationDe(1), null);
await seance.ouvrir(NOM);
assert.equal(montage.charge().propositions.length, 3);
assert.equal(controleur.generationDe(1), null);
await seance.passerEnEcriture();
await controleur.generer({ ...PREMIERE, graine: 2 });
assert.notEqual(controleur.generationDe(4), null);
await seance.ouvrir(AUTRE);
assert.equal(controleur.generationDe(4), null);
});
test("une autre proposition sous le même identifiant, adoptée d'une relecture, n'a pas de génération ; la même, revenue par un retour, garde la sienne", async () => {
const reglages = { graine: 1, nombre: 1, arret: 200, historique: 1000 };
const montage = await monter({ charges: [chargeDemo('petite-conflit')] });
const { seance, controleur } = montage;
// Le retour à l'instant d'avant retire la proposition 1 ; le retour à
// l'instant d'après la rend, la même : sa génération la suit.
const avant = montage.journal().entrees.at(-1).revision;
await controleur.generer(reglages);
const apres = montage.journal().entrees.at(-1).revision;
const gardee = controleur.generationDe(1);
assert.notEqual(gardee, null);
await seance.revenir(avant);
assert.deepEqual(montage.charge().propositions, []);
assert.equal(controleur.generationDe(1), null);
await seance.revenir(apres);
assert.deepEqual(controleur.generationDe(1), gardee);
// Une autre séance, sur un autre support, a produit sa proposition 1
// sous d'autres réglages ; ses fichiers remplacent ceux-ci pendant la
// lecture, et le passage en écriture les relit. Chacun des trois champs
// contrôlés, seul à différer, suffit à refuser la génération gardée.
const [originale] = rechercher(configurationDe(montage.charge()), reglages);
for (const [champ, autres] of [
['graine', { ...reglages, graine: 2 }],
['arret', { ...reglages, arret: 300 }],
['historique', { ...reglages, historique: 500 }],
]) {
const ici = await monter({ charges: [chargeDemo('petite-conflit')] });
await ici.controleur.generer(reglages);
assert.notEqual(ici.controleur.generationDe(1), null, champ);
const ailleurs = await monter({ charges: [chargeDemo('petite-conflit')] });
await ailleurs.controleur.generer(autres);
await ailleurs.seance.fermer();
await ici.seance.passerEnLecture();
for (const suffixe of [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal]) {
ici.fs.deposer('documents', `${NOM}${suffixe}`, ailleurs.fs.contenu('documents', `${NOM}${suffixe}`));
}
await ici.seance.passerEnEcriture();
const [relue] = ici.charge().propositions;
assert.equal(relue.id, 1, champ);
for (const cle of ['graine', 'arret', 'historique']) {
assert.equal(relue[cle] === originale[cle], cle !== champ, `${champ} : ${cle}`);
}
assert.equal(ici.controleur.generationDe(1), null, champ);
}
});
});

View file

@ -14,7 +14,9 @@
// et total comptent dans la seule
// proposition, total valant arret
// ← { type: 'proposition', proposition } celle de rang rang de rechercher,
// telle quelle
// telle quelle, course comprise
// (evalues, releves), sans date
// ni durée
// ← { type: 'erreur', code, details } REGLAGE_HORS_DOMAINE {reglage}
// d'exigerReglages, avant tout
// calcul ; puis, par

View file

@ -47,6 +47,18 @@ describe('travailleur de génération (§ 5.7, § 5.10)', () => {
assert.equal(postes.length, avancements.length + 1);
});
test("la proposition postée porte la course du moteur, mouvements évalués et points de relevé, sans rien de plus : aucune date, aucune durée", () => {
const configuration = configurationDe('petite-conflit');
const reglages = { graine: 9, nombre: 2, arret: 2_500, historique: 100 };
const { postes, envoyer } = porteeFactice();
envoyer({ type: 'proposer', configuration, reglages, rang: 2 });
const [{ proposition }] = postes.filter(({ type }) => type === 'proposition');
assert.deepEqual(Object.keys(proposition), ['id', 'graine', 'arret', 'historique', 'plan', 'evalues', 'releves']);
assert.equal(proposition.evalues, 2_500);
assert.deepEqual(proposition.releves.map(({ mouvements }) => mouvements), [1, 10, 100, 1_000]);
assert.deepStrictEqual(proposition, rechercher(configuration, reglages)[1]);
});
test('le même travailleur répond à chaque proposer, dans un ordre quelconque des rangs', () => {
const configuration = configurationDe('petite');
const reglages = { graine: 31, nombre: 4, arret: 2000, historique: 1000 };

View file

@ -440,6 +440,12 @@ const EXEMPLES = new Map([
['ANIMATEURS_RENCONTRES', { index: 4, participant: 9 }],
['JAMAIS_SERVIES', {}],
['HORS_OBJECTIF_MAX', {}],
['RESTE_NEGATIF', { groupe: 1, appartenance: 'Atelier Ressac' }],
['RESTE_OBJECTIFS_NEGATIF', { tour: 2 }],
['DIVERSITE_RENCONTRES', { index: 4, participant: 9 }],
['DIVERSITE_AFFILIES', { index: 4, participant: 9 }],
['AFFILIES_PLAFOND', { index: 4, participant: 9 }],
['BORNE_DIVERSITE', { index: 4, participant: 9 }],
// Génération
['GENERATION_EN_COURS', { remede: null }],
['TRAVAILLEUR_EN_ECHEC', { remede: null }],
@ -1054,6 +1060,12 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
assert.throws(() => libelle('TABLE_INCONNUE', { table: 9, detail: 'inventee' }), RangeError);
});
test("un reste négatif nomme l'appartenance quand l'appelant la traduit, son rang sinon, compté à partir de 1", () => {
assert.match(libelle('RESTE_NEGATIF', { groupe: 1, appartenance: 'Atelier Ressac' }), /l'appartenance « Atelier Ressac »/);
assert.match(libelle('RESTE_NEGATIF', { groupe: 1 }), /l'appartenance de rang 2 /);
assert.match(libelle('RESTE_NEGATIF', { groupe: 1, appartenance: null }), /l'appartenance de rang 2 /);
});
test('chaque refus de valider et de generer a son texte, dans la table de son domaine', async () => {
const parTable = new Map((await tablesDuDossier()).map(({ table, module }) => [table, module.TABLE]));
// Les refus que lèvent les épreuves de valider et de generer

View file

@ -18,8 +18,9 @@
// désigne sans identifiant l'est par son rang à partir de 0, que le texte
// affiche à partir de 1 ; une personne qu'une violation d'indicateur désigne
// par son index se nomme par participant quand l'appelant l'a traduit, par
// son rang sinon. INCONNU, aux mêmes détails que la faute de placement du
// stockage, a son texte dans libelles/stockage.js.
// son rang sinon ; l'appartenance d'un reste négatif, de même, par son
// libellé ou par son rang. INCONNU, aux mêmes détails que la faute de
// placement du stockage, a son texte dans libelles/stockage.js.
import { capitale, compte, nomDans } from './aides.js';
// Les cas d'une table que le plan ne rattache pas à l'instance, par detail.
@ -34,6 +35,14 @@ const CAS_DE_TABLE = new Map([
const personneDe = ({ index, participant }) =>
participant === undefined || participant === null ? `la personne de rang ${index + 1}` : `la personne n° ${participant}`;
// L'appartenance d'un reste négatif : son libellé quand l'appelant l'a
// traduit, son rang dans les appartenances sinon, compté à partir de 0 par
// le moteur.
const appartenanceDe = ({ groupe, appartenance }) =>
appartenance === undefined || appartenance === null
? `l'appartenance de rang ${groupe + 1}`
: `l'appartenance « ${appartenance} »`;
// Un rang de réservation, compté à partir de 0 par le moteur.
const reservationDe = (rang) => `la réservation n° ${rang + 1}`;
@ -204,6 +213,34 @@ export const TABLE = new Map([
'HORS_OBJECTIF_MAX',
() => "Le plus de tours hors objectif d'une personne dépasse le plus de tours possibles : le calcul est faux.",
],
[
'RESTE_NEGATIF',
(details) =>
`Les collisions cumulées de ${appartenanceDe(details)} passent sous la part prouvée inévitable : le reste est négatif, le calcul est faux.`,
],
[
'RESTE_OBJECTIFS_NEGATIF',
({ tour }) =>
`Au tour ${tour}, les tours hors objectif passent sous la part prouvée inévitable : le reste est négatif, le calcul est faux.`,
],
[
'DIVERSITE_RENCONTRES',
(details) => `Les appartenances rencontrées par ${personneDe(details)} dépassent ses rencontres : le calcul est faux.`,
],
[
'DIVERSITE_AFFILIES',
(details) =>
`Les appartenances rencontrées par ${personneDe(details)} dépassent les personnes affiliées qu'elle rencontre : le calcul est faux.`,
],
[
'AFFILIES_PLAFOND',
(details) =>
`Les personnes affiliées que rencontre ${personneDe(details)} dépassent son plafond réalisé des affiliés : le calcul est faux.`,
],
[
'BORNE_DIVERSITE',
(details) => `Les appartenances rencontrées par ${personneDe(details)} dépassent sa borne de diversité : le calcul est faux.`,
],
// --- Les critères du classement (§ 5.7) -----------------------------------------
['moteur.critere.ecartAuPlafondAPrioriMax', 'écart au plafond a priori'],

View file

@ -23,6 +23,14 @@
* @property {string} protocole comment le propriétaire la relève, et où il l'écrit
*/
/**
* Longueur, en caractères, du nom de référence sur lequel se cale
* LARGEUR_TRONCATURE_NOM_PX : le banc le compose de ce nombre de fois le
* glyphe le plus large de l'alphabet visé (§ 14.1), jamais d'un nom de
* démonstration, que sa régénération change.
*/
export const LONGUEUR_NOM_REFERENCE = 14;
const PROPRIETAIRES = new Set(['banc', 'node', 'manuel', 'execution']);
const STATUTS = new Set(['mesuree', 'defautDeTravail']);
@ -283,6 +291,42 @@ export const CONSTANTES = Object.freeze(
"banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le " +
'rapport ρ des médianes, écrit dans ce registre',
},
{
nom: 'SEUIL_MATRICE_PARTICIPANTS',
valeur: 40,
unite: 'participants',
mesure:
"nombre de participants au-delà duquel une case de la matrice « qui a rencontré qui » tombe sous la " +
'cible de 24 px dans la largeur de la carte, au cadre fixé',
raison:
'au-delà, la matrice coûte et ne se lit plus : la vue renvoie à la fiche de chaque personne, qui porte ' +
'ses rencontres (§ 12.5). La page éprouve en plus, par la géométrie, que les cases tiennent 24 px ' +
'dans la largeur réelle de la carte',
configuration: 'banc de mesure, cadre de 1 366 × 700 px, facteur 1, vue des paires de la page de qualité',
proprietaire: 'banc',
statut: 'defautDeTravail',
protocole:
'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de participants dont la case de la ' +
'matrice tient 24 px dans la largeur de la carte de la vue, noms en tête compris, relevé sur le rendu ; ' +
'la valeur proposée, adoptée à la main dans ce registre',
},
{
nom: 'LARGEUR_TRONCATURE_NOM_PX',
valeur: 180,
unite: 'px',
mesure: "largeur à laquelle un nom se tronque dans une étiquette de graphique",
raison:
"un nom plus long déborderait sur le tracé ou sur l'étiquette voisine ; le nom entier reste dans " +
"l'infobulle et dans le jumeau (§ 12.6)",
configuration: "banc de mesure, cadre de 1 366 × 700 px, facteur 1, police Noto Sans, corps des étiquettes de graphique",
proprietaire: 'banc',
statut: 'defautDeTravail',
protocole:
`banc de mesure, police Noto Sans : la largeur rendue d'un nom de ${LONGUEUR_NOM_REFERENCE} caractères, ` +
"chacun le glyphe le plus large de l'alphabet visé, au corps des étiquettes de graphique, dans la marge " +
"gauche d'une vue à barres — jamais un nom de démonstration ; arrondie au pixel supérieur, adoptée à " +
'la main dans ce registre',
},
].map((entree) => Object.freeze(entree)),
);

View file

@ -2,7 +2,7 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les
// dix-huit entrées du contrat de données, chacune complète et de statut
// vingt entrées du contrat de données, chacune complète et de statut
// épinglé, la vérification qui nomme l'entrée et le champ d'une faute, et
// l'accord du registre avec le relevé committé du banc : le banc écrit un
// relevé, jamais le registre, et une constante du banc déclarée mesurée
@ -15,7 +15,7 @@ import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { accordRegistre } from '../../scripts/banc/releve.js';
import releveCommite from '../../scripts/banc/releve.json' with { type: 'json' };
import { CONSTANTES, constante, verifierRegistre } from './constantes.js';
import { CONSTANTES, LONGUEUR_NOM_REFERENCE, constante, verifierRegistre } from './constantes.js';
// Le contrat de données : nom, valeur de départ, unité, propriétaire,
// statut.
@ -38,6 +38,8 @@ const CONTRAT = [
['HAUTEUR_BANDE_IMPRESSION', null, 'mm', 'execution', 'mesuree'],
['TOLERANCE_CHAINE_TEMOIN', 0.011, 'rapport', 'banc', 'mesuree'],
['RALENTISSEMENT_PLEINE_CHARGE', 2, '×', 'banc', 'defautDeTravail'],
['SEUIL_MATRICE_PARTICIPANTS', 40, 'participants', 'banc', 'defautDeTravail'],
['LARGEUR_TRONCATURE_NOM_PX', 180, 'px', 'banc', 'defautDeTravail'],
];
// Les constantes du banc dont le registre a adopté la valeur relevée.
@ -47,8 +49,8 @@ const ADOPTEES = ['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN'];
const copie = () => CONSTANTES.map((entree) => ({ ...entree }));
describe('constantes : le registre (§ 14.1, § 19.10)', () => {
test('le registre porte les dix-huit entrées du contrat, dans son ordre, chacune complète', () => {
assert.equal(CONSTANTES.length, 18);
test('le registre porte les vingt entrées du contrat, dans son ordre, chacune complète', () => {
assert.equal(CONSTANTES.length, 20);
assert.deepEqual(
CONSTANTES.map(({ nom, valeur, unite, proprietaire, statut }) => [nom, valeur, unite, proprietaire, statut]),
CONTRAT,
@ -74,6 +76,26 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
assert.match(entree('TOLERANCE_CHAINE_TEMOIN').protocole, /repli/);
});
test('les deux seuils de la page de qualité attendent le banc, protocole écrit (B29) ; la troncature se cale sur une longueur de nom écrite ici', () => {
const entree = (nom) => CONSTANTES.find((c) => c.nom === nom);
const matrice = entree('SEUIL_MATRICE_PARTICIPANTS');
assert.match(matrice.mesure, /24 px/);
assert.match(matrice.protocole, /cadre de 1 366 × 700 px/);
assert.match(matrice.raison, /fiche/);
const troncature = entree('LARGEUR_TRONCATURE_NOM_PX');
assert.ok(Number.isInteger(LONGUEUR_NOM_REFERENCE) && LONGUEUR_NOM_REFERENCE > 0);
assert.ok(troncature.protocole.includes(`nom de ${LONGUEUR_NOM_REFERENCE} caractères`), troncature.protocole);
assert.match(troncature.protocole, /glyphe le plus large/);
// Jamais les noms d'une démonstration, que sa régénération change.
assert.match(troncature.protocole, /jamais un nom de démonstration/);
// Le banc les relève ; le registre garde leur défaut de travail jusqu'à
// l'adoption à la main d'une valeur relevée.
for (const seuil of [matrice, troncature]) {
assert.equal(seuil.proprietaire, 'banc', seuil.nom);
assert.equal(seuil.statut, 'defautDeTravail', seuil.nom);
}
});
test('constante rend la valeur par le nom ; un nom inconnu lève', () => {
assert.equal(constante('SEUIL_GLISSER_PX'), 4);
assert.equal(constante('K_LISTES'), 0.3);

403
src/geometrie/graphique.js Normal file
View file

@ -0,0 +1,403 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La géométrie des graphiques de la page de qualité (§ 12.9) : module frère
// de celui du plan, qui n'en importe aucune arithmétique. L'unité y est le
// rang et le nombre, rendus en pixels d'écran, jamais le centimètre. Les
// fonctions sont pures : un composant reçoit d'elles des nombres déjà rangés,
// des pixels et des chemins, et n'en calcule aucun.
//
// Un axe des rangs va de 0 à n : le rang r occupe l'intervalle [r − 1, r],
// son centre est r − 0,5. Un axe de comptes va de zéro à sa plus grande
// valeur (D49). Les chemins SVG n'emploient que des commandes absolues, leurs
// nombres arrondis au centième de pixel.
import { CIBLE_MIN_PX } from './designation.js';
/** Les quatre niveaux de l'échelle séquentielle : 0, 1, 2, puis 3 et plus (D33). */
export const NIVEAUX = Object.freeze([0, 1, 2, 3]);
// Mantisses des pas ronds, du plus petit au plus grand.
const MANTISSES = [1, 2, 5];
// m × 10ⁿ, au plus près. Recopiée de ronde (echelle.js), non importée : le
// plan et les graphiques ne partagent pas de module (§ 12.9). Une puissance
// négative divise par 10⁻ⁿ, que l'arithmétique flottante représente
// exactement, plutôt que de multiplier par 10ⁿ, qu'elle arrondit.
const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n);
// Tolérance des bornes d'un compte de graduations : un quotient tel que
// 0,3 / 0,1 tombe juste sous l'entier qu'il vaut.
const EPSILON = 1e-9;
const estFini = (v) => typeof v === 'number' && Number.isFinite(v);
const estPositif = (v) => estFini(v) && v >= 0;
const estEntierPositif = (v) => Number.isInteger(v) && v >= 0;
// Le même nombre, zéro négatif ramené à zéro : −0 + 0 vaut 0, et une
// épreuve stricte ou un chemin n'en voient jamais le signe.
const sansZeroNegatif = (v) => v + 0;
function exiger(condition, message) {
if (!condition) throw new RangeError(message);
}
// Un intervalle [a, b] de deux nombres finis.
const estIntervalle = (paire) => Array.isArray(paire) && paire.length === 2 && estFini(paire[0]) && estFini(paire[1]);
// Un nombre de pixels écrit dans un chemin : arrondi au centième, sans zéro
// négatif.
function px(valeur) {
return String(sansZeroNegatif(Math.round(valeur * 100) / 100));
}
/**
* L'échelle linéaire d'un axe de comptes ou de rangs. Le domaine part de
* zéro (D49) ; un domaine nul, [0, 0], pose toute valeur au premier bord de
* la plage. La plage va du pixel de zéro à celui du maximum, dans un sens ou
* dans l'autre : [hauteur, 0] pour un axe vertical. depuisPx rend 0 quand le
* domaine ou la plage est réduit à un point. Lève RangeError sur un domaine
* qui ne part pas de zéro, de maximum négatif ou non fini, ou sur une plage
* qui n'est pas deux nombres finis.
* @param {{domaine: [number, number], plage: [number, number]}} options
* @returns {{domaine: number[], plage: number[], versPx: (v: number) => number, depuisPx: (p: number) => number}}
*/
export function echelleLineaire({ domaine, plage }) {
exiger(estIntervalle(domaine), `domaine : deux nombres finis attendus, reçu ${String(domaine)}`);
exiger(domaine[0] === 0, `domaine : un axe part de zéro, reçu ${domaine[0]}`);
exiger(domaine[1] >= 0, `domaine : maximum ≥ 0 attendu, reçu ${domaine[1]}`);
exiger(estIntervalle(plage), `plage : deux nombres finis attendus, reçu ${String(plage)}`);
const [, max] = domaine;
const [p0, p1] = plage;
return {
domaine: [0, max],
plage: [p0, p1],
versPx: (v) => sansZeroNegatif(max === 0 ? p0 : p0 + (v / max) * (p1 - p0)),
depuisPx: (p) => (max === 0 || p1 === p0 ? 0 : sansZeroNegatif(((p - p0) / (p1 - p0)) * max)),
};
}
/**
* L'échelle logarithmique des mouvements évalués (§ 12.14) : 1 mouvement au
* premier bord de la plage, le maximum au second. Un domaine [1, 1], un seul
* point de relevé, pose 1 au premier bord. versPx lève RangeError sous 1 :
* zéro mouvement n'a pas d'abscisse, et la vue l'écrit au lieu de le tracer.
* Lève RangeError sur un domaine qui ne part pas de 1 ou de maximum sous 1,
* ou sur une plage qui n'est pas deux nombres finis.
* @param {{domaine: [number, number], plage: [number, number]}} options
* @returns {{domaine: number[], plage: number[], versPx: (v: number) => number}}
*/
export function echelleLog10({ domaine, plage }) {
exiger(estIntervalle(domaine), `domaine : deux nombres finis attendus, reçu ${String(domaine)}`);
exiger(domaine[0] === 1 && domaine[1] >= 1, `domaine : [1, max ≥ 1] attendu, reçu ${String(domaine)}`);
exiger(estIntervalle(plage), `plage : deux nombres finis attendus, reçu ${String(plage)}`);
const [, max] = domaine;
const [p0, p1] = plage;
const decades = Math.log10(max);
return {
domaine: [1, max],
plage: [p0, p1],
versPx: (v) => {
exiger(estFini(v) && v >= 1, `mouvements : nombre fini ≥ 1 attendu, reçu ${String(v)}`);
return sansZeroNegatif(decades === 0 ? p0 : p0 + (Math.log10(v) / decades) * (p1 - p0));
},
};
}
/**
* Le domaine d'un axe de comptes : [0, la plus grande valeur de toutes les
* listes reçues], null ignoré ; [0, 0] sans valeur. Lève RangeError sur ce
* qui n'est pas une liste, ou sur une valeur qui n'est ni null ni un nombre
* fini ≥ 0.
* @param {...Array<number|null>} listes
* @returns {[number, number]}
*/
export function domaineDe(...listes) {
let max = 0;
for (const liste of listes) {
exiger(Array.isArray(liste), `domaine : liste attendue, reçu ${String(liste)}`);
for (const v of liste) {
if (v === null) continue;
exiger(estPositif(v), `domaine : nombre fini ≥ 0 ou null attendu, reçu ${String(v)}`);
if (v > max) max = v;
}
}
return [0, max];
}
/**
* Les graduations d'un axe : les multiples du plus petit pas rond — 1, 2 ou 5
* × 10ᵏ — dont au plus n tombent dans [min, max], croissants. Sous entieres,
* le pas est un entier, 1 au moins : un axe de comptes ne porte jamais de
* graduation fractionnaire. La recherche part de la décade de (max − min) / n
* et monte, mantisse par mantisse ; d'un pas qui donne trois graduations ou
* plus, le pas rond suivant en garde au moins une : pour n ≥ 2, la liste
* n'est vide que si le premier pas essayé n'a aucun multiple dans le domaine.
* Sans entieres, ce pas ne dépasse pas (max − min) / n et le domaine en porte
* toujours un ; sous entieres, il vaut 1 au moins, et un domaine non nul sans
* entier, tel 0,2..0,8, rend la liste vide. Un domaine nul se gradue de sa
* seule valeur. Lève RangeError sur des bornes non finies, min > max, ou un n
* qui n'est pas un entier ≥ 2.
* @param {number} min
* @param {number} max
* @param {number} n nombre maximal de graduations
* @param {{entieres?: boolean}} [options]
* @returns {number[]}
*/
export function graduations(min, max, n, { entieres = false } = {}) {
exiger(estFini(min) && estFini(max) && min <= max, `graduations : min ≤ max finis attendus, reçu ${min}, ${max}`);
exiger(Number.isInteger(n) && n >= 2, `graduations : entier ≥ 2 attendu, reçu ${String(n)}`);
if (min === max) return [min];
const depart = Math.floor(Math.log10((max - min) / n));
for (let k = entieres ? Math.max(depart, 0) : depart; ; k += 1) {
for (const m of MANTISSES) {
// Les multiples i × m × 10ᵏ du domaine, i de premier à dernier, chacun
// écrit par ronde(i × m, k), au plus près.
const pas = ronde(m, k);
const premier = Math.ceil(min / pas - EPSILON);
const dernier = Math.floor(max / pas + EPSILON);
if (dernier - premier + 1 > n) continue;
const marques = [];
for (let i = premier; i <= dernier; i += 1) marques.push(sansZeroNegatif(ronde(i * m, k)));
return marques;
}
}
}
// Les valeurs d'un tracé : une liste de nombres finis ou de null.
function exigerValeurs(valeurs) {
exiger(Array.isArray(valeurs), `valeurs : liste attendue, reçu ${String(valeurs)}`);
for (const v of valeurs) exiger(v === null || estFini(v), `valeur : nombre fini ou null attendu, reçu ${String(v)}`);
}
/**
* Le chemin en escalier d'une série par rang (§ 12.3) : valeurs[i] est celle
* du rang i + 1. Chaque rang porte un trait horizontal sur son intervalle ;
* un trait vertical relie deux rangs voisins de valeurs différentes ; aucun
* segment n'est oblique. Une valeur null interrompt le chemin, qui reprend
* au rang suivant par un déplacement sans trait (D51) : aucun segment ne
* passe au-dessus d'un rang null. '' quand aucune valeur n'est un nombre.
* Lève RangeError sur une valeur qui n'est ni null ni un nombre fini.
* @param {Array<number|null>} valeurs
* @param {{versPx: (v: number) => number}} ex axe des rangs, de 0 à n
* @param {{versPx: (v: number) => number}} ey axe des valeurs
* @returns {string}
*/
export function cheminEscalier(valeurs, ex, ey) {
exigerValeurs(valeurs);
const morceaux = [];
let precedente = null;
valeurs.forEach((v, i) => {
if (v === null) {
precedente = null;
return;
}
if (precedente === null) morceaux.push(`M ${px(ex.versPx(i))} ${px(ey.versPx(v))}`);
else if (v !== precedente) morceaux.push(`V ${px(ey.versPx(v))}`);
morceaux.push(`H ${px(ex.versPx(i + 1))}`);
precedente = v;
});
return morceaux.join(' ');
}
/**
* Le tracé d'un repère par rang — plafond réalisé, borne, règle à 100 % : une
* règle horizontale sur tous les rangs quand la valeur est la même partout,
* l'escalier de cheminEscalier sinon (§ 12.3 : une règle unique mentirait).
* Une valeur null rend la série non constante. Aucun rang : une règle vide.
* @param {Array<number|null>} plafonds
* @param {{versPx: (v: number) => number}} ex axe des rangs, de 0 à n
* @param {{versPx: (v: number) => number}} ey axe des valeurs
* @returns {{forme: 'regle'|'escalier', d: string}}
*/
export function regleOuEscalier(plafonds, ex, ey) {
exigerValeurs(plafonds);
if (plafonds.length === 0) return { forme: 'regle', d: '' };
const premier = plafonds[0];
if (premier !== null && plafonds.every((v) => v === premier)) {
return { forme: 'regle', d: `M ${px(ex.versPx(0))} ${px(ey.versPx(premier))} H ${px(ex.versPx(plafonds.length))}` };
}
return { forme: 'escalier', d: cheminEscalier(plafonds, ex, ey) };
}
/**
* Le chemin qui relie des points de relevé (§ 12.14, D49) : un segment de
* chaque point au suivant, à l'abscisse de ses mouvements. Un point de
* valeur null n'est pas relié : le chemin reprend au point suivant par un
* déplacement. Le point lui-même se marque à part, par le composant. ''
* sans point. Lève RangeError sur un point qui n'est pas {x, y}, x fini et
* y fini ou null.
* @param {Array<{x: number, y: number|null}>} points x en mouvements
* @param {{versPx: (v: number) => number}} ex échelle des mouvements
* @param {{versPx: (v: number) => number}} ey axe des valeurs
* @returns {string}
*/
export function cheminPoints(points, ex, ey) {
exiger(Array.isArray(points), `points : liste attendue, reçu ${String(points)}`);
const morceaux = [];
let relie = false;
for (const point of points) {
exiger(typeof point === 'object' && point !== null && estFini(point.x), `point : {x, y} attendu, reçu ${String(point)}`);
exiger(point.y === null || estFini(point.y), `point : y fini ou null attendu, reçu ${String(point.y)}`);
if (point.y === null) {
relie = false;
continue;
}
morceaux.push(`${relie ? 'L' : 'M'} ${px(ex.versPx(point.x))} ${px(ey.versPx(point.y))}`);
relie = true;
}
return morceaux.join(' ');
}
/**
* Le rang sous un pixel, sur un axe des rangs de 0 à n (§ 12.7, pointage au
* plus proche) : le rang r dont l'intervalle [r − 1, r[ contient le pixel,
* borné à 1 et à n hors du tracé. Lève RangeError sur un pixel non fini ou
* un n qui n'est pas un entier ≥ 1.
* @param {number} pixel
* @param {{depuisPx: (p: number) => number}} ex
* @param {number} n
* @returns {number}
*/
export function rangLePlusProche(pixel, ex, n) {
exiger(estFini(pixel), `pixel : nombre fini attendu, reçu ${String(pixel)}`);
exiger(Number.isInteger(n) && n >= 1, `rangs : entier ≥ 1 attendu, reçu ${String(n)}`);
const rang = Math.floor(ex.depuisPx(pixel)) + 1;
return Math.min(n, Math.max(1, rang));
}
/**
* L'indice du point de relevé le plus proche d'un pixel, sur l'abscisse
* commune des petits multiples (§ 12.7) : la plus petite distance, le
* premier à égalité. Lève RangeError sur un pixel ou une abscisse non finis,
* ou une liste vide.
* @param {number} pixel
* @param {number[]} abscisses pixels des points, dans leur ordre
* @returns {number}
*/
export function pointLePlusProche(pixel, abscisses) {
exiger(estFini(pixel), `pixel : nombre fini attendu, reçu ${String(pixel)}`);
exiger(Array.isArray(abscisses) && abscisses.length > 0, 'abscisses : liste non vide attendue');
let meilleur = 0;
abscisses.forEach((x, i) => {
exiger(estFini(x), `abscisse : nombre fini attendu, reçu ${String(x)}`);
if (Math.abs(x - pixel) < Math.abs(abscisses[meilleur] - pixel)) meilleur = i;
});
return meilleur;
}
/**
* Les barres horizontales d'une vue (§ 12.4) : une ligne par entrée, l'une
* sous l'autre, chacune de un ou deux segments empilés depuis zéro sur l'axe
* commun ex — le second commence où finit le premier. Un segment nul garde
* sa place, de largeur nulle. hauteurTotale compte les lignes et les écarts
* entre elles. Lève RangeError sur une ligne qui n'a pas un ou deux segments,
* un segment qui n'est pas un nombre fini ≥ 0, ou une hauteur ou un écart
* qui ne l'est pas.
* @param {number[][]} lignes
* @param {{versPx: (v: number) => number}} ex
* @param {number} hauteur hauteur d'une ligne, en px
* @param {number} ecart espace entre deux lignes, en px
* @returns {{lignes: Array<{y: number, hauteur: number, segments: Array<{x: number, largeur: number}>}>, hauteurTotale: number}}
*/
export function barres(lignes, ex, hauteur, ecart) {
exiger(Array.isArray(lignes), `barres : liste attendue, reçu ${String(lignes)}`);
exiger(estPositif(hauteur) && estPositif(ecart), `barres : hauteur et écart ≥ 0 attendus, reçu ${hauteur}, ${ecart}`);
const rendues = lignes.map((segments, i) => {
exiger(Array.isArray(segments) && segments.length >= 1 && segments.length <= 2, 'barre : un ou deux segments attendus');
let cumul = 0;
const traces = segments.map((v) => {
exiger(estPositif(v), `segment : nombre fini ≥ 0 attendu, reçu ${String(v)}`);
const x = ex.versPx(cumul);
cumul += v;
return { x, largeur: ex.versPx(cumul) - x };
});
return { y: i * (hauteur + ecart), hauteur, segments: traces };
});
const hauteurTotale = lignes.length === 0 ? 0 : lignes.length * hauteur + (lignes.length - 1) * ecart;
return { lignes: rendues, hauteurTotale };
}
/**
* n colonnes de barres côte à côte, de même largeur, séparées par ecart —
* chacune porte son propre axe (§ 12.13) : jamais empilées, jamais sur un
* axe commun. Une largeur plus petite que les écarts laisse des colonnes de
* largeur nulle. Lève RangeError sur un n qui n'est pas un entier ≥ 1, ou
* une largeur ou un écart qui n'est pas un nombre fini ≥ 0.
* @param {number} largeur
* @param {number} n
* @param {number} ecart
* @returns {Array<{x: number, largeur: number}>}
*/
export function colonnesJuxtaposees(largeur, n, ecart) {
exiger(Number.isInteger(n) && n >= 1, `colonnes : entier ≥ 1 attendu, reçu ${String(n)}`);
exiger(estPositif(largeur) && estPositif(ecart), `colonnes : largeur et écart ≥ 0 attendus, reçu ${largeur}, ${ecart}`);
const colonne = Math.max(0, (largeur - (n - 1) * ecart) / n);
return Array.from({ length: n }, (_, i) => ({ x: i * (colonne + ecart), largeur: colonne }));
}
/**
* La grille d'une matrice ou des tables par tour (§ 12.5, D33) : des cases
* carrées d'un nombre entier de pixels, autant que la largeur disponible en
* tient par colonne. Elle est dessinable quand elle a au moins une ligne et
* une colonne et que sa case tient la cible de CIBLE_MIN_PX et la hauteur
* du texte qu'elle porte : la largeur réelle de la carte décide, quel que
* soit le seuil mesuré au cadre du banc. largeur et hauteur sont celles des
* cases seules. Lève RangeError sur des comptes qui ne sont pas des entiers
* ≥ 0, ou une largeur ou une hauteur de texte qui n'est pas un nombre fini
* ≥ 0.
* @param {number} lignes
* @param {number} colonnes
* @param {number} largeurDispo px
* @param {number} hauteurTexte px, la hauteur de ligne du compte écrit
* @returns {{cote: number, dessinable: boolean, largeur: number, hauteur: number}}
*/
export function grille(lignes, colonnes, largeurDispo, hauteurTexte) {
exiger(
estEntierPositif(lignes) && estEntierPositif(colonnes),
`grille : comptes entiers ≥ 0 attendus, reçu ${lignes}, ${colonnes}`,
);
exiger(
estPositif(largeurDispo) && estPositif(hauteurTexte),
`grille : largeur et texte ≥ 0 attendus, reçu ${largeurDispo}, ${hauteurTexte}`,
);
const cote = colonnes === 0 ? 0 : Math.floor(largeurDispo / colonnes);
const dessinable = lignes >= 1 && colonnes >= 1 && cote >= CIBLE_MIN_PX && cote >= hauteurTexte;
return { cote, dessinable, largeur: cote * colonnes, hauteur: cote * lignes };
}
/**
* Le niveau d'un compte sur l'échelle séquentielle (D33, D35) : le compte
* lui-même jusqu'à 2, puis 3 pour 3 et plus. Lève RangeError sur ce qui
* n'est pas un entier ≥ 0.
* @param {number} valeur
* @returns {number}
*/
export function niveau(valeur) {
exiger(estEntierPositif(valeur), `niveau : entier ≥ 0 attendu, reçu ${String(valeur)}`);
return Math.min(valeur, NIVEAUX[NIVEAUX.length - 1]);
}
/**
* Le nombre de caractères qu'une étiquette garde dans maxPx (§ 12.6), sur des
* largeurs mesurées par l'appelant, sur la police rendue : pour un nom de L
* caractères, largeurs[k] est celle de ses k + 1 premiers caractères suivis
* de la marque de troncature, pour k < L − 1, et largeurs[L − 1] celle du nom
* entier. Rend L quand le nom entier tient ; sinon le plus grand k + 1 dont
* la largeur tient, l'appelant écrivant ce préfixe suivi de la marque ; 0
* quand rien ne tient, et pour un nom vide. Aucune monotonie n'est supposée.
* Lève RangeError sur une largeur ou un maximum qui n'est pas un nombre fini
* ≥ 0.
* @param {number[]} largeurs px
* @param {number} maxPx
* @returns {number}
*/
export function tronquer(largeurs, maxPx) {
exiger(Array.isArray(largeurs), `largeurs : liste attendue, reçu ${String(largeurs)}`);
exiger(estPositif(maxPx), `largeur maximale : nombre fini ≥ 0 attendu, reçu ${String(maxPx)}`);
for (const l of largeurs) exiger(estPositif(l), `largeur : nombre fini ≥ 0 attendu, reçu ${String(l)}`);
for (let k = largeurs.length - 1; k >= 0; k -= 1) {
if (largeurs[k] <= maxPx) return k + 1;
}
return 0;
}

View file

@ -0,0 +1,468 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la géométrie des graphiques (§ 12.9, § 14.10) : échelles,
// graduations, escaliers et points, barres, colonnes, grilles, pointage au
// plus proche et troncature. Les chemins se lisent commande par commande :
// un escalier ne porte que M, H et V, et un rang null n'est traversé par
// aucun trait. Les cas chiffrés sont écrits en clair ; les balayages
// vérifient chaque résultat par sa définition, non par une seconde
// implémentation.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { constante } from './constantes.js';
import { CIBLE_MIN_PX } from './designation.js';
import {
NIVEAUX,
barres,
cheminEscalier,
cheminPoints,
colonnesJuxtaposees,
domaineDe,
echelleLineaire,
echelleLog10,
graduations,
grille,
niveau,
pointLePlusProche,
rangLePlusProche,
regleOuEscalier,
tronquer,
} from './graphique.js';
// Les commandes d'un chemin, chacune avec ses nombres : « M 0 10 H 5 » donne
// [['M', 0, 10], ['H', 5]].
function commandes(d) {
return [...d.matchAll(/([A-Z])([^A-Z]*)/g)].map(([, lettre, reste]) => [
lettre,
...reste.trim().split(/\s+/).filter((mot) => mot !== '').map(Number),
]);
}
// Les segments tracés d'un chemin fait de M, H, V et L : chacun de son point
// de départ à son point d'arrivée. Un M ne trace rien, il déplace.
function segments(d) {
const traces = [];
let x = Number.NaN;
let y = Number.NaN;
for (const [lettre, a, b] of commandes(d)) {
const depart = { x, y };
if (lettre === 'M' || lettre === 'L') [x, y] = [a, b];
else if (lettre === 'H') x = a;
else if (lettre === 'V') y = a;
else throw new Error(`commande inattendue : ${lettre}`);
if (lettre !== 'M') traces.push({ lettre, depart, arrivee: { x, y } });
}
return traces;
}
// Un axe de comptes à l'écran : 0 en bas d'un tracé de 200 px de haut.
const axeComptes = (max) => echelleLineaire({ domaine: [0, max], plage: [200, 0] });
// Un axe de rangs de 10 px par rang, de 0 à n.
const axeRangs = (n) => echelleLineaire({ domaine: [0, n], plage: [0, 10 * n] });
describe('graphique : les échelles (§ 12.9, D49)', () => {
test("l'échelle linéaire rend le pixel d'un nombre et le nombre d'un pixel, dans les deux sens de la plage", () => {
const ex = echelleLineaire({ domaine: [0, 260], plage: [0, 1000] });
assert.equal(ex.versPx(0), 0);
assert.equal(ex.versPx(260), 1000);
assert.equal(ex.versPx(130), 500);
assert.equal(ex.depuisPx(500), 130);
const ey = axeComptes(27);
assert.equal(ey.versPx(0), 200);
assert.equal(ey.versPx(27), 0);
assert.equal(ey.depuisPx(200), 0);
assert.deepEqual([ey.domaine, ey.plage], [[0, 27], [200, 0]]);
});
test("un domaine qui ne part pas de zéro est refusé : chaque axe va de zéro à sa plus grande valeur", () => {
for (const domaine of [[1, 27], [-1, 27], [14, 27]]) {
assert.throws(() => echelleLineaire({ domaine, plage: [0, 100] }), /zéro/, String(domaine));
}
for (const domaine of [[0, -1], [0, Number.NaN], [0, Number.POSITIVE_INFINITY], [0], 'abc', null]) {
assert.throws(() => echelleLineaire({ domaine, plage: [0, 100] }), RangeError, String(domaine));
}
for (const plage of [[0], [0, Number.NaN], null]) {
assert.throws(() => echelleLineaire({ domaine: [0, 1], plage }), RangeError, String(plage));
}
});
test('un domaine nul (min = max = 0) pose toute valeur en bord de plage, et tout pixel à zéro', () => {
const ey = axeComptes(0);
assert.equal(ey.versPx(0), 200);
assert.equal(ey.depuisPx(0), 0);
assert.equal(ey.depuisPx(137), 0);
// Une plage réduite à un point ne divise pas par zéro non plus.
const plate = echelleLineaire({ domaine: [0, 5], plage: [40, 40] });
assert.equal(plate.versPx(3), 40);
assert.equal(plate.depuisPx(90), 0);
});
test("l'axe en pourcentages : une échelle de 0 à 100, graduée d'entiers ronds", () => {
const ey = echelleLineaire({ domaine: [0, 100], plage: [120, 0] });
assert.equal(ey.versPx(100), 0);
assert.equal(ey.versPx(50), 60);
assert.deepEqual(graduations(0, 100, 5, { entieres: true }), [0, 50, 100]);
assert.deepEqual(graduations(0, 100, 6, { entieres: true }), [0, 20, 40, 60, 80, 100]);
});
test("l'échelle log10 des mouvements, de 1 à 200 000 : 1 au bord gauche, croissante, 200 000 au bord droit", () => {
const ex = echelleLog10({ domaine: [1, 200_000], plage: [0, 530] });
assert.equal(ex.versPx(1), 0);
assert.equal(ex.versPx(200_000), 530);
const points = [1, 10, 100, 1_000, 10_000, 100_000, 200_000].map((v) => ex.versPx(v));
for (let i = 1; i < points.length; i += 1) assert.ok(points[i] > points[i - 1], `${points}`);
// Une décade a la même largeur partout : 530 px pour log10(200 000).
const decade = 530 / Math.log10(200_000);
assert.ok(Math.abs(points[1] - decade) < 1e-9 && Math.abs(points[4] - points[3] - decade) < 1e-9);
assert.deepEqual([ex.domaine, ex.plage], [[1, 200_000], [0, 530]]);
});
test("l'échelle log10 d'un seul point de relevé, à 1 mouvement, le pose au bord gauche", () => {
const ex = echelleLog10({ domaine: [1, 1], plage: [0, 530] });
assert.equal(ex.versPx(1), 0);
});
test("l'échelle log10 refuse un domaine qui ne part pas de 1, et une valeur sous 1 : zéro mouvement n'a pas d'abscisse", () => {
for (const domaine of [[0, 10], [10, 100], [1, 0.5], [1, Number.NaN], null]) {
assert.throws(() => echelleLog10({ domaine, plage: [0, 100] }), RangeError, String(domaine));
}
assert.throws(() => echelleLog10({ domaine: [1, 10], plage: [0] }), RangeError);
const ex = echelleLog10({ domaine: [1, 1_000], plage: [0, 300] });
for (const v of [0, 0.5, -1, Number.NaN]) assert.throws(() => ex.versPx(v), RangeError, String(v));
});
test('domaineDe : de zéro à la plus grande valeur de toutes les séries, null ignoré', () => {
assert.deepEqual(domaineDe([3, 27, null, 14], [26, 28]), [0, 28]);
assert.deepEqual(domaineDe([null, null]), [0, 0]);
assert.deepEqual(domaineDe(), [0, 0]);
assert.deepEqual(domaineDe([0, 0]), [0, 0]);
for (const fautive of [[-1], [Number.NaN], ['3'], 'abc']) {
assert.throws(() => domaineDe(fautive), RangeError, String(fautive));
}
});
});
describe('graphique : les graduations (§ 14.10)', () => {
// Vrai quand le pas vaut 1, 2 ou 5 × 10ⁿ, à l'arrondi flottant près.
const estRond = (pas) => {
const puissance = 10 ** Math.floor(Math.log10(pas) + 1e-12);
return [1, 2, 5].some((m) => Math.abs(pas / puissance - m) < 1e-9);
};
test('un domaine 0..3, graduations entières : 0, 1, 2, 3, jamais un demi', () => {
assert.deepEqual(graduations(0, 3, 10, { entieres: true }), [0, 1, 2, 3]);
// Sans l'exigence d'entiers, le même domaine se gradue de demi en demi.
assert.deepEqual(graduations(0, 3, 10), [0, 0.5, 1, 1.5, 2, 2.5, 3]);
});
test("sur un axe de comptes, aucune graduation n'est fractionnaire : 0..10 en cinq au plus donne 0, 5, 10", () => {
assert.deepEqual(graduations(0, 10, 5, { entieres: true }), [0, 5, 10]);
let essais = 0;
for (let max = 1; max <= 300; max += 1) {
for (let n = 2; n <= 10; n += 1) {
const marques = graduations(0, max, n, { entieres: true });
assert.ok(marques.every((v) => Number.isInteger(v)), `0..${max} en ${n} : ${marques}`);
assert.ok(marques.length >= 1 && marques.length <= n, `0..${max} en ${n} : ${marques}`);
assert.equal(marques[0], 0);
assert.ok(marques.at(-1) <= max, `0..${max} en ${n}`);
if (marques.length > 1) {
const pas = marques[1] - marques[0];
assert.ok(estRond(pas) && pas >= 1, `0..${max} en ${n} : pas ${pas}`);
// Le pas rond précédent aurait donné trop de graduations.
essais += 1;
}
}
}
assert.ok(essais > 2_000, `${essais}`);
});
test('le pas est le plus petit pas rond qui tient dans le nombre de graduations demandé', () => {
assert.deepEqual(graduations(0, 27, 6, { entieres: true }), [0, 5, 10, 15, 20, 25]);
assert.deepEqual(graduations(0, 27, 5, { entieres: true }), [0, 10, 20]);
assert.deepEqual(graduations(0, 200_000, 5), [0, 50_000, 100_000, 150_000, 200_000]);
assert.deepEqual(graduations(0, 0.3, 4), [0, 0.1, 0.2, 0.3]);
assert.deepEqual(graduations(14, 27, 3, { entieres: true }), [15, 20, 25]);
});
test('un domaine nul se gradue de sa seule valeur ; un domaine ou un nombre fautif est refusé', () => {
assert.deepEqual(graduations(0, 0, 5, { entieres: true }), [0]);
assert.deepEqual(graduations(7, 7, 5), [7]);
// Un axe se gradue de deux traits au moins : sous deux, le pas rond
// suivant pourrait n'avoir aucun multiple dans le domaine.
for (const [min, max, n] of [[3, 1, 5], [0, Number.NaN, 5], [Number.NEGATIVE_INFINITY, 1, 5], [0, 10, 1], [0, 10, 2.5]]) {
assert.throws(() => graduations(min, max, n), RangeError, `${min}, ${max}, ${n}`);
}
});
test('deux traits au plus gardent les multiples du pas qui tombent dans le domaine, jamais aucun', () => {
assert.deepEqual(graduations(0, 10, 2, { entieres: true }), [0, 10]);
assert.deepEqual(graduations(3, 7, 2, { entieres: true }), [4, 6]);
assert.deepEqual(graduations(0.6, 0.9, 2), [0.6, 0.8]);
});
test('sous entieres, un domaine non nul sans entier ne porte aucune graduation', () => {
assert.deepEqual(graduations(0.2, 0.8, 5, { entieres: true }), []);
assert.deepEqual(graduations(0.2, 0.8, 5), [0.2, 0.4, 0.6, 0.8]);
// Un seul entier dans le domaine suffit à le graduer.
assert.deepEqual(graduations(0.2, 1.3, 5, { entieres: true }), [1]);
});
});
describe("graphique : l'escalier et la règle (§ 12.3, D51)", () => {
test('un pas par rang, H et V seulement, jamais une diagonale', () => {
const d = cheminEscalier([3, 5, 5, 2], axeRangs(4), axeComptes(10));
assert.equal(d, 'M 0 140 H 10 V 100 H 20 H 30 V 160 H 40');
for (const { lettre, depart, arrivee } of segments(d)) {
assert.ok(lettre === 'H' || lettre === 'V', lettre);
assert.ok(depart.x === arrivee.x || depart.y === arrivee.y, `${JSON.stringify(depart)} → ${JSON.stringify(arrivee)}`);
}
assert.equal(commandes(d).filter(([l]) => l === 'H').length, 4);
});
test('à valeurs égales, un trait horizontal par rang et aucun vertical', () => {
const d = cheminEscalier([27, 27, 27], axeRangs(3), axeComptes(27));
assert.equal(d, 'M 0 0 H 10 H 20 H 30');
});
test('260 rangs sur 1 000 px : 260 pas, du bord gauche au bord droit, aucun segment oblique', () => {
const valeurs = Array.from({ length: 260 }, (_, i) => 14 + (i % 14));
const ex = echelleLineaire({ domaine: [0, 260], plage: [0, 1000] });
const d = cheminEscalier(valeurs, ex, axeComptes(27));
const liste = commandes(d);
assert.deepEqual(liste[0].slice(0, 2), ['M', 0]);
assert.deepEqual(liste.at(-1), ['H', 1000]);
assert.equal(liste.filter(([l]) => l === 'H').length, 260);
assert.ok(segments(d).every(({ lettre }) => lettre === 'H' || lettre === 'V'));
});
test("un rang null interrompt l'escalier : aucun segment ne le traverse, au milieu, au premier ou au dernier rang", () => {
const ex = axeRangs(5);
const ey = axeComptes(100);
// Au milieu : le tracé reprend au rang 4 par un déplacement.
const milieu = cheminEscalier([90, 80, null, 70, 100], ex, ey);
assert.equal(milieu, 'M 0 20 H 10 V 40 H 20 M 30 60 H 40 V 0 H 50');
// Au premier rang : le tracé commence au bord du rang 2.
assert.equal(cheminEscalier([null, 80, 80, 80, 80], ex, ey), 'M 10 40 H 20 H 30 H 40 H 50');
// Au dernier rang : le tracé s'arrête au bord droit du rang 4.
assert.equal(cheminEscalier([80, 80, 80, 80, null], ex, ey), 'M 0 40 H 10 H 20 H 30 H 40');
for (const [valeurs, nul] of [[[90, 80, null, 70, 100], 3], [[null, 80, 80, 80, 80], 1], [[80, 80, 80, 80, null], 5]]) {
const gauche = ex.versPx(nul - 1);
const droite = ex.versPx(nul);
for (const { depart, arrivee } of segments(cheminEscalier(valeurs, ex, ey))) {
const [a, b] = [Math.min(depart.x, arrivee.x), Math.max(depart.x, arrivee.x)];
assert.ok(b <= gauche || a >= droite, `rang ${nul} traversé : ${a} → ${b}`);
}
}
});
test('un rang isolé entre deux nulls garde son pas ; tout null, ou aucun rang, ne trace rien', () => {
assert.equal(cheminEscalier([null, 50, null], axeRangs(3), axeComptes(100)), 'M 10 100 H 20');
assert.equal(cheminEscalier([null, null], axeRangs(2), axeComptes(100)), '');
assert.equal(cheminEscalier([], axeRangs(1), axeComptes(100)), '');
});
test('une valeur fautive est refusée', () => {
for (const valeurs of [[1, Number.NaN], [1, '2'], [1, undefined], 'abc']) {
assert.throws(() => cheminEscalier(valeurs, axeRangs(2), axeComptes(10)), RangeError, String(valeurs));
}
});
test('la règle quand le plafond est constant, l’escalier dès qu’il varie', () => {
assert.deepEqual(regleOuEscalier([27, 27, 27, 27], axeRangs(4), axeComptes(27)), { forme: 'regle', d: 'M 0 0 H 40' });
assert.deepEqual(regleOuEscalier([100, 100], axeRangs(2), axeComptes(100)), { forme: 'regle', d: 'M 0 0 H 20' });
assert.deepEqual(regleOuEscalier([27, 26, 27], axeRangs(3), axeComptes(27)), {
forme: 'escalier',
d: cheminEscalier([27, 26, 27], axeRangs(3), axeComptes(27)),
});
// Un plafond null n'est pas constant : son rang interrompt le tracé.
assert.deepEqual(regleOuEscalier([27, null], axeRangs(2), axeComptes(27)), { forme: 'escalier', d: 'M 0 0 H 10' });
assert.deepEqual(regleOuEscalier([], axeRangs(1), axeComptes(27)), { forme: 'regle', d: '' });
});
});
describe('graphique : les points de relevé (§ 12.14, D49)', () => {
const ex = echelleLog10({ domaine: [1, 1_000], plage: [0, 300] });
const ey = axeComptes(20);
test('des segments entre points de relevé, à leurs abscisses en mouvements', () => {
const d = cheminPoints([{ x: 1, y: 0 }, { x: 10, y: 10 }, { x: 100, y: 15 }, { x: 1_000, y: 20 }], ex, ey);
assert.equal(d, 'M 0 200 L 100 100 L 200 50 L 300 0');
});
test("un seul point de relevé : un déplacement, aucun trait ; aucun point, aucun chemin", () => {
assert.equal(cheminPoints([{ x: 1, y: 5 }], ex, ey), 'M 0 150');
assert.equal(cheminPoints([], ex, ey), '');
});
test('une valeur null interrompt les segments, qui reprennent au point suivant', () => {
const d = cheminPoints([{ x: 1, y: 0 }, { x: 10, y: null }, { x: 100, y: 15 }, { x: 1_000, y: 20 }], ex, ey);
assert.equal(d, 'M 0 200 M 200 50 L 300 0');
});
test('un point fautif est refusé', () => {
for (const points of [[{ x: 1, y: Number.NaN }], [{ x: 1 }], [null], 'abc']) {
assert.throws(() => cheminPoints(points, ex, ey), RangeError, JSON.stringify(points));
}
});
});
describe('graphique : le pointage au plus proche (§ 12.7)', () => {
test('260 rangs sur 1 000 px : chaque pixel désigne un rang, chaque rang est atteint, dans l’ordre', () => {
const ex = echelleLineaire({ domaine: [0, 260], plage: [0, 1000] });
const vus = new Set();
let precedent = 1;
for (let px = 0; px <= 1000; px += 1) {
const rang = rangLePlusProche(px, ex, 260);
assert.ok(Number.isInteger(rang) && rang >= 1 && rang <= 260, `${px} → ${rang}`);
assert.ok(rang >= precedent, `${px} → ${rang}`);
precedent = rang;
vus.add(rang);
}
assert.equal(vus.size, 260);
for (let rang = 1; rang <= 260; rang += 1) assert.equal(rangLePlusProche(ex.versPx(rang - 0.5), ex, 260), rang);
});
test('un seul rang : tout pixel le désigne', () => {
const ex = axeRangs(1);
for (const px of [-50, 0, 5, 10, 900]) assert.equal(rangLePlusProche(px, ex, 1), 1);
});
test('hors du tracé, le rang se borne au premier et au dernier : jamais le rang 0, jamais n + 1', () => {
const ex = axeRangs(26);
assert.equal(rangLePlusProche(-1, ex, 26), 1);
assert.equal(rangLePlusProche(-400, ex, 26), 1);
assert.equal(rangLePlusProche(260, ex, 26), 26);
assert.equal(rangLePlusProche(5_000, ex, 26), 26);
});
test('un pixel ou un nombre de rangs fautif est refusé', () => {
for (const [px, n] of [[Number.NaN, 3], [0, 0], [0, 2.5], [0, -1]]) {
assert.throws(() => rangLePlusProche(px, axeRangs(3), n), RangeError, `${px}, ${n}`);
}
});
test('le point de relevé le plus proche, le premier à égalité, borné aux points existants', () => {
const abscisses = [0, 100, 200, 300];
assert.equal(pointLePlusProche(-40, abscisses), 0);
assert.equal(pointLePlusProche(49, abscisses), 0);
assert.equal(pointLePlusProche(50, abscisses), 0);
assert.equal(pointLePlusProche(51, abscisses), 1);
assert.equal(pointLePlusProche(260, abscisses), 3);
assert.equal(pointLePlusProche(9_999, abscisses), 3);
assert.equal(pointLePlusProche(123, [42]), 0);
for (const [px, liste] of [[0, []], [Number.NaN, [1]], [0, [Number.NaN]], [0, 'abc']]) {
assert.throws(() => pointLePlusProche(px, liste), RangeError, `${px}, ${liste}`);
}
});
});
describe('graphique : les barres et les colonnes (§ 12.4, § 12.13)', () => {
const ex = echelleLineaire({ domaine: [0, 10], plage: [0, 200] });
test('un ou deux segments empilés, depuis zéro, une ligne sous l’autre', () => {
const { lignes, hauteurTotale } = barres([[3, 2], [6], [0, 4]], ex, 20, 4);
assert.deepEqual(lignes, [
{ y: 0, hauteur: 20, segments: [{ x: 0, largeur: 60 }, { x: 60, largeur: 40 }] },
{ y: 24, hauteur: 20, segments: [{ x: 0, largeur: 120 }] },
{ y: 48, hauteur: 20, segments: [{ x: 0, largeur: 0 }, { x: 0, largeur: 80 }] },
]);
assert.equal(hauteurTotale, 68);
assert.deepEqual(barres([], ex, 20, 4), { lignes: [], hauteurTotale: 0 });
});
test('une ligne sans segment, à trois segments, ou un segment négatif est refusée', () => {
for (const lignes of [[[]], [[1, 2, 3]], [[-1]], [[Number.NaN]], ['abc'], 'abc']) {
assert.throws(() => barres(lignes, ex, 20, 4), RangeError, JSON.stringify(lignes));
}
assert.throws(() => barres([[1]], ex, -1, 4), RangeError);
assert.throws(() => barres([[1]], ex, 20, Number.NaN), RangeError);
});
test('deux colonnes juxtaposées partagent la largeur, chacune son axe', () => {
assert.deepEqual(colonnesJuxtaposees(500, 2, 20), [
{ x: 0, largeur: 240 },
{ x: 260, largeur: 240 },
]);
assert.deepEqual(colonnesJuxtaposees(300, 1, 20), [{ x: 0, largeur: 300 }]);
// Plus étroite que ses écarts, une colonne garde une largeur nulle.
assert.deepEqual(colonnesJuxtaposees(10, 2, 20), [
{ x: 0, largeur: 0 },
{ x: 20, largeur: 0 },
]);
for (const [largeur, n, ecart] of [[500, 0, 20], [500, 1.5, 20], [-1, 2, 20], [500, 2, -1]]) {
assert.throws(() => colonnesJuxtaposees(largeur, n, ecart), RangeError, `${largeur}, ${n}, ${ecart}`);
}
});
});
describe('graphique : la grille et ses niveaux (§ 12.5, D33, B29)', () => {
test(`une case tient ${CIBLE_MIN_PX} px : dessinable ; à 23 px, non`, () => {
assert.deepEqual(grille(10, 10, 240, 16), { cote: 24, dessinable: true, largeur: 240, hauteur: 240 });
assert.deepEqual(grille(10, 10, 239, 16), { cote: 23, dessinable: false, largeur: 230, hauteur: 230 });
assert.equal(CIBLE_MIN_PX, 24);
});
test('au seuil de la matrice, la case tient la cible dans la largeur qui le définit ; au seuil plus un, non', () => {
const seuil = constante('SEUIL_MATRICE_PARTICIPANTS');
const largeur = seuil * CIBLE_MIN_PX;
assert.equal(grille(seuil, seuil, largeur, 16).dessinable, true);
assert.equal(grille(seuil + 1, seuil + 1, largeur, 16).dessinable, false);
});
test("sous le seuil, une carte plus étroite que n cases de la cible ne dessine pas la matrice : la largeur réelle décide", () => {
const n = constante('SEUIL_MATRICE_PARTICIPANTS') - 1;
// Le seuil compte des personnes, non des pixels : un pixel de moins que
// n cases de CIBLE_MIN_PX suffit à faire tomber la grille.
const etroite = grille(n, n, n * CIBLE_MIN_PX - 1, 16);
assert.equal(etroite.dessinable, false);
assert.ok(etroite.cote < CIBLE_MIN_PX);
// 30 cases de 24 px demandent 720 px.
assert.equal(grille(30, 30, 700, 16).dessinable, false);
});
test('une case qui ne tient pas la hauteur de son texte, une grille vide, ne se dessinent pas', () => {
assert.equal(grille(4, 4, 120, 31).dessinable, false);
assert.equal(grille(4, 4, 120, 30).dessinable, true);
assert.deepEqual(grille(0, 3, 120, 16), { cote: 40, dessinable: false, largeur: 120, hauteur: 0 });
assert.deepEqual(grille(3, 0, 120, 16), { cote: 0, dessinable: false, largeur: 0, hauteur: 0 });
for (const args of [[1.5, 3, 100, 16], [3, -1, 100, 16], [3, 3, Number.NaN, 16], [3, 3, 100, -1]]) {
assert.throws(() => grille(...args), RangeError, `${args}`);
}
});
test('quatre niveaux aux bornes fixes : 0, 1, 2, puis 3 et plus', () => {
assert.deepEqual([...NIVEAUX], [0, 1, 2, 3]);
assert.ok(Object.isFrozen(NIVEAUX));
assert.deepEqual([0, 1, 2, 3, 7].map(niveau), [0, 1, 2, 3, 3]);
for (const v of [-1, 1.5, Number.NaN, '2']) assert.throws(() => niveau(v), RangeError, String(v));
});
});
describe('graphique : la troncature sur des largeurs mesurées (§ 12.6)', () => {
// Les largeurs d'un nom de cinq lettres : les quatre préfixes suivis de la
// marque, puis le nom entier.
const largeurs = [18, 27, 36, 45, 48];
test('le nom entier quand il tient, sinon le plus long préfixe qui tient suivi de la marque', () => {
assert.equal(tronquer(largeurs, 48), 5);
assert.equal(tronquer(largeurs, 100), 5);
assert.equal(tronquer(largeurs, 47), 4);
assert.equal(tronquer(largeurs, 30), 2);
assert.equal(tronquer(largeurs, 18), 1);
});
test('rien ne tient : zéro ; un nom vide reste vide', () => {
assert.equal(tronquer(largeurs, 17), 0);
assert.equal(tronquer([20], 19), 0);
assert.equal(tronquer([20], 20), 1);
assert.equal(tronquer([], 50), 0);
});
test('une largeur fautive est refusée', () => {
for (const [liste, max] of [[[Number.NaN], 10], [[-1], 10], [[1], Number.NaN], [[1], -1], ['abc', 10]]) {
assert.throws(() => tronquer(liste, max), RangeError, `${liste}, ${max}`);
}
});
});

View file

@ -90,7 +90,24 @@
Les teintes d'appartenance ne vont que sur les listes, jamais sur le
plan : six teintes au plus, catégorielles, chacune à 30° au moins des
teintes de conflit, distinctes deux à deux sous les trois dichromaties et,
deux teintes voisines, en clarté (TEINTES_APPARTENANCE, marques.js). */
deux teintes voisines, en clarté (TEINTES_APPARTENANCE, marques.js).
Le graphique (§ 12.7) : deux séries, chacune à 3:1 sur la carte, la page
et la grille de fond, séparées l'une de l'autre et des six teintes
d'appartenance sous les trois dichromaties ; le repère d'un plafond et
celui d'une borne, neutres, à 3:1, que leur trait distingue — mixte et
pointillé ; l'axe, neutre ; « prouvé inévitable », neutre, et « reste »,
la teinte de la première série, séparés l'un de l'autre ; l'échelle à
quatre niveaux, d'une seule teinte, le niveau 0 au plus près du fond de
la carte et chaque niveau suivant plus loin, du clair au foncé dans le
clair, du sombre au clair dans le sombre ; ses deux encres neutres,
chacune à 4,5:1 sur les niveaux qu'elle sert, nommées d'après le thème
clair — l'encre foncée sert les niveaux 0 et 1, la claire les niveaux 2
et 3, et le sombre inverse leurs clartés avec l'échelle ; la bordure des
cases, à 3:1 sur la carte. La grille et le niveau 0 ne portent aucune
information : hors de la mesure 3:1 (FONDS_GRAPHIQUE, marques.js). Aucun
jeton du graphique ne prend une teinte de conflit, d'appartenance ni le
rouge du retrait. */
@font-face {
font-family: 'Noto Sans';
@ -273,6 +290,27 @@ textarea {
/* Le document imprimé : le papier, et le contour du filigrane. */
--couleur-papier: #ffffff;
--couleur-filigrane: #6e6e6e;
/* Graphiques (§ 12.7) : deux séries ; le repère d'un plafond et celui
d'une borne, l'axe ; la grille de fond ; les deux segments d'une barre
de collisions ; l'échelle à quatre niveaux, ses deux encres et la
bordure des cases ; le signe d'un point marqué. */
--couleur-graphique-serie-1: #1a61ac;
--couleur-graphique-serie-2: #8f5e00;
--couleur-graphique-repere: #4b5563;
--couleur-graphique-borne: #6b7280;
--couleur-graphique-axe: #5f6875;
--couleur-graphique-grille: #e4e8ee;
--couleur-graphique-prouve: #737c88;
--couleur-graphique-reste: var(--couleur-graphique-serie-1);
--couleur-graphique-niveau-0: #dde9f8;
--couleur-graphique-niveau-1: #6b8cb8;
--couleur-graphique-niveau-2: #2c65af;
--couleur-graphique-niveau-3: #1b3d6b;
--couleur-graphique-encre-foncee: #0e1218;
--couleur-graphique-encre-claire: #ffffff;
--couleur-graphique-bordure-case: #6b7380;
--couleur-graphique-marque: #3d4552;
}
:root[data-theme='sombre'] {
@ -375,4 +413,21 @@ textarea {
--couleur-papier: #ffffff;
--couleur-filigrane: #6e6e6e;
--couleur-graphique-serie-1: #8fc0e8;
--couleur-graphique-serie-2: #e0aa1a;
--couleur-graphique-repere: #b4bdc9;
--couleur-graphique-borne: #959fac;
--couleur-graphique-axe: #8a94a1;
--couleur-graphique-grille: #2a3038;
--couleur-graphique-prouve: #87909c;
--couleur-graphique-reste: var(--couleur-graphique-serie-1);
--couleur-graphique-niveau-0: #1d2b3f;
--couleur-graphique-niveau-1: #587193;
--couleur-graphique-niveau-2: #6a95cd;
--couleur-graphique-niveau-3: #a6c3e8;
--couleur-graphique-encre-foncee: #ffffff;
--couleur-graphique-encre-claire: #0f1318;
--couleur-graphique-bordure-case: #8a94a1;
--couleur-graphique-marque: #c9d0d9;
}

View file

@ -18,6 +18,12 @@
// et de l'action principale, qui se valident par calcul sur chacun de leurs
// fonds. Un signe ne porte jamais seul une information : un libellé
// l'accompagne toujours.
//
// La famille « graphique » (§ 12.7, § 13.3) a ses listes à part :
// MARQUES_GRAPHIQUE, TEXTES_GRAPHIQUE et FONDS_GRAPHIQUE, de même forme,
// validées par calcul comme les autres. Elles restent hors de MARQUES et de
// TEXTES tant qu'aucun écran ne les rend : la garde transversale exige que
// chaque entrée de MARQUES soit rendue sur un écran.
const c = (nom) => `--couleur-${nom}`;
@ -202,3 +208,83 @@ export const TEXTES = Object.freeze([
texte('lien', 'action-encre', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'action-teinte']),
texte('information', 'information', ['information-fond', 'page-fond', 'carte-fond']),
]);
// --- La famille « graphique » (§ 12.7, § 13.3, D30, D43) ---------------------
const graphique = (nom) => `graphique-${nom}`;
/** L'échelle séquentielle des graphiques — la matrice des rencontres et la
* grille tables × tours (D33, D35) : quatre niveaux, 0, 1, 2, 3 et plus,
* { niveau, jeton, encre }, encre le jeton du compte écrit dans la case.
* Une seule teinte ; la clarté s'éloigne du fond de la carte du niveau 0
* au niveau 3, dans chaque thème : du clair au foncé dans le clair, du
* sombre au clair dans le sombre. Deux encres neutres, nommées d'après le
* thème clair : l'encre foncée sert les niveaux 0 et 1, les plus proches
* du fond, l'encre claire les niveaux 2 et 3. Le thème sombre inverse
* l'échelle et, avec elle, la clarté des deux encres, qui gardent leurs
* niveaux : une même déclaration se mesure dans les deux thèmes. */
export const NIVEAUX_GRAPHIQUE = Object.freeze(
[
[0, 'encre-foncee'],
[1, 'encre-foncee'],
[2, 'encre-claire'],
[3, 'encre-claire'],
].map(([niveau, encre]) =>
Object.freeze({ niveau, jeton: c(graphique(`niveau-${niveau}`)), encre: c(graphique(encre)) }),
),
);
/** Les seuils de l'échelle : ecartTeinte, le plus grand écart de teinte HSL
* admis entre deux niveaux, en degrés — une échelle est une seule teinte ;
* clarte, le pas minimal dont chaque niveau s'éloigne du fond de la carte
* de plus que le précédent, en L d'OKLab × 100. */
export const ECHELLE_GRAPHIQUE = Object.freeze({ ecartTeinte: 6, clarte: 10 });
// Le fond de la zone de tracé : la carte, et la grille de fond qu'une
// marque tracée croise.
const TRACE = ['carte-fond', graphique('grille')];
/** Les marques des graphiques, même forme que MARQUES, seuil 3 : les deux
* séries, mesurées aussi sur la page ; le repère d'un plafond (trait mixte)
* et celui d'une borne (pointillé), neutres, chacun son jeton (D30) ;
* l'axe ; les deux segments d'une barre de collisions, « prouvé
* inévitable » et « reste » ; les niveaux 1 à 3 de l'échelle et la bordure
* d'une case, sur la carte ; le signe d'un point marqué. Le niveau 0 et la
* grille n'en sont pas (FONDS_GRAPHIQUE). */
export const MARQUES_GRAPHIQUE = Object.freeze([
marque('graphiqueSerie1', graphique('serie-1'), [...TRACE, 'page-fond']),
marque('graphiqueSerie2', graphique('serie-2'), [...TRACE, 'page-fond']),
marque('graphiqueRepere', graphique('repere'), TRACE),
marque('graphiqueBorne', graphique('borne'), TRACE),
marque('graphiqueAxe', graphique('axe'), ['carte-fond']),
marque('graphiqueProuve', graphique('prouve'), TRACE),
marque('graphiqueReste', graphique('reste'), TRACE),
...NIVEAUX_GRAPHIQUE.filter(({ niveau }) => niveau > 0).map(({ niveau }) =>
marque(`graphiqueNiveau${niveau}`, graphique(`niveau-${niveau}`), ['carte-fond']),
),
marque('graphiqueBordureCase', graphique('bordure-case'), ['carte-fond']),
marque('graphiqueMarque', graphique('marque'), TRACE),
]);
// Les niveaux qu'une encre sert, en noms courts de jetons.
const niveauxServis = (encre) =>
NIVEAUX_GRAPHIQUE.filter((n) => n.encre === c(graphique(encre))).map(({ niveau }) => graphique(`niveau-${niveau}`));
/** Les encres des comptes écrits dans les cases, même forme que TEXTES,
* seuil 4,5 : une entrée par encre, dans l'ordre des niveaux, mesurée sur
* chaque niveau que NIVEAUX_GRAPHIQUE lui donne. */
export const TEXTES_GRAPHIQUE = Object.freeze([
texte('graphiqueEncreFoncee', graphique('encre-foncee'), niveauxServis('encre-foncee')),
texte('graphiqueEncreClaire', graphique('encre-claire'), niveauxServis('encre-claire')),
]);
/** Les fonds des graphiques, { nom, jeton } : la grille de fond et le
* niveau 0 de l'échelle. Ils ne portent aucune information que la bordure
* et le chiffre d'une case ne portent déjà : hors de la mesure 3:1, sans
* seuil, et sans data-marque — un fond n'est pas une marque. */
export const FONDS_GRAPHIQUE = Object.freeze(
[
['graphiqueGrille', 'grille'],
['graphiqueNiveau0', 'niveau-0'],
].map(([nom, jeton]) => Object.freeze({ nom, jeton: c(graphique(jeton)) })),
);

View file

@ -5,11 +5,36 @@
// § 7.5, § 7.6, § 7.7), et la palette de jetons.css validée par calcul sur
// lui (§ 12.7) : chaque entrée tient son seuil sur chacun de ses fonds, dans
// les deux thèmes, en couleurs d'origine puis sous les trois dichromaties.
// La famille « graphique » (§ 12.7, § 13.3) a ses listes à part, hors de
// MARQUES et de TEXTES, éprouvées de même.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { CONFLITS, JETONS_CONFLIT, MARQUES, SEPARATIONS, SIGNES, TEINTES_APPARTENANCE, TEXTES } from './marques.js';
import { lireBlocs, lireJetons, teinte, verifierClartes, verifierPalette, verifierSeparation } from './palette.js';
import {
CONFLITS,
ECHELLE_GRAPHIQUE,
FONDS_GRAPHIQUE,
JETONS_CONFLIT,
MARQUES,
MARQUES_GRAPHIQUE,
NIVEAUX_GRAPHIQUE,
SEPARATIONS,
SIGNES,
TEINTES_APPARTENANCE,
TEXTES,
TEXTES_GRAPHIQUE,
} from './marques.js';
import {
contraste,
lireBlocs,
lireJetons,
simuler,
teinte,
verifierClartes,
verifierPalette,
verifierSeparation,
verifierTeintesReservees,
} from './palette.js';
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
const THEMES = ['clair', 'sombre'];
@ -299,3 +324,157 @@ describe('palette de jetons.css validée par calcul (§ 12.7, § 14.5)', () => {
assert.deepEqual(verifierSeparation(JETONS, JETONS_CONFLIT, autres, SEPARATIONS.autresMarques), []);
});
});
// --- La famille « graphique » (§ 12.7, § 13.3, D30, D43) ---------------------
const g = (nom) => `--couleur-graphique-${nom}`;
// Les seize jetons de la famille, dans l'ordre du contrat.
const JETONS_GRAPHIQUE = [
'serie-1', 'serie-2', 'repere', 'borne', 'axe', 'grille', 'prouve', 'reste',
'niveau-0', 'niveau-1', 'niveau-2', 'niveau-3', 'encre-claire', 'encre-foncee', 'bordure-case', 'marque',
].map(g);
const NOMS_MARQUES_GRAPHIQUE = [
'graphiqueSerie1', 'graphiqueSerie2', 'graphiqueRepere', 'graphiqueBorne', 'graphiqueAxe',
'graphiqueProuve', 'graphiqueReste', 'graphiqueNiveau1', 'graphiqueNiveau2', 'graphiqueNiveau3',
'graphiqueBordureCase', 'graphiqueMarque',
];
const CARTE = '--couleur-carte-fond';
const PAGE = '--couleur-page-fond';
const SIMULATIONS_ET_ORIGINE = ['origine', 'protanopie', 'deuteranopie', 'tritanopie'];
describe('la famille « graphique » : ses listes, hors de MARQUES et de TEXTES', () => {
test('les seize jetons du contrat existent dans les deux thèmes, et aucun autre jeton ne porte le préfixe de la famille', () => {
for (const theme of THEMES) {
const famille = [...lireJetons(JETONS, theme).keys()].filter((n) => n.startsWith('--couleur-graphique-'));
assert.deepEqual([...famille].sort(), [...JETONS_GRAPHIQUE].sort(), theme);
}
});
test('MARQUES_GRAPHIQUE : douze marques, seuil 3, figées, chacune peinte de son jeton de la famille ; le repère d’un plafond et celui d’une borne ont chacun le leur', () => {
assert.deepEqual(MARQUES_GRAPHIQUE.map((m) => m.nom), NOMS_MARQUES_GRAPHIQUE);
assert.ok(Object.isFrozen(MARQUES_GRAPHIQUE) && MARQUES_GRAPHIQUE.every((m) => Object.isFrozen(m) && Object.isFrozen(m.fonds)));
for (const marque of MARQUES_GRAPHIQUE) {
assert.deepEqual(Object.keys(marque).sort(), ['fonds', 'jeton', 'nom', 'seuil'], marque.nom);
assert.equal(marque.seuil, 3, marque.nom);
assert.ok(marque.jeton.startsWith('--couleur-graphique-'), marque.nom);
assert.ok(marque.fonds.includes(CARTE), `${marque.nom} : ${CARTE}`);
}
assert.equal(parNom(MARQUES_GRAPHIQUE, 'graphiqueRepere').jeton, g('repere'));
assert.equal(parNom(MARQUES_GRAPHIQUE, 'graphiqueBorne').jeton, g('borne'));
for (const nom of ['graphiqueSerie1', 'graphiqueSerie2']) {
assert.ok(parNom(MARQUES_GRAPHIQUE, nom).fonds.includes(PAGE), `${nom} : ${PAGE}`);
}
});
test('les listes de la famille restent hors de MARQUES et de TEXTES : aucun nom ni aucun jeton partagé, MARQUES garde ses vingt-deux entrées', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
const nomsGraphique = [...MARQUES_GRAPHIQUE, ...TEXTES_GRAPHIQUE, ...FONDS_GRAPHIQUE].map((e) => e.nom);
assert.equal(new Set(nomsGraphique).size, nomsGraphique.length);
const registres = [...MARQUES, ...TEXTES, ...SIGNES];
assert.deepEqual(registres.filter((e) => nomsGraphique.includes(e.nom)).map((e) => e.nom), []);
assert.deepEqual(registres.filter((e) => e.jeton.startsWith('--couleur-graphique-')).map((e) => e.nom), []);
});
test('l’échelle : quatre niveaux, 0 à 3, chacun son jeton et l’encre de son compte ; deux encres au plus ; figée', () => {
assert.deepEqual(
NIVEAUX_GRAPHIQUE.map(({ niveau, jeton }) => [niveau, jeton]),
[0, 1, 2, 3].map((k) => [k, g(`niveau-${k}`)]),
);
assert.ok(Object.isFrozen(NIVEAUX_GRAPHIQUE) && NIVEAUX_GRAPHIQUE.every(Object.isFrozen));
const encres = [...new Set(NIVEAUX_GRAPHIQUE.map(({ encre }) => encre))];
assert.ok(encres.length >= 1 && encres.length <= 2, `${encres.length} encres`);
for (const encre of encres) assert.ok([g('encre-claire'), g('encre-foncee')].includes(encre), encre);
assert.deepEqual(Object.keys(ECHELLE_GRAPHIQUE).sort(), ['clarte', 'ecartTeinte']);
assert.ok(Object.isFrozen(ECHELLE_GRAPHIQUE));
});
test('TEXTES_GRAPHIQUE : une entrée par encre, seuil 4,5, mesurée sur chaque niveau que NIVEAUX_GRAPHIQUE lui donne et sur aucun autre', () => {
const encres = [...new Set(NIVEAUX_GRAPHIQUE.map(({ encre }) => encre))];
assert.deepEqual(TEXTES_GRAPHIQUE.map((t) => t.jeton), encres);
assert.ok(Object.isFrozen(TEXTES_GRAPHIQUE) && TEXTES_GRAPHIQUE.every((t) => Object.isFrozen(t) && Object.isFrozen(t.fonds)));
for (const texte of TEXTES_GRAPHIQUE) {
assert.equal(texte.seuil, 4.5, texte.nom);
assert.deepEqual([...texte.fonds], NIVEAUX_GRAPHIQUE.filter((n) => n.encre === texte.jeton).map((n) => n.jeton), texte.nom);
}
});
test('FONDS_GRAPHIQUE : la grille et le niveau 0, sans seuil, hors de la mesure 3:1 ; les niveaux 1 à 3 sont des marques, mesurées sur la carte', () => {
assert.deepEqual(FONDS_GRAPHIQUE.map((f) => [f.nom, f.jeton]), [['graphiqueGrille', g('grille')], ['graphiqueNiveau0', g('niveau-0')]]);
assert.ok(Object.isFrozen(FONDS_GRAPHIQUE) && FONDS_GRAPHIQUE.every(Object.isFrozen));
assert.ok(FONDS_GRAPHIQUE.every((f) => !('seuil' in f)));
const jetonsMarques = MARQUES_GRAPHIQUE.map((m) => m.jeton);
for (const { jeton } of FONDS_GRAPHIQUE) assert.ok(!jetonsMarques.includes(jeton), jeton);
for (const k of [1, 2, 3]) assert.deepEqual([...parNom(MARQUES_GRAPHIQUE, `graphiqueNiveau${k}`).fonds], [CARTE], `niveau ${k}`);
});
test('chaque jeton et chaque fond des listes de la famille existe dans les deux thèmes, sans canal alpha', () => {
const noms = new Set([
...[...MARQUES_GRAPHIQUE, ...TEXTES_GRAPHIQUE].flatMap((e) => [e.jeton, ...e.fonds]),
...FONDS_GRAPHIQUE.map((f) => f.jeton),
]);
for (const nom of JETONS_GRAPHIQUE) assert.ok(noms.has(nom), nom);
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
for (const nom of noms) {
assert.ok(jetons.has(nom), `${theme} : ${nom}`);
assert.equal(jetons.get(nom)[3], 1, `${theme} : ${nom}`);
}
}
});
});
describe('la famille « graphique » validée par calcul (§ 12.7, § 14.5, D43)', () => {
const series = [g('serie-1'), g('serie-2')];
const appartenances = () => TEINTES_APPARTENANCE.map(({ marque }) => marque);
test('chaque marque de la famille tient 3:1 sur chacun de ses fonds — séries sur la carte et la page, repère et borne sur la carte —, dans les deux thèmes, sous les trois simulations', () => {
assert.deepEqual(verifierPalette(JETONS, MARQUES_GRAPHIQUE), []);
});
test('chaque encre tient 4,5:1 sur chaque niveau qu’elle sert, dans les deux thèmes, sous les trois simulations', () => {
assert.deepEqual(verifierPalette(JETONS, TEXTES_GRAPHIQUE), []);
});
test(`les deux séries se séparent l’une de l’autre et des six teintes d’appartenance de ${SEPARATIONS.autresMarques} au moins (OKLab × 100), sous les trois simulations`, () => {
assert.deepEqual(verifierSeparation(JETONS, series, appartenances(), SEPARATIONS.autresMarques), []);
});
test(`« prouvé inévitable » et « reste » se séparent l’un de l’autre de ${SEPARATIONS.autresMarques} au moins, et « reste », chromatique, des teintes d’appartenance, sous les trois simulations`, () => {
assert.deepEqual(verifierSeparation(JETONS, [g('prouve'), g('reste')], [], SEPARATIONS.autresMarques), []);
assert.deepEqual(verifierSeparation(JETONS, [g('reste')], appartenances(), SEPARATIONS.autresMarques), []);
});
test('aucun jeton de la famille n’est à moins de 30° d’une teinte de conflit, ni ne porte la valeur d’une teinte de conflit, d’appartenance ou du retrait', () => {
const fautes = verifierTeintesReservees(JETONS, JETONS_CONFLIT).filter((f) => f.jeton.startsWith('--couleur-graphique-'));
assert.deepEqual(fautes, []);
const reservees = [...JETONS_CONFLIT, ...appartenances(), '--couleur-retrait'];
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
const valeurs = new Set(reservees.map((n) => String(jetons.get(n))));
assert.deepEqual(JETONS_GRAPHIQUE.filter((n) => valeurs.has(String(jetons.get(n)))), [], theme);
}
});
test('le repère, la borne, l’axe et les deux encres sont neutres dans les deux thèmes : sans teinte', () => {
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
for (const nom of ['repere', 'borne', 'axe', 'encre-claire', 'encre-foncee'].map(g)) {
assert.equal(teinte(jetons.get(nom)), null, `${theme} : ${nom}`);
}
}
});
test('la bordure des cases tient 3:1 sur la carte ; le niveau 0 et la grille ne sont pas mesurés : le niveau 0 se tient sous 3:1, au plus près du fond', () => {
assert.deepEqual([...parNom(MARQUES_GRAPHIQUE, 'graphiqueBordureCase').fonds], [CARTE]);
for (const theme of THEMES) {
const jetons = lireJetons(JETONS, theme);
for (const simulation of SIMULATIONS_ET_ORIGINE) {
const vue = (c) => (simulation === 'origine' ? c : simuler(c, simulation));
assert.ok(contraste(vue(jetons.get(g('niveau-0'))), vue(jetons.get(CARTE))) < 3, `${theme} ${simulation}`);
}
}
});
});

View file

@ -5,13 +5,14 @@
// réservée (§ 12.7, § 13.3, § 14.5). Les fonctions s'éprouvent d'abord sur des
// feuilles d'épreuve écrites ici, où chaque défaut est posé exprès, puis sur
// le vrai fichier de jetons : ses familles, ses deux thèmes, les jetons que
// lisent les composants, les teintes des trois sortes de conflit.
// lisent les composants, les teintes des trois sortes de conflit, l'échelle
// séquentielle des graphiques.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { JETONS_CONFLIT, JETON_RETRAIT, MARQUES } from './marques.js';
import { ECHELLE_GRAPHIQUE, JETONS_CONFLIT, JETON_RETRAIT, MARQUES, MARQUES_GRAPHIQUE, NIVEAUX_GRAPHIQUE } from './marques.js';
import {
SIMULATIONS,
contraste,
@ -517,3 +518,119 @@ describe('jetons.css', () => {
assert.deepEqual(peintes.map((m) => [m.nom, [...m.fonds]]), [['retrait', ['--couleur-carte-fond', '--couleur-carte-fond-sourd']]]);
});
});
// L'échelle séquentielle des graphiques (§ 12.7, D33, D43) : les niveaux
// d'une seule teinte, la clarté s'éloignant du fond de la carte d'un niveau
// au suivant. Rend les fautes, dans l'ordre thème, puis sorte :
// { theme, sorte: 'achromatique', jeton } — un niveau sans teinte ;
// { theme, sorte: 'teinte', ecart } — deux niveaux dont les teintes HSL
// s'écartent de plus de ecartTeinte degrés, l'écart le plus grand ;
// { theme, sorte: 'eloignement', jeton, pas } — un niveau dont la clarté
// (L d'OKLab × 100) ne s'éloigne pas du fond d'au moins clarte de plus
// que celle du niveau qui le précède, pas l'éloignement mesuré. Le sens
// est celui du niveau 1 par rapport au fond, et le niveau 0 ne se lit
// que par son pas jusqu'au niveau 1 : un niveau 0 qui, dans ce sens,
// n'est pas d'au moins clarte en deçà du niveau 1 fait une faute au
// niveau 1. Le côté du fond où tombe le niveau 0 n'est pas lu.
// Un jeton absent d'un thème lève.
function fautesEchelle(texteCss, niveaux, fond, { ecartTeinte, clarte }) {
const fautes = [];
for (const theme of ['clair', 'sombre']) {
const jetons = lireJetons(texteCss, theme);
const lire = (nom) => {
if (!jetons.has(nom)) throw new Error(`jeton absent du thème ${theme} : ${nom}`);
return jetons.get(nom);
};
const teintes = niveaux.map((nom) => teinte(lire(nom)));
niveaux.forEach((jeton, k) => {
if (teintes[k] === null) fautes.push({ theme, sorte: 'achromatique', jeton });
});
const chromatiques = teintes.filter((h) => h !== null);
let ecart = 0;
chromatiques.forEach((h, i) => {
for (const autre of chromatiques.slice(i + 1)) {
const brut = Math.abs(h - autre);
ecart = Math.max(ecart, Math.min(brut, 360 - brut));
}
});
if (ecart > ecartTeinte) fautes.push({ theme, sorte: 'teinte', ecart });
const clartes = niveaux.map((nom) => 100 * oklab(lire(nom))[0]);
const clarteFond = 100 * oklab(lire(fond))[0];
const sens = Math.sign(clartes[1] - clarteFond);
for (let k = 1; k < niveaux.length; k += 1) {
const pas = (clartes[k] - clartes[k - 1]) * sens;
if (sens === 0 || pas < clarte) fautes.push({ theme, sorte: 'eloignement', jeton: niveaux[k], pas });
}
}
return fautes;
}
describe('l’échelle séquentielle des graphiques (§ 12.7, D33, D43)', () => {
const NIVEAUX = ['--couleur-n0', '--couleur-n1', '--couleur-n2', '--couleur-n3'];
const SEUILS = { ecartTeinte: 6, clarte: 10 };
// Quatre bleus de teinte 214° à 1° près, du clair au foncé, sur une carte
// blanche ; dans le sombre, du sombre au clair sur une carte sombre.
const CLAIR = { '--couleur-carte': '#ffffff', '--couleur-n0': '#dde9f8', '--couleur-n1': '#6b8cb8', '--couleur-n2': '#2c65af', '--couleur-n3': '#1b3d6b' };
const SOMBRE = { '--couleur-carte': '#1b1f25', '--couleur-n0': '#1d2b3f', '--couleur-n1': '#587193', '--couleur-n2': '#6a95cd', '--couleur-n3': '#a6c3e8' };
const lire = (clair, sombre = SOMBRE) => fautesEchelle(feuille(clair, sombre), NIVEAUX, '--couleur-carte', SEUILS);
test('une échelle d’une teinte qui s’éloigne du fond, du clair au foncé dans le clair et du sombre au clair dans le sombre, passe', () => {
assert.deepEqual(lire(CLAIR), []);
});
test('un niveau plus proche du fond que le précédent est relevé, à son rang', () => {
const fautes = lire({ ...CLAIR, '--couleur-n2': '#8aa6cc' });
assert.deepEqual(fautes.map((f) => `${f.theme} ${f.sorte} ${f.jeton}`), ['clair eloignement --couleur-n2']);
assert.ok(fautes[0].pas < 0);
});
test('un niveau 0 sombre en thème clair est relevé : il n’est plus le plus proche du fond', () => {
const fautes = lire({ ...CLAIR, '--couleur-n0': '#14294a' });
assert.deepEqual(fautes.map((f) => `${f.theme} ${f.sorte} ${f.jeton}`), ['clair eloignement --couleur-n1']);
});
test('deux niveaux trop proches en clarté sont relevés, même dans le bon sens', () => {
const fautes = lire(CLAIR, { ...SOMBRE, '--couleur-n2': '#5b7aa3' });
assert.deepEqual(fautes.map((f) => `${f.theme} ${f.sorte} ${f.jeton}`), ['sombre eloignement --couleur-n2']);
assert.ok(fautes[0].pas > 0 && fautes[0].pas < SEUILS.clarte);
});
test('une teinte qui dérive d’un niveau à l’autre et un niveau gris sont relevés : une échelle est une seule teinte', () => {
// Le niveau 2 vire au violet : à plus de 6° des trois autres.
const derive = lire({ ...CLAIR, '--couleur-n2': '#3c4fb0' });
assert.deepEqual(derive.map((f) => `${f.theme} ${f.sorte}`), ['clair teinte']);
assert.ok(derive[0].ecart > SEUILS.ecartTeinte);
const gris = lire({ ...CLAIR, '--couleur-n0': '#ececec' });
assert.deepEqual(gris.map((f) => `${f.theme} ${f.sorte}`), ['clair achromatique']);
const { '--couleur-n3': _absent, ...sansN3 } = CLAIR;
assert.throws(() => lire(sansN3), /jeton absent du thème clair : --couleur-n3/);
});
test('l’échelle de jetons.css : une seule teinte, à ECHELLE_GRAPHIQUE.ecartTeinte degrés au plus, chaque niveau plus loin du fond de la carte que le précédent d’ECHELLE_GRAPHIQUE.clarte au moins, dans les deux thèmes', () => {
const niveaux = NIVEAUX_GRAPHIQUE.map(({ jeton }) => jeton);
assert.equal(niveaux.length, 4);
assert.deepEqual(fautesEchelle(JETONS, niveaux, '--couleur-carte-fond', ECHELLE_GRAPHIQUE), []);
// Les niveaux d'une même teinte se distinguent aussi deux à deux en clarté.
assert.deepEqual(verifierClartes(JETONS, niveaux, ECHELLE_GRAPHIQUE.clarte), []);
});
test('une série à 25° d’une teinte de conflit est refusée, comme tout jeton chromatique', () => {
// Conflit à 0° ; une série orangée à 25° est relevée, une bleue passe.
const css = feuille({ '--couleur-conflit': '#ff0000', '--couleur-graphique-serie-1': '#ff6a00', '--couleur-graphique-serie-2': '#1a61ac' });
assert.ok(Math.abs(teinte(hex('#ff6a00')) - 25) < 0.1);
assert.deepEqual(
verifierTeintesReservees(css, ['--couleur-conflit']).map((f) => `${f.theme}:${f.jeton}`),
['clair:--couleur-graphique-serie-1', 'sombre:--couleur-graphique-serie-1'],
);
});
test('les marques de la famille tiennent 3:1 sur leurs fonds dans le vrai fichier : la borne comprise', () => {
const borne = MARQUES_GRAPHIQUE.find((m) => m.jeton === '--couleur-graphique-borne');
assert.notEqual(borne, undefined);
assert.deepEqual(verifierPalette(JETONS, [borne]), []);
// Absente d'un thème, elle lève.
const sansBorne = JETONS.replace(/(:root\[data-theme='sombre'\][^}]*?)--couleur-graphique-borne:[^;]*;/, '$1');
assert.notEqual(sansBorne, JETONS);
assert.throws(() => verifierPalette(sansBorne, [borne]), /--couleur-graphique-borne/);
});
});

View file

@ -72,7 +72,7 @@ import { poserFeuille } from '../../test/navigateur/feuilles.js';
import { composantes, couleursPeintes, designation, sousTheme, visible } from '../../test/navigateur/rendu.js';
import { provenanceVisible } from './apropos.js';
import { TABLES_INTERFACE } from './libelles.js';
import { CONFLITS, MARQUES } from './marques.js';
import { CONFLITS, FONDS_GRAPHIQUE, MARQUES } from './marques.js';
const { t: tTemoin } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' });
const { ouverture: OUVERTURE, fermeture: FERMETURE } = MARQUE_TEMOIN;
@ -750,6 +750,37 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
}
});
test('marques de graphique : le relevé d’un écran connaît MARQUES_GRAPHIQUE et les mesure à 3:1 ; une marque inconnue des deux listes et un fond de graphique restent hors du registre', () => {
const { fautes, vues, mesures } = avecFragment(
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +
'<rect data-marque="graphiqueSerie1" x="4" y="4" width="24" height="24" style="fill:var(--couleur-graphique-serie-1)" />' +
'<rect data-marque="graphiqueSerie3" x="60" y="4" width="24" height="24" style="fill:var(--couleur-graphique-serie-2)" />' +
'<rect data-marque="graphiqueGrille" x="120" y="4" width="24" height="24" style="fill:var(--couleur-graphique-grille)" /></svg>' +
'<svg width="40" height="40" style="background:var(--couleur-graphique-borne)">' +
'<rect data-marque="graphiqueBorne" x="4" y="4" width="24" height="24" style="fill:var(--couleur-graphique-borne)" /></svg>',
(c) => releverTheme(c).marques,
);
expect(vues).toEqual(['graphiqueSerie1', 'graphiqueBorne']);
expect(mesures).toBe(2);
expect(fautes).toHaveLength(3);
expect(fautes[0]).toContain('graphiqueSerie3');
expect(fautes[0]).toContain('hors du registre');
expect(fautes[1]).toContain('graphiqueGrille');
expect(fautes[1]).toContain('hors du registre');
expect(fautes[2]).toContain('1.00:1');
expect(FONDS_GRAPHIQUE.map((f) => f.nom)).toContain('graphiqueGrille');
});
test('marques de graphique : le relevé du parcours photographié (stylesDuTheme) les connaît aussi — une série sur la carte ne porte aucune faute et se range sous son jeton, rôle marque', () => {
const { styles, fautes } = avecFragment(
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +
'<rect data-marque="graphiqueSerie1" x="4" y="4" width="24" height="24" style="fill:var(--couleur-graphique-serie-1)" /></svg>',
(c) => stylesDuTheme(c),
);
expect(fautes).toEqual([]);
expect(styles.filter((s) => s.role === 'marque').map((s) => s.jeton)).toEqual(['--couleur-graphique-serie-1']);
});
test('marques sous une fenêtre modale : une marque HTML de la page, qui ne s’y mesure pas, ne compte pas comme vue ; une marque dans la fenêtre, si', () => {
const { vues } = avecFragment(
'<p data-marque="gel" style="border:2px solid var(--couleur-gel)">dehors</p>' +

View file

@ -5,12 +5,13 @@
// que consomme le moteur (§ 4, § 4.5, § 5.2), et conversion d'un plan entre
// ses deux formes (§ 8.9). Les formes sont décrites dans types.js.
//
// L'instance porte aussi ce que les critères des objectifs et des animateurs
// lisent, calculé une fois par configuration : l'objectif en vigueur à chaque
// table et chaque tour, la correspondance de chaque personne à chaque
// objectif distinct, ses couples (personne, tour) possibles, les animateurs
// de chaque table à chaque tour et la borne des animateurs de chacun ; puis
// l'état de chaque critère et la liste des priorités complète.
// L'instance porte aussi les étiquettes de chacun, en index, que lit la
// diversité par animateur, et ce que les critères des objectifs et des
// animateurs lisent, calculé une fois par configuration : l'objectif en
// vigueur à chaque table et chaque tour, la correspondance de chaque personne
// à chaque objectif distinct, ses couples (personne, tour) possibles, les
// animateurs de chaque table à chaque tour et la borne des animateurs de
// chacun ; puis l'état de chaque critère et la liste des priorités complète.
//
// Ce module définit aussi, une seule fois pour tout le moteur (§ 13.2), ce
// que les autres modules lisent de ces formes : les valeurs du contrat —
@ -200,6 +201,30 @@ function numeroterGroupes(presents) {
return { groupe, groupes };
}
// Les étiquettes des présents en index (D39). libellesEtiquettes porte les
// étiquettes distinctes que les présents portent, rangées par unités de
// code, sans égard à la langue : l'égalité de chaînes fait l'étiquette,
// comme le groupe. Les étiquettes de la personne p vont, dans etiquettes, de
// debutEtiquettes[p] à debutEtiquettes[p + 1] : leurs index, croissants,
// chacun une fois, même quand sa liste en répète un. Sans étiquette,
// libellesEtiquettes et etiquettes sont vides et debutEtiquettes ne porte que
// des zéros. La Map ne sert qu'à retrouver l'index d'un libellé. Le coût suit
// le nombre total d'étiquettes portées, plus le tri des distinctes.
function indexerEtiquettes(presents) {
const libellesEtiquettes = [...new Set(presents.flatMap(({ etiquettes = [] }) => etiquettes))].sort();
const indexDe = new Map(libellesEtiquettes.map((libelle, e) => [libelle, e]));
const debutEtiquettes = new Int32Array(presents.length + 1);
const etiquettes = [];
presents.forEach(({ etiquettes: siennes = [] }, p) => {
const index = siennes.map((libelle) => indexDe.get(libelle)).sort((a, b) => a - b);
index.forEach((e, i) => {
if (i === 0 || e !== index[i - 1]) etiquettes.push(e);
});
debutEtiquettes[p + 1] = etiquettes.length;
});
return { debutEtiquettes, etiquettes: Int32Array.from(etiquettes), libellesEtiquettes };
}
// Tables imposées par les réservations : fixe[p * R + r] = index de table,
// LIBRE sinon. Chaque réservation est vérifiée dans sa forme — participant et
// table connus, tour dans 1..R —, exclus compris ; celle d'un exclu s'arrête
@ -718,6 +743,7 @@ function instanceDe(configuration) {
const ids = presents.map((participant) => participant.id);
const indexDe = new Map(ids.map((id, p) => [id, p]));
const { groupe, groupes } = numeroterGroupes(presents);
const { debutEtiquettes, etiquettes, libellesEtiquettes } = indexerEtiquettes(presents);
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R });
const siegesLibres = siegesLibresApres(fixe, { N, T, R, capacite, idsTables });
const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R);
@ -739,6 +765,9 @@ function instanceDe(configuration) {
capacite,
groupe,
groupes,
debutEtiquettes,
etiquettes,
libellesEtiquettes,
fixe,
statut,
ancresParTable,

View file

@ -1141,6 +1141,57 @@ describe('normaliser : animateurs et borne des animateurs (§ 4.5, D6, D21)', ()
});
});
describe('normaliser : les étiquettes des personnes, en index (§ 4.5, D39)', () => {
test('les étiquettes distinctes des présents se rangent par unités de code ; chacun porte les siennes en index croissants, une fois chacune', () => {
const instance = normaliser(configuration({
participants: [
etiquete(4, ['finance', 'Montréal', 'finance']),
etiquete(2, ['Écoute', 'bénévole']),
etiquete(3),
etiquete(6, ['Finance']),
{ ...etiquete(5, ['ressources humaines', 'finance']), exclu: true },
etiquete(7, []),
],
tables: tablesDe([11, 12], 4),
tours: 1,
}));
assert.deepEqual(instance.ids, [2, 3, 4, 6, 7]);
// Par unités de code, « F » (0x46) < « M » < « b » < « f » < « É »
// (0xC9), là où l'ordre alphabétique mettrait « Écoute » en deuxième ;
// deux orthographes font deux étiquettes, et « ressources humaines », que
// seul l'exclu porte, n'y entre pas.
assert.deepEqual(instance.libellesEtiquettes, ['Finance', 'Montréal', 'bénévole', 'finance', 'Écoute']);
// 2 : bénévole (2), Écoute (4) ; 3 : aucune ; 4 : Montréal (1), finance
// (3), qu'il porte deux fois ; 6 : Finance (0) ; 7 : aucune.
assert.deepEqual(instance.debutEtiquettes, Int32Array.from([0, 2, 2, 4, 5, 5]));
assert.deepEqual(instance.etiquettes, Int32Array.from([2, 4, 1, 3, 0]));
});
test('chaque personne retrouve ses étiquettes distinctes, et seulement elles : lecture directe de la configuration', () => {
const instance = normaliser(CORRESPONDANCE);
const { presents } = lectureDirecte(CORRESPONDANCE);
const lues = presents.map((_, p) =>
[...instance.etiquettes.subarray(instance.debutEtiquettes[p], instance.debutEtiquettes[p + 1])]
.map((e) => instance.libellesEtiquettes[e]));
const attendues = presents.map(({ etiquettes = [] }) =>
[...new Set(etiquettes)].sort((a, b) => instance.libellesEtiquettes.indexOf(a) - instance.libellesEtiquettes.indexOf(b)));
assert.deepEqual(lues, attendues);
assert.deepEqual(instance.libellesEtiquettes, ['Montréal', 'bénévole', 'finance', 'ressources humaines']);
assert.equal(instance.debutEtiquettes.length, instance.N + 1);
});
test('sans étiquette : aucun libellé, aucune entrée, et debutEtiquettes ne porte que des zéros', () => {
const instance = normaliser(configuration({
participants: [...personnes([3, 1]), etiquete(2, []), { ...etiquete(4, ['finance']), exclu: true }],
tables: tablesDe([12, 11], 2),
tours: 2,
}));
assert.deepEqual(instance.libellesEtiquettes, []);
assert.deepEqual(instance.debutEtiquettes, new Int32Array(4));
assert.deepEqual(instance.etiquettes, new Int32Array(0));
});
});
describe('normaliser : refus des objectifs, du mode et des priorités (A12, A16)', () => {
const base = configuration({ participants: personnes([1, 2, 3]), tables: tablesDe([11, 12], 2), tours: 2 });
const avec = (modification) => () => normaliser({ ...base, ...modification });

187
src/moteur/diversite.js Normal file
View file

@ -0,0 +1,187 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La diversité par animateur (§ 4.5, § 5.4, § 12.13) : pour chaque
// animateur, les personnes, appartenances et étiquettes distinctes qu'il
// rencontre en animant, sur la soirée — l'union de ses tours animés — et à
// chacun de ses tours animés. Ces mesures décrivent un plan et ne classent
// rien : ni la recherche ni le classement ne les lisent.
//
// L'animateur f anime au tour r à la table que sa réservation lui fixe
// (instance.animes). Une personne q compte pour f à ce tour quand f et q y
// sont assis tous deux, que q n'est pas f, et que q peut occuper cette table
// à ce tour : sa table fixée quand une réservation le fixe, sinon une table
// qui garde un siège libre. C'est la relation que lisent les animateurs
// rencontrés de parcourirTours (indicateurs.js) : la somme, sur les
// animateurs, des personnes rencontrées en animant égale celle, sur les
// personnes, des animateurs rencontrés. Un animateur qu'un plan ajusté
// assied ailleurs qu'à sa table à un tour qu'il anime n'y rencontre
// personne ; le tour reste écrit, à comptes nuls.
//
// Une appartenance compte une fois, quels que soient ses affiliés ; une
// étiquette, une fois, quels que soient ses porteurs. Les comptes d'un tour
// se lisent au même passage que ceux de la soirée : chaque table animée se
// parcourt une fois par animateur qui l'anime.
import { LIBRE, RESERVE, SANS_GROUPE, exigerPlanIndexe } from './configuration.js';
/**
* @typedef {Object} DiversiteTour
* @property {number} tour index du tour, à partir de 0
* @property {number} t index de la table animée
* @property {number} personnes U_{f,r}
* @property {number} appartenances A_{f,r}
* @property {number|null} etiquettes E_{f,r} ; null sans étiquette portée
*
* @typedef {Object} DiversiteLigne
* @property {number} p index canonique de l'animateur
* @property {number} toursAnimes T_f
* @property {number} personnes U_f
* @property {number} appartenances A_f
* @property {number|null} etiquettes E_f ; null sans étiquette portée
* @property {DiversiteTour[]} parTour un par tour animé, tours croissants
*
* @typedef {{moins: number, plus: number, auMinimum: number}} TroisNombres
* la plus petite valeur, la plus grande, et les animateurs à la plus petite
*/
/**
* La diversité par animateur d'un plan indexé (§ 12.13, D38).
*
* colonnes.appartenances est faux quand les présents portent au plus une
* appartenance ; colonnes.etiquettes, quand aucun présent ne porte
* d'étiquette, et chaque compte d'étiquettes vaut alors null. Les lignes vont
* du moins varié au plus varié, sur la soirée seule : appartenances
* croissantes, puis étiquettes (null compté 0), puis personnes, puis l'index ;
* sans la colonne des appartenances, ce compte, qu'aucune colonne ne montre,
* ne départage pas : étiquettes, puis personnes, puis l'index. equite porte,
* pour chaque colonne présente, ses trois nombres sur la soirée ; null pour
* une colonne absente ou sans animateur. Aucun animateur : lignes vide.
*
* Ne rend que des listes et des objets simples ; ne modifie ni l'instance ni
* le plan ; lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {{colonnes: {appartenances: boolean, etiquettes: boolean},
* lignes: DiversiteLigne[],
* equite: {appartenances: TroisNombres|null, etiquettes: TroisNombres|null}}}
*/
export function diversiteAnimateurs(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R, fixe, siegesLibres, groupe, groupes, A, animateurs, debutAnimes, animes } = instance;
const { debutEtiquettes, etiquettes, libellesEtiquettes } = instance;
const colonnes = { appartenances: groupes.length > 1, etiquettes: libellesEtiquettes.length > 0 };
// Les assis de chaque table à chaque tour, ligne t × R + r de debutAssis à
// la suivante, par index croissants.
const debutAssis = new Int32Array(T * R + 1);
for (let i = 0; i < N * R; i += 1) {
if (tableDe[i] !== RESERVE) debutAssis[tableDe[i] * R + (i % R) + 1] += 1;
}
for (let ligne = 0; ligne < T * R; ligne += 1) debutAssis[ligne + 1] += debutAssis[ligne];
const assis = new Int32Array(debutAssis[T * R]);
const rempli = debutAssis.slice(0, T * R);
for (let i = 0; i < N * R; i += 1) {
if (tableDe[i] === RESERVE) continue;
const ligne = tableDe[i] * R + (i % R);
assis[rempli[ligne]] = Math.floor(i / R);
rempli[ligne] += 1;
}
// Les tours animés de chaque animateur, par rang : les tours parcourus
// dans l'ordre, chacun table par table, les rend croissants.
const lignesAnimees = Array.from({ length: A }, () => []);
for (let r = 0; r < R; r += 1) {
for (let t = 0; t < T; t += 1) {
const ligne = t * R + r;
for (let k = debutAnimes[ligne]; k < debutAnimes[ligne + 1]; k += 1) lignesAnimees[animes[k]].push(ligne);
}
}
// Marques datées : une personne, un groupe ou une étiquette marqués du rang
// de l'animateur sont déjà comptés pour sa soirée ; marqués du jeton du
// tour en cours, pour ce tour. Aucune remise à zéro.
const personneVue = new Int32Array(N).fill(-1);
const groupeVu = new Int32Array(groupes.length).fill(-1);
const groupeVuAuTour = new Int32Array(groupes.length).fill(-1);
const etiquetteVue = new Int32Array(libellesEtiquettes.length).fill(-1);
const etiquetteVueAuTour = new Int32Array(libellesEtiquettes.length).fill(-1);
let jeton = -1;
const lignes = [];
for (let f = 0; f < A; f += 1) {
const soi = animateurs[f];
const soiree = { personnes: 0, appartenances: 0, etiquettes: 0 };
const parTour = [];
for (const ligne of lignesAnimees[f]) {
jeton += 1;
const t = Math.floor(ligne / R);
const r = ligne % R;
const tour = { tour: r, t, personnes: 0, appartenances: 0, etiquettes: 0 };
const present = tableDe[soi * R + r] === t;
for (let k = debutAssis[ligne]; present && k < debutAssis[ligne + 1]; k += 1) {
const q = assis[k];
const imposee = fixe[q * R + r];
if (q === soi || (imposee === LIBRE ? siegesLibres[ligne] === 0 : imposee !== t)) continue;
tour.personnes += 1;
if (personneVue[q] !== f) {
personneVue[q] = f;
soiree.personnes += 1;
}
const g = groupe[q];
if (g !== SANS_GROUPE) {
if (groupeVuAuTour[g] !== jeton) {
groupeVuAuTour[g] = jeton;
tour.appartenances += 1;
}
if (groupeVu[g] !== f) {
groupeVu[g] = f;
soiree.appartenances += 1;
}
}
for (let j = debutEtiquettes[q]; j < debutEtiquettes[q + 1]; j += 1) {
const e = etiquettes[j];
if (etiquetteVueAuTour[e] !== jeton) {
etiquetteVueAuTour[e] = jeton;
tour.etiquettes += 1;
}
if (etiquetteVue[e] !== f) {
etiquetteVue[e] = f;
soiree.etiquettes += 1;
}
}
}
if (!colonnes.etiquettes) tour.etiquettes = null;
parTour.push(tour);
}
lignes.push({
p: soi,
toursAnimes: parTour.length,
personnes: soiree.personnes,
appartenances: soiree.appartenances,
etiquettes: colonnes.etiquettes ? soiree.etiquettes : null,
parTour,
});
}
const avant = (a, b) => (a.etiquettes ?? 0) - (b.etiquettes ?? 0) || a.personnes - b.personnes || a.p - b.p;
lignes.sort(colonnes.appartenances ? (a, b) => a.appartenances - b.appartenances || avant(a, b) : avant);
return {
colonnes,
lignes,
equite: {
appartenances: colonnes.appartenances ? troisNombres(lignes, 'appartenances') : null,
etiquettes: colonnes.etiquettes ? troisNombres(lignes, 'etiquettes') : null,
},
};
}
// Les trois nombres d'une colonne sur la soirée : la plus petite valeur, la
// plus grande, et le nombre de lignes à la plus petite ; null sans ligne.
function troisNombres(lignes, nom) {
if (lignes.length === 0) return null;
const valeurs = lignes.map((ligne) => ligne[nom]);
const moins = valeurs.reduce((a, b) => Math.min(a, b));
const plus = valeurs.reduce((a, b) => Math.max(a, b));
return { moins, plus, auMinimum: valeurs.filter((valeur) => valeur === moins).length };
}

View file

@ -0,0 +1,480 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la diversité par animateur (§ 4.5, § 5.4, § 12.13) : les
// personnes, appartenances et étiquettes distinctes que chaque animateur
// rencontre en animant, sur la soirée et à chacun de ses tours animés, le
// tri du moins varié au plus varié, les colonnes et les trois nombres de
// chaque colonne. Les cas comptés à la main nomment chacun la règle qu'ils
// éprouvent ; une lecture directe de la configuration, qui n'emprunte rien
// au module, les recoupe sur des plans quelconques, réservations déplacées
// et tables surchargées comprises. Identifiants et index diffèrent, et les
// tables arrivent hors de l'ordre de leurs identifiants.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { RESERVE, normaliser } from './configuration.js';
import { diversiteAnimateurs } from './diversite.js';
import { parcourirTours } from './indicateurs.js';
const AUBIER = 'Coopérative Aubier';
const RESSAC = 'Atelier Ressac';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
// Participant présent : son appartenance, ses étiquettes et les tours où il
// anime, chacun facultatif.
const personne = (id, appartenance = null, etiquettes, anime) => ({
id,
nom: `P${id}`,
appartenance,
...(etiquettes === undefined ? {} : { etiquettes }),
...(anime === undefined ? {} : { anime }),
});
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
const configuration = ({ participants, tables, tours, reservations = [] }) => ({
participants,
tables,
tours,
reservations,
contraintes: SANS_CONTRAINTE,
});
// Plan indexé d'un plan écrit par identifiants : tours[r] associe à chaque
// identifiant de table la liste des identifiants qui s'y assoient ; tout
// présent absent d'un tour va en réserve. Aucune réservation ni capacité
// n'est examinée : le plan peut être ajusté.
function planDe(instance, tours) {
const { N, R, indexDe, indexTableDe } = instance;
const tableDe = new Int32Array(N * R).fill(RESERVE);
tours.forEach((tables, r) => {
for (const [table, ids] of tables) {
for (const id of ids) tableDe[indexDe.get(id) * R + r] = indexTableDe.get(table);
}
});
return tableDe;
}
// Lecture directe des § 4.5 et § 5.4 sur la configuration, sans rien du
// module éprouvé : f anime au tour r à la table que sa réservation lui fixe ;
// une personne compte pour f quand elle est assise à cette table, f aussi,
// qu'elle n'est pas f, et qu'elle peut l'occuper — sa table fixée, sinon une
// table dont les réservations des présents laissent un siège. Les lignes
// suivent les animateurs par index croissant.
function lectureDirecte(config, instance, tableDe) {
const presents = config.participants.filter((p) => p.exclu !== true);
const presentsParId = new Set(presents.map(({ id }) => id));
const reservations = config.reservations.filter((res) => presentsParId.has(res.participant));
const couvre = (res, tour) => res.portee === 'tous' || res.tour === tour;
const fixee = (id, tour) => reservations.find((res) => res.participant === id && couvre(res, tour))?.table;
const capacite = (table) => config.tables.find(({ id }) => id === table).capacite;
const libre = (table, tour) =>
reservations.filter((res) => res.table === table && couvre(res, tour)).length < capacite(table);
const peutOccuper = (id, table, tour) => {
const sienne = fixee(id, tour);
return sienne === undefined ? libre(table, tour) : sienne === table;
};
const assiseA = (id, tour) => {
const t = tableDe[instance.indexDe.get(id) * instance.R + tour - 1];
return t === RESERVE ? null : instance.idsTables[t];
};
const avecEtiquettes = presents.some(({ etiquettes = [] }) => etiquettes.length > 0);
const compter = (rencontrees) => ({
personnes: rencontrees.length,
appartenances: new Set(rencontrees.filter((q) => q.appartenance !== null).map((q) => q.appartenance)).size,
etiquettes: avecEtiquettes ? new Set(rencontrees.flatMap(({ etiquettes = [] }) => etiquettes)).size : null,
});
return presents
.filter(({ anime = [] }) => anime.length > 0)
.sort((a, b) => a.id - b.id)
.map((f) => {
const parTour = f.anime.map((tour) => {
const table = fixee(f.id, tour);
const rencontrees = assiseA(f.id, tour) !== table ? [] : presents.filter(
(q) => q.id !== f.id && assiseA(q.id, tour) === table && peutOccuper(q.id, table, tour),
);
return { tour: tour - 1, table, rencontrees };
});
const soiree = [...new Set(parTour.flatMap(({ rencontrees }) => rencontrees))];
return {
p: instance.indexDe.get(f.id),
toursAnimes: f.anime.length,
...compter(soiree),
parTour: parTour.map(({ tour, table, rencontrees }) => ({
tour,
t: instance.indexTableDe.get(table),
...compter(rencontrees),
})),
};
});
}
// L'ordre du § 12.13 (D38), écrit à part : appartenances, puis étiquettes,
// puis personnes croissantes, puis l'index ; sans la colonne des
// appartenances, les étiquettes d'abord, puis personnes et index.
function dansLOrdre(lignes, avecAppartenances) {
const cle = (l) => [...(avecAppartenances ? [l.appartenances] : []), l.etiquettes ?? 0, l.personnes, l.p];
for (let i = 1; i < lignes.length; i += 1) {
const [a, b] = [cle(lignes[i - 1]), cle(lignes[i])];
const k = a.findIndex((v, j) => v !== b[j]);
if (k === -1 || a[k] > b[k]) return false;
}
return true;
}
// Plan quelconque, qu'une graine détermine : chaque case vaut une table ou la
// réserve, sans égard aux réservations ni aux capacités.
function planQuelconque(instance, graine) {
const { N, T, R } = instance;
const tableDe = new Int32Array(N * R);
let etat = graine >>> 0;
for (let i = 0; i < N * R; i += 1) {
etat = (Math.imul(etat, 1_103_515_245) + 12_345) >>> 0;
tableDe[i] = (etat >>> 16) % (T + 1) - 1;
}
return tableDe;
}
// Tables 21 (index 0) et 22 (index 1), quatre places, deux tours. 6 anime
// 21 aux deux tours ; 7 anime 21 au tour 1, avec 6 ; 8 anime 22 au tour 1 ;
// 9, exclu, animerait 22 au tour 2 ; 5 est réservé à 22 au tour 2.
const CAS = configuration({
participants: [
personne(6, AUBIER, undefined, [1, 2]),
personne(1, AUBIER, ['finance']),
personne(2, RESSAC, ['finance', 'Montréal']),
personne(3, AUBIER),
personne(4, null, ['bénévole']),
personne(5, RESSAC),
personne(7, null, ['finance'], [1]),
personne(8, RESSAC, undefined, [1]),
{ ...personne(9, AUBIER, ['ressources humaines'], [2]), exclu: true },
],
tables: [{ id: 22, numero: 2, capacite: 4 }, { id: 21, numero: 1, capacite: 4 }],
tours: 2,
reservations: [tous(6, 21), auTour(7, 21, 1), auTour(8, 22, 1), auTour(5, 22, 2), tous(9, 22)],
});
// Tour 1 : 6, 7, 1, 2 à 21 ; 8, 3, 4, 5 à 22. Tour 2 : 6, 5, 3, 8 à 21,
// où 5 s'assied hors de sa réservation ; 1, 2, 4, 7 à 22. Le plan honore
// toutes les autres réservations.
const PLAN_CAS = [
[[21, [6, 7, 1, 2]], [22, [8, 3, 4, 5]]],
[[21, [6, 5, 3, 8]], [22, [1, 2, 4, 7]]],
];
describe('diversiteAnimateurs : ce que chaque animateur rencontre en animant (§ 4.5, § 5.4, B4)', () => {
const instance = normaliser(CAS);
const diversite = diversiteAnimateurs(instance, planDe(instance, PLAN_CAS));
const ligneDe = (id) => diversite.lignes.find(({ p }) => p === instance.indexDe.get(id));
test('compté à la main : la soirée réunit les tours animés, chaque tour garde ses propres comptes', () => {
// Index : 1 → 0, …, 8 → 7 ; table 21 → 1, 22 → 0.
// 6, au tour 1 à 21 : 7, 1, 2 — deux appartenances, finance et Montréal ;
// au tour 2 : 3 et 8, 5 n'y pouvant s'asseoir — Aubier et Ressac, aucune
// étiquette. La soirée : 7, 1, 2, 3, 8.
assert.deepEqual(ligneDe(6), {
p: 5,
toursAnimes: 2,
personnes: 5,
appartenances: 2,
etiquettes: 2,
parTour: [
{ tour: 0, t: 1, personnes: 3, appartenances: 2, etiquettes: 2 },
{ tour: 1, t: 1, personnes: 2, appartenances: 2, etiquettes: 0 },
],
});
});
test('un animateur d’un seul tour a une seule entrée par tour, égale à sa soirée', () => {
const { parTour, toursAnimes, personnes, appartenances, etiquettes } = ligneDe(7);
assert.equal(toursAnimes, 1);
assert.deepEqual(parTour, [{ tour: 0, t: 1, personnes, appartenances, etiquettes }]);
assert.deepEqual([personnes, appartenances, etiquettes], [3, 2, 2]);
});
test('deux animateurs d’une même table au même tour se comptent l’un l’autre, chacun hors de ses propres rencontres', () => {
// 6 compte 7 au tour 1, 7 compte 6 ; ni l'un ni l'autre ne se compte.
assert.equal(ligneDe(7).personnes, 3);
assert.equal(ligneDe(6).parTour[0].personnes, 3);
});
test('une personne assise à la table d’un animateur à un tour où il n’anime pas ne compte pas pour lui', () => {
// 8 n'anime qu'au tour 1 : au tour 2, ni 6 ni 5 ni 3 ne le comptent ; 7
// n'anime pas au tour 2, où 1, 2 et 4 partagent sa table.
assert.deepEqual(ligneDe(8).parTour, [{ tour: 0, t: 0, personnes: 3, appartenances: 2, etiquettes: 1 }]);
assert.equal(ligneDe(8).personnes, 3);
});
test('une personne assise à une table qu’elle ne peut occuper, sur un plan ajusté, ne compte pas', () => {
// 5, réservé à 22 au tour 2, est assis à 21 : 6 ne le compte pas.
assert.equal(ligneDe(6).parTour[1].personnes, 2);
// Une table sans siège libre à ce tour : 11 et 12 y sont réservés, 13,
// libre, s'y ajoute sur un plan ajusté.
const plein = configuration({
participants: [personne(11, AUBIER, undefined, [1]), personne(12, RESSAC), personne(13, RESSAC, ['finance'])],
tables: [{ id: 31, numero: 1, capacite: 2 }, { id: 32, numero: 2, capacite: 2 }],
tours: 1,
reservations: [tous(11, 31), tous(12, 31)],
});
const autre = normaliser(plein);
const { lignes } = diversiteAnimateurs(autre, planDe(autre, [[[31, [11, 12, 13]]]]));
assert.deepEqual(lignes[0].parTour, [{ tour: 0, t: 0, personnes: 1, appartenances: 1, etiquettes: 0 }]);
});
test('une étiquette portée par trois personnes de la table se compte une fois', () => {
// 6, au tour 1 : 7, 1 et 2 portent finance, 2 porte aussi Montréal.
assert.equal(ligneDe(6).parTour[0].etiquettes, 2);
});
test('un animateur exclu est absent ; les lignes vont du moins varié au plus varié', () => {
// Appartenances égales (2) ; étiquettes 1 pour 8, 2 pour 7 et 6 ; puis 3
// personnes pour 7, 5 pour 6.
assert.deepEqual(diversite.lignes.map(({ p }) => instance.ids[p]), [8, 7, 6]);
assert.deepEqual(diversite.colonnes, { appartenances: true, etiquettes: true });
assert.deepEqual(diversite.equite, {
appartenances: { moins: 2, plus: 2, auMinimum: 3 },
etiquettes: { moins: 1, plus: 2, auMinimum: 1 },
});
});
test('le tri et les trois nombres ne lisent que la soirée', () => {
// Au seul tour 2, 6 n'aurait aucune étiquette et passerait en tête ; au
// seul tour 1, il précéderait 7 à comptes égaux.
assert.deepEqual(diversite.lignes.map(({ p }) => instance.ids[p]), [8, 7, 6]);
assert.equal(diversite.equite.etiquettes.moins, 1);
});
test('le détail ne rend que des listes et des objets simples', () => {
assert.ok(Array.isArray(diversite.lignes));
for (const ligne of diversite.lignes) {
assert.ok(Array.isArray(ligne.parTour));
assert.equal(Object.getPrototypeOf(ligne), Object.prototype);
}
});
test('un plan hors de la forme de l’instance lève RangeError', () => {
assert.throws(() => diversiteAnimateurs(instance, new Int32Array(3)), RangeError);
});
});
describe('diversiteAnimateurs : le tri du moins varié au plus varié (§ 12.13, D38, B22)', () => {
// Tables 41 à 44, trois places — quatre pour larges —, un tour ; un
// animateur réservé à chacune.
const tables = [41, 42, 43, 44].map((id, i) => ({ id, numero: i + 1, capacite: 3 }));
const larges = tables.map((table) => ({ ...table, capacite: 4 }));
const reservations = [tous(10, 41), tous(20, 42), tous(30, 43), tous(40, 44)];
test('les appartenances d’abord, avant les personnes ; à égalité de tout, l’index', () => {
// 10 rencontre trois personnes d'une appartenance ; 20, deux personnes
// de deux appartenances ; 30 et 40, une personne chacun.
const config = configuration({
participants: [
personne(10, null, undefined, [1]), personne(20, null, undefined, [1]),
personne(30, null, undefined, [1]), personne(40, null, undefined, [1]),
personne(1, AUBIER), personne(2, AUBIER), personne(3, AUBIER),
personne(4, AUBIER), personne(5, RESSAC), personne(6, AUBIER), personne(7, RESSAC),
],
tables: larges,
tours: 1,
reservations,
});
const instance = normaliser(config);
const plan = planDe(instance, [[[41, [10, 1, 2, 3]], [42, [20, 4, 5]], [43, [30, 6]], [44, [40, 7]]]]);
const { lignes, colonnes, equite } = diversiteAnimateurs(instance, plan);
// Une appartenance pour 30, 40 (une personne) et 10 (trois), deux pour
// 20 : par les personnes d'abord, 20 précéderait 10. 30 et 40, égaux en
// tout, se rangent par l'index.
assert.deepEqual(lignes.map(({ p }) => instance.ids[p]), [30, 40, 10, 20]);
assert.deepEqual(colonnes, { appartenances: true, etiquettes: false });
// Sans étiquette portée : la colonne manque, chaque compte vaut null.
assert.ok(lignes.every(({ etiquettes, parTour }) => etiquettes === null && parTour.every((tour) => tour.etiquettes === null)));
assert.deepEqual(equite, { appartenances: { moins: 1, plus: 2, auMinimum: 3 }, etiquettes: null });
});
test('une seule appartenance et des étiquettes : la colonne des étiquettes seule, qui ordonne avant les personnes', () => {
// 10 : 1, Aubier, finance — 1 appartenance, 1 étiquette, 1 personne.
// 20 : 2 et 3 sans appartenance, tous deux finance — 0, 1, 2.
// 30 : 4, Aubier, finance et Montréal — 1, 2, 1. 40 : personne.
const config = configuration({
participants: [
personne(10, null, undefined, [1]), personne(20, null, undefined, [1]),
personne(30, null, undefined, [1]), personne(40, null, undefined, [1]),
personne(1, AUBIER, ['finance']), personne(2, null, ['finance']),
personne(3, null, ['finance']), personne(4, AUBIER, ['finance', 'Montréal']),
],
tables,
tours: 1,
reservations,
});
const instance = normaliser(config);
const plan = planDe(instance, [[[41, [10, 1]], [42, [20, 2, 3]], [43, [30, 4]], [44, [40]]]]);
const { lignes, colonnes, equite } = diversiteAnimateurs(instance, plan);
assert.deepEqual(colonnes, { appartenances: false, etiquettes: true });
// Étiquettes 0 pour 40 ; 1 pour 10 (une personne) et 20 (deux) ; 2 pour
// 30. Les appartenances, qu'aucune colonne ne montre, ne départagent pas
// 10 et 20 : 20 n'en a aucune et viendrait sinon en tête des deux.
assert.deepEqual(lignes.map(({ p }) => instance.ids[p]), [40, 10, 20, 30]);
assert.deepEqual(lignes.map(({ appartenances }) => appartenances), [0, 1, 0, 1]);
assert.deepEqual(equite, { appartenances: null, etiquettes: { moins: 0, plus: 2, auMinimum: 1 } });
});
test('les trois nombres de chaque colonne se lisent sur toutes les lignes, non sur la première du tri', () => {
// 10 rencontre 1, Aubier, finance et Montréal : 1 appartenance, 2
// étiquettes. 20 rencontre 2, Aubier, et 3, Ressac, sans étiquette : 2
// appartenances, 0 étiquette. Le tri met 10 en tête, et le minimum des
// étiquettes, 0, est à la seconde ligne.
const config = configuration({
participants: [
personne(10, null, undefined, [1]), personne(20, null, undefined, [1]),
personne(1, AUBIER, ['finance', 'Montréal']), personne(2, AUBIER), personne(3, RESSAC),
],
tables,
tours: 1,
reservations: reservations.slice(0, 2),
});
const instance = normaliser(config);
const { lignes, colonnes, equite } = diversiteAnimateurs(instance, planDe(instance, [[[41, [10, 1]], [42, [20, 2, 3]]]]));
assert.deepEqual(colonnes, { appartenances: true, etiquettes: true });
assert.deepEqual(lignes.map(({ p, appartenances, etiquettes }) => [instance.ids[p], appartenances, etiquettes]), [[10, 1, 2], [20, 2, 0]]);
assert.deepEqual(equite, {
appartenances: { moins: 1, plus: 2, auMinimum: 1 },
etiquettes: { moins: 0, plus: 2, auMinimum: 1 },
});
});
test('ni étiquette ni plus d’une appartenance : aucune colonne, les comptes de personnes restent', () => {
const config = configuration({
participants: [personne(10, null, undefined, [1]), personne(20, null, undefined, [1]), personne(1), personne(2), personne(3)],
tables,
tours: 1,
reservations: reservations.slice(0, 2),
});
const instance = normaliser(config);
const { lignes, colonnes, equite } = diversiteAnimateurs(instance, planDe(instance, [[[41, [10, 1, 2]], [42, [20, 3]]]]));
assert.deepEqual(colonnes, { appartenances: false, etiquettes: false });
assert.deepEqual(lignes.map(({ p, personnes }) => [instance.ids[p], personnes]), [[20, 1], [10, 2]]);
assert.deepEqual(equite, { appartenances: null, etiquettes: null });
});
test('sans animateur, aucune ligne et aucun des trois nombres', () => {
const config = configuration({
participants: [personne(1, AUBIER, ['finance']), personne(2, RESSAC), { ...personne(3, AUBIER, undefined, [1]), exclu: true }],
tables: tables.slice(0, 1),
tours: 2,
reservations: [tous(3, 41)],
});
const instance = normaliser(config);
const diversite = diversiteAnimateurs(instance, planDe(instance, [[[41, [1, 2]]], [[41, [1, 2]]]]));
assert.deepEqual(diversite, {
colonnes: { appartenances: true, etiquettes: true },
lignes: [],
equite: { appartenances: null, etiquettes: null },
});
});
});
describe('diversiteAnimateurs : recoupements sur des plans quelconques', () => {
// Trois tables, trois tours, des animateurs d'un, deux et trois tours, deux
// qui animent une même table au même tour, un exclu, des réservations de
// personnes qui n'animent pas. 5 anime 52 (index 2) au tour 1 puis 53
// (index 0) au tour 3 : ses tours animés, rangés par table, sortiraient
// décroissants. Au tour 2, 3, 4 et 9 remplissent les trois places de 52,
// que 3 et 4 animent : une personne libre qu'un plan y assied ne compte
// pour aucun des deux.
const config = configuration({
participants: [
personne(2, AUBIER, ['finance'], [1, 2, 3]),
personne(3, RESSAC, undefined, [2]),
personne(4, null, ['Montréal', 'bénévole'], [2]),
personne(5, AUBIER, ['bénévole'], [1, 3]),
personne(6, RESSAC, ['finance']),
personne(7, null),
personne(8, AUBIER, ['ressources humaines', 'finance']),
personne(9, RESSAC, ['Montréal']),
personne(10, RESSAC),
personne(11, null, ['bénévole']),
{ ...personne(12, AUBIER, ['finance'], [1]), exclu: true },
],
tables: [{ id: 53, numero: 3, capacite: 3 }, { id: 51, numero: 1, capacite: 4 }, { id: 52, numero: 2, capacite: 3 }],
tours: 3,
reservations: [
tous(2, 51), auTour(3, 52, 2), auTour(4, 52, 2), auTour(9, 52, 2), auTour(5, 52, 1), auTour(5, 53, 3),
auTour(6, 53, 1), auTour(7, 53, 1), auTour(8, 51, 2), tous(12, 52),
],
});
const instance = normaliser(config);
const GRAINES = Array.from({ length: 40 }, (_, i) => i + 1);
test('chaque ligne égale la lecture directe de la configuration', () => {
const ecarts = [];
for (const graine of GRAINES) {
const plan = planQuelconque(instance, graine);
const lues = [...diversiteAnimateurs(instance, plan).lignes].sort((a, b) => a.p - b.p);
try {
assert.deepEqual(lues, lectureDirecte(config, instance, plan));
} catch {
ecarts.push(graine);
}
}
assert.deepEqual(ecarts, []);
});
test('les lignes suivent l’ordre du § 12.13 et les trois nombres lisent la soirée', () => {
for (const graine of GRAINES) {
const { lignes, equite } = diversiteAnimateurs(instance, planQuelconque(instance, graine));
assert.ok(dansLOrdre(lignes, true), `graine ${graine}`);
for (const nom of ['appartenances', 'etiquettes']) {
const valeurs = lignes.map((ligne) => ligne[nom]);
const moins = Math.min(...valeurs);
assert.deepEqual(equite[nom], {
moins,
plus: Math.max(...valeurs),
auMinimum: valeurs.filter((v) => v === moins).length,
}, `graine ${graine}, ${nom}`);
}
}
});
test('chaque tour reste sous la soirée, et la soirée ne dépasse pas la somme de ses tours', () => {
for (const graine of GRAINES) {
const plan = planQuelconque(instance, graine);
const directes = lectureDirecte(config, instance, plan);
const { lignes } = diversiteAnimateurs(instance, plan);
assert.deepEqual(lignes.map(({ p }) => instance.ids[p]).sort((a, b) => a - b), [2, 3, 4, 5]);
for (const ligne of lignes) {
assert.equal(ligne.parTour.length, ligne.toursAnimes);
assert.deepEqual(ligne.parTour.map(({ tour }) => tour), [...ligne.parTour.map(({ tour }) => tour)].sort((a, b) => a - b));
for (const tour of ligne.parTour) {
for (const nom of ['personnes', 'appartenances', 'etiquettes']) assert.ok(tour[nom] <= ligne[nom], `graine ${graine}`);
}
const directe = directes.find(({ p }) => p === ligne.p);
assert.ok(ligne.personnes <= ligne.parTour.reduce((somme, tour) => somme + tour.personnes, 0));
assert.equal(ligne.personnes, directe.personnes);
}
}
});
test('les personnes rencontrées en animant, sommées sur les animateurs, égalent les animateurs rencontrés de parcourirTours', () => {
for (const graine of GRAINES) {
const plan = planQuelconque(instance, graine);
const sommeU = diversiteAnimateurs(instance, plan).lignes.reduce((somme, { personnes }) => somme + personnes, 0);
const sommeM = parcourirTours(instance, plan).rencontresAnimateurs.reduce((somme, m) => somme + m, 0);
assert.equal(sommeU, sommeM, `graine ${graine}`);
}
});
test('un animateur absent de sa table au tour qu’il anime n’y rencontre personne, et son tour reste écrit', () => {
const plan = planQuelconque(instance, 1);
const p = instance.indexDe.get(2);
// 2 anime 51 (index 1) aux trois tours ; on l'assied à 53 au tour 1.
plan[p * instance.R] = 0;
const ligne = diversiteAnimateurs(instance, plan).lignes.find((l) => l.p === p);
assert.deepEqual(ligne.parTour[0], { tour: 0, t: 1, personnes: 0, appartenances: 0, etiquettes: 0 });
});
});

159
src/moteur/paires.js Normal file
View file

@ -0,0 +1,159 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les paires répétées, la ligne d'une personne et la matrice « qui a
// rencontré qui » (§ 5.4, § 12.5), pour le seul placement que la page de
// qualité montre : aucune n'entre dans la recherche ni dans le classement.
//
// Le compte de chaque paire et sa répartition entre choisies et imposées se
// lisent dans parcourirTours (indicateurs.js), le passage dont mesurer tire
// ses rencontres répétées : parPaire porte les rencontres de chaque paire, et
// une paire figure parmi les revues d'un tour exactement quand elle est
// réunie deux fois au moins et que l'une de ses rencontres, jusqu'à ce tour,
// a lieu hors d'une table où les deux sont réservés — libre[k] de
// parcourirTours. Une paire répétée qui n'est jamais revue est imposée : les
// deux listes ont les longueurs de mesures.rencontresRepetees (§ 13.2).
//
// Les tours d'une paire se lisent dans le plan, case à case : les tours où les
// deux sont assis à la même table, la réserve n'étant pas une table. Ils sont
// autant que son compte.
//
// Chaque fonction prend en dernier paramètre, facultatif, le parcours déjà lu
// du même plan : un appelant qui en tire d'autres mesures ne relit pas le
// plan. Sans lui, la fonction le lit, et lève ce que lève exigerPlanIndexe.
// Rien de rendu n'est un tableau typé.
import { RESERVE } from './configuration.js';
import { parcourirTours } from './indicateurs.js';
/**
* @typedef {Object} RencontreAuTour
* @property {number} tour index du tour, à partir de 0
* @property {number} t index de la table
*
* @typedef {Object} PaireRepetee
* @property {number} a index de personne, a < b
* @property {number} b
* @property {number} fois rencontres de la paire, deux au moins
* @property {RencontreAuTour[]} tours tours croissants
*
* @typedef {Object} Rencontre
* @property {number} q index de la personne rencontrée
* @property {number} fois une au moins
* @property {RencontreAuTour[]} tours tours croissants
*/
// Rang de la paire (a, b), a < b, dans un triangle supérieur n × n rangé
// ligne par ligne : la ligne a s'ouvre après les (n − 1) + … + (n − a) cases
// des lignes qui la précèdent. C'est le rang sous lequel parPaire range ses
// comptes (indicateurs.js, qui ne l'exporte pas), et celui des cases de la
// matrice.
const rangPaire = (n, a, b) => (a * (2 * n - a - 1)) / 2 + (b - a - 1);
// Rencontres de la paire (a, b), lues dans parPaire.
const compte = (N, parPaire, a, b) => parPaire[a < b ? rangPaire(N, a, b) : rangPaire(N, b, a)];
// Tours où a et b sont assis à la même table, croissants, avec cette table.
function toursEnsemble({ R }, tableDe, a, b) {
const tours = [];
for (let r = 0; r < R; r += 1) {
const t = tableDe[a * R + r];
if (t !== RESERVE && t === tableDe[b * R + r]) tours.push({ tour: r, t });
}
return tours;
}
// Ordre des paires : fois décroissant, puis a, puis b croissants.
const ordrePaires = (x, y) => y.fois - x.fois || x.a - y.a || x.b - y.b;
// Vrai pour un index de personne de l'instance.
const estIndex = ({ N }, p) => Number.isInteger(p) && p >= 0 && p < N;
/**
* Paires réunies deux fois au moins, en deux listes : choisies, quand une
* rencontre au moins a lieu hors d'une table où les deux sont réservés ce
* tour-là ; imposées, quand chacune a lieu à une telle table (§ 5.4, D32).
* Chaque liste est triée par fois décroissant, puis par a, puis par b
* croissants ; une paire porte chacun de ses tours de rencontre.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @param {ReturnType<typeof parcourirTours>} [parcours] le même plan, déjà lu
* @returns {{choisies: PaireRepetee[], imposees: PaireRepetee[]}}
*/
export function pairesRepetees(instance, tableDe, parcours = parcourirTours(instance, tableDe)) {
const { N } = instance;
const { parTour, parPaire, reunies } = parcours;
const revue = new Uint8Array(parPaire.length);
for (const tables of parTour) {
for (const { revues } of tables) {
for (const [a, b] of revues) revue[rangPaire(N, a, b)] = 1;
}
}
const choisies = [];
const imposees = [];
for (let i = 0; i < reunies.length; i += 2) {
const a = reunies[i];
const b = reunies[i + 1];
const k = rangPaire(N, a, b);
const fois = parPaire[k];
if (fois < 2) continue;
const paire = { a, b, fois, tours: toursEnsemble(instance, tableDe, a, b) };
(revue[k] === 1 ? choisies : imposees).push(paire);
}
return { choisies: choisies.sort(ordrePaires), imposees: imposees.sort(ordrePaires) };
}
/**
* La ligne de la personne p (§ 12.5, D33) : chaque personne qu'elle
* rencontre, avec le nombre de fois et les tours, par fois décroissant puis
* par index croissant. Choisies et imposées ne s'y séparent pas.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @param {number} p index de personne ; lève RangeError hors de 0..N − 1
* @param {ReturnType<typeof parcourirTours>} [parcours] le même plan, déjà lu
* @returns {Rencontre[]}
*/
export function rencontresDe(instance, tableDe, p, parcours = parcourirTours(instance, tableDe)) {
if (!estIndex(instance, p)) throw new RangeError(`personne ${p} hors des index 0..${instance.N - 1}`);
const { N } = instance;
const ligne = [];
for (let q = 0; q < N; q += 1) {
if (q === p) continue;
const fois = compte(N, parcours.parPaire, p, q);
if (fois > 0) ligne.push({ q, fois, tours: toursEnsemble(instance, tableDe, p, q) });
}
// Les personnes entrent par index croissant, et le tri est stable.
return ligne.sort((x, y) => y.fois - x.fois);
}
/**
* Les comptes de rencontres des personnes d'ordre, deux à deux : le triangle
* supérieur strict, sans diagonale, rangé ligne par ligne — la case (i, j),
* i < j, au rang i · (2n − i − 1) / 2 + (j − i − 1), n la longueur d'ordre,
* porte les rencontres de ordre[i] et ordre[j]. Le compte est exact ; les
* niveaux de l'échelle se lisent ailleurs (niveau, géométrie).
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @param {number[]} ordre index de personnes distincts ; lève RangeError
* sur un index hors de 0..N − 1 ou répété
* @param {ReturnType<typeof parcourirTours>} [parcours] le même plan, déjà lu
* @returns {number[]} n · (n − 1) / 2 comptes
*/
export function matriceRencontres(instance, tableDe, ordre, parcours = parcourirTours(instance, tableDe)) {
const { N } = instance;
const vu = new Uint8Array(N);
for (const p of ordre) {
if (!estIndex(instance, p) || vu[p] === 1) throw new RangeError(`ordre : personne ${p} hors des index ou répétée`);
vu[p] = 1;
}
const n = ordre.length;
const comptes = new Array((n * (n - 1)) / 2);
for (let i = 0; i < n; i += 1) {
for (let j = i + 1; j < n; j += 1) {
comptes[rangPaire(n, i, j)] = compte(N, parcours.parPaire, ordre[i], ordre[j]);
}
}
return comptes;
}

613
src/moteur/paires.test.js Normal file
View file

@ -0,0 +1,613 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des paires répétées, de la ligne d'une personne et de la matrice
// des rencontres (§ 5.4, § 12.5). Chaque plan s'écrit par identifiants, une
// liste par table, et passe par normaliser et indexerPlan comme un plan
// enregistré. Les valeurs attendues s'écrivent en clair, comptées à la main
// dans le commentaire qui les précède, ou sortent d'une lecture directe du
// plan par identifiants, écrite ici sans rien du moteur : chaque paire, ses
// tables tour par tour, et la réservation des deux membres à chacune.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer, parcourirTours } from './indicateurs.js';
import { matriceRencontres, pairesRepetees, rencontresDe } from './paires.js';
const SANS_CONTRAINTE = Object.freeze({
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
});
// Noms inventés : leur ordre alphabétique est l'inverse de celui des
// identifiants, qui fait l'ordre des index. Un tri sur les noms rendrait
// les paires à rebours.
const NOMS = Object.freeze([
'Théo Pervenche',
'Ondine Lacasse',
'Maëlle Quenouille',
'Iris Ombrelle',
'Basile Tourteau',
]);
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
/**
* Instance et plan indexé d'un plan écrit par identifiants. Les
* participants portent les identifiants 1..N, sauf ceux d'exclus, qui sont
* exclus et n'entrent dans aucune liste ; les tables portent 1..T, toutes de
* la capacité donnée ; tours[r][i] liste les participants assis à la table
* i + 1 au tour r + 1, reserves[r] ceux qui n'y sont assis nulle part.
*/
function preparer({ N, capacite, tours, reserves = tours.map(() => []), reservations = [], exclus = [] }) {
const participants = Array.from({ length: N }, (_, i) => ({
id: i + 1,
nom: NOMS[i % NOMS.length],
appartenance: i % 2 === 0 ? 'Coopérative Aubier' : 'Atelier Ressac',
...(exclus.includes(i + 1) ? { exclu: true } : {}),
}));
const tables = tours[0].map((_, i) => ({ id: i + 1, numero: i + 1, capacite }));
const instance = normaliser({ participants, tables, tours: tours.length, reservations, contraintes: SANS_CONTRAINTE });
const plan = { tables: tables.map(({ id }) => id), tours, reserves };
return { instance, tableDe: indexerPlan(instance, plan) };
}
// Vrai quand une réservation assied l'identifiant id à la table d'identifiant
// table au tour r + 1.
const reserve = (reservations, id, table, r) =>
reservations.some(
(res) => res.participant === id && res.table === table && (res.portee === 'tous' || res.tour === r + 1),
);
/**
* Lecture directe d'un plan par identifiants : chaque paire d'identifiants
* x < y assise à la même table à un tour au moins, ses rencontres
* [{tour, t}] dans l'ordre des tours, et vrai sous reservee quand chacune
* a lieu à une table où les deux sont réservés ce tour-là. Les tables
* portent l'identifiant i + 1 à l'index i.
*/
function lireDirectement({ tours, reservations = [] }) {
const paires = new Map();
tours.forEach((tables, r) => {
tables.forEach((liste, t) => {
for (const x of liste) {
for (const y of liste) {
if (x >= y) continue;
const cle = `${x}-${y}`;
if (!paires.has(cle)) paires.set(cle, { x, y, tours: [], reservee: true });
const paire = paires.get(cle);
paire.tours.push({ tour: r, t });
if (!reserve(reservations, x, t + 1, r) || !reserve(reservations, y, t + 1, r)) paire.reservee = false;
}
}
});
});
return [...paires.values()];
}
// Les paires répétées attendues, en index, rangées par fois décroissant puis
// par index, à partir de la lecture directe.
function attendues(description, instance) {
const choisies = [];
const imposees = [];
for (const { x, y, tours, reservee } of lireDirectement(description)) {
if (tours.length < 2) continue;
const ligne = { a: instance.indexDe.get(x), b: instance.indexDe.get(y), fois: tours.length, tours };
(reservee ? imposees : choisies).push(ligne);
}
const ordre = (u, v) => v.fois - u.fois || u.a - v.a || u.b - v.b;
return { choisies: choisies.sort(ordre), imposees: imposees.sort(ordre) };
}
// Rencontres de chaque paire d'index, lues directement : comptes[a][b].
function comptesDirects(description, instance) {
const comptes = Array.from({ length: instance.N }, () => new Array(instance.N).fill(0));
for (const { x, y, tours } of lireDirectement(description)) {
const a = instance.indexDe.get(x);
const b = instance.indexDe.get(y);
comptes[a][b] = tours.length;
comptes[b][a] = tours.length;
}
return comptes;
}
// Plan de la configuration construite du § 12.5 : 12 personnes, 4 tables de
// 3, 6 tours. Chaque tour est une permutation des identifiants, tirée d'un
// générateur de Park et Miller semé par graine, découpée en tables de trois.
function planConstruit(graine) {
let etat = graine;
const suivant = () => {
etat = (etat * 48271) % 2147483647;
return etat;
};
return Array.from({ length: 6 }, () => {
const ids = Array.from({ length: 12 }, (_, i) => i + 1);
for (let i = ids.length - 1; i > 0; i -= 1) {
const j = suivant() % (i + 1);
[ids[i], ids[j]] = [ids[j], ids[i]];
}
return [0, 1, 2, 3].map((t) => ids.slice(3 * t, 3 * t + 3).sort((u, v) => u - v));
});
}
// Rang de la case (i, j), i < j, d'un triangle n × n rangé ligne par ligne,
// écrit ici en clair : la ligne i s'ouvre après n − 1, n − 2, … n − i cases.
function rangCase(n, i, j) {
let rang = 0;
for (let ligne = 0; ligne < i; ligne += 1) rang += n - 1 - ligne;
return rang + (j - i - 1);
}
// Réservées ensemble aux tours 1 et 2 à la table 1, Théo (1) et Ondine (2)
// sont réunis par le moteur au tour 3, à la table 2 : leur répétition est
// choisie. Maëlle (3) et Iris (4) se réunissent aux trois tours, sans
// réservation : choisie aussi.
const RESERVEES_PUIS_REUNIES = Object.freeze({
N: 4,
capacite: 2,
tours: [
[[1, 2], [3, 4]],
[[1, 2], [3, 4]],
[[3, 4], [1, 2]],
],
reservations: [auTour(1, 1, 1), auTour(2, 1, 1), auTour(1, 1, 2), auTour(2, 1, 2)],
});
// Théo (1) et Ondine (2), ancrés à la table 1, s'y retrouvent aux trois
// tours : répétition imposée. Maëlle (3) et Iris (4) se réunissent aux tours
// 1 et 3 à la table 2 : choisie. Maëlle passe le tour 2 à la table 1.
const ANCRES_ENSEMBLE = Object.freeze({
N: 4,
capacite: 3,
tours: [
[[1, 2], [3, 4]],
[[1, 2, 3], [4]],
[[1, 2], [3, 4]],
],
reservations: [tous(1, 1), tous(2, 1)],
});
// Cinq personnes, trois tours. Au tour 1, Iris (4) et Basile (5) occupent
// la table 1, les trois autres la table 2 ; aux tours 2 et 3, tous sont à la
// table 1. Les paires de {1, 2, 3} et la paire (4, 5) se voient trois fois,
// les six paires croisées deux fois. La première paire réunie est (4, 5),
// au tour 1, à la table 1 : un ordre de première rencontre la rangerait en
// tête.
const EGALITES = Object.freeze({
N: 5,
capacite: 5,
tours: [
[[4, 5], [1, 2, 3]],
[[1, 2, 3, 4, 5], []],
[[1, 2, 3, 4, 5], []],
],
});
// Trois paires imposées, toutes réservées ensemble à chacune de leurs
// rencontres. Au tour 1 : Maëlle (3) et Basile (5) à la table 1, Iris (4) et
// le sixième à la table 2, Théo (1) et Ondine (2), ancrés, à la table 3. Au
// tour 2, Iris rejoint Maëlle et Basile à la table 1 ; au tour 3, Maëlle et
// Iris y restent seuls. En index : (2, 4) deux fois, réunie la première ;
// (0, 1) trois fois, réunie ensuite ; (2, 3) deux fois, réunie au tour 2.
// L'ordre de première rencontre est (2, 4), (0, 1), (2, 3) ; le tri rend
// (0, 1), puis (2, 3) avant (2, 4), départagées par b.
const IMPOSEES_A_TRIER = Object.freeze({
N: 6,
capacite: 3,
tours: [
[[3, 5], [4, 6], [1, 2]],
[[3, 4, 5], [6], [1, 2]],
[[3, 4], [5, 6], [1, 2]],
],
reservations: [
tous(1, 3),
tous(2, 3),
tous(3, 1),
auTour(4, 1, 2),
auTour(4, 1, 3),
auTour(5, 1, 1),
auTour(5, 1, 2),
],
});
describe('paires : les paires répétées (§ 5.4, § 12.5)', () => {
test('réservées ensemble à deux tours et réunies par le moteur au troisième : la paire est choisie', () => {
const { instance, tableDe } = preparer(RESERVEES_PUIS_REUNIES);
assert.deepEqual(pairesRepetees(instance, tableDe), {
choisies: [
{
a: 0,
b: 1,
fois: 3,
tours: [
{ tour: 0, t: 0 },
{ tour: 1, t: 0 },
{ tour: 2, t: 1 },
],
},
{
a: 2,
b: 3,
fois: 3,
tours: [
{ tour: 0, t: 1 },
{ tour: 1, t: 1 },
{ tour: 2, t: 0 },
],
},
],
imposees: [],
});
});
test('réservées ensemble à tous leurs tours : la paire est imposée, et ne se range pas parmi les choisies', () => {
const { instance, tableDe } = preparer(ANCRES_ENSEMBLE);
assert.deepEqual(pairesRepetees(instance, tableDe), {
choisies: [
{
a: 2,
b: 3,
fois: 2,
tours: [
{ tour: 0, t: 1 },
{ tour: 2, t: 1 },
],
},
],
imposees: [
{
a: 0,
b: 1,
fois: 3,
tours: [
{ tour: 0, t: 0 },
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
],
},
],
});
});
test('les imposées se trient comme les choisies : fois décroissant, puis a, puis b, jamais la première rencontre', () => {
const { instance, tableDe } = preparer(IMPOSEES_A_TRIER);
assert.deepEqual(pairesRepetees(instance, tableDe), {
choisies: [],
imposees: [
{
a: 0,
b: 1,
fois: 3,
tours: [
{ tour: 0, t: 2 },
{ tour: 1, t: 2 },
{ tour: 2, t: 2 },
],
},
{
a: 2,
b: 3,
fois: 2,
tours: [
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
],
},
{
a: 2,
b: 4,
fois: 2,
tours: [
{ tour: 0, t: 0 },
{ tour: 1, t: 0 },
],
},
],
});
});
test("R = 1 : aucune paire ne se répète, quelle que soit l'occupation", () => {
const { instance, tableDe } = preparer({ N: 5, capacite: 5, tours: [[[1, 2, 3, 4, 5]]], reservations: [tous(1, 1)] });
assert.deepEqual(pairesRepetees(instance, tableDe), { choisies: [], imposees: [] });
});
test('égalité de fois : les index départagent, a puis b, jamais la première rencontre ni les noms', () => {
// fois 3 : (1, 2), (1, 3), (2, 3), (4, 5) ; fois 2 : (1, 4), (1, 5),
// (2, 4), (2, 5), (3, 4), (3, 5) — en index, un de moins.
const { instance, tableDe } = preparer(EGALITES);
const { choisies, imposees } = pairesRepetees(instance, tableDe);
assert.deepEqual(
choisies.map(({ a, b, fois }) => [a, b, fois]),
[
[0, 1, 3],
[0, 2, 3],
[1, 2, 3],
[3, 4, 3],
[0, 3, 2],
[0, 4, 2],
[1, 3, 2],
[1, 4, 2],
[2, 3, 2],
[2, 4, 2],
],
);
assert.deepEqual(choisies[3].tours, [
{ tour: 0, t: 0 },
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
]);
assert.deepEqual(imposees, []);
});
test('la réserve ne réunit personne : deux personnes en réserve au même tour ne se rencontrent pas', () => {
// Théo (1) et Ondine (2), ensemble aux tours 1 et 3, tous deux en réserve
// au tour 2 : deux rencontres, pas trois.
const { instance, tableDe } = preparer({
N: 3,
capacite: 3,
tours: [[[1, 2, 3]], [[3]], [[1, 2]]],
reserves: [[], [1, 2], [3]],
});
assert.deepEqual(pairesRepetees(instance, tableDe), {
choisies: [
{
a: 0,
b: 1,
fois: 2,
tours: [
{ tour: 0, t: 0 },
{ tour: 2, t: 0 },
],
},
],
imposees: [],
});
});
test('les paires se désignent par index : un exclu décale les index, pas les tours', () => {
// Ondine (2) est exclue : Théo (1) a l'index 0, Maëlle (3) l'index 1.
const { instance, tableDe } = preparer({ N: 3, capacite: 2, tours: [[[1, 3]], [[1, 3]]], exclus: [2] });
assert.deepEqual(pairesRepetees(instance, tableDe), {
choisies: [
{
a: 0,
b: 1,
fois: 2,
tours: [
{ tour: 0, t: 0 },
{ tour: 1, t: 0 },
],
},
],
imposees: [],
});
});
describe('la partition choisies / imposées égale la lecture directe et mesures.rencontresRepetees', () => {
const cas = [
['réservées puis réunies', RESERVEES_PUIS_REUNIES],
['ancrés ensemble', ANCRES_ENSEMBLE],
['égalités', EGALITES],
['imposées à trier', IMPOSEES_A_TRIER],
[
'une rencontre hors réservation, au milieu, rend la répétition choisie',
{
N: 3,
capacite: 3,
tours: [[[1, 2, 3]], [[1, 2, 3]], [[1, 2, 3]]],
reservations: [tous(1, 1), auTour(2, 1, 1), auTour(2, 1, 3), tous(3, 1)],
},
],
[
'deux partiellement fixés réservés ensemble à chaque rencontre',
{
N: 4,
capacite: 3,
tours: [
[[1, 2, 4], [3]],
[[1, 2], [3, 4]],
[[1, 2], [3, 4]],
],
reservations: [tous(2, 1), auTour(3, 2, 2), auTour(3, 2, 3), auTour(4, 2, 2), auTour(4, 2, 3)],
},
],
];
for (const [nom, description] of cas) {
test(nom, () => {
const { instance, tableDe } = preparer(description);
const paires = pairesRepetees(instance, tableDe);
assert.deepEqual(paires, attendues(description, instance));
assert.deepEqual(
{ choisies: paires.choisies.length, imposees: paires.imposees.length },
mesurer(instance, tableDe).rencontresRepetees,
);
});
}
});
test('un parcours déjà lu rend les mêmes paires que celui que la fonction lit elle-même', () => {
const { instance, tableDe } = preparer(ANCRES_ENSEMBLE);
const parcours = parcourirTours(instance, tableDe);
assert.deepEqual(pairesRepetees(instance, tableDe, parcours), pairesRepetees(instance, tableDe));
});
});
describe("paires : la ligne d'une personne (§ 12.5)", () => {
test("les personnes rencontrées, les plus revues d'abord, puis par index ; une seule rencontre compte", () => {
// Iris (4) : Basile (5) trois fois ; Théo, Ondine, Maëlle deux fois.
const { instance, tableDe } = preparer(EGALITES);
assert.deepEqual(rencontresDe(instance, tableDe, 3), [
{
q: 4,
fois: 3,
tours: [
{ tour: 0, t: 0 },
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
],
},
{
q: 0,
fois: 2,
tours: [
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
],
},
{
q: 1,
fois: 2,
tours: [
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
],
},
{
q: 2,
fois: 2,
tours: [
{ tour: 1, t: 0 },
{ tour: 2, t: 0 },
],
},
]);
// Maëlle (3) d'ANCRES_ENSEMBLE : Iris deux fois, Théo et Ondine une fois,
// au tour 2.
const ancres = preparer(ANCRES_ENSEMBLE);
assert.deepEqual(rencontresDe(ancres.instance, ancres.tableDe, 2), [
{
q: 3,
fois: 2,
tours: [
{ tour: 0, t: 1 },
{ tour: 2, t: 1 },
],
},
{ q: 0, fois: 1, tours: [{ tour: 1, t: 0 }] },
{ q: 1, fois: 1, tours: [{ tour: 1, t: 0 }] },
]);
});
test('une personne qui ne rencontre personne a une ligne vide', () => {
const { instance, tableDe } = preparer({ N: 3, capacite: 2, tours: [[[1, 2], [3]]] });
assert.deepEqual(rencontresDe(instance, tableDe, 2), []);
});
test('un parcours déjà lu rend la même ligne', () => {
const { instance, tableDe } = preparer(EGALITES);
assert.deepEqual(rencontresDe(instance, tableDe, 0, parcourirTours(instance, tableDe)), rencontresDe(instance, tableDe, 0));
});
test("un index hors de l'instance lève", () => {
const { instance, tableDe } = preparer(EGALITES);
for (const p of [-1, 5, 1.5, '1', null]) {
assert.throws(() => rencontresDe(instance, tableDe, p), RangeError);
}
});
});
describe('paires : la matrice des rencontres (§ 12.5)', () => {
test("le triangle dans l'ordre donné porte le compte exact de chaque paire, sans diagonale", () => {
// Ordre : Basile, Théo, Iris, Ondine, Maëlle (index 4, 0, 3, 1, 2).
// Basile–Théo 2, Basile–Iris 3, Basile–Ondine 2, Basile–Maëlle 2 ;
// Théo–Iris 2, Théo–Ondine 3, Théo–Maëlle 3 ; Iris–Ondine 2, Iris–Maëlle
// 2 ; Ondine–Maëlle 3.
const { instance, tableDe } = preparer(EGALITES);
const matrice = matriceRencontres(instance, tableDe, [4, 0, 3, 1, 2]);
assert.ok(Array.isArray(matrice));
assert.deepEqual(matrice, [2, 3, 2, 2, 2, 3, 3, 2, 2, 3]);
});
test('un compte au-delà de trois reste exact ; une paire jamais réunie vaut 0', () => {
// Théo et Ondine, ancrés ensemble à quatre tours ; Maëlle seule à la
// table 2.
const { instance, tableDe } = preparer({
N: 3,
capacite: 2,
tours: [
[[1, 2], [3]],
[[1, 2], [3]],
[[1, 2], [3]],
[[1, 2], [3]],
],
reservations: [tous(1, 1), tous(2, 1)],
});
assert.deepEqual(matriceRencontres(instance, tableDe, [0, 1, 2]), [4, 0, 0]);
});
test("un ordre d'une ou de zéro personne rend un triangle vide ; un sous-ensemble se lit dans son ordre", () => {
const { instance, tableDe } = preparer(EGALITES);
assert.deepEqual(matriceRencontres(instance, tableDe, []), []);
assert.deepEqual(matriceRencontres(instance, tableDe, [2]), []);
assert.deepEqual(matriceRencontres(instance, tableDe, [3, 4]), [3]);
});
test('un parcours déjà lu rend la même matrice', () => {
const { instance, tableDe } = preparer(EGALITES);
const ordre = [4, 3, 2, 1, 0];
assert.deepEqual(
matriceRencontres(instance, tableDe, ordre, parcourirTours(instance, tableDe)),
matriceRencontres(instance, tableDe, ordre),
);
});
test("un ordre qui répète une personne ou sort de l'instance lève", () => {
const { instance, tableDe } = preparer(EGALITES);
for (const ordre of [[0, 0], [0, 5], [-1, 0], [0.5, 1], ['0', 1]]) {
assert.throws(() => matriceRencontres(instance, tableDe, ordre), RangeError);
}
});
});
describe('paires : la configuration construite du § 12.5 (12 personnes, 4 tables de 3, 6 tours)', () => {
// Douze créneaux de voisinage pour onze autres : au moins une répétition
// pour chacun, quel que soit le plan. Vingt plans tirés.
for (let graine = 1; graine <= 20; graine += 1) {
test(`plan tiré de la graine ${graine}`, () => {
const description = { N: 12, capacite: 3, tours: planConstruit(graine) };
const { instance, tableDe } = preparer(description);
const paires = pairesRepetees(instance, tableDe);
assert.deepEqual(paires, attendues(description, instance));
assert.deepEqual(paires.imposees, []);
assert.deepEqual(
{ choisies: paires.choisies.length, imposees: 0 },
mesurer(instance, tableDe).rencontresRepetees,
);
const repetes = new Set(paires.choisies.flatMap(({ a, b }) => [a, b]));
assert.equal(repetes.size, 12);
// La ligne de chaque personne égale sa ligne de la matrice, lue dans
// un ordre qui n'est pas celui des index, et la lecture directe.
const comptes = comptesDirects(description, instance);
const ordre = Array.from({ length: 12 }, (_, i) => (5 * i + graine) % 12);
const matrice = matriceRencontres(instance, tableDe, ordre);
assert.equal(matrice.length, 66);
for (let i = 0; i < 12; i += 1) {
for (let j = i + 1; j < 12; j += 1) {
assert.equal(matrice[rangCase(12, i, j)], comptes[ordre[i]][ordre[j]]);
}
}
for (let p = 0; p < 12; p += 1) {
const ligne = rencontresDe(instance, tableDe, p);
assert.ok(ligne.some(({ fois }) => fois >= 2), `personne ${p} sans répétition`);
const position = ordre.indexOf(p);
for (let q = 0; q < 12; q += 1) {
if (q === p) continue;
const autre = ordre.indexOf(q);
const cellule = matrice[rangCase(12, Math.min(position, autre), Math.max(position, autre))];
const trouvee = ligne.find((rencontre) => rencontre.q === q);
assert.equal(trouvee === undefined ? 0 : trouvee.fois, cellule);
assert.equal(cellule, comptes[p][q]);
if (trouvee !== undefined) assert.equal(trouvee.tours.length, trouvee.fois);
}
for (let k = 1; k < ligne.length; k += 1) {
const [avant, apres] = [ligne[k - 1], ligne[k]];
assert.ok(avant.fois > apres.fois || (avant.fois === apres.fois && avant.q < apres.q));
}
}
});
}
});

View file

@ -35,6 +35,14 @@
// asseoir p à une table admissible n'ôte rien au plafond, puisque
// a_t ≥ 0 et c_t − 1 − a_t ≥ 0.
//
// La même formule borne aussi les affiliés que p rencontre (§ 5.5, B5) : la
// population comptée n'est plus tout le monde mais les seuls affiliés, et
// chaque terme se restreint à eux — N_aff − ε_p au lieu de N − 1, a^aff_t les
// ancrés affiliés, n^aff_p les mobiles affiliés, α_{t,r} les affiliés assis,
// ε_p valant 1 quand p est affilié, 0 sinon. C'est Φ_aff(p), le plafond
// réalisé des affiliés, d'où la borne de diversité A_max(p). Une population
// porte son effectif, ses mobiles, ses ancrés par table et ses membres.
//
// Une personne se désigne par son index dans l'instance, une table par son
// index ; un tableau rendu suit l'ordre de instance.ids. Aucune fonction ne
// modifie l'instance.
@ -42,30 +50,63 @@
// Une case d'itinéraire en réserve vaut RESERVE, et RESERVE vaut LIBRE
// (configuration.js) : une rangée de instance.fixe se lit comme l'itinéraire
// qui laisse en réserve chaque tour libre.
import { LIBRE, RESERVE, STATUT, decrire, exigerPlanIndexe } from './configuration.js';
import { LIBRE, RESERVE, SANS_GROUPE, STATUT, decrire, exigerPlanIndexe } from './configuration.js';
import { ErreurConfiguration } from './erreurs.js';
// Nombre de multiensembles au-delà duquel l'énumération refuse une
// signature.
const LIMITE_ENUMERATION = 300_000;
// La formule du § 5.5. ancres : Σ a_t sur les tables distinctes ; sieges :
// les sièges qui changent d'occupant ; nP : n_p.
function borne(instance, nP, ancres, sieges) {
return Math.min(instance.N - 1, ancres + Math.min(nP, sieges));
// La population de tout le monde : N personnes, n mobiles, a_t ancrés à la
// table t ; membres null dit que chacun en est.
const tous = (instance) => ({
effectif: instance.N,
mobiles: instance.n,
ancresParTable: instance.ancresParTable,
membres: null,
});
// La population des affiliés : N_aff personnes, n^aff mobiles, a^aff_t
// ancrés affiliés à la table t ; membres[p] vaut 1 pour un affilié.
function affiliesDe({ N, R, T, groupe, statut, fixe }) {
const membres = new Uint8Array(N);
const ancresParTable = new Int32Array(T);
let effectif = 0;
let mobiles = 0;
for (let p = 0; p < N; p += 1) {
if (groupe[p] === SANS_GROUPE) continue;
membres[p] = 1;
effectif += 1;
if (statut[p] === STATUT.ANCRE) ancresParTable[fixe[p * R]] += 1;
else mobiles += 1;
}
return { effectif, mobiles, ancresParTable, membres };
}
// n_p : les mobiles — partiellement fixés compris — que p peut rencontrer,
// p excepté quand il en est un.
const mobilesRencontrables = (instance, p) =>
instance.statut[p] === STATUT.ANCRE ? instance.n : instance.n - 1;
// ε_p : 1 quand p compte dans la population, 0 sinon.
const epsilon = (population, p) => (population.membres === null ? 1 : population.membres[p]);
// La formule du § 5.5, sur une population. ancres : Σ a_t sur les tables
// distinctes ; sieges : les sièges qui changent d'occupant ; nP : n_p. Le
// premier terme compte les autres membres de la population, p excepté quand
// il en est un : N − 1 pour tout le monde, N_aff − ε_p pour les affiliés.
function borne(population, eps, nP, ancres, sieges) {
return Math.min(population.effectif - eps, ancres + Math.min(nP, sieges));
}
// n_p : les mobiles de la population — partiellement fixés compris — que p
// peut rencontrer, p excepté quand il en est un.
const mobilesRencontrables = (instance, population, p, eps) =>
population.mobiles - (eps === 1 && instance.statut[p] !== STATUT.ANCRE ? 1 : 0);
// Table d'ancrage de p, −1 s'il n'est pas ancré.
const ancrageDe = (instance, p) =>
instance.statut[p] === STATUT.ANCRE ? instance.fixe[p * instance.R] : -1;
// a_t vu de p : les ancrés de la table t, p non compté.
const ancresVus = (instance, t, ancrage) => instance.ancresParTable[t] - (t === ancrage ? 1 : 0);
// a_t vu de p : les ancrés de la population à la table t, p non compté
// quand il en est un.
const ancresVus = (population, t, ancrage, eps) =>
population.ancresParTable[t] - (eps === 1 && t === ancrage ? 1 : 0);
// Tables qu'un tour libre peut recevoir, par index croissant : celles où les
// ancrés laissent au moins un siège.
@ -83,22 +124,24 @@ function dejaVisitee(cases, debut, r) {
}
// Plafond de p sur la rangée cases[debut … debut + R − 1], une table par
// tour. occupation porte o_{t,r} en [t × R + r] et fait la base du plafond
// réalisé ; null fait celle des capacités.
function plafondSurRangee(instance, p, cases, debut, occupation) {
// tour, pour la population donnée. occupation porte, en [t × R + r], les
// membres de la population assis à t au tour r et fait la base du plafond
// réalisé ; null fait celle des capacités, qui ne sert qu'à tout le monde.
function plafondSurRangee(instance, population, p, cases, debut, occupation) {
const { R, capacite } = instance;
const ancrage = ancrageDe(instance, p);
const eps = epsilon(population, p);
let ancres = 0;
let sieges = 0;
for (let r = 0; r < R; r += 1) {
const t = cases[debut + r];
if (t === RESERVE) continue;
const a = ancresVus(instance, t, ancrage);
const a = ancresVus(population, t, ancrage, eps);
const occupants = occupation === null ? capacite[t] : occupation[t * R + r];
sieges += occupants - 1 - a;
sieges += occupants - eps - a;
if (!dejaVisitee(cases, debut, r)) ancres += a;
}
return borne(instance, mobilesRencontrables(instance, p), ancres, sieges);
return borne(population, eps, mobilesRencontrables(instance, population, p, eps), ancres, sieges);
}
// Lève RangeError quand une case de l'itinéraire n'est ni RESERVE ni un index
@ -138,7 +181,38 @@ export function plafondItineraire(instance, p, itineraire) {
throw new RangeError(`itinéraire de ${itineraire.length} tours, ${R} attendus`);
}
exigerCases(itineraire, T);
return plafondSurRangee(instance, p, itineraire, 0, null);
return plafondSurRangee(instance, tous(instance), p, itineraire, 0, null);
}
/**
* L'occupation d'un plan (§ 5.4, B4), rangée t × R + r : occupants, o_{t,r},
* les personnes assises à la table t au tour r ; affilies, α_{t,r}, celles
* d'entre elles qui portent une appartenance, compté seulement sous l'option
* {affilies}, null sans elle. Un tour en réserve n'occupe aucune table. Des
* Array, jamais un tableau typé.
*
* Lève ce que lève exigerPlanIndexe (configuration.js).
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe plan indexé, [p × R + r]
* @param {{affilies?: boolean}} [options]
* @returns {{occupants: number[], affilies: number[]|null}}
*/
export function occupations(instance, tableDe, { affilies = false } = {}) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R, groupe } = instance;
const occupants = new Array(T * R).fill(0);
const assisAffilies = affilies ? new Array(T * R).fill(0) : null;
for (let p = 0; p < N; p += 1) {
const affilie = assisAffilies !== null && groupe[p] !== SANS_GROUPE;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) continue;
occupants[t * R + r] += 1;
if (affilie) assisAffilies[t * R + r] += 1;
}
}
return { occupants, affilies: assisAffilies };
}
/**
@ -154,22 +228,57 @@ export function plafondItineraire(instance, p, itineraire) {
* @returns {number[]}
*/
export function plafondsRealises(instance, tableDe) {
exigerPlanIndexe(instance, tableDe);
const { N, T, R } = instance;
const occupation = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) occupation[t * R + r] += 1;
}
}
const { occupants } = occupations(instance, tableDe);
const { N, R } = instance;
const population = tous(instance);
const plafonds = [];
for (let p = 0; p < N; p += 1) {
plafonds.push(plafondSurRangee(instance, p, tableDe, p * R, occupation));
plafonds.push(plafondSurRangee(instance, population, p, tableDe, p * R, occupants));
}
return plafonds;
}
/**
* Borne de diversité de chacun (§ 5.5, B5), sur l'occupation du plan :
*
* A_max(p) = min(Φ_aff(p), G, (N_aff − g_p) + 1) p affilié
* A_max(p) = min(Φ_aff(p), G) sinon
*
* Φ_aff(p), rendu en phiAffilies, est le plafond réalisé des affiliés :
* min(N_aff − ε_p, Σ_{t∈D} a^aff_t + min(n^aff_p, Σ_{(t,r)∈I} v^aff_{t,r})),
* v^aff_{t,r} = α_{t,r} − ε_p − a^aff_t, chaque terme compté sur les seuls
* affiliés, p non compté. Il borne |F(p)|, les affiliés que p rencontre. G
* compte les appartenances des personnes présentes (instance.groupes) ;
* N_aff, les présents affiliés ; g_p, l'effectif de l'appartenance de p.
*
* Chaque appartenance autre que celle de p compte au moins une personne :
* N_aff − g_p ≥ G − 1, et le troisième terme ne descend jamais sous G. Il
* s'écrit pour que le code se lise contre la formule du § 5.5.
*
* Deux Array dans l'ordre canonique. Lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe plan indexé, [p × R + r]
* @returns {{phiAffilies: number[], aMax: number[]}}
*/
export function borneDiversite(instance, tableDe) {
const { affilies } = occupations(instance, tableDe, { affilies: true });
const { N, R, groupe, groupes } = instance;
const G = groupes.length;
const population = affiliesDe(instance);
const effectifs = new Int32Array(G);
for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) effectifs[groupe[p]] += 1;
const phiAffilies = [];
const aMax = [];
for (let p = 0; p < N; p += 1) {
const phi = plafondSurRangee(instance, population, p, tableDe, p * R, affilies);
phiAffilies.push(phi);
const g = groupe[p];
aMax.push(g === SANS_GROUPE ? Math.min(phi, G) : Math.min(phi, G, population.effectif - effectifs[g] + 1));
}
return { phiAffilies, aMax };
}
// Clé de signature de p : statut, table d'ancrage, multiensemble trié des
// tables fixées. Le plafond a priori ne dépend que d'elle : la formule ne lit
// ni l'ordre des tours ni l'identité de p, et les tables admissibles sont les
@ -195,9 +304,11 @@ function signature(instance, p) {
// elle.
function maximiser(instance, p, admissibles) {
const { T, R, fixe, capacite } = instance;
const population = tous(instance);
const ancrage = ancrageDe(instance, p);
const nP = mobilesRencontrables(instance, p);
const sieges = (t) => capacite[t] - 1 - ancresVus(instance, t, ancrage);
const nP = mobilesRencontrables(instance, population, p, 1);
const vus = (t) => ancresVus(population, t, ancrage, 1);
const sieges = (t) => capacite[t] - 1 - vus(t);
const dansD0 = new Uint8Array(T);
let ancres0 = 0;
let sieges0 = 0;
@ -211,12 +322,12 @@ function maximiser(instance, p, admissibles) {
sieges0 += sieges(t);
if (dansD0[t] === 0) {
dansD0[t] = 1;
ancres0 += ancresVus(instance, t, ancrage);
ancres0 += vus(t);
}
}
// Sans tour libre, l'itinéraire est imposé ; sans table admissible, les
// tours libres restent en réserve.
if (f === 0 || admissibles.length === 0) return borne(instance, nP, ancres0, sieges0);
if (f === 0 || admissibles.length === 0) return borne(population, 1, nP, ancres0, sieges0);
// Les sièges ajoutés ne dépassent jamais f fois le plus grand v_t : au-delà
// de cette valeur, w n'a pas de case à remplir.
@ -227,7 +338,7 @@ function maximiser(instance, p, admissibles) {
let meilleur = new Int32Array((f + 1) * largeur).fill(-1);
meilleur[0] = 0;
for (const t of admissibles) {
const gain = dansD0[t] === 1 ? 0 : ancresVus(instance, t, ancrage);
const gain = dansD0[t] === 1 ? 0 : vus(t);
const v = sieges(t);
const suivant = meilleur.slice();
for (let s = 0; s < f; s += 1) {
@ -245,7 +356,7 @@ function maximiser(instance, p, admissibles) {
let plafond = -1;
for (let w = 0; w <= W; w += 1) {
const ajout = meilleur[f * largeur + w];
if (ajout >= 0) plafond = Math.max(plafond, borne(instance, nP, ancres0 + ajout, sieges0 + w));
if (ajout >= 0) plafond = Math.max(plafond, borne(population, 1, nP, ancres0 + ajout, sieges0 + w));
}
return plafond;
}

View file

@ -7,16 +7,18 @@
// démonstration et sa variante, arbitrées par l'énumération ; la limite de
// l'énumération à sa frontière exacte ; le coût du plafond a priori et du
// diagnostic sur la grande démonstration, sa variante et la configuration
// pessimiste, chacun sous un seuil tiré de sa référence (§ 17, point 4).
// pessimiste, chacun sous un seuil tiré de sa référence (§ 17, point 4) ; la
// borne de diversité contre chaque plan de deux salles inventées (§ 5.5).
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { configurationPessimiste, FORME_PESSIMISTE } from '../../test/fixtures/banc/pessimiste.js';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { LIBRE, STATUT, normaliser } from './configuration.js';
import { LIBRE, RESERVE, STATUT, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { ErreurConfiguration } from './erreurs.js';
import { plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js';
import { mesurer as mesurerPlan } from './indicateurs.js';
import { borneDiversite, plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
@ -332,3 +334,152 @@ describe("plafond a priori : le coût, sous un seuil tiré d'une référence (§
});
}
});
// Appartenances inventées des salles énumérées.
const AUBIER = 'Coopérative Aubier';
const RESSAC = 'Atelier Ressac';
const BRISANT = 'Collectif Brisant';
/**
* Instance d'une salle aux appartenances données : personnes[i] = [id,
* appartenance ou null] ; la table d'index t a la capacité capacites[t] et
* l'identifiant 100 + t ; reservations : [id, index de table, tour ou null],
* null pour tous les tours.
*/
function salle({ personnes, capacites, tours, reservations }) {
return normaliser({
participants: personnes.map(([id, appartenance]) => ({ id, nom: `P${id}`, appartenance })),
tables: capacites.map((capacite, t) => ({ id: 100 + t, numero: t + 1, capacite })),
tours,
reservations: reservations.map(([participant, t, tour]) =>
tour === null
? { participant, table: 100 + t, portee: 'tous' }
: { participant, table: 100 + t, portee: 'tour', tour },
),
contraintes: SANS_CONTRAINTE,
});
}
// Chaque affectation du tour r : les personnes fixées à leur table, chacune
// des autres, par index croissant, à chaque table qui garde un siège, puis en
// réserve quand reserve est vrai. Une affectation est un Int32Array de N
// cases, index de table ou RESERVE.
function affectationsDuTour({ N, T, R, fixe, capacite }, r, reserve) {
const sieges = Int32Array.from(capacite);
const courante = new Int32Array(N).fill(RESERVE);
const aPlacer = [];
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) {
aPlacer.push(p);
} else {
courante[p] = t;
sieges[t] -= 1;
}
}
const affectations = [];
const poser = (i) => {
if (i === aPlacer.length) {
affectations.push(Int32Array.from(courante));
return;
}
const p = aPlacer[i];
for (let t = 0; t < T; t += 1) {
if (sieges[t] === 0) continue;
sieges[t] -= 1;
courante[p] = t;
poser(i + 1);
sieges[t] += 1;
}
if (reserve) {
courante[p] = RESERVE;
poser(i + 1);
}
};
poser(0);
return affectations;
}
// Chaque plan de l'instance, sur le gabarit de plafondsAPrioriParEnumeration :
// une affectation par tour, les suites d'affectations dans l'ordre
// lexicographique, de (0, …, 0) au dernier rang de chaque tour. Appelle
// visiter(tableDe) pour chacun ; le même Int32Array sert d'un plan à l'autre.
// Rend le nombre de plans visités.
function enumererPlans(instance, reserve, visiter) {
const { N, R } = instance;
const parTour = Array.from({ length: R }, (_, r) => affectationsDuTour(instance, r, reserve));
const choix = new Int32Array(R);
const tableDe = new Int32Array(N * R);
let plans = 0;
for (;;) {
for (let r = 0; r < R; r += 1) {
const affectation = parTour[r][choix[r]];
for (let p = 0; p < N; p += 1) tableDe[p * R + r] = affectation[p];
}
visiter(tableDe);
plans += 1;
let r = R - 1;
while (r >= 0 && choix[r] === parTour[r].length - 1) r -= 1;
if (r < 0) return plans;
choix[r] += 1;
for (let q = r + 1; q < R; q += 1) choix[q] = 0;
}
}
describe('borne de diversité : chaque plan de deux salles inventées (§ 5.5, B5, § 14.12)', () => {
// Pour chaque plan : A(p) ≤ A_max(p) et |F(p)| ≤ Φ_aff(p) pour chacun ;
// sur l'ensemble des plans, Φ_aff(p) atteint pour une personne au moins.
// Rend les écarts, en lignes lisibles, les personnes qui atteignent leur
// Φ_aff non nul à un plan au moins, le nombre de plans et celui des plans
// qui laissent quelqu'un en réserve.
function eprouver(instance, reserve) {
const ecarts = [];
const atteintes = new Set();
let avecReserve = 0;
const plans = enumererPlans(instance, reserve, (tableDe) => {
const { phiAffilies, aMax } = borneDiversite(instance, tableDe);
const { appartenancesVues, affilies } = mesurerPlan(instance, tableDe);
if (tableDe.includes(RESERVE)) avecReserve += 1;
for (let p = 0; p < instance.N; p += 1) {
const id = instance.ids[p];
if (appartenancesVues[p] > aMax[p]) ecarts.push(`${tableDe.join(',')} : A(${id}) > A_max`);
if (affilies[p] > phiAffilies[p]) ecarts.push(`${tableDe.join(',')} : |F(${id})| > Φ_aff`);
if (phiAffilies[p] > 0 && affilies[p] === phiAffilies[p]) atteintes.add(id);
}
});
return { ecarts: ecarts.slice(0, 10), atteintes: [...atteintes].sort((x, y) => x - y), plans, avecReserve };
}
test('une table de 3 et une de 2, 2 tours, la réserve permise : un ancré affilié, un partiellement fixé, une personne sans appartenance', () => {
// 1 (Aubier) ancré à la table de 3 ; 3 (Ressac) fixé à la table de 2 au
// tour 1 ; 2 (Aubier), 4 (Ressac), 5 sans appartenance, libres.
const instance = salle({
personnes: [[1, AUBIER], [2, AUBIER], [3, RESSAC], [4, RESSAC], [5, null]],
capacites: [3, 2],
tours: 2,
reservations: [[1, 0, null], [3, 1, 1]],
});
const { ecarts, atteintes, plans, avecReserve } = eprouver(instance, true);
assert.deepEqual(ecarts, []);
// 19 affectations au tour 1, 63 au tour 2 ; tous sauf 18 laissent
// quelqu'un en réserve. Chacun atteint son Φ_aff à un plan au moins.
assert.deepEqual({ plans, avecReserve }, { plans: 1197, avecReserve: 1179 });
assert.deepEqual(atteintes, [1, 2, 3, 4, 5]);
});
test('trois tables de 2, 3 tours, chacun assis : un ancré seul de son appartenance, un partiellement fixé', () => {
// 1 (Brisant) ancré à la table d'index 2 ; 5 (Ressac) fixé à la table
// d'index 0 au tour 2 ; 2, 3, 6 (Aubier), 4 (Ressac), libres.
const instance = salle({
personnes: [[1, BRISANT], [2, AUBIER], [3, AUBIER], [4, RESSAC], [5, RESSAC], [6, AUBIER]],
capacites: [2, 2, 2],
tours: 3,
reservations: [[1, 2, null], [5, 0, 2]],
});
const { ecarts, atteintes, plans, avecReserve } = eprouver(instance, false);
assert.deepEqual(ecarts, []);
// 30 affectations aux tours 1 et 3, 12 au tour 2.
assert.deepEqual({ plans, avecReserve }, { plans: 10_800, avecReserve: 0 });
assert.deepEqual(atteintes, [1, 2, 3, 4, 5, 6]);
});
});

View file

@ -19,7 +19,10 @@ import {
planDepuisIndex,
} from './configuration.js';
import { ErreurConfiguration } from './erreurs.js';
import { mesurer } from './indicateurs.js';
import {
borneDiversite,
occupations,
plafondItineraire,
plafondsAPriori,
plafondsAPrioriParEnumeration,
@ -604,3 +607,273 @@ describe("plafond a priori : la programmation dynamique contre l'énumération (
assert.ok(tablesPleines >= 50, `${tablesPleines} instances à table pleine d'ancrés, 50 au moins`);
});
});
// Appartenances inventées des épreuves de la borne de diversité.
const AUBIER = 'Coopérative Aubier';
const RESSAC = 'Atelier Ressac';
const BRISANT = 'Collectif Brisant';
/**
* Instance d'une salle aux appartenances données. personnes[i] = [id,
* appartenance ou null] ; la table d'index t a la capacité capacites[t] et
* l'identifiant 10 × (T − t), qu'un index lu comme un identifiant ne désigne
* pas. reservations : [id, index de table, tour ou null], null pour tous les
* tours ; exclus : des ids. Lève ce que normaliser lève.
*/
function salle({ personnes, capacites, tours, reservations = [], exclus = [] }) {
const T = capacites.length;
const idTable = (t) => 10 * (T - t);
return normaliser({
participants: personnes.map(([id, appartenance]) => ({
id,
nom: `P${id}`,
appartenance,
...(exclus.includes(id) ? { exclu: true } : {}),
})),
tables: capacites.map((capacite, t) => ({ id: idTable(t), numero: t + 1, capacite })),
tours,
reservations: reservations.map(([participant, t, tour]) =>
tour === null
? { participant, table: idTable(t), portee: 'tous' }
: { participant, table: idTable(t), portee: 'tour', tour },
),
contraintes: SANS_CONTRAINTE,
});
}
// Vrai quand valeurs est une liste ordinaire, jamais un tableau typé : le
// détail ne rend que des Array (contrainte 42).
const listeOrdinaire = (valeurs) => Array.isArray(valeurs) && !ArrayBuffer.isView(valeurs);
// Les bornes de diversité d'un plan, et ses mesures : A(p) ≤ A_max(p) et
// |F(p)| ≤ Φ_aff(p) pour chacun, que chaque épreuve relit sur ses chiffres.
function bornesEtMesures(instance, tableDe) {
const bornes = borneDiversite(instance, tableDe);
const mesures = mesurer(instance, tableDe);
for (let p = 0; p < instance.N; p += 1) {
assert.ok(mesures.appartenancesVues[p] <= bornes.aMax[p], `A(${instance.ids[p]}) > A_max`);
assert.ok(mesures.affilies[p] <= bornes.phiAffilies[p], `|F(${instance.ids[p]})| > Φ_aff`);
}
return { ...bornes, appartenances: mesures.appartenancesVues, affilies: mesures.affilies };
}
describe('occupations : o_{t,r} et α_{t,r} (§ 5.4, B4)', () => {
// Ids 3, 5, 8, 11, 14 aux index 0 à 4 : Aubier, sans, Ressac, Aubier,
// sans. Deux tables de 3, d'identifiants 20 et 10 ; 14 en réserve au
// tour 1, 11 au tour 2.
const instance = salle({
personnes: [[3, AUBIER], [5, null], [8, RESSAC], [11, AUBIER], [14, null]],
capacites: [3, 3],
tours: 2,
});
const tableDe = planIndexe(instance, [[[3, 5, 8], [11]], [[3], [5, 8, 14]]], [[14], [11]]);
test("sans l'option, les occupants seuls, rangés t·R + r ; la réserve n'occupe aucune table", () => {
const { occupants, affilies } = occupations(instance, tableDe);
// t0 : 3 puis 1 ; t1 : 1 puis 3.
assert.deepEqual(occupants, [3, 1, 1, 3]);
assert.equal(affilies, null);
assert.ok(listeOrdinaire(occupants));
});
test('avec {affilies}, les occupants qui portent une appartenance', () => {
const { occupants, affilies } = occupations(instance, tableDe, { affilies: true });
// t0 : 3 et 8, puis 3 ; t1 : 11, puis 8 seul (5 et 14 sans appartenance).
assert.deepEqual(occupants, [3, 1, 1, 3]);
assert.deepEqual(affilies, [2, 1, 1, 1]);
assert.ok(listeOrdinaire(affilies));
assert.equal(occupations(instance, tableDe, { affilies: false }).affilies, null);
});
test('un plan indexé hors forme lève comme plafondsRealises', () => {
assert.throws(() => occupations(instance, new Int32Array(9)), RangeError);
const faux = Int32Array.from(tableDe);
faux[5] = 2;
assert.throws(() => occupations(instance, faux, { affilies: true }), /participant 8, tour 2/);
});
});
describe('borneDiversite : Φ_aff(p) et A_max(p) sur l\u{2019}occupation (§ 5.5, B5, D41)', () => {
test("tous d'une même appartenance : A_max = 1 pour qui rencontre quelqu'un, 0 pour qui ne rencontre personne", () => {
// Cinq personnes d'Aubier, une table de 4 et une de 2, deux tours : 10
// est seul à la table de 2 aux deux tours. N_aff = 5, G = 1, g_p = 5 :
// le troisième terme vaut 1. Pour 2, 4, 6, 8 : 3 sièges par tour, 6 ;
// n^aff_p = 4 ; Φ_aff = min(4, min(4, 6)) = 4. Pour 10 : 0 siège.
const instance = salle({
personnes: [[2, AUBIER], [4, AUBIER], [6, AUBIER], [8, AUBIER], [10, AUBIER]],
capacites: [4, 2],
tours: 2,
});
const tableDe = planIndexe(instance, [[[2, 4, 6, 8], [10]], [[2, 4, 6, 8], [10]]]);
const obtenu = bornesEtMesures(instance, tableDe);
assert.deepEqual(obtenu.phiAffilies, [4, 4, 4, 4, 0]);
assert.deepEqual(obtenu.aMax, [1, 1, 1, 1, 0]);
assert.deepEqual(obtenu.appartenances, [1, 1, 1, 1, 0]);
assert.ok(listeOrdinaire(obtenu.phiAffilies) && listeOrdinaire(obtenu.aMax));
});
test("Φ_aff l'emporte à une table pauvre en affiliés, G quand on en rencontre beaucoup", () => {
// Aubier 1, 2, 5 ; Ressac 3, 6 ; Brisant 7, 8 ; 4 sans appartenance.
// Deux tables de 4 : 1, 2, 3, 4 à l'une, 5 à 8 à l'autre. G = 3,
// N_aff = 7 ; troisième terme 5 pour Aubier, 6 pour les autres.
const personnes = [[1, AUBIER], [2, AUBIER], [3, RESSAC], [4, null], [5, AUBIER], [6, RESSAC], [7, BRISANT], [8, BRISANT]];
const listes = [[1, 2, 3, 4], [5, 6, 7, 8]];
// Un tour : à la première table α = 3, Φ_aff = 3 − 1 = 2 pour 1, 2
// et 3, sous G = 3 ; 4, sans appartenance, n'ôte rien : Φ_aff = 3. À la
// seconde, α = 4, Φ_aff = 3 pour chacun.
const unTour = salle({ personnes, capacites: [4, 4], tours: 1 });
const un = bornesEtMesures(unTour, planIndexe(unTour, [listes]));
assert.deepEqual(un.phiAffilies, [2, 2, 2, 3, 3, 3, 3, 3]);
assert.deepEqual(un.aMax, [2, 2, 2, 3, 3, 3, 3, 3]);
assert.deepEqual(un.appartenances, [2, 2, 1, 2, 2, 2, 3, 3]);
// Deux tours, les mêmes tables : Φ_aff double, plafonné par n^aff_p et
// N_aff − ε_p ; 5 monte à 6, au-dessus de G = 3 et de son troisième
// terme 5 : G borne seul.
const deuxTours = salle({ personnes, capacites: [4, 4], tours: 2 });
const deux = bornesEtMesures(deuxTours, planIndexe(deuxTours, [listes, listes]));
assert.deepEqual(deux.phiAffilies, [4, 4, 4, 6, 6, 6, 6, 6]);
assert.deepEqual(deux.aMax, [3, 3, 3, 3, 3, 3, 3, 3]);
});
test('le troisième terme égale G quand chaque autre appartenance a une seule personne, sans jamais descendre sous lui', () => {
// Aubier 1, 2, 3 ; Ressac 4 ; Brisant 5 ; une table de 5, deux tours.
// N_aff = 5 : pour Aubier, (5 − 3) + 1 = 3 = G ; pour 4 et 5,
// (5 − 1) + 1 = 5. Φ_aff = min(4, min(4, 8)) = 4 pour chacun.
// Chaque appartenance autre que celle de p compte au moins une
// personne : N_aff − g_p ≥ G − 1, et le terme ne passe jamais sous G.
const instance = salle({
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, RESSAC], [5, BRISANT]],
capacites: [5],
tours: 2,
});
const obtenu = bornesEtMesures(instance, planIndexe(instance, [[[1, 2, 3, 4, 5]], [[1, 2, 3, 4, 5]]]));
assert.deepEqual(obtenu.phiAffilies, [4, 4, 4, 4, 4]);
assert.deepEqual(obtenu.aMax, [3, 3, 3, 3, 3]);
assert.deepEqual(obtenu.appartenances, [3, 3, 3, 2, 2]);
});
test('seule de son appartenance (g_p = 1), p ne se compte pas parmi les affiliés qu\u{2019}elle rencontre', () => {
// Ressac 1 seule, avec Aubier 2, 3, 4 à une table de 4 ; Brisant 5 et 6
// à une table de 2 ; un tour. G = 3, N_aff = 6.
// 1 α = 4 : Φ_aff = min(5, min(5, 4 − 1)) = 3 ; terme (6 − 1) + 1
// 2 à 4 Φ_aff = 3, terme (6 − 3) + 1 = 4 ; A_max = 3
// 5, 6 α = 2 : Φ_aff = 1
const instance = salle({
personnes: [[1, RESSAC], [2, AUBIER], [3, AUBIER], [4, AUBIER], [5, BRISANT], [6, BRISANT]],
capacites: [4, 2],
tours: 1,
});
const obtenu = bornesEtMesures(instance, planIndexe(instance, [[[1, 2, 3, 4], [5, 6]]]));
assert.deepEqual(obtenu.phiAffilies, [3, 3, 3, 3, 1, 1]);
assert.deepEqual(obtenu.aMax, [3, 3, 3, 3, 1, 1]);
assert.deepEqual(obtenu.appartenances, [1, 2, 2, 2, 1, 1]);
assert.deepEqual(obtenu.affilies, [3, 3, 3, 3, 1, 1]);
});
test("ancrés affiliés : un par table visitée, une fois ; p ancré ne se compte pas ; un ancré sans appartenance n'est pas un affilié", () => {
// Trois tables de 3, trois tours. 1 (Aubier) ancré à t0, 2 (Ressac) à
// t1, 9 (sans appartenance) à t2 ; mobiles 3, 7 (Aubier), 4 (Ressac),
// 5, 8 (Brisant), 6 (sans). N_aff = 7, n^aff = 5, G = 3 ; a^aff vaut 1
// à t0 et t1, 0 à t2.
const instance = salle({
personnes: [[1, AUBIER], [2, RESSAC], [3, AUBIER], [4, RESSAC], [5, BRISANT], [6, null], [7, AUBIER], [8, BRISANT], [9, null]],
capacites: [3, 3, 3],
tours: 3,
reservations: [[1, 0, null], [2, 1, null], [9, 2, null]],
});
const tableDe = planIndexe(instance, [
[[1, 3, 4], [2, 5, 6], [9, 7, 8]],
[[1, 3, 8], [2, 4, 7], [9, 5, 6]],
[[1, 5, 7], [2, 3, 6], [9, 4, 8]],
]);
// α par tour : t0 3, 3, 3 ; t1 2, 3, 2 ; t2 2, 1, 2.
// 1 ancré à t0 : a^aff vu 0, sièges 2 + 2 + 2 ; n^aff_p = 5 → 5
// 2 ancré à t1 : sièges 1 + 2 + 1 = 4 → 4
// 3 t0, t0, t1 : ancrés 1 + 1 (t0 une fois) ; sièges 1 + 1 + 0 → 4
// 4 t0, t1, t2 : ancrés 2 ; sièges 1 + 1 + 1 → 5
// 5 t1, t2, t0 : ancrés 2 ; sièges 0 + 0 + 1 → 3
// 6 sans appartenance, t1, t2, t1 : ancrés 1 ; sièges 1 + 1 + 1 → 4
// 7 t2, t1, t0 : ancrés 2 ; sièges 1 + 1 + 1 → 5
// 8 t2, t0, t2 : ancrés 1 ; sièges 1 + 1 + 1 → 4
// 9 ancré sans appartenance à t2 : ancré vu 0, sièges 2 + 1 + 2 → 5
const obtenu = bornesEtMesures(instance, tableDe);
assert.deepEqual(obtenu.phiAffilies, [5, 4, 4, 5, 3, 4, 5, 4, 5]);
assert.deepEqual(obtenu.aMax, [3, 3, 3, 3, 3, 3, 3, 3, 3]);
// Φ_aff est atteint par 1, 2, 3, 4, 5, 7 et 8.
assert.deepEqual(obtenu.affilies, [5, 4, 4, 5, 3, 3, 5, 4, 4]);
});
test("mobiles affiliés : n^aff_p ne compte pas p, et borne quand les sièges dépassent", () => {
// 1 (Aubier) ancré à la table de 2 avec 5, sans appartenance ; 2
// (Aubier), 3 et 4 (Ressac) à la table de 5, deux tours. N_aff = 4,
// n^aff = 3, G = 2.
// 1 α = 1 à sa table : 0
// 2..4 sièges 2 + 2 = 4, n^aff_p = 2 : min(3, min(2, 4)) = 2
// 5 sans appartenance, ancré affilié 1 vu, α − a^aff = 0 : 1
const instance = salle({
personnes: [[1, AUBIER], [2, AUBIER], [3, RESSAC], [4, RESSAC], [5, null]],
capacites: [2, 5],
tours: 2,
reservations: [[1, 0, null]],
});
const obtenu = bornesEtMesures(instance, planIndexe(instance, [[[1, 5], [2, 3, 4]], [[1, 5], [2, 3, 4]]]));
assert.deepEqual(obtenu.phiAffilies, [0, 2, 2, 2, 1]);
assert.deepEqual(obtenu.aMax, [0, 2, 2, 2, 1]);
assert.deepEqual(obtenu.affilies, [0, 2, 2, 2, 1]);
});
test("table incomplète : l'occupation, jamais la capacité", () => {
// Deux tables de 4, occupées par 3 et 2 : Aubier 1, Ressac 2, 3 sans
// appartenance, puis Aubier 4, Ressac 5. G = 2, N_aff = 4. Sur les
// capacités, Φ_aff de 1 vaudrait 3 ; sur l'occupation, α = 2 : 1.
const instance = salle({
personnes: [[1, AUBIER], [2, RESSAC], [3, null], [4, AUBIER], [5, RESSAC]],
capacites: [4, 4],
tours: 1,
});
const obtenu = bornesEtMesures(instance, planIndexe(instance, [[[1, 2, 3], [4, 5]]]));
assert.deepEqual(obtenu.phiAffilies, [1, 1, 2, 1, 1]);
assert.deepEqual(obtenu.aMax, [1, 1, 2, 1, 1]);
});
test("personne en réserve : ses tours en réserve n'entrent ni dans son Φ_aff ni dans l'occupation des autres", () => {
// Aubier 1, 2 ; Ressac 3, 4 ; 5 sans appartenance ; deux tables de 3,
// deux tours ; 1 en réserve au tour 2.
// 1 tour 1 seul, α = 3 : 2 ; A_max = min(2, 2, 3) = 2
// 2 α = 3 puis 3 : min(3, min(3, 4)) = 3
// 3 idem : 3
// 4 α = 1 (5 sans appartenance) puis 3 : 0 + 2 = 2
// 5 α = 1 puis 0 : 1
const instance = salle({
personnes: [[1, AUBIER], [2, AUBIER], [3, RESSAC], [4, RESSAC], [5, null]],
capacites: [3, 3],
tours: 2,
});
const tableDe = planIndexe(instance, [[[1, 2, 3], [4, 5]], [[2, 3, 4], [5]]], [[], [1]]);
const obtenu = bornesEtMesures(instance, tableDe);
assert.deepEqual(obtenu.phiAffilies, [2, 3, 3, 2, 1]);
assert.deepEqual(obtenu.aMax, [2, 2, 2, 2, 1]);
});
test("G compte les appartenances des seules personnes présentes : un exclu n'en apporte aucune", () => {
// Aubier 1, 2, 3 et 4 sans appartenance à une table de 4 ; 5, seul de
// Ressac, exclu. G = 1 : A_max(4) = min(Φ_aff = 3, 1) = 1.
const instance = salle({
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, null], [5, RESSAC]],
capacites: [4],
tours: 1,
exclus: [5],
});
assert.deepEqual(instance.groupes, [AUBIER]);
const obtenu = bornesEtMesures(instance, planIndexe(instance, [[[1, 2, 3, 4]]]));
assert.deepEqual(obtenu.phiAffilies, [2, 2, 2, 3]);
assert.deepEqual(obtenu.aMax, [1, 1, 1, 1]);
});
test('sans aucune appartenance, Φ_aff et A_max sont nuls ; un plan hors forme lève', () => {
const { instance } = forme({ capacites: [3, 3], ancres: [1, 0], autres: 4, tours: 2 });
const tableDe = planBrasse(instance, 3);
assert.deepEqual(borneDiversite(instance, tableDe), { phiAffilies: [0, 0, 0, 0, 0], aMax: [0, 0, 0, 0, 0] });
assert.throws(() => borneDiversite(instance, tableDe.subarray(1)), RangeError);
});
});

View file

@ -125,6 +125,18 @@
// pas : les échanges de même score y sont acceptés, un score courant
// inchangé y marque aussi bien un plateau que la descente parcourt, et une
// relance couperait ce parcours, d'autant plus souvent que L est court.
//
// La course (§ 5.10). Chaque proposition rapporte ses mouvements évalués —
// le compte d'arrêt, moins quand le n-uplet devient nul, zéro quand le
// placement initial l'est déjà — et ses points de relevé : la meilleure
// affectation copiée après k mouvements évalués, pour chaque puissance de
// dix k strictement sous le dernier. Ce dernier point est le plan de la
// proposition, qui ne se copie pas : au plus ⌊log₁₀ arret⌋ + 1 copies. La
// copie se prend à la fin de l'itération de rang k − 1, avant la relance
// que l'itération suivante peut faire ; elle ne tire rien et ne change
// aucune comparaison. La boucle ne lit le compte d'arrêt qu'à sa borne :
// le point k est donc le plan que la même graine rend au compte d'arrêt k.
// La recherche ne lit pas l'horloge et ne date rien.
import { FLUX, creerPcg32 } from '../demo/prng.js';
import {
LIBRE,
@ -154,6 +166,11 @@ import { plafondsAPriori } from './plafond.js';
* @property {number} arret
* @property {number} historique longueur de l'historique d'acceptation
* @property {import('./types.js').Plan} plan
* @property {number} evalues mouvements évalués, de 0 à arret
* @property {{mouvements: number, plan: import('./types.js').Plan}[]} releves
* les points de relevé copiés, mouvements croissants : chaque
* puissance de dix strictement sous evalues ; le dernier point,
* evalues, est plan
*
* @typedef {Object} EtatRecherche
* Rendu par creerEtat ; les champs qui suivent se lisent, aucun ne s'écrit
@ -756,7 +773,10 @@ function lireSignal(signal) {
}
// Descente par acceptation tardive depuis un placement initial tiré de rng ;
// rend la meilleure affectation vue, en plan indexé. L'ordre d'acceptation
// rend { plan, evalues, releves } : la meilleure affectation vue, en plan
// indexé, les mouvements évalués, et les copies de la meilleure affectation
// aux points de relevé, { mouvements, tableDe }, mouvements croissants (en-tête
// du module, « La course »). L'ordre d'acceptation
// suit la meilleure affectation (ordreAccepte). Dans l'ordre des
// contraintes, un score courant inchangé pendant PATIENCE × historique
// itérations fait relancer la descente (en-tête du module). La bascule vers
@ -789,7 +809,12 @@ function descendre(structure, rng, { arret, historique, dejaFaits, total, signal
let ordre = ordreAccepte(forme, meilleurScore);
const patience = PATIENCE * historique;
let immobile = 0;
for (let i = 0; i < arret && !estNul(courant); i += 1) {
// releve : la prochaine puissance de dix ; i, à la sortie de la boucle, les
// mouvements évalués.
const releves = [];
let releve = 1;
let i = 0;
for (; i < arret && !estNul(courant); i += 1) {
if (immobile >= patience && ordre === ordreContraintes) {
etat = etatInitial(structure, rng);
composer(forme, etat, courant);
@ -813,32 +838,55 @@ function descendre(structure, rng, { arret, historique, dejaFaits, total, signal
}
memoire.set(courant, v);
immobile = change ? 0 : immobile + 1;
// Après releve mouvements évalués : la copie se garde quand la boucle
// continue, la borne de la boucle relue ; sinon ce point est le plan rendu.
if (i + 1 === releve) {
if (i + 1 < arret && !estNul(courant)) {
releves.push({ mouvements: releve, tableDe: Int32Array.from(meilleurPlan) });
}
releve *= 10;
}
const fait = dejaFaits + i + 1;
if (fait % CADENCE === 0) {
progression?.(fait, total);
lireSignal(signal);
}
}
return meilleurPlan;
return { plan: meilleurPlan, evalues: i, releves };
}
// Le plan de la proposition de graine dérivée graineDerivee : la descente
// depuis creerPcg32(graineDerivee, FLUX.RECHERCHE), rendue par identifiants.
// rechercher et regenerer l'appellent tous deux. À instance égale, le plan ne
// dépend que de la graine dérivée, d'options.arret et d'options.historique :
// les autres options ne servent qu'à rapporter l'avancement et à lire le
// signal.
function proposer(instance, structure, graineDerivee, options) {
// La descente de la proposition de graine dérivée graineDerivee, depuis
// creerPcg32(graineDerivee, FLUX.RECHERCHE), en plans indexés (descendre).
// rechercher, proposerUne et regenerer l'appellent tous trois. À instance
// égale, le résultat ne dépend que de la graine dérivée, d'options.arret et
// d'options.historique : les autres options ne servent qu'à rapporter
// l'avancement et à lire le signal.
function proposer(structure, graineDerivee, options) {
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
return planDepuisIndex(instance, descendre(structure, rng, options));
return descendre(structure, rng, options);
}
// La proposition d'identifiant id : ses trois réglages, puis le plan, les
// mouvements évalués et les points de relevé de sa descente, chaque plan
// rendu par identifiants.
const propositionDe = (instance, { id, graine, arret, historique }, { plan, evalues, releves }) => ({
id,
graine,
arret,
historique,
plan: planDepuisIndex(instance, plan),
evalues,
releves: releves.map(({ mouvements, tableDe }) => ({ mouvements, plan: planDepuisIndex(instance, tableDe) })),
});
/**
* Propositions de placement d'une configuration (§ 5.7) : nombre
* propositions, d'identifiants 1 à nombre, chacune la meilleure affectation
* vue en arret mouvements évalués, relances comprises (en-tête du module).
* Chacune porte sa graine dérivée, arret et historique, défaut résolu : les
* trois décident de son plan, que regenerer rend à partir d'eux. À
* trois décident de son plan, que regenerer rend à partir d'eux. Elle porte
* aussi sa course, evalues et releves (en-tête du module) : le point de
* relevé k est le plan que regenerer rend au compte d'arrêt k. À
* configuration et réglages égaux, le résultat est identique. Ne modifie pas
* la configuration.
*
@ -872,7 +920,7 @@ export function rechercher(configuration, reglages, { signal, progression } = {}
for (let id = 1; id <= nombre; id += 1) {
lireSignal(signal);
const graineDerivee = graines.suivant();
const plan = proposer(instance, structure, graineDerivee, {
const descente = proposer(structure, graineDerivee, {
arret,
historique,
dejaFaits: (id - 1) * arret,
@ -880,7 +928,7 @@ export function rechercher(configuration, reglages, { signal, progression } = {}
signal,
progression,
});
propositions.push({ id, graine: graineDerivee, arret, historique, plan });
propositions.push(propositionDe(instance, { id, graine: graineDerivee, arret, historique }, descente));
}
return propositions;
}
@ -920,7 +968,7 @@ export function proposerUne(configuration, reglages, rang, { signal, progression
let graineDerivee = 0;
for (let tirage = 1; tirage <= rang; tirage += 1) graineDerivee = graines.suivant();
lireSignal(signal);
const plan = proposer(instance, structure, graineDerivee, {
const descente = proposer(structure, graineDerivee, {
arret,
historique,
dejaFaits: 0,
@ -928,7 +976,7 @@ export function proposerUne(configuration, reglages, rang, { signal, progression
signal,
progression,
});
return { id: rang, graine: graineDerivee, arret, historique, plan };
return propositionDe(instance, { id: rang, graine: graineDerivee, arret, historique }, descente);
}
/**
@ -956,7 +1004,8 @@ export function verifierReglages({ graine, arret, nombre, historique = HISTORIQU
* elle-même fait aussi partie du résultat, ordre des tirages, PATIENCE et
* ordres d'acceptation : une proposition rendue par une autre version de ce
* module n'est pas garantie régénérable (§ 8.9). Aucun autre champ n'est lu,
* ni id ni plan. Ne modifie ni la configuration ni la proposition.
* ni id ni plan. Rend le seul plan, sans la course que rechercher joint à
* la proposition. Ne modifie ni la configuration ni la proposition.
*
* Lève RangeError quand l'un des trois champs n'est pas un entier de son
* domaine, graine de 0 à 2^32 − 1, arret et historique ≥ 1 : un champ absent
@ -971,12 +1020,13 @@ export function verifierReglages({ graine, arret, nombre, historique = HISTORIQU
export function regenerer(configuration, { graine, arret, historique }) {
exigerChampsProposition({ graine, arret, historique });
const instance = normaliser(configuration);
return proposer(instance, preparer(instance), graine, {
const { plan } = proposer(preparer(instance), graine, {
arret,
historique,
dejaFaits: 0,
total: arret,
});
return planDepuisIndex(instance, plan);
}
/**

View file

@ -14,7 +14,9 @@
// chacun des trois modes et sous un ordre déplacé (A13). La petite démonstration et sa
// variante conflit atteignent ensuite leur optimum sur vingt graines, trois
// propositions chacune ; une recherche longue sur la grande démonstration
// rend des plans que jugent verifierInvariants et verifierIndicateurs.
// rend des plans que jugent verifierInvariants et verifierIndicateurs, et
// chaque point de relevé de sa course égale le plan que regenerer rend à son
// compte.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
@ -27,6 +29,7 @@ import {
defaire,
proposerEtAppliquer,
rechercher,
regenerer,
scoreComplet,
scoreIncremental,
} from './recherche.js';
@ -278,3 +281,31 @@ describe('recherche : le mode ne touche pas au défaut, grande démonstration et
}
}
});
// Les puissances de dix strictement sous n, croissantes : les abscisses des
// points de relevé copiés d'une descente de n mouvements évalués.
function puissancesSous(n) {
const liste = [];
for (let k = 1; k < n; k *= 10) liste.push(k);
return liste;
}
describe('recherche : la course sur la grande démonstration (§ 5.10, B6, D40)', () => {
// Arrêt au compte : 200 000 mouvements évalués, six points copiés, de 1 à
// 100 000, chacun le plan que la même graine rend à son compte ; le
// dernier point, 200 000, est le plan de la proposition.
for (const cle of ['grande', 'grande-sans-exception']) {
test(`${cle}, arret 200 000 : le point de relevé k égale regenerer au compte k, de 1 à 100 000`, () => {
const configuration = CATALOGUE.find((entree) => entree.cle === cle).construire();
const [proposition] = rechercher(configuration, { graine: 31, arret: 200_000, nombre: 1 });
const { graine, historique, evalues, releves, plan } = proposition;
assert.equal(evalues, 200_000);
assert.deepEqual(releves.map(({ mouvements }) => mouvements), puissancesSous(evalues));
assert.equal(releves.length, 6);
for (const { mouvements, plan: copie } of releves) {
assert.deepStrictEqual(copie, regenerer(configuration, { graine, arret: mouvements, historique }), `${cle}, point ${mouvements}`);
}
assert.deepStrictEqual(plan, regenerer(configuration, { graine, arret: evalues, historique }));
});
}
});

View file

@ -1542,3 +1542,150 @@ describe('rechercher : le mode ne touche pas au défaut, sans objectif (A21, con
});
}
});
// --- La course de la recherche (§ 5.10, B6, D40) ------------------------------
// Les puissances de dix strictement sous n, croissantes : les abscisses des
// points de relevé copiés d'une descente de n mouvements évalués. Le compte
// se fait en entiers, sans logarithme flottant.
function puissancesSous(n) {
const liste = [];
for (let k = 1; k < n; k *= 10) liste.push(k);
return liste;
}
// ⌊log₁₀ n⌋ + 1 pour un entier n ≥ 1 : le nombre de ses chiffres décimaux.
const chiffres = (n) => String(n).length;
// Confronte la course d'une proposition à regenerer : ses abscisses sont les
// puissances de dix strictement sous evalues ; chaque point de relevé k est
// le plan que la même graine rend au compte d'arrêt k ; le dernier point,
// evalues, est le plan de la proposition. Rend le nombre de points copiés.
function confronterCourse(configuration, proposition, nom) {
const { graine, historique, evalues, releves, plan } = proposition;
assert.ok(Number.isSafeInteger(evalues) && evalues >= 0, `${nom} : evalues ${evalues}`);
assert.deepEqual(releves.map(({ mouvements }) => mouvements), puissancesSous(evalues), nom);
for (const releve of releves) {
assert.deepEqual(Object.keys(releve), ['mouvements', 'plan'], nom);
assert.deepStrictEqual(
releve.plan,
regenerer(configuration, { graine, arret: releve.mouvements, historique }),
`${nom}, point ${releve.mouvements}`,
);
}
if (evalues > 0) {
assert.deepStrictEqual(plan, regenerer(configuration, { graine, arret: evalues, historique }), `${nom}, dernier point`);
}
return releves.length;
}
// Six personnes « finance », une table sans objectif de deux rangée
// d'abord, une table « finance » de six, un tour, sous minimum : le plan
// initial assied deux personnes à la première table, et la descente
// s'arrête sur le n-uplet nul, toutes à la seconde, avant son compte.
const soireeQuiSAnnule = () => ({
priorites: [[e('toursHorsObjectif')], [e('ecartAuPlafondAPrioriMax')], ...parDefaut().slice(1).filter(([{ critere }]) => critere !== 'toursHorsObjectif')],
participants: Array.from({ length: 6 }, (_, rang) => ({ id: rang + 1, nom: `F${rang + 1}`, appartenance: null, exclu: false, etiquettes: ['finance'] })),
tables: [{ id: 1, numero: 1, capacite: 2 }, { id: 2, numero: 2, capacite: 6 }],
tours: 1,
reservations: [],
contraintes: { ...SANS_CONTRAINTE },
objectifs: [{ table: 2, tour: null, regle: 'ou', etiquettes: ['finance'] }],
modeObjectifs: 'minimum',
});
describe('rechercher : la course de la recherche (§ 5.10, B6, D40)', () => {
test("chaque proposition porte ses mouvements évalués et ses points de relevé, après son plan ; proposerUne rend les mêmes", () => {
const reglages = { graine: 5, arret: 2_500, nombre: 3, historique: 37 };
const propositions = rechercher(demo('petite-conflit'), reglages);
for (const proposition of propositions) {
assert.deepEqual(Object.keys(proposition), ['id', 'graine', 'arret', 'historique', 'plan', 'evalues', 'releves']);
assert.deepStrictEqual(proposerUne(demo('petite-conflit'), reglages, proposition.id), proposition);
}
});
test("arrêt au compte : le point de relevé k égale regenerer au compte k (1, 10, 100, 1 000), et le plan rendu celui du compte entier — salle réservée, variante conflit avec bascule et relance, historique 37", () => {
// Dans la variante conflit, chaque descente bascule puis relance avant
// 5 000 mouvements (épreuve de regenerer plus haut) : un point copié
// pendant l'ordre des contraintes, ou juste après une relance, doit
// encore égaler la régénération à son compte.
const reglages = { graine: 5, arret: 5_000, nombre: 3, historique: 37 };
let points = 0;
for (const [nom, construire] of [
['salleReservee', salleReservee],
['petite-conflit', () => demo('petite-conflit')],
]) {
const configuration = construire();
for (const proposition of rechercher(configuration, reglages)) {
assert.equal(proposition.evalues, reglages.arret, `${nom}, proposition ${proposition.id}`);
points += confronterCourse(configuration, proposition, `${nom}, proposition ${proposition.id}`);
}
}
assert.equal(points, 6 * 4);
});
test("arrêt sur le n-uplet nul : moins de mouvements évalués que le compte, des points sous ce nombre seulement, chacun égal à regenerer", () => {
const configuration = soireeQuiSAnnule();
const propositions = rechercher(configuration, { graine: 1, arret: 100_000, nombre: 4 });
for (const proposition of propositions) {
assert.ok(proposition.evalues > 0 && proposition.evalues < 100_000, `evalues ${proposition.evalues}`);
confronterCourse(configuration, proposition, `proposition ${proposition.id}`);
}
// Au moins une descente dure plus d'un mouvement : la liste de ses points
// n'est pas vide, et la confrontation porte sur autre chose que le plan.
assert.ok(propositions.some(({ releves }) => releves.length > 0));
// Une descente qui s'annule pile à une puissance de dix : ce dernier point
// est le plan rendu, non copié ; seules les puissances strictement sous
// lui sont des points de relevé.
for (const [graine, evalues, attendus] of [[39, 10, [1]], [144, 100, [1, 10]]]) {
const [proposition] = rechercher(configuration, { graine, arret: 100_000, nombre: 1 });
assert.equal(proposition.evalues, evalues, `graine ${graine}`);
assert.deepEqual(proposition.releves.map(({ mouvements }) => mouvements), attendus, `graine ${graine}`);
confronterCourse(configuration, proposition, `graine ${graine}`);
}
});
test("plan initial au n-uplet nul : aucun mouvement évalué, aucun point de relevé", () => {
for (const proposition of rechercher(troisTablesDeDeux(), { graine: 8, arret: 50_000, nombre: 2 })) {
assert.equal(proposition.evalues, 0);
assert.deepEqual(proposition.releves, []);
}
});
test("compte 1 : un mouvement, aucun point copié ; compte 1 000 : 1, 10 et 100, le point 1 000 étant le plan ; compte 2 500 : 1, 10, 100 et 1 000", () => {
const configuration = demo('grande');
const cas = [
[1, []],
[1_000, [1, 10, 100]],
[2_500, [1, 10, 100, 1_000]],
];
for (const [arret, attendus] of cas) {
const [proposition] = rechercher(configuration, { graine: 3, arret, nombre: 1 });
assert.equal(proposition.evalues, arret, `compte ${arret}`);
assert.deepEqual(proposition.releves.map(({ mouvements }) => mouvements), attendus, `compte ${arret}`);
confronterCourse(configuration, proposition, `compte ${arret}`);
}
});
test("au plus ⌊log₁₀ arret⌋ + 1 copies, autour de chaque puissance de dix", () => {
const configuration = demo('petite-conflit');
const comptes = [1, 2, 9, 10, 11, 99, 100, 101, 999, 1_000, 1_001];
for (const arret of comptes) {
for (const proposition of rechercher(configuration, { graine: 13, arret, nombre: 2, historique: 50 })) {
assert.ok(proposition.releves.length <= chiffres(arret), `compte ${arret} : ${proposition.releves.length} copies`);
assert.deepEqual(proposition.releves.map(({ mouvements }) => mouvements), puissancesSous(proposition.evalues), `compte ${arret}`);
}
}
assert.equal(chiffres(999), 3);
assert.equal(chiffres(1_000), 4);
});
test("regenerer rend le seul plan, sans la course : ses clés sont celles du plan d'une proposition", () => {
const configuration = demo('petite-conflit');
const [proposition] = rechercher(configuration, { graine: 2, arret: 300, nombre: 1 });
const plan = regenerer(configuration, proposition);
assert.deepEqual(Object.keys(plan), Object.keys(proposition.plan));
assert.deepStrictEqual(plan, proposition.plan);
assert.equal('evalues' in plan || 'releves' in plan, false);
});
});

View file

@ -83,6 +83,17 @@
* sans appartenance
* @property {string[]} groupes index de groupe → libellé ; ordre de première
* apparition en parcourant les ids croissants
* @property {Int32Array} debutEtiquettes N + 1 cases : les étiquettes de la
* personne p vont, dans etiquettes, de
* debutEtiquettes[p] à debutEtiquettes[p + 1] ;
* que des zéros sans étiquette
* @property {Int32Array} etiquettes index dans libellesEtiquettes des
* étiquettes distinctes de chacun,
* croissants, personne par personne
* @property {string[]} libellesEtiquettes index d'étiquette → libellé : les
* étiquettes distinctes des présents,
* rangées par unités de code ; [] sans
* étiquette
* @property {Int32Array} fixe [p * R + r] → index de table imposé (r à
* partir de 0), LIBRE (−1) si libre
* @property {Uint8Array} statut STATUT.MOBILE (0), STATUT.PARTIELLEMENT_FIXE
@ -116,7 +127,11 @@
* debutAnimes[t * R + r + 1]
* @property {Int32Array} animes rangs des animateurs qui animent à la table
* t au tour r, croissants, ligne par ligne
* @property {Int32Array} borneAnimateurs B(p) = min(#Φ(p), Σ_r φ_r(p)) (D21)
* @property {Int32Array} borneAnimateurs B(p), la borne des animateurs
* (§ 4.5, D21) : le plus petit du nombre
* d'animateurs que p peut rejoindre et de la
* somme, sur les tours, du plus grand nombre
* d'animateurs à une table qu'il peut occuper
* @property {Array<{nom: string, coche: boolean, sansObjet: boolean,
* valeurs: string[]}>} etatCriteres
* un état par critère, dans l'ordre de CRITERES (criteres.js)

View file

@ -14,9 +14,26 @@ import { LIBRE } from './configuration.js';
* @typedef {{code: string, participant?: number, table?: number, tour?: number,
* detail?: string}} Violation ids, tours numérotés à partir de 1
*
* @typedef {{code: string, index?: number}} ViolationIndicateur
* @typedef {{code: string, index?: number, groupe?: number,
* tour?: number}} ViolationIndicateur
* index : rang de la personne dans l'ordre canonique, celui de
* instance.ids ; absent d'une violation qui porte sur un total
* instance.ids ; absent d'une violation qui porte sur un total.
* groupe : rang de l'appartenance dans instance.groupes, à partir
* de 0 ; tour : numéroté à partir de 1. Seuls les restes les portent
*
* @typedef {Object} ComplementsIndicateurs ce que la page de qualité ajoute
* @property {boolean} planchersValides vrai quand le plan assoit chacun à
* chaque tour et honore les réservations : les planchers n'y valent
* que là (§ 12.4)
* @property {ArrayLike<number>|null} planchersCollisions plancher des
* collisions cumulées par appartenance, ordre de instance.groupes,
* 0 quand le diagnostic se tait
* @property {Array<{hors: number|null, plancher: number|null}>|null}
* objectifsParTour un élément par tour ; un tour sans couple
* possible porte hors et plancher tous deux null, et la garde ne
* le lit pas
* @property {ArrayLike<number>|null} phiAffilies Φ_aff(p), borneDiversite
* @property {ArrayLike<number>|null} aMax A_max(p), borneDiversite
*/
// Index de table d'une liste que plan.tables ne rattache à aucune table de
@ -206,6 +223,80 @@ function exigerLongueur(valeurs, N, nom, attendu) {
}
}
// Les dépassements de la diversité d'une personne, ajoutés à violations,
// quand la garde a ses compléments : A(p) ≤ rencontres(p), A(p) ≤ |F(p)|,
// |F(p)| ≤ Φ_aff(p), A(p) ≤ A_max(p), les deux derniers quand la borne est
// donnée. complements vaut null sans compléments.
function verifierDiversite(p, rencontres, mesures, complements, violations) {
if (complements === null) return;
const { appartenancesVues: vues, affilies } = mesures;
if (!tient(rencontres[p], vues[p])) violations.push({ code: 'DIVERSITE_RENCONTRES', index: p });
if (!tient(affilies[p], vues[p])) violations.push({ code: 'DIVERSITE_AFFILIES', index: p });
const { phiAffilies, aMax } = complements;
if (phiAffilies !== null && !tient(phiAffilies[p], affilies[p])) violations.push({ code: 'AFFILIES_PLAFOND', index: p });
if (aMax !== null && !tient(aMax[p], vues[p])) violations.push({ code: 'BORNE_DIVERSITE', index: p });
}
// Les compléments lus, chacun null quand il manque ; null sans compléments.
// Lève TypeError quand complements n'est ni un objet ni null, que
// planchersValides n'est pas un booléen, qu'une liste attendue n'en est pas
// une, qu'un élément de objectifsParTour n'est pas un objet, ou que mesures
// n'a pas les listes que les compléments font lire ;
// RangeError quand une liste n'a pas la longueur de sa population : N pour
// les personnes, mesures.parGroupe pour les appartenances.
function lireComplements(complements, mesures, N) {
if (complements === null) return null;
if (typeof complements !== 'object') throw new TypeError('complements : objet ou null attendu');
const { planchersValides, planchersCollisions = null, objectifsParTour = null } = complements;
const { phiAffilies = null, aMax = null } = complements;
if (typeof planchersValides !== 'boolean') throw new TypeError('complements.planchersValides : booléen attendu');
exigerLongueur(mesures.appartenancesVues, N, 'appartenancesVues', 'liste');
exigerLongueur(mesures.affilies, N, 'affilies', 'liste');
if (phiAffilies !== null) exigerLongueur(phiAffilies, N, 'phiAffilies', 'liste ou null');
if (aMax !== null) exigerLongueur(aMax, N, 'aMax', 'liste ou null');
if (planchersCollisions !== null) {
if (!Array.isArray(mesures.parGroupe)) throw new TypeError('parGroupe : liste attendue avec planchersCollisions');
if (planchersCollisions.length !== mesures.parGroupe.length) {
throw new RangeError(
`planchersCollisions : ${planchersCollisions.length} cases, ${mesures.parGroupe.length} attendues comme parGroupe`,
);
}
}
if (objectifsParTour !== null) {
if (!Array.isArray(objectifsParTour)) throw new TypeError('objectifsParTour : liste ou null attendue');
objectifsParTour.forEach((element, r) => {
if (element === null || typeof element !== 'object') {
throw new TypeError(`objectifsParTour[${r}] : objet attendu, {hors, plancher}`);
}
});
}
return { planchersValides, planchersCollisions, objectifsParTour, phiAffilies, aMax };
}
// Les restes négatifs, ajoutés à violations, sur un plan où les planchers
// valent : collisions cumulées sous leur plancher, appartenance par
// appartenance, puis hors objectif sous leur plancher, tour par tour. Un
// tour dont hors et plancher sont tous deux null est sans objet et passe ;
// un seul des deux null fait tomber l'inégalité, comme toute non-valeur.
function verifierRestes(complements, parGroupe, violations) {
if (complements === null || !complements.planchersValides) return;
const { planchersCollisions, objectifsParTour } = complements;
if (planchersCollisions !== null) {
for (let g = 0; g < planchersCollisions.length; g += 1) {
if (!tient(parGroupe[g].collisionsCumulees, planchersCollisions[g])) {
violations.push({ code: 'RESTE_NEGATIF', groupe: g });
}
}
}
if (objectifsParTour !== null) {
for (let r = 0; r < objectifsParTour.length; r += 1) {
const { hors, plancher } = objectifsParTour[r];
if (hors === null && plancher === null) continue;
if (!tient(hors, plancher)) violations.push({ code: 'RESTE_OBJECTIFS_NEGATIF', tour: r + 1 });
}
}
}
// Les dépassements des objectifs et des animateurs d'une personne (§ 5.4,
// A9), ajoutés à violations : s(p) ≤ P(p), s(p) ≤ tours assis, m(p) ≤ B(p).
// P(p) ne se compare pas aux tours assis : un plan édité qui laisse une
@ -236,6 +327,20 @@ function verifierPersonne(p, { toursAssis, objectifs, animateurs }, violations)
* personne contre le plus de tours possibles (HORS_OBJECTIF_MAX). Une mesure
* absente se lit comme null : seule la liste rencontres est exigée.
*
* Avec ses compléments, que seule la page de qualité lui passe (§ 12.3, D42),
* elle éprouve en plus, pour chacun, A(p) ≤ rencontres(p)
* (DIVERSITE_RENCONTRES), A(p) ≤ |F(p)| (DIVERSITE_AFFILIES), |F(p)| ≤
* Φ_aff(p) (AFFILIES_PLAFOND) et A(p) ≤ A_max(p) (BORNE_DIVERSITE), A(p) et
* |F(p)| lus dans mesures.appartenancesVues et mesures.affilies, les deux
* derniers seulement quand la borne est donnée ; puis, sur un plan où les
* planchers valent, que le reste n'est jamais négatif : les collisions
* cumulées de chaque appartenance, mesures.parGroupe, au moins son plancher
* (RESTE_NEGATIF, groupe), et les tours hors objectif de chaque tour au moins
* le sien (RESTE_OBJECTIFS_NEGATIF, tour), un tour sans couple possible,
* hors et plancher null, n'étant pas lu. Un complément null n'est pas lu.
* Sans compléments, null ou absents, la sortie est celle des trois premiers
* paramètres seuls.
*
* Les trois listes suivent l'ordre canonique, celui de instance.ids, et N est
* la longueur de mesures.rencontres. plafondsAPriori vaut null quand le
* plafond a priori est inconnu (§ 12.6) : la ligne perd ce terme, et seule la
@ -249,7 +354,8 @@ function verifierPersonne(p, { toursAssis, objectifs, animateurs }, violations)
* violation désigne la personne par index, son rang dans l'ordre canonique,
* que instance.ids[index] traduit. Les violations se rangent par index
* croissant, puis de gauche à droite sur la ligne, suivie des dépassements
* de la personne ; les deux violations des totaux viennent en dernier.
* de la personne, puis de sa diversité ; les deux violations des totaux
* viennent ensuite, et les restes en dernier, appartenances puis tours.
*
* Lève RangeError quand une liste de plafonds, ou une liste par personne des
* objectifs, des animateurs ou des tours assis, n'a pas N cases : elle décrit
@ -264,13 +370,15 @@ function verifierPersonne(p, { toursAssis, objectifs, animateurs }, violations)
* les mesures d'un plan ; toursAssis n'est lu qu'avec les objectifs
* @param {ArrayLike<number>|null} plafondsAPriori
* @param {ArrayLike<number>} plafondsRealises
* @param {ComplementsIndicateurs|null} [complements]
* @returns {ViolationIndicateur[]}
*/
export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises) {
export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises, complements = null) {
const { rencontres } = mesures;
const N = rencontres.length;
exigerLongueur(plafondsRealises, N, 'plafondsRealises', 'liste');
if (plafondsAPriori !== null) exigerLongueur(plafondsAPriori, N, 'plafondsAPriori', 'liste ou null');
const lus = lireComplements(complements, mesures, N);
const lues = { toursAssis: mesures.toursAssis, objectifs: mesures.objectifs ?? null, animateurs: mesures.animateurs ?? null };
if (lues.objectifs !== null) {
exigerLongueur(lues.objectifs.tours, N, 'objectifs.tours', 'liste');
@ -293,6 +401,7 @@ export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises)
}
if (!tient(realise, rencontres[p])) violations.push({ code: 'DEPASSEMENT', index: p });
verifierPersonne(p, lues, violations);
verifierDiversite(p, rencontres, mesures, lus, violations);
}
if (lues.objectifs !== null) {
const { jamaisServies, concernes, horsObjectifMax, possibles } = lues.objectifs;
@ -300,5 +409,6 @@ export function verifierIndicateurs(mesures, plafondsAPriori, plafondsRealises)
const plusDePossibles = Array.prototype.reduce.call(possibles, (plus, n) => Math.max(plus, n), 0);
if (!tient(plusDePossibles, horsObjectifMax)) violations.push({ code: 'HORS_OBJECTIF_MAX' });
}
verifierRestes(lus, mesures.parGroupe, violations);
return violations;
}

View file

@ -24,8 +24,9 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { planchersCollisions } from './diagnostic.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { borneDiversite, plafondsAPriori, plafondsRealises } from './plafond.js';
import { ErreurConfiguration } from './erreurs.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
@ -912,3 +913,233 @@ describe('verifierIndicateurs : les objectifs et les animateurs (§ 5.4, A9)', (
assert.throws(() => verifierIndicateurs(avec((m) => { m.animateurs.bornes.push(1); }), plafonds(), plafonds()), RangeError);
});
});
describe('verifierIndicateurs : les compléments de la page de qualité (§ 12.3, § 14.12, D42)', () => {
// Quatre personnes, trois rencontres chacune ; deux appartenances, de
// collisions cumulées 2 et 1 sur des planchers 1 et 1 ; deux tours, hors
// objectif 2 puis 0 sur des planchers 1 et 0. A(p) 2, 2, 1, 2 ; |F(p)| 3,
// 2, 2, 3 ; Φ_aff 3, 3, 2, 3 ; A_max 2, 2, 2, 3. Chaque inégalité y tient,
// et seule la personne de rang 2 n'y est pas à l'égalité de A_max.
const base = () => ({
rencontres: [3, 3, 3, 3],
affilies: [3, 2, 2, 3],
appartenancesVues: [2, 2, 1, 2],
parGroupe: [
{ groupe: 'Coopérative Aubier', effectif: 2, collisionsCumulees: 2, pairesDistinctes: 1, excedent: 1 },
{ groupe: 'Atelier Ressac', effectif: 2, collisionsCumulees: 1, pairesDistinctes: 1, excedent: 0 },
],
});
const complementsDeBase = () => ({
planchersValides: true,
planchersCollisions: [1, 1],
objectifsParTour: [{ hors: 2, plancher: 1 }, { hors: 0, plancher: 0 }],
phiAffilies: [3, 3, 2, 3],
aMax: [2, 2, 2, 3],
});
const plafonds = () => [3, 3, 3, 3];
// La base, mesures et compléments, dont modifier(mesures, complements) a
// changé une valeur.
const avec = (modifier) => {
const mesures = base();
const complements = complementsDeBase();
modifier(mesures, complements);
return { mesures, complements };
};
const verifier = ({ mesures, complements }) => verifierIndicateurs(mesures, plafonds(), plafonds(), complements);
// Chaque code, la mesure forgée qui le lève, seul.
const MUTATIONS_COMPLEMENTS = [
['collisions cumulées 0 sous le plancher 1 de Ressac', (m) => { m.parGroupe[1].collisionsCumulees = 0; },
[{ code: 'RESTE_NEGATIF', groupe: 1 }]],
['hors objectif 0 sous le plancher 1 du tour 1', (m, c) => { c.objectifsParTour[0].hors = 0; },
[{ code: 'RESTE_OBJECTIFS_NEGATIF', tour: 1 }]],
['A(p) 2 au-delà de rencontres 1', (m) => { m.rencontres[0] = 1; },
[{ code: 'DIVERSITE_RENCONTRES', index: 0 }]],
['A(p) 2 au-delà de |F(p)| 1', (m) => { m.affilies[1] = 1; },
[{ code: 'DIVERSITE_AFFILIES', index: 1 }]],
['|F(p)| 3 au-delà de Φ_aff 2', (m, c) => { c.phiAffilies[3] = 2; },
[{ code: 'AFFILIES_PLAFOND', index: 3 }]],
['A(p) 2 au-delà de A_max 1', (m, c) => { c.aMax[1] = 1; },
[{ code: 'BORNE_DIVERSITE', index: 1 }]],
];
test('les compléments d’un plan qui tient ne lèvent rien ; chaque inégalité est large', () => {
assert.deepEqual(verifier(avec(() => {})), []);
const egalite = avec((m, c) => {
m.affilies = [3, 3, 3, 3];
m.appartenancesVues = [3, 3, 3, 3];
m.parGroupe[0].collisionsCumulees = 1;
c.phiAffilies = [3, 3, 3, 3];
c.aMax = [3, 3, 3, 3];
c.objectifsParTour[0].hors = 1;
});
assert.deepEqual(verifier(egalite), []);
});
test('chaque code a sa mesure forgée, et aucun autre ne tombe', () => {
const cas = MUTATIONS_COMPLEMENTS.map(([libelle, modifier, attendu]) => [libelle, () => verifier(avec(modifier)), attendu]);
assert.deepEqual(ecarts(cas), []);
});
test('sans compléments, aucun des six codes ne tombe : la sortie est celle des trois paramètres', () => {
// Chaque mesure forgée, puis toutes à la fois, avec une DEPASSEMENT :
// sans compléments, null ou absents, seule la ligne est éprouvée.
const toutes = avec((m, c) => {
for (const [, modifier] of MUTATIONS_COMPLEMENTS) modifier(m, c);
m.rencontres[2] = 4;
});
const cas = [];
for (const [libelle, modifier] of [...MUTATIONS_COMPLEMENTS, ['toutes', () => {}]]) {
const { mesures } = libelle === 'toutes' ? toutes : avec(modifier);
const attendu = verifierIndicateurs(mesures, plafonds(), plafonds());
cas.push([`${libelle}, compléments null`, () => verifierIndicateurs(mesures, plafonds(), plafonds(), null), attendu]);
cas.push([`${libelle}, compléments absents`, () => verifierIndicateurs(mesures, plafonds(), plafonds(), undefined), attendu]);
}
assert.deepEqual(ecarts(cas), []);
assert.deepEqual(verifierIndicateurs(toutes.mesures, plafonds(), plafonds()), [{ code: 'DEPASSEMENT', index: 2 }]);
});
test('les deux restes ne s’éprouvent que sur un plan où les planchers valent', () => {
const horsDomaine = avec((m, c) => {
c.planchersValides = false;
m.parGroupe[1].collisionsCumulees = 0;
c.objectifsParTour[0].hors = 0;
});
assert.deepEqual(verifier(horsDomaine), []);
// La diversité s'éprouve, elle, hors du domaine des planchers.
horsDomaine.complements.aMax[1] = 1;
assert.deepEqual(verifier(horsDomaine), [{ code: 'BORNE_DIVERSITE', index: 1 }]);
});
test('un complément null n’est pas lu : planchers des collisions, des objectifs, Φ_aff, A_max', () => {
const cas = [
['planchersCollisions null', (m, c) => { m.parGroupe[1].collisionsCumulees = 0; c.planchersCollisions = null; }],
['objectifsParTour null', (m, c) => { c.objectifsParTour[0].hors = 0; c.objectifsParTour = null; }],
['phiAffilies null', (m, c) => { c.phiAffilies[3] = 2; c.phiAffilies = null; }],
['aMax null', (m, c) => { c.aMax[1] = 1; c.aMax = null; }],
['parGroupe absent, planchersCollisions null', (m, c) => { delete m.parGroupe; c.planchersCollisions = null; }],
].map(([libelle, modifier]) => [libelle, () => verifier(avec(modifier)), []]);
// Φ_aff et A_max null : leurs deux codes se taisent, ceux que les mesures
// seules décident s'éprouvent encore.
cas.push([
'phiAffilies et aMax null, A(p) au-delà de rencontres et de |F(p)|',
() => verifier(avec((m, c) => {
m.rencontres[0] = 1;
m.affilies[1] = 1;
c.phiAffilies = null;
c.aMax = null;
})),
[{ code: 'DIVERSITE_RENCONTRES', index: 0 }, { code: 'DIVERSITE_AFFILIES', index: 1 }],
]);
assert.deepEqual(ecarts(cas), []);
});
test('un tour sans objet, hors et plancher null, n’est pas lu ; un seul des deux null fait tomber le tour', () => {
const cas = [
['hors et plancher null au tour 2', (m, c) => { c.objectifsParTour[1] = { hors: null, plancher: null }; }, []],
['hors et plancher null au tour 1, hors 0 sous le plancher 1 au tour 2', (m, c) => {
c.objectifsParTour = [{ hors: null, plancher: null }, { hors: 0, plancher: 1 }];
}, [{ code: 'RESTE_OBJECTIFS_NEGATIF', tour: 2 }]],
['plancher null seul', (m, c) => { c.objectifsParTour[1].plancher = null; }, [{ code: 'RESTE_OBJECTIFS_NEGATIF', tour: 2 }]],
].map(([libelle, modifier, attendu]) => [libelle, () => verifier(avec(modifier)), attendu]);
assert.deepEqual(ecarts(cas), []);
});
test('les violations d’une personne suivent sa ligne ; les restes viennent après les totaux, appartenance puis tour', () => {
const { mesures, complements } = avec((m, c) => {
for (const [, modifier] of MUTATIONS_COMPLEMENTS) modifier(m, c);
m.parGroupe[0].collisionsCumulees = 0;
c.objectifsParTour[1].plancher = 1;
m.rencontres[1] = 4;
m.objectifs = { tours: [0, 0, 0, 0], possibles: [0, 0, 0, 0], jamaisServies: 3, horsObjectifMax: 0, concernes: 2 };
m.toursAssis = [2, 2, 2, 2];
});
assert.deepEqual(verifierIndicateurs(mesures, plafonds(), plafonds(), complements), [
{ code: 'DIVERSITE_RENCONTRES', index: 0 },
{ code: 'DEPASSEMENT', index: 1 },
{ code: 'DIVERSITE_AFFILIES', index: 1 },
{ code: 'BORNE_DIVERSITE', index: 1 },
{ code: 'AFFILIES_PLAFOND', index: 3 },
{ code: 'JAMAIS_SERVIES' },
{ code: 'RESTE_NEGATIF', groupe: 0 },
{ code: 'RESTE_NEGATIF', groupe: 1 },
{ code: 'RESTE_OBJECTIFS_NEGATIF', tour: 1 },
{ code: 'RESTE_OBJECTIFS_NEGATIF', tour: 2 },
]);
});
test('une valeur qui n’est pas un nombre fait tomber les inégalités qui la lisent', () => {
const cas = [
['A(p) NaN', (m) => { m.appartenancesVues[2] = Number.NaN; },
[{ code: 'DIVERSITE_RENCONTRES', index: 2 }, { code: 'DIVERSITE_AFFILIES', index: 2 }, { code: 'BORNE_DIVERSITE', index: 2 }]],
['|F(p)| en texte', (m) => { m.affilies[2] = '2'; },
[{ code: 'DIVERSITE_AFFILIES', index: 2 }, { code: 'AFFILIES_PLAFOND', index: 2 }]],
['Φ_aff indéfini', (m, c) => { c.phiAffilies[2] = undefined; }, [{ code: 'AFFILIES_PLAFOND', index: 2 }]],
['A_max null', (m, c) => { c.aMax[2] = null; }, [{ code: 'BORNE_DIVERSITE', index: 2 }]],
['plancher des collisions NaN', (m, c) => { c.planchersCollisions[0] = Number.NaN; }, [{ code: 'RESTE_NEGATIF', groupe: 0 }]],
['hors objectif null', (m, c) => { c.objectifsParTour[1].hors = null; }, [{ code: 'RESTE_OBJECTIFS_NEGATIF', tour: 2 }]],
].map(([libelle, modifier, attendu]) => [libelle, () => verifier(avec(modifier)), attendu]);
assert.deepEqual(ecarts(cas), []);
});
test('un complément d’une autre forme lève : longueurs, listes manquantes, validité non booléenne', () => {
const cas = [
['Φ_aff de 3 cases', (m, c) => { c.phiAffilies.pop(); }, RangeError],
['A_max de 5 cases', (m, c) => { c.aMax.push(3); }, RangeError],
['un plancher de collisions de plus que parGroupe', (m, c) => { c.planchersCollisions.push(0); }, RangeError],
['parGroupe absent, des planchers de collisions', (m) => { delete m.parGroupe; }, TypeError],
['A(p) absent', (m) => { delete m.appartenancesVues; }, TypeError],
['|F(p)| de 3 cases', (m) => { m.affilies.pop(); }, RangeError],
['objectifsParTour qui n’est pas une liste', (m, c) => { c.objectifsParTour = { hors: 0, plancher: 0 }; }, TypeError],
// Une erreur nommée, et non celle d'une déstructuration de null.
['un tour null dans objectifsParTour', (m, c) => { c.objectifsParTour[1] = null; },
{ name: 'TypeError', message: /^objectifsParTour\[1\] : objet attendu/ }],
['un tour nombre dans objectifsParTour, hors du domaine des planchers', (m, c) => {
c.planchersValides = false;
c.objectifsParTour[0] = 2;
}, { name: 'TypeError', message: /^objectifsParTour\[0\] : objet attendu/ }],
['planchersValides en texte', (m, c) => { c.planchersValides = 'oui'; }, TypeError],
['planchersValides absent', (m, c) => { delete c.planchersValides; }, TypeError],
];
for (const [libelle, modifier, erreur] of cas) assert.throws(() => verifier(avec(modifier)), erreur, libelle);
for (const valeur of [3, 'compléments']) {
assert.throws(() => verifierIndicateurs(base(), plafonds(), plafonds(), valeur), TypeError, String(valeur));
}
});
test('ne modifie ni les mesures ni les compléments, que tout tienne ou que tout tombe', () => {
const geler = (valeur) => {
if (valeur !== null && typeof valeur === 'object') {
for (const champ of Object.values(valeur)) geler(champ);
Object.freeze(valeur);
}
return valeur;
};
const tout = avec((m, c) => {
for (const [, modifier] of MUTATIONS_COMPLEMENTS) modifier(m, c);
});
for (const entrees of [avec(() => {}), tout]) {
geler(entrees);
const copie = structuredClone(entrees);
verifier(entrees);
assert.deepEqual(entrees, copie);
}
});
test('la petite démonstration et son plan parfait tiennent ; un plancher forgé au-delà de ses collisions lève', () => {
const tableDe = indexerPlan(INSTANCE, PLAN_PARFAIT);
const mesures = mesurer(INSTANCE, tableDe);
const { phiAffilies, aMax } = borneDiversite(INSTANCE, tableDe);
// Le plancher de chaque appartenance, 0 quand le diagnostic se tait.
const parNom = new Map(planchersCollisions(INSTANCE).map(({ groupe, plancherCumulees }) => [groupe, plancherCumulees]));
const planchers = INSTANCE.groupes.map((groupe) => parNom.get(groupe) ?? 0);
const complements = { planchersValides: true, planchersCollisions: planchers, objectifsParTour: null, phiAffilies, aMax };
const realises = plafondsRealises(INSTANCE, tableDe);
assert.deepEqual(verifierIndicateurs(mesures, plafondsAPriori(INSTANCE), realises, complements), []);
const forges = planchers.map((plancher, g) => (g === 1 ? mesures.parGroupe[g].collisionsCumulees + 1 : plancher));
assert.deepEqual(
verifierIndicateurs(mesures, plafondsAPriori(INSTANCE), realises, { ...complements, planchersCollisions: forges }),
[{ code: 'RESTE_NEGATIF', groupe: 1 }],
);
});
});

View file

@ -2080,13 +2080,16 @@ describe('arborescence : un seul propriétaire des valeurs du contrat (types.js)
});
});
// Modules que le § 14.13 tient à 100 % de branches : le registre des
// critères et la correspondance aux objectifs, le module des indicateurs et
// du plafond, et les chemins de reprise du stockage.
// Modules que le § 14.13 tient à 100 % de branches : les calculs du
// moteur qui décrivent ou bornent un plan, la géométrie des graphiques, et
// les chemins de reprise du stockage.
const MODULES_A_SEUIL = [
'src/geometrie/graphique.js',
'src/moteur/correspondance.js',
'src/moteur/criteres.js',
'src/moteur/diversite.js',
'src/moteur/indicateurs.js',
'src/moteur/paires.js',
'src/moteur/plafond.js',
'src/stockage/depot.js',
'src/stockage/journal.js',
@ -2227,9 +2230,12 @@ describe('arborescence : la couverture (§ 14.13)', () => {
thresholds: {
branches: 90,
perFile: true,
'src/geometrie/graphique.js': { branches: 100 },
'src/moteur/correspondance.js': { branches: 100 },
'src/moteur/criteres.js': { branches: 100 },
'src/moteur/diversite.js': { branches: 100 },
'src/moteur/indicateurs.js': { branches: 100 },
'src/moteur/paires.js': { branches: 100 },
'src/moteur/plafond.js': { branches: 95 },
'src/stockage/journal.js': { branches: 100, lines: 100 },
'src/csv/**': { branches: 100 },
@ -2237,9 +2243,12 @@ describe('arborescence : la couverture (§ 14.13)', () => {
};
const fichiers = {
...COMMANDE_CONFORME,
'src/geometrie/graphique.js': '',
'src/moteur/correspondance.js': '',
'src/moteur/criteres.js': '',
'src/moteur/diversite.js': '',
'src/moteur/indicateurs.js': '',
'src/moteur/paires.js': '',
'src/stockage/depot.js': '',
'src/stockage/journal.js': '',
};
@ -2256,9 +2265,12 @@ describe('arborescence : la couverture (§ 14.13)', () => {
assert.deepEqual(
avecArbre(fichiers, (racine) => releverCouverture(racine, sansInclude)).filter((e) => e.endsWith('mesure')),
[
'src/geometrie/graphique.js : hors de la mesure',
'src/moteur/correspondance.js : hors de la mesure',
'src/moteur/criteres.js : hors de la mesure',
'src/moteur/diversite.js : hors de la mesure',
'src/moteur/indicateurs.js : hors de la mesure',
'src/moteur/paires.js : hors de la mesure',
'src/stockage/depot.js : hors de la mesure',
'src/stockage/journal.js : hors de la mesure',
],

View file

@ -26,7 +26,7 @@ import { MARQUE_TEMOIN, creerTraduction } from '../../src/application/traduction
import { constante } from '../../src/geometrie/constantes.js';
import JETONS_CSS from '../../src/interface/jetons.css?raw';
import { TABLES_INTERFACE } from '../../src/interface/libelles.js';
import { MARQUES, TEXTES } from '../../src/interface/marques.js';
import { MARQUES, MARQUES_GRAPHIQUE, TEXTES } from '../../src/interface/marques.js';
import { serialiserCharge } from '../../src/stockage/canonique.js';
import { analyser } from '../../src/stockage/document.js';
import { SUFFIXES } from '../../src/stockage/noms.js';
@ -362,6 +362,14 @@ function voisinage(element, jeton) {
return { peinture: null, voisins: [] };
}
// Le registre que releverMarques connaît : MARQUES, et les marques de
// graphique (MARQUES_GRAPHIQUE), tenues hors de MARQUES tant qu'aucun
// écran ne les rend toutes — la couverture des écrans ne lit que MARQUES.
// Le relevé d'un écran comme celui du parcours photographié les mesure
// ainsi à 3:1. Les fonds de graphique (FONDS_GRAPHIQUE) ne sont pas des
// marques et n'y entrent pas.
const REGISTRE_MARQUES = Object.freeze([...MARQUES, ...MARQUES_GRAPHIQUE]);
/**
* Les marques rendues sous racine (§ 14.5, § 19.5) : chacune nommée au
* registre, peinte de son jeton, à 3:1 de ce qui l'entoure, sans
@ -386,9 +394,9 @@ export function releverMarques(racine, { fenetre = false } = {}) {
if (fenetre && !entiereDansLaFenetre(element)) continue;
if (!rendu(element)) continue;
const nom = element.dataset.marque;
const entree = MARQUES.find((m) => m.nom === nom);
const entree = REGISTRE_MARQUES.find((m) => m.nom === nom);
if (entree === undefined) {
fautes.push(`${situer(element)} : marque hors du registre MARQUES`);
fautes.push(`${situer(element)} : marque hors du registre`);
continue;
}
for (const a of attenuations(element)) fautesAttenuation.push(`${situer(element)} : ${designation(a)} ${a.propriete}: ${a.valeur}`);

View file

@ -107,11 +107,11 @@ export default defineConfig({
// ligne par couche et par module de src/, pleins compris — que Vitest
// tait sinon quand un agent le lance — et à 0 % ceux qu'aucune épreuve ne
// charge ; coverage/index.html montre, ligne à ligne, ce qui n'est jamais
// exécuté. Six modules tiennent 100 % de branches, seuil dur : la
// commande échoue en deçà. Une branche nue s'y couvre par un cas ajouté,
// ou part avec le code mort ; le seuil ne s'abaisse pas, et
// test/arborescence.test.js garde ces six seuils, ces deux rapports
// et les deux commandes qui lancent la mesure.
// exécuté. Les modules nommés ci-dessous tiennent 100 % de branches,
// seuil dur : la commande échoue en deçà. Une branche nue s'y couvre par
// un cas ajouté, ou part avec le code mort ; le seuil ne s'abaisse pas,
// et test/arborescence.test.js garde ces seuils, ces deux rapports et
// les deux commandes qui lancent la mesure.
coverage: {
provider: 'v8',
include: ['src/**/*.js'],
@ -119,9 +119,12 @@ export default defineConfig({
reporter: [['text', { skipFull: false }], 'html'],
reportsDirectory: 'coverage',
thresholds: {
'src/geometrie/graphique.js': { branches: 100 },
'src/moteur/correspondance.js': { branches: 100 },
'src/moteur/criteres.js': { branches: 100 },
'src/moteur/diversite.js': { branches: 100 },
'src/moteur/indicateurs.js': { branches: 100 },
'src/moteur/paires.js': { branches: 100 },
'src/moteur/plafond.js': { branches: 100 },
'src/stockage/depot.js': { branches: 100 },
'src/stockage/journal.js': { branches: 100 },