[ADD] engine: criteria registry, priority tiers, goal matching

The search and the ranking each hard-code their own order. The registry
names every criterion once, with its direction and family, and compiles
an operator order of tiers with integer weights (1 to 100) into the two
orders the engine reads; with no stored order it returns exactly today's
search tuple, acceptance switch and ranking. A stored order that breaks
a rule (shared lone criterion, weight, order inside a tier) is refused
by name. Goal matching reads OU/ET rules over normalised tags.
Checked: 60 tests, 100% branches on both modules (guard updated).

--- FR ---

[ADD] moteur : registre des critères, paliers, correspondance

La recherche et le classement figent chacun leur ordre. Le registre nomme
chaque critère une fois, avec son sens et sa famille, et compile un ordre
de paliers à poids entiers (1 à 100) en les deux ordres que lit le
moteur ; sans ordre écrit, il rend exactement le n-uplet, la bascule et
le classement d'aujourd'hui. Un ordre qui enfreint une règle (critère
seul partagé, poids, ordre dans un palier) est refusé par son nom. La
correspondance lit les règles OU/ET sur des étiquettes normalisées.
Vérifié : 60 épreuves, 100 % de branches aux deux modules (garde suivie).

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 13:45:29 -04:00
parent 52ff9649ab
commit a5cf1b2630
6 changed files with 1257 additions and 6 deletions

View file

@ -0,0 +1,37 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La correspondance d'une personne à un objectif de table (§ 4.5, D18) : la
// seule fonction qui la décide. Le moteur, les avis de l'application et
// l'import la lisent ; aucune autre arithmétique de la correspondance n'existe.
//
// Les étiquettes se comparent par égalité de chaînes, comme les
// appartenances (§ 5.2) : la forme et la réconciliation des orthographes se
// font à l'entrée (src/stockage/etiquettes.js, src/csv/etiquettes.js), et
// deux orthographes qu'un fichier retouché à la main porterait restent deux
// étiquettes ici.
/**
* Vrai quand la personne qui porte ces étiquettes correspond à l'objectif
* selon sa règle : « ou », au moins une des étiquettes de l'objectif ; « et »,
* chacune d'elles. Une personne sans étiquette ne correspond à aucun
* objectif, et un objectif sans étiquette n'attend personne, sous l'une et
* l'autre règle : « toutes » d'une liste vide ne fait correspondre personne.
*
* La règle est résolue : un objectif qui suit le défaut de l'événement
* (regle nulle dans la charge) reçoit la règle par défaut avant l'appel.
* Lève RangeError pour toute autre règle que 'ou' et 'et'. Ne modifie pas ce
* qu'elle reçoit.
*
* @param {readonly string[]} etiquettes les étiquettes de la personne
* @param {{regle: 'ou'|'et', etiquettes: readonly string[]}} objectif
* @returns {boolean}
*/
export function correspondA(etiquettes, { regle, etiquettes: visees }) {
if (regle !== 'ou' && regle !== 'et') {
throw new RangeError(`règle d'objectif ${String(regle)} : 'ou' ou 'et' attendu`);
}
if (etiquettes.length === 0 || visees.length === 0) return false;
const portee = (motCle) => etiquettes.includes(motCle);
return regle === 'ou' ? visees.some(portee) : visees.every(portee);
}

View file

@ -0,0 +1,79 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la correspondance d'une personne à un objectif (§ 4.5, D18) :
// la règle « au moins une » (OU), la règle « toutes » (ET), la personne sans
// étiquette, l'égalité de chaînes sans normalisation, et le refus d'une
// règle inconnue. Les étiquettes sont des mots génériques inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { correspondA } from './correspondance.js';
// Un objectif de règle et d'étiquettes données.
const objectif = (regle, ...etiquettes) => ({ regle, etiquettes });
describe('correspondA : la règle « au moins une » (OU)', () => {
test('une seule étiquette commune suffit, à toute place des deux listes', () => {
assert.equal(correspondA(['finance'], objectif('ou', 'finance', 'ressources humaines')), true);
assert.equal(correspondA(['bénévole', 'ressources humaines'], objectif('ou', 'finance', 'ressources humaines')), true);
});
test("aucune étiquette commune : la personne ne correspond pas", () => {
assert.equal(correspondA(['Montréal', 'bénévole'], objectif('ou', 'finance', 'ressources humaines')), false);
});
});
describe('correspondA : la règle « toutes » (ET)', () => {
test('la personne porte chacune des étiquettes, et d’autres encore', () => {
assert.equal(correspondA(['bénévole', 'finance', 'Montréal'], objectif('et', 'finance', 'Montréal')), true);
});
test("une étiquette manque : la personne ne correspond pas, même si elle porte l'autre", () => {
assert.equal(correspondA(['finance'], objectif('et', 'finance', 'Montréal')), false);
assert.equal(correspondA(['Montréal', 'bénévole'], objectif('et', 'finance', 'Montréal')), false);
});
test('un objectif d’une seule étiquette se lit pareil sous les deux règles', () => {
for (const regle of ['ou', 'et']) {
assert.equal(correspondA(['finance', 'bénévole'], objectif(regle, 'finance')), true, regle);
assert.equal(correspondA(['bénévole'], objectif(regle, 'finance')), false, regle);
}
});
});
describe('correspondA : les listes vides', () => {
test('une personne sans étiquette ne correspond à aucun objectif, sous les deux règles', () => {
for (const regle of ['ou', 'et']) {
assert.equal(correspondA([], objectif(regle, 'finance')), false, regle);
assert.equal(correspondA([], objectif(regle)), false, regle);
}
});
test("un objectif sans étiquette n'attend personne, même sous « toutes »", () => {
for (const regle of ['ou', 'et']) assert.equal(correspondA(['finance'], objectif(regle)), false, regle);
});
});
describe("correspondA : l'égalité de chaînes (§ 4.5)", () => {
test('la casse, les blancs et la forme de composition distinguent deux étiquettes', () => {
assert.equal(correspondA(['Finance'], objectif('ou', 'finance')), false);
assert.equal(correspondA(['finance '], objectif('ou', 'finance')), false);
// « é » composé (U+00E9) contre « e » suivi de l'accent combinant (U+0301).
assert.equal(correspondA(['b\u{e9}n\u{e9}vole'], objectif('ou', 'be\u{301}ne\u{301}vole')), false);
assert.equal(correspondA(['b\u{e9}n\u{e9}vole'], objectif('et', 'b\u{e9}n\u{e9}vole')), true);
});
});
describe('correspondA : la règle inconnue', () => {
test('une règle non résolue ou inconnue lève RangeError, sans se lire comme OU ni comme ET', () => {
for (const regle of [null, undefined, 'OU', 'toutes', '']) {
assert.throws(() => correspondA(['finance'], objectif(regle, 'finance')), RangeError, String(regle));
}
});
test('ne modifie pas les listes reçues', () => {
const personne = Object.freeze(['Montréal', 'finance']);
const vise = Object.freeze({ regle: 'et', etiquettes: Object.freeze(['finance', 'Montréal']) });
assert.equal(correspondA(personne, vise), true);
});
});

376
src/moteur/criteres.js Normal file
View file

@ -0,0 +1,376 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des critères (§ 5.3, § 5.7) : leurs noms, leurs familles, les
// cases qui les font compter, l'ordre des priorités par défaut, les critères
// seuls à leur palier, les trois modes des objectifs et leurs valeurs, la
// validation, le complément et la compilation d'une liste de priorités.
// Ces faits n'existent que là : la recherche, le classement, le schéma, les
// commandes et l'écran les lisent.
//
// Une liste de priorités range les sept critères en paliers, du premier au
// dernier : [[{critere, poids}, …], …]. Elle est complète, chaque critère y
// figure une fois, coché ou non ; un critère qui ne compte pas garde son
// rang et ne pose rien à la compilation (D10). Dans un palier, les critères
// suivent l'ordre de CRITERES (A16) : deux listes de même effet s'écrivent
// de la même façon.
//
// Les noms reçus de la saisie ne servent jamais de clé avant d'être reconnus
// par CRITERES ou MODES_OBJECTIFS : un nom hérité d'Object (« constructor »,
// « __proto__ ») est inconnu, et ne lit rien dans les tables du registre.
/**
* Les sept critères, dans l'ordre du tableau du § 5.7, qui est aussi l'ordre
* par défaut (D11).
*/
export const CRITERES = Object.freeze([
'ecartAuPlafondAPrioriMax',
'excedentCollisions',
'collisionsCumulees',
'toursHorsObjectif',
'rencontresRepetees',
'animateursNonRencontres',
'redondance',
]);
/** La famille de chaque critère (§ 5.7), qui fait celle de son palier (D15). */
export const FAMILLE = Object.freeze({
ecartAuPlafondAPrioriMax: 'rencontres',
excedentCollisions: 'contrainte',
collisionsCumulees: 'contrainte',
toursHorsObjectif: 'contrainte',
rencontresRepetees: 'contrainte',
animateursNonRencontres: 'raffinement',
redondance: 'raffinement',
});
/**
* Les clés de contraintes qui font compter chaque critère (§ 5.3, A8) : le
* critère est coché quand l'une d'elles l'est ; une liste vide, toujours.
*/
export const CASES = Object.freeze({
ecartAuPlafondAPrioriMax: Object.freeze([]),
excedentCollisions: Object.freeze(['separerAppartenances']),
collisionsCumulees: Object.freeze(['separerAppartenances']),
toursHorsObjectif: Object.freeze(['respecterObjectifs']),
rencontresRepetees: Object.freeze(['nouveauxVoisins', 'nouvelleTable']),
animateursNonRencontres: Object.freeze(['rencontrerAnimateurs']),
redondance: Object.freeze(['varierAppartenances']),
});
// La valeur d'une case facultative absente des contraintes (A16) : les
// objectifs cochés, les animateurs décochés. Les quatre autres cases sont
// toujours présentes dans une configuration normalisée.
const CASES_ABSENTES = Object.freeze({ respecterObjectifs: true, rencontrerAnimateurs: false });
/** L'ordre par défaut (D11) : chaque critère seul à son palier, au poids 1. */
export const PRIORITES_PAR_DEFAUT = Object.freeze(
CRITERES.map((critere) => Object.freeze([Object.freeze({ critere, poids: 1 })])),
);
/** Le plus grand poids d'un critère ; le plus petit est 1. */
export const POIDS_MAX = 100;
const POIDS_MIN = 1;
/** Les trois modes des objectifs (D20), le défaut en tête. */
export const MODES_OBJECTIFS = Object.freeze(['places', 'chacun', 'minimum']);
/**
* Par mode, les valeurs que les tours hors objectif classent, dans l'ordre où
* le classement les compare (§ 5.3, A8).
*/
export const VALEURS_DES_MODES = Object.freeze({
places: Object.freeze(['toursHorsObjectif']),
chacun: Object.freeze(['jamaisServies', 'toursHorsObjectif']),
minimum: Object.freeze(['horsObjectifMax', 'toursHorsObjectif']),
});
/**
* Par mode, les grandeurs que la recherche descend pour les tours hors
* objectif, une composante chacune : celles qui classent, et sous minimum le
* nombre de personnes au plus grand nombre de tours hors objectif, qui
* façonne la descente sans classer (D20).
*/
export const COMPOSANTES_DES_MODES = Object.freeze({
places: VALEURS_DES_MODES.places,
chacun: VALEURS_DES_MODES.chacun,
minimum: Object.freeze(['horsObjectifMax', 'personnesAuMax', 'toursHorsObjectif']),
});
// Les deux grandeurs que la recherche descend pour l'écart au plafond a
// priori : le plus grand écart, puis la somme des carrés des écarts (§ 5.7).
const COMPOSANTES_DE_L_ECART = Object.freeze(['ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori']);
// Les critères seuls à leur palier sous tout mode (A16) : l'écart, dont la
// valeur nulle est le certificat du § 5.5, et les rencontres répétées, dont
// les deux valeurs ne s'échangent pas au même taux contre un autre critère
// (D8, D9).
const TOUJOURS_SEULS = Object.freeze(['ecartAuPlafondAPrioriMax', 'rencontresRepetees']);
function exigerCritere(nom) {
if (!CRITERES.includes(nom)) throw new RangeError(`critère ${String(nom)} : inconnu du registre`);
}
function exigerMode(mode) {
if (!MODES_OBJECTIFS.includes(mode)) throw new RangeError(`mode des objectifs ${String(mode)} : inconnu`);
}
/**
* Vrai quand les cases de ces contraintes font compter le critère (§ 5.3,
* A8) : l'écart toujours ; un autre quand l'une de ses cases est cochée, une
* case facultative absente valant son défaut. « A un objet » ne se lit pas
* ici : il dépend de l'instance. Lève RangeError pour un critère inconnu.
*
* @param {string} nom
* @param {Object<string, boolean>} contraintes
* @returns {boolean}
*/
export function estCoche(nom, contraintes) {
exigerCritere(nom);
const cases = CASES[nom];
return cases.length === 0 || cases.some((cle) => contraintes[cle] ?? CASES_ABSENTES[cle] === true);
}
/**
* Les critères seuls à leur palier sous ce mode, dans l'ordre de CRITERES :
* l'écart et les rencontres répétées toujours, et les tours hors objectif
* sous les modes qui leur font comparer deux valeurs (D8, D23). Chacun y
* porte le poids 1 (A16) : un critère qui compare deux valeurs l'une après
* l'autre ne se pondère pas. Lève RangeError pour un mode inconnu.
*
* @param {'places'|'chacun'|'minimum'} [mode='places']
* @returns {string[]}
*/
export function seulsASonPalier(mode = 'places') {
exigerMode(mode);
return CRITERES.filter((nom) => TOUJOURS_SEULS.includes(nom) || (nom === 'toursHorsObjectif' && mode !== 'places'));
}
/**
* Les grandeurs que la recherche descend pour ce critère, une composante
* chacune, dans l'ordre : deux pour l'écart, celles du mode pour les tours
* hors objectif, la grandeur du critère seule pour les autres. Un critère qui
* en descend plusieurs est seul à son palier ; dans un palier partagé, chaque
* critère n'en descend qu'une. Lève RangeError pour un nom ou un mode
* inconnus.
*
* @param {string} nom
* @param {'places'|'chacun'|'minimum'} [mode='places']
* @returns {string[]}
*/
export function composantesDe(nom, mode = 'places') {
exigerCritere(nom);
exigerMode(mode);
if (nom === 'ecartAuPlafondAPrioriMax') return [...COMPOSANTES_DE_L_ECART];
if (nom === 'toursHorsObjectif') return [...COMPOSANTES_DES_MODES[mode]];
return [nom];
}
/**
* Les grandeurs que ce critère classe, dans l'ordre où le classement les
* compare (D9, D20) : pour les rencontres répétées, celles que le moteur a
* choisies sous « nouveaux voisins », sinon les retours choisis ; pour les
* tours hors objectif, celles du mode ; pour les autres, la grandeur du
* critère seule. Lève RangeError pour un nom ou un mode inconnus. Rend une
* liste neuve.
*
* @param {string} nom
* @param {Object<string, boolean>} contraintes
* @param {'places'|'chacun'|'minimum'} [mode='places']
* @returns {string[]}
*/
export function valeursQuiClassent(nom, contraintes, mode = 'places') {
exigerCritere(nom);
exigerMode(mode);
if (nom === 'rencontresRepetees') {
return [contraintes.nouveauxVoisins ? 'rencontresRepeteesChoisies' : 'retoursChoisis'];
}
if (nom === 'toursHorsObjectif') return [...VALEURS_DES_MODES[mode]];
return [nom];
}
// Vrai pour une entrée de palier de la forme {critere, poids} : un objet,
// ni liste ni null, qui porte en propre critere, une chaîne, et poids, jugé
// à part. Une clé de plus ne se relève pas ici : le moteur ne parcourt les
// clés d'aucun objet (§ 14.7), et le schéma du document juge les clés.
function aLaFormeDUneEntree(entree) {
return typeof entree === 'object'
&& entree !== null
&& !Array.isArray(entree)
&& Object.hasOwn(entree, 'critere')
&& Object.hasOwn(entree, 'poids')
&& typeof entree.critere === 'string';
}
// Le rang d'un critère reconnu dans CRITERES.
function rangDe(nom) {
return CRITERES.indexOf(nom);
}
// Vrai pour un poids du domaine : un entier de 1 à POIDS_MAX. Le poids 1
// d'un critère seul à son palier se juge avec le palier, après son partage.
function poidsDuDomaine(poids) {
return Number.isInteger(poids) && poids >= POIDS_MIN && poids <= POIDS_MAX;
}
/**
* La première faute d'une liste de priorités sous ce mode, ou null quand
* elle est valide (§ 5.7, A12, A16). Les fautes sont des données :
* - forme : la liste, un palier ou une entrée n'ont pas leur forme, ou les
* critères d'un palier ne suivent pas l'ordre de CRITERES — critere nomme
* alors le premier rangé avant celui qui le précède ;
* - palierVide : un palier sans critère ;
* - critereInconnu : un nom hors de CRITERES ;
* - critereRepete : un critère nommé une seconde fois ;
* - poids : hors des entiers de 1 à POIDS_MAX, ou autre que 1 pour un
* critère de seulsASonPalier(mode) ;
* - seulASonPalier : un critère de seulsASonPalier(mode) qui partage son
* palier, quel que soit son poids dans le domaine ;
* - critereAbsent : un critère que la liste ne nomme pas, le premier dans
* l'ordre de CRITERES.
* La liste se lit dans l'ordre, palier par palier, entrée par entrée : la
* forme, le nom, la répétition et le poids hors de 1 à POIDS_MAX se
* relèvent entrée par entrée. Une fois les entrées d'un palier lues se
* jugent son partage, puis le poids 1 du critère seul à son palier, puis
* l'ordre de ses critères ; l'absence, une fois la liste lue. palier est
* l'index du palier en cause, à partir de 0 ; critere, le nom en cause.
*
* Lève RangeError pour un mode inconnu. Ne modifie pas la liste.
*
* @param {unknown} liste
* @param {'places'|'chacun'|'minimum'} [mode='places']
* @returns {null|{faute: string, critere?: string, palier?: number}}
*/
export function verifierPriorites(liste, mode = 'places') {
exigerMode(mode);
if (!Array.isArray(liste)) return { faute: 'forme' };
const seuls = seulsASonPalier(mode);
const vus = [];
for (let palier = 0; palier < liste.length; palier += 1) {
const entrees = liste[palier];
if (!Array.isArray(entrees)) return { faute: 'forme', palier };
if (entrees.length === 0) return { faute: 'palierVide', palier };
for (const entree of entrees) {
if (!aLaFormeDUneEntree(entree)) return { faute: 'forme', palier };
const { critere, poids } = entree;
if (!CRITERES.includes(critere)) return { faute: 'critereInconnu', critere, palier };
if (vus.includes(critere)) return { faute: 'critereRepete', critere, palier };
if (!poidsDuDomaine(poids)) return { faute: 'poids', critere, palier };
vus.push(critere);
}
const seul = entrees.find(({ critere }) => seuls.includes(critere));
if (seul !== undefined) {
if (entrees.length > 1) return { faute: 'seulASonPalier', critere: seul.critere, palier };
if (seul.poids !== 1) return { faute: 'poids', critere: seul.critere, palier };
}
const deplace = entrees.find(({ critere }, k) => k > 0 && rangDe(critere) < rangDe(entrees[k - 1].critere));
if (deplace !== undefined) return { faute: 'forme', critere: deplace.critere, palier };
}
const absent = CRITERES.find((nom) => !vus.includes(nom));
return absent === undefined ? null : { faute: 'critereAbsent', critere: absent };
}
/**
* La liste où chaque critère qu'elle ne nomme pas prend sa place de l'ordre
* par défaut, seul à son palier, au poids 1 (A16) : juste après le palier
* du critère qui le précède dans CRITERES, en tête pour le premier. Les
* critères manquants se placent dans l'ordre de CRITERES, chacun après le
* précédent déjà placé. Une liste complète se rend égale. Rend une liste
* neuve, paliers et entrées copiés ; ne modifie pas celle qu'elle reçoit,
* dont les entrées ont la forme {critere, poids}.
*
* @param {Array<Array<{critere: string, poids: number}>>} liste
* @returns {Array<Array<{critere: string, poids: number}>>}
*/
export function completerPriorites(liste) {
const complete = liste.map((palier) => palier.map(({ critere, poids }) => ({ critere, poids })));
const palierDe = (nom) => complete.findIndex((palier) => palier.some(({ critere }) => critere === nom));
CRITERES.forEach((nom, rang) => {
if (palierDe(nom) >= 0) return;
const place = rang === 0 ? 0 : palierDe(CRITERES[rang - 1]) + 1;
complete.splice(place, 0, [{ critere: nom, poids: 1 }]);
});
return complete;
}
// La famille d'un palier (D15) : « raffinement » quand tous ses critères le
// sont ; sinon celle de son critère quand il est seul, « contrainte » quand
// il en porte plusieurs — l'écart, seul de sa famille, ne partage jamais
// son palier.
function familleDuPalier(criteres) {
if (criteres.every(({ nom }) => FAMILLE[nom] === 'raffinement')) return 'raffinement';
return criteres.length === 1 ? FAMILLE[criteres[0].nom] : 'contrainte';
}
/**
* Compile une liste de priorités valide en n-uplet de recherche et en ordre
* de classement (§ 5.7, D9, D10, D15).
*
* compte(nom) dit si le critère compte : coché, et avec un objet. Il est lu
* une fois par critère, dans l'ordre de la liste. Un critère qui ne compte
* pas sort de son palier et ne pose aucune composante ; un palier dont aucun
* critère ne compte disparaît, les autres gardant leur ordre.
*
* Chaque palier rendu porte sa famille (D15), ses critères qui comptent
* {nom, poids}, dans l'ordre de la liste, et les rangs de ses composantes
* dans le n-uplet, consécutifs : celles de composantesDe pour un critère
* seul, une seule, somme pondérée, pour un palier partagé.
*
* ordreScore range les composantes dans l'ordre des paliers. ordreContraintes
* est l'ordre qu'adopte la recherche dès que le plus grand écart au plafond a
* priori est nul : les composantes de l'écart passent juste avant le premier
* palier « raffinement » qui suit le sien, à la fin s'il n'y en a pas, les
* autres gardant leur ordre et chaque palier ses composantes ensemble.
* rangEcart est le rang de la composante du plus grand écart, null quand
* l'écart ne compte pas — l'ordre des contraintes égale alors celui du
* score. taille, le nombre de composantes, est au plus 10.
*
* Lève RangeError pour un mode inconnu, ou pour une liste que
* verifierPriorites refuse sous ce mode, le message nommant la faute. Rend
* des objets neufs ; ne modifie pas la liste.
*
* @param {Array<Array<{critere: string, poids: number}>>} liste
* @param {(nom: string) => boolean} compte
* @param {'places'|'chacun'|'minimum'} [mode='places']
* @returns {{paliers: Array<{famille: string, criteres: Array<{nom: string, poids: number}>, composantes: number[]}>,
* ordreScore: number[], ordreContraintes: number[], rangEcart: number|null, taille: number}}
*/
export function compiler(liste, compte, mode = 'places') {
exigerMode(mode);
const faute = verifierPriorites(liste, mode);
if (faute !== null) throw new RangeError(`liste de priorités refusée : ${JSON.stringify(faute)}`);
const paliers = [];
let taille = 0;
let palierEcart = -1;
for (const entrees of liste) {
const criteres = entrees.filter(({ critere }) => compte(critere)).map(({ critere, poids }) => ({ nom: critere, poids }));
if (criteres.length === 0) continue;
const largeur = criteres.length === 1 ? composantesDe(criteres[0].nom, mode).length : 1;
if (criteres[0].nom === 'ecartAuPlafondAPrioriMax') palierEcart = paliers.length;
paliers.push({
famille: familleDuPalier(criteres),
criteres,
composantes: Array.from({ length: largeur }, (_, k) => taille + k),
});
taille += largeur;
}
const ordreScore = Array.from({ length: taille }, (_, k) => k);
if (palierEcart < 0) {
return { paliers, ordreScore, ordreContraintes: [...ordreScore], rangEcart: null, taille };
}
const suivant = paliers.findIndex((palier, i) => i > palierEcart && palier.famille === 'raffinement');
const fin = suivant < 0 ? paliers.length : suivant;
const ordreDesPaliers = [
...paliers.slice(0, palierEcart),
...paliers.slice(palierEcart + 1, fin),
paliers[palierEcart],
...paliers.slice(fin),
];
return {
paliers,
ordreScore,
ordreContraintes: ordreDesPaliers.flatMap(({ composantes }) => composantes),
rangEcart: paliers[palierEcart].composantes[0],
taille,
};
}

744
src/moteur/criteres.test.js Normal file
View file

@ -0,0 +1,744 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du registre des critères (§ 5.3, § 5.7, D8 à D11, D15, D20, D23) :
// les constantes du registre, la validation d'une liste de priorités faute
// par faute sous chaque mode, son complément, les valeurs qui classent, et
// la compilation. La compilation de l'ordre par défaut se compare, sous les
// seize combinaisons des quatre cases, au n-uplet que la recherche descend
// sans le registre : six composantes, celles d'un critère décoché nulles,
// comparées dans l'ordre du score [0..5] puis, l'écart annulé, dans celui
// des contraintes [2, 3, 4, 0, 1, 5].
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import {
CASES,
COMPOSANTES_DES_MODES,
CRITERES,
FAMILLE,
MODES_OBJECTIFS,
POIDS_MAX,
PRIORITES_PAR_DEFAUT,
VALEURS_DES_MODES,
compiler,
completerPriorites,
composantesDe,
estCoche,
seulsASonPalier,
valeursQuiClassent,
verifierPriorites,
} from './criteres.js';
// Les quatre cases que porte toute configuration.
const QUATRE_CASES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances'];
// Les contraintes d'un masque de quatre bits, dans l'ordre de QUATRE_CASES.
const contraintesDu = (masque) =>
Object.fromEntries(QUATRE_CASES.map((cle, bit) => [cle, (masque & (1 << bit)) !== 0]));
const TOUTES_COCHEES = contraintesDu(0b1111);
// Une entrée de palier.
const e = (critere, poids = 1) => ({ critere, poids });
// La liste par défaut, palier par palier, en entrées fraîches.
const parDefaut = () => CRITERES.map((nom) => [e(nom)]);
// La liste dont le palier d'index i est remplacé par les paliers donnés
// (aucun : le palier est retiré).
const remplacer = (liste, i, ...paliers) => [...liste.slice(0, i), ...paliers, ...liste.slice(i + 1)];
// Le compte qui fait compter les noms donnés, et eux seuls.
const comptant = (...noms) => (nom) => noms.includes(nom);
// Le compte des cases d'aujourd'hui : le critère coché, et ni objectif ni
// case des animateurs (aucun des deux nouveaux critères n'a d'objet).
const compteDesCases = (contraintes) => (nom) =>
estCoche(nom, contraintes) && nom !== 'toursHorsObjectif' && nom !== 'animateursNonRencontres';
// Le nom de chaque composante du n-uplet compilé, par rang : la grandeur
// qu'elle descend quand son palier n'a qu'un critère, sinon les critères
// joints par « + ».
function nomsDesComposantes({ paliers, taille }, mode = 'places') {
const noms = new Array(taille);
for (const { criteres, composantes } of paliers) {
const grandeurs = criteres.length === 1
? composantesDe(criteres[0].nom, mode)
: [criteres.map(({ nom }) => nom).join('+')];
assert.equal(grandeurs.length, composantes.length);
composantes.forEach((rang, k) => { noms[rang] = grandeurs[k]; });
}
return noms;
}
// Les deux ordres compilés, lus en noms de composantes.
function ordresNommes(compilation, mode) {
const noms = nomsDesComposantes(compilation, mode);
return {
score: compilation.ordreScore.map((rang) => noms[rang]),
contraintes: compilation.ordreContraintes.map((rang) => noms[rang]),
};
}
// Le n-uplet de la recherche sans le registre : six composantes nommées, et
// leurs deux ordres de comparaison.
const COMPOSANTES_SANS_REGISTRE = [
'ecartAuPlafondAPrioriMax',
'carresEcartsAuPlafondAPriori',
'excedentCollisions',
'collisionsCumulees',
'rencontresRepetees',
'redondance',
];
const SCORE_SANS_REGISTRE = [0, 1, 2, 3, 4, 5];
const CONTRAINTES_SANS_REGISTRE = [2, 3, 4, 0, 1, 5];
// Les composantes que la recherche sans registre tient nulles sous ces
// contraintes, par rang.
const nullesSansRegistre = (c) => [
false,
false,
!c.separerAppartenances,
!c.separerAppartenances,
!c.nouveauxVoisins && !c.nouvelleTable,
!c.varierAppartenances,
];
describe('criteres : le registre (contrat de données, § 5.7)', () => {
test("les sept critères, dans l'ordre du tableau du § 5.7", () => {
assert.deepEqual(CRITERES, [
'ecartAuPlafondAPrioriMax',
'excedentCollisions',
'collisionsCumulees',
'toursHorsObjectif',
'rencontresRepetees',
'animateursNonRencontres',
'redondance',
]);
assert.ok(Object.isFrozen(CRITERES));
});
test('la famille de chaque critère', () => {
assert.deepEqual(
CRITERES.map((nom) => FAMILLE[nom]),
['rencontres', 'contrainte', 'contrainte', 'contrainte', 'contrainte', 'raffinement', 'raffinement'],
);
assert.ok(Object.isFrozen(FAMILLE));
});
test('les cases qui font compter chaque critère (tableau de A8)', () => {
assert.deepEqual(CRITERES.map((nom) => CASES[nom]), [
[],
['separerAppartenances'],
['separerAppartenances'],
['respecterObjectifs'],
['nouveauxVoisins', 'nouvelleTable'],
['rencontrerAnimateurs'],
['varierAppartenances'],
]);
assert.ok(Object.isFrozen(CASES) && CRITERES.every((nom) => Object.isFrozen(CASES[nom])));
});
test("l'ordre par défaut : chaque critère seul à son palier, au poids 1 ; le poids au plus 100", () => {
assert.deepEqual(PRIORITES_PAR_DEFAUT, parDefaut());
assert.ok(Object.isFrozen(PRIORITES_PAR_DEFAUT));
assert.ok(PRIORITES_PAR_DEFAUT.every((palier) => Object.isFrozen(palier) && Object.isFrozen(palier[0])));
assert.equal(POIDS_MAX, 100);
});
test('les trois modes, leurs valeurs qui classent et les grandeurs que la recherche descend', () => {
assert.deepEqual(MODES_OBJECTIFS, ['places', 'chacun', 'minimum']);
assert.deepEqual(VALEURS_DES_MODES, {
places: ['toursHorsObjectif'],
chacun: ['jamaisServies', 'toursHorsObjectif'],
minimum: ['horsObjectifMax', 'toursHorsObjectif'],
});
assert.deepEqual(COMPOSANTES_DES_MODES, {
places: ['toursHorsObjectif'],
chacun: ['jamaisServies', 'toursHorsObjectif'],
minimum: ['horsObjectifMax', 'personnesAuMax', 'toursHorsObjectif'],
});
for (const table of [MODES_OBJECTIFS, VALEURS_DES_MODES, COMPOSANTES_DES_MODES]) assert.ok(Object.isFrozen(table));
for (const mode of MODES_OBJECTIFS) {
assert.ok(Object.isFrozen(VALEURS_DES_MODES[mode]) && Object.isFrozen(COMPOSANTES_DES_MODES[mode]));
}
});
test('seulsASonPalier : écart et rencontres répétées ; tours hors objectif sous chacun et minimum (D8, D23)', () => {
assert.deepEqual(seulsASonPalier('places'), ['ecartAuPlafondAPrioriMax', 'rencontresRepetees']);
for (const mode of ['chacun', 'minimum']) {
assert.deepEqual(seulsASonPalier(mode), ['ecartAuPlafondAPrioriMax', 'toursHorsObjectif', 'rencontresRepetees']);
}
assert.deepEqual(seulsASonPalier(), seulsASonPalier('places'));
});
test("composantesDe : deux pour l'écart, celles du mode pour les tours hors objectif, la grandeur seule ailleurs", () => {
const autres = ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'animateursNonRencontres', 'redondance'];
for (const mode of MODES_OBJECTIFS) {
assert.deepEqual(composantesDe('ecartAuPlafondAPrioriMax', mode), [
'ecartAuPlafondAPrioriMax',
'carresEcartsAuPlafondAPriori',
]);
assert.deepEqual(composantesDe('toursHorsObjectif', mode), COMPOSANTES_DES_MODES[mode]);
for (const nom of autres) assert.deepEqual(composantesDe(nom, mode), [nom]);
}
assert.deepEqual(composantesDe('toursHorsObjectif'), ['toursHorsObjectif']);
});
test("un nom de critère ou de mode inconnu lève RangeError, même un nom hérité d'Object", () => {
for (const nom of ['inconnu', '__proto__', 'constructor', 'toString', undefined]) {
assert.throws(() => estCoche(nom, TOUTES_COCHEES), RangeError, String(nom));
assert.throws(() => composantesDe(nom), RangeError, String(nom));
assert.throws(() => valeursQuiClassent(nom, TOUTES_COCHEES), RangeError, String(nom));
}
for (const mode of ['tous', '__proto__', 'constructor', null]) {
assert.throws(() => seulsASonPalier(mode), RangeError, String(mode));
assert.throws(() => composantesDe('redondance', mode), RangeError, String(mode));
assert.throws(() => valeursQuiClassent('redondance', TOUTES_COCHEES, mode), RangeError, String(mode));
assert.throws(() => verifierPriorites(parDefaut(), mode), RangeError, String(mode));
assert.throws(() => compiler(parDefaut(), () => true, mode), RangeError, String(mode));
}
});
});
describe('criteres : les cases (§ 5.3, A8)', () => {
test("l'écart compte toujours ; une case, deux critères de collision ; les rencontres sous l'une ou l'autre case", () => {
for (let masque = 0; masque < 16; masque += 1) {
const c = contraintesDu(masque);
assert.equal(estCoche('ecartAuPlafondAPrioriMax', c), true);
assert.equal(estCoche('excedentCollisions', c), c.separerAppartenances);
assert.equal(estCoche('collisionsCumulees', c), c.separerAppartenances);
assert.equal(estCoche('rencontresRepetees', c), c.nouveauxVoisins || c.nouvelleTable);
assert.equal(estCoche('redondance', c), c.varierAppartenances);
}
});
test('les deux cases facultatives : absentes, les objectifs cochés et les animateurs décochés', () => {
assert.equal(estCoche('toursHorsObjectif', TOUTES_COCHEES), true);
assert.equal(estCoche('animateursNonRencontres', TOUTES_COCHEES), false);
assert.equal(estCoche('toursHorsObjectif', { ...TOUTES_COCHEES, respecterObjectifs: false }), false);
assert.equal(estCoche('toursHorsObjectif', { ...TOUTES_COCHEES, respecterObjectifs: true }), true);
assert.equal(estCoche('animateursNonRencontres', { ...TOUTES_COCHEES, rencontrerAnimateurs: true }), true);
assert.equal(estCoche('animateursNonRencontres', { ...TOUTES_COCHEES, rencontrerAnimateurs: false }), false);
});
});
describe('criteres : verifierPriorites, faute par faute (D8, A12, A16)', () => {
test("l'ordre par défaut est valide sous chaque mode ; le mode par défaut est places", () => {
for (const mode of MODES_OBJECTIFS) assert.equal(verifierPriorites(parDefaut(), mode), null, mode);
assert.equal(verifierPriorites(PRIORITES_PAR_DEFAUT), null);
});
test('un palier partagé, des poids de 1 à 100, un ordre réglé sont valides', () => {
const liste = [
[e('excedentCollisions', 100), e('collisionsCumulees', 3)],
[e('ecartAuPlafondAPrioriMax')],
[e('rencontresRepetees')],
[e('animateursNonRencontres', 2), e('redondance', 7)],
[e('toursHorsObjectif')],
];
for (const mode of MODES_OBJECTIFS) assert.equal(verifierPriorites(liste, mode), null, mode);
});
test("forme : dans un palier, les critères hors de l'ordre du tableau du § 5.7 (A16)", () => {
for (const mode of MODES_OBJECTIFS) {
const inverses = remplacer(remplacer(parDefaut(), 2), 1, [e('collisionsCumulees', 3), e('excedentCollisions', 100)]);
assert.deepEqual(verifierPriorites(inverses, mode), {
faute: 'forme', critere: 'excedentCollisions', palier: 1,
}, mode);
// Le premier critère rangé avant celui qui le précède est nommé, même
// quand les deux autres suivent l'ordre.
const troisieme = remplacer(remplacer(remplacer(parDefaut(), 6), 5), 2, [
e('collisionsCumulees'), e('redondance'), e('animateursNonRencontres'),
]);
assert.deepEqual(verifierPriorites(troisieme, mode), {
faute: 'forme', critere: 'animateursNonRencontres', palier: 2,
}, mode);
assert.throws(
() => compiler(inverses, () => true, mode),
(erreur) => erreur instanceof RangeError && /"forme"/.test(erreur.message),
);
}
// Sous places, où les tours hors objectif partagent un palier, un
// raffinement rangé devant eux fait une faute de forme.
const raffinementEnTete = remplacer(remplacer(parDefaut(), 6), 3, [e('redondance'), e('toursHorsObjectif', 2)]);
assert.deepEqual(verifierPriorites(raffinementEnTete, 'places'), {
faute: 'forme', critere: 'toursHorsObjectif', palier: 3,
});
});
test("forme : une liste, un palier ou une entrée qui n'ont pas la forme attendue", () => {
for (const mode of MODES_OBJECTIFS) {
for (const liste of [undefined, null, 'ecart', {}, { length: 0 }]) {
assert.deepEqual(verifierPriorites(liste, mode), { faute: 'forme' }, `${mode} ${String(liste)}`);
}
for (const palier of [null, e('redondance'), 'redondance']) {
assert.deepEqual(verifierPriorites(remplacer(parDefaut(), 6, palier), mode), { faute: 'forme', palier: 6 });
}
const entrees = [
null,
'redondance',
['redondance', 1],
{ poids: 1 },
{ critere: 'collisionsCumulees' },
{ critere: 3, poids: 1 },
Object.create({ critere: 'collisionsCumulees', poids: 1 }),
];
for (const entree of entrees) {
assert.deepEqual(verifierPriorites(remplacer(parDefaut(), 2, [entree]), mode), { faute: 'forme', palier: 2 });
}
}
});
test('palierVide : un palier sans critère', () => {
for (const mode of MODES_OBJECTIFS) {
const liste = [...parDefaut().slice(0, 3), [], ...parDefaut().slice(3)];
assert.deepEqual(verifierPriorites(liste, mode), { faute: 'palierVide', palier: 3 });
}
});
test("critereInconnu : un nom hors du registre, même hérité d'Object", () => {
for (const mode of MODES_OBJECTIFS) {
for (const nom of ['ecart', 'Redondance', '__proto__', 'constructor', 'hasOwnProperty', '']) {
const liste = [...parDefaut(), [e(nom)]];
assert.deepEqual(verifierPriorites(liste, mode), { faute: 'critereInconnu', critere: nom, palier: 7 });
}
}
});
test('critereRepete : un critère nommé deux fois, dans deux paliers ou dans le même', () => {
for (const mode of MODES_OBJECTIFS) {
assert.deepEqual(verifierPriorites([...parDefaut(), [e('redondance')]], mode), {
faute: 'critereRepete', critere: 'redondance', palier: 7,
});
assert.deepEqual(verifierPriorites(remplacer(parDefaut(), 6, [e('redondance'), e('redondance', 2)]), mode), {
faute: 'critereRepete', critere: 'redondance', palier: 6,
});
}
});
test("critereAbsent : la liste est complète ; le premier absent dans l'ordre du registre est nommé", () => {
for (const mode of MODES_OBJECTIFS) {
assert.deepEqual(verifierPriorites(remplacer(parDefaut(), 5), mode), {
faute: 'critereAbsent', critere: 'animateursNonRencontres',
});
assert.deepEqual(verifierPriorites(remplacer(remplacer(parDefaut(), 5), 1), mode), {
faute: 'critereAbsent', critere: 'excedentCollisions',
});
assert.deepEqual(verifierPriorites([], mode), { faute: 'critereAbsent', critere: 'ecartAuPlafondAPrioriMax' });
}
});
test("poids : hors de 1 à 100, non entier ou non nombre ; différent de 1 pour l'écart et les rencontres répétées", () => {
for (const mode of MODES_OBJECTIFS) {
for (const poids of [0, -1, 101, 1.5, Number.NaN, Infinity, '2', null, undefined]) {
assert.deepEqual(verifierPriorites(remplacer(parDefaut(), 6, [{ critere: 'redondance', poids }]), mode), {
faute: 'poids', critere: 'redondance', palier: 6,
}, `${mode} ${String(poids)}`);
}
for (const [rang, nom] of [[0, 'ecartAuPlafondAPrioriMax'], [4, 'rencontresRepetees']]) {
assert.deepEqual(verifierPriorites(remplacer(parDefaut(), rang, [e(nom, 2)]), mode), {
faute: 'poids', critere: nom, palier: rang,
});
}
assert.equal(verifierPriorites(remplacer(parDefaut(), 6, [e('redondance', 100)]), mode), null);
}
});
test('poids : les tours hors objectif seuls à leur palier au poids 1 sous chacun et minimum, de 1 à 100 sous places (A16, D8)', () => {
for (const poids of [2, 7, 100]) {
const liste = remplacer(parDefaut(), 3, [e('toursHorsObjectif', poids)]);
assert.equal(verifierPriorites(liste, 'places'), null, String(poids));
for (const mode of ['chacun', 'minimum']) {
assert.deepEqual(verifierPriorites(liste, mode), {
faute: 'poids', critere: 'toursHorsObjectif', palier: 3,
}, `${mode} ${poids}`);
assert.throws(() => compiler(liste, () => true, mode), (erreur) => erreur instanceof RangeError && /"poids"/.test(erreur.message));
}
}
});
test('un critère seul à son palier, joint et pondéré : le partage se juge avant le poids 1 et avant l’ordre, sous chaque mode', () => {
for (const mode of MODES_OBJECTIFS) {
// Rangé après l'excédent, contre l'ordre du tableau : le partage prime.
const ecartJoint = remplacer(remplacer(parDefaut(), 1), 0, [e('excedentCollisions'), e('ecartAuPlafondAPrioriMax', 2)]);
assert.deepEqual(verifierPriorites(ecartJoint, mode), {
faute: 'seulASonPalier', critere: 'ecartAuPlafondAPrioriMax', palier: 0,
}, mode);
// Un poids hors du domaine se relève à la lecture de l'entrée, avant le
// partage du palier.
const horsDomaine = remplacer(remplacer(parDefaut(), 1), 0, [e('ecartAuPlafondAPrioriMax', 101), e('excedentCollisions')]);
assert.deepEqual(verifierPriorites(horsDomaine, mode), {
faute: 'poids', critere: 'ecartAuPlafondAPrioriMax', palier: 0,
}, mode);
}
for (const mode of ['chacun', 'minimum']) {
const toursJoints = remplacer(remplacer(parDefaut(), 3), 1, [e('excedentCollisions', 5), e('toursHorsObjectif', 9)]);
assert.deepEqual(verifierPriorites(toursJoints, mode), {
faute: 'seulASonPalier', critere: 'toursHorsObjectif', palier: 1,
}, mode);
}
});
test('seulASonPalier : écart ou rencontres répétées joints à un autre critère, sous chaque mode', () => {
for (const mode of MODES_OBJECTIFS) {
const ecartJoint = remplacer(remplacer(parDefaut(), 1), 0, [e('ecartAuPlafondAPrioriMax'), e('excedentCollisions')]);
assert.deepEqual(verifierPriorites(ecartJoint, mode), {
faute: 'seulASonPalier', critere: 'ecartAuPlafondAPrioriMax', palier: 0,
});
const repeteesJointes = remplacer(remplacer(parDefaut(), 6), 4, [e('rencontresRepetees'), e('redondance')]);
assert.deepEqual(verifierPriorites(repeteesJointes, mode), {
faute: 'seulASonPalier', critere: 'rencontresRepetees', palier: 4,
});
// Joint par un critère rangé avant lui dans le palier.
const repeteesApres = remplacer(remplacer(parDefaut(), 4), 2, [e('collisionsCumulees'), e('rencontresRepetees')]);
assert.deepEqual(verifierPriorites(repeteesApres, mode), {
faute: 'seulASonPalier', critere: 'rencontresRepetees', palier: 2,
});
}
});
test('seulASonPalier : tours hors objectif joints, refusés sous chacun et minimum, admis sous places (D23)', () => {
const joints = remplacer(remplacer(parDefaut(), 3), 1, [e('excedentCollisions'), e('toursHorsObjectif', 2)]);
const enTete = remplacer(remplacer(parDefaut(), 6), 3, [e('toursHorsObjectif', 2), e('redondance')]);
assert.equal(verifierPriorites(joints, 'places'), null);
assert.equal(verifierPriorites(enTete, 'places'), null);
for (const mode of ['chacun', 'minimum']) {
assert.deepEqual(verifierPriorites(joints, mode), { faute: 'seulASonPalier', critere: 'toursHorsObjectif', palier: 1 });
assert.deepEqual(verifierPriorites(enTete, mode), { faute: 'seulASonPalier', critere: 'toursHorsObjectif', palier: 3 });
}
});
test("la première faute dans l'ordre de la liste est rendue", () => {
const liste = [[e('inconnu')], [], ...parDefaut()];
assert.deepEqual(verifierPriorites(liste), { faute: 'critereInconnu', critere: 'inconnu', palier: 0 });
});
});
describe('criteres : completerPriorites (A16)', () => {
test('une liste complète se rend égale, en copie', () => {
const liste = [[e('excedentCollisions', 4), e('collisionsCumulees', 3)], ...remplacer(remplacer(parDefaut(), 2), 1)];
const complete = completerPriorites(liste);
assert.deepEqual(complete, liste);
assert.notEqual(complete, liste);
assert.notEqual(complete[0], liste[0]);
assert.notEqual(complete[0][0], liste[0][0]);
});
test("un critère absent prend sa place de l'ordre par défaut, seul à son palier, au poids 1", () => {
const ancienne = [
[e('excedentCollisions')],
[e('ecartAuPlafondAPrioriMax')],
[e('collisionsCumulees', 2), e('redondance', 5)],
[e('rencontresRepetees')],
];
assert.deepEqual(completerPriorites(Object.freeze(ancienne.map((p) => Object.freeze(p)))), [
[e('excedentCollisions')],
[e('ecartAuPlafondAPrioriMax')],
[e('collisionsCumulees', 2), e('redondance', 5)],
[e('toursHorsObjectif')],
[e('rencontresRepetees')],
[e('animateursNonRencontres')],
]);
assert.equal(verifierPriorites(completerPriorites(ancienne)), null);
});
test("le premier critère absent se place en tête ; une liste vide devient l'ordre par défaut", () => {
assert.deepEqual(completerPriorites([[e('redondance')], [e('excedentCollisions')]]), [
[e('ecartAuPlafondAPrioriMax')],
[e('redondance')],
[e('excedentCollisions')],
[e('collisionsCumulees')],
[e('toursHorsObjectif')],
[e('rencontresRepetees')],
[e('animateursNonRencontres')],
]);
assert.deepEqual(completerPriorites([]), parDefaut());
});
});
describe('criteres : valeursQuiClassent (D9, D20)', () => {
test('les rencontres répétées sous les quatre combinaisons des deux cases de voisinage', () => {
for (const nouveauxVoisins of [false, true]) {
for (const nouvelleTable of [false, true]) {
const c = { ...TOUTES_COCHEES, nouveauxVoisins, nouvelleTable };
for (const mode of MODES_OBJECTIFS) {
assert.deepEqual(
valeursQuiClassent('rencontresRepetees', c, mode),
nouveauxVoisins ? ['rencontresRepeteesChoisies'] : ['retoursChoisis'],
`${nouveauxVoisins} ${nouvelleTable} ${mode}`,
);
}
}
}
});
test('les tours hors objectif selon le mode ; les autres critères, leur grandeur seule', () => {
const autres = ['ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'animateursNonRencontres', 'redondance'];
for (const mode of MODES_OBJECTIFS) {
assert.deepEqual(valeursQuiClassent('toursHorsObjectif', TOUTES_COCHEES, mode), VALEURS_DES_MODES[mode]);
for (const nom of autres) assert.deepEqual(valeursQuiClassent(nom, TOUTES_COCHEES, mode), [nom]);
}
assert.deepEqual(valeursQuiClassent('toursHorsObjectif', TOUTES_COCHEES), ['toursHorsObjectif']);
});
test('la liste rendue est une copie : la modifier ne touche pas le registre', () => {
const valeurs = valeursQuiClassent('toursHorsObjectif', TOUTES_COCHEES, 'chacun');
valeurs.push('autre');
assert.deepEqual(VALEURS_DES_MODES.chacun, ['jamaisServies', 'toursHorsObjectif']);
});
});
describe('criteres : compiler, le défaut contre la recherche sans registre (D11, contrainte 33)', () => {
test('sous les seize combinaisons et chaque mode, composantes et ordres égalent [0..5] et [2, 3, 4, 0, 1, 5] à la composante nulle près', () => {
for (let masque = 0; masque < 16; masque += 1) {
const c = contraintesDu(masque);
const nulles = nullesSansRegistre(c);
const attendus = (ordre) => ordre.filter((k) => !nulles[k]).map((k) => COMPOSANTES_SANS_REGISTRE[k]);
for (const mode of MODES_OBJECTIFS) {
const compilation = compiler(PRIORITES_PAR_DEFAUT, compteDesCases(c), mode);
const { score, contraintes } = ordresNommes(compilation, mode);
const message = `masque ${masque.toString(2).padStart(4, '0')}, ${mode}`;
assert.deepEqual(score, attendus(SCORE_SANS_REGISTRE), message);
assert.deepEqual(contraintes, attendus(CONTRAINTES_SANS_REGISTRE), message);
assert.equal(compilation.taille, score.length, message);
assert.deepEqual(compilation.ordreScore, [...Array(compilation.taille).keys()], message);
assert.equal(compilation.rangEcart, 0, message);
}
}
});
test("sous les quatre cases cochées : cinq paliers d'un critère, de poids 1, dans l'ordre fixe de référence", () => {
const { paliers, taille } = compiler(PRIORITES_PAR_DEFAUT, compteDesCases(TOUTES_COCHEES), 'places');
assert.deepEqual(paliers, [
{ famille: 'rencontres', criteres: [{ nom: 'ecartAuPlafondAPrioriMax', poids: 1 }], composantes: [0, 1] },
{ famille: 'contrainte', criteres: [{ nom: 'excedentCollisions', poids: 1 }], composantes: [2] },
{ famille: 'contrainte', criteres: [{ nom: 'collisionsCumulees', poids: 1 }], composantes: [3] },
{ famille: 'contrainte', criteres: [{ nom: 'rencontresRepetees', poids: 1 }], composantes: [4] },
{ famille: 'raffinement', criteres: [{ nom: 'redondance', poids: 1 }], composantes: [5] },
]);
assert.equal(taille, 6);
});
test('sans objectif, les trois modes compilent la même chose, sous chaque combinaison des cases', () => {
for (let masque = 0; masque < 16; masque += 1) {
const compte = compteDesCases(contraintesDu(masque));
const reference = compiler(PRIORITES_PAR_DEFAUT, compte, 'places');
for (const mode of ['chacun', 'minimum']) assert.deepEqual(compiler(PRIORITES_PAR_DEFAUT, compte, mode), reference);
}
});
});
describe('criteres : compiler, les paliers et la bascule (A11, D15)', () => {
test("l'écart déplacé en troisième : la bascule le range avant le premier palier raffinement qui le suit", () => {
const liste = [
[e('excedentCollisions')],
[e('collisionsCumulees')],
[e('ecartAuPlafondAPrioriMax')],
[e('toursHorsObjectif')],
[e('rencontresRepetees')],
[e('animateursNonRencontres')],
[e('redondance')],
];
const compilation = compiler(liste, compteDesCases(TOUTES_COCHEES));
assert.equal(compilation.rangEcart, 2);
assert.deepEqual(ordresNommes(compilation), {
score: ['excedentCollisions', 'collisionsCumulees', 'ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori', 'rencontresRepetees', 'redondance'],
contraintes: ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori', 'redondance'],
});
assert.deepEqual(compilation.ordreContraintes, [0, 1, 4, 2, 3, 5]);
});
test("tours hors objectif avant l'écart : rangEcart est le rang de la composante du plus grand écart, non celui de son palier", () => {
const liste = [
[e('toursHorsObjectif')],
[e('ecartAuPlafondAPrioriMax')],
[e('excedentCollisions')],
[e('collisionsCumulees')],
[e('rencontresRepetees')],
[e('animateursNonRencontres')],
[e('redondance')],
];
const attendus = {
places: { rangEcart: 1, ordreContraintes: [0, 3, 4, 5, 1, 2, 6, 7] },
chacun: { rangEcart: 2, ordreContraintes: [0, 1, 4, 5, 6, 2, 3, 7, 8] },
minimum: { rangEcart: 3, ordreContraintes: [0, 1, 2, 5, 6, 7, 3, 4, 8, 9] },
};
for (const mode of MODES_OBJECTIFS) {
const { rangEcart, ordreContraintes, ordreScore, taille } = compiler(liste, () => true, mode);
assert.deepEqual({ rangEcart, ordreContraintes }, attendus[mode], mode);
assert.deepEqual(ordreScore, [...Array(taille).keys()], mode);
}
const compilation = compiler(liste, () => true, 'minimum');
assert.deepEqual(ordresNommes(compilation, 'minimum').contraintes, [
'horsObjectifMax', 'personnesAuMax', 'toursHorsObjectif',
'excedentCollisions', 'collisionsCumulees', 'rencontresRepetees',
'ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori',
'animateursNonRencontres', 'redondance',
]);
});
test("l'écart en dernier : les deux ordres sont ceux du score", () => {
const liste = [...parDefaut().slice(1), [e('ecartAuPlafondAPrioriMax')]];
const compilation = compiler(liste, () => true);
assert.equal(compilation.rangEcart, 6);
assert.deepEqual(compilation.ordreContraintes, compilation.ordreScore);
});
test("aucun raffinement après l'écart : l'écart passe à la fin", () => {
const liste = [
[e('animateursNonRencontres')],
[e('ecartAuPlafondAPrioriMax')],
[e('excedentCollisions')],
[e('collisionsCumulees'), e('redondance')],
[e('toursHorsObjectif')],
[e('rencontresRepetees')],
];
const compilation = compiler(liste, () => true);
assert.deepEqual(
compilation.paliers.map(({ famille }) => famille),
['raffinement', 'rencontres', 'contrainte', 'contrainte', 'contrainte', 'contrainte'],
);
assert.equal(compilation.rangEcart, 1);
assert.deepEqual(compilation.ordreContraintes, [0, 3, 4, 5, 6, 1, 2]);
});
test('palier mixte { tours hors objectif × 2, redondance × 1 } : contrainte, une composante', () => {
const liste = remplacer(remplacer(parDefaut(), 6), 3, [e('toursHorsObjectif', 2), e('redondance')]);
const compilation = compiler(liste, () => true, 'places');
assert.deepEqual(compilation.paliers[3], {
famille: 'contrainte',
criteres: [{ nom: 'toursHorsObjectif', poids: 2 }, { nom: 'redondance', poids: 1 }],
composantes: [4],
});
assert.equal(compilation.taille, 7);
// La bascule ne s'arrête pas au palier mixte : le premier raffinement
// qui suit l'écart est celui des animateurs.
assert.deepEqual(ordresNommes(compilation).contraintes.slice(2, 5), [
'toursHorsObjectif+redondance',
'rencontresRepetees',
'ecartAuPlafondAPrioriMax',
]);
});
test("palier de deux raffinements : raffinement, une composante, où la bascule s'arrête", () => {
const liste = remplacer(remplacer(parDefaut(), 6), 5, [e('animateursNonRencontres', 3), e('redondance', 2)]);
const compilation = compiler(liste, () => true);
assert.deepEqual(compilation.paliers.at(-1), {
famille: 'raffinement',
criteres: [{ nom: 'animateursNonRencontres', poids: 3 }, { nom: 'redondance', poids: 2 }],
composantes: [6],
});
assert.deepEqual(compilation.ordreContraintes, [2, 3, 4, 5, 0, 1, 6]);
});
test("objectifs et animateurs comptés : un palier chacun, aux rangs de l'ordre par défaut", () => {
const compilation = compiler(PRIORITES_PAR_DEFAUT, () => true, 'places');
assert.deepEqual(ordresNommes(compilation), {
score: [
'ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori', 'excedentCollisions', 'collisionsCumulees',
'toursHorsObjectif', 'rencontresRepetees', 'animateursNonRencontres', 'redondance',
],
contraintes: [
'excedentCollisions', 'collisionsCumulees', 'toursHorsObjectif', 'rencontresRepetees',
'ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori', 'animateursNonRencontres', 'redondance',
],
});
assert.equal(compilation.taille, 8);
});
test('un palier dont aucun critère ne compte disparaît ; un critère qui ne compte pas sort de son palier', () => {
const liste = remplacer(remplacer(parDefaut(), 6), 5, [e('animateursNonRencontres', 3), e('redondance', 2)]);
const compilation = compiler(liste, comptant('ecartAuPlafondAPrioriMax', 'collisionsCumulees', 'redondance'));
assert.deepEqual(compilation.paliers, [
{ famille: 'rencontres', criteres: [{ nom: 'ecartAuPlafondAPrioriMax', poids: 1 }], composantes: [0, 1] },
{ famille: 'contrainte', criteres: [{ nom: 'collisionsCumulees', poids: 1 }], composantes: [2] },
{ famille: 'raffinement', criteres: [{ nom: 'redondance', poids: 2 }], composantes: [3] },
]);
assert.deepEqual(compilation.ordreContraintes, [2, 0, 1, 3]);
});
test('un palier mixte dont seul le raffinement compte devient raffinement', () => {
const liste = remplacer(remplacer(parDefaut(), 6), 3, [e('toursHorsObjectif', 2), e('redondance')]);
const compilation = compiler(liste, (nom) => nom !== 'toursHorsObjectif');
assert.deepEqual(compilation.paliers[3], {
famille: 'raffinement', criteres: [{ nom: 'redondance', poids: 1 }], composantes: [4],
});
assert.deepEqual(compilation.ordreContraintes, [2, 3, 0, 1, 4, 5, 6]);
});
test("l'écart qui ne compte pas (plafond a priori inconnu) : aucune bascule, rangEcart nul", () => {
const compilation = compiler(PRIORITES_PAR_DEFAUT, (nom) => nom !== 'ecartAuPlafondAPrioriMax');
assert.equal(compilation.rangEcart, null);
assert.deepEqual(compilation.ordreContraintes, compilation.ordreScore);
// Deux listes distinctes : permuter l'une ne touche pas l'autre.
assert.notEqual(compilation.ordreContraintes, compilation.ordreScore);
assert.equal(compilation.paliers[0].criteres[0].nom, 'excedentCollisions');
});
test('aucun critère qui compte : un n-uplet vide', () => {
assert.deepEqual(compiler(PRIORITES_PAR_DEFAUT, () => false, 'minimum'), {
paliers: [], ordreScore: [], ordreContraintes: [], rangEcart: null, taille: 0,
});
});
test('compte est lu une fois par critère, par son nom, dans l’ordre de la liste', () => {
const lus = [];
compiler(PRIORITES_PAR_DEFAUT, (nom) => { lus.push(nom); return true; });
assert.deepEqual(lus, CRITERES);
});
test('une liste invalide lève RangeError, qui nomme la faute', () => {
assert.throws(() => compiler([], () => true), (erreur) => erreur instanceof RangeError && /critereAbsent/.test(erreur.message));
const joints = remplacer(remplacer(parDefaut(), 3), 1, [e('excedentCollisions'), e('toursHorsObjectif')]);
assert.throws(() => compiler(joints, () => true, 'chacun'), (erreur) => erreur instanceof RangeError && /seulASonPalier/.test(erreur.message));
assert.equal(compiler(joints, () => true, 'places').taille, 7);
});
});
describe('criteres : compiler, les composantes des tours hors objectif selon le mode (D20)', () => {
test('une, deux et trois composantes consécutives, dans leur palier ; la bascule les garde ensemble', () => {
const attendues = {
places: ['toursHorsObjectif'],
chacun: ['jamaisServies', 'toursHorsObjectif'],
minimum: ['horsObjectifMax', 'personnesAuMax', 'toursHorsObjectif'],
};
for (const mode of MODES_OBJECTIFS) {
const compilation = compiler(PRIORITES_PAR_DEFAUT, () => true, mode);
const largeur = attendues[mode].length;
assert.deepEqual(compilation.paliers[3].composantes, [...Array(largeur).keys()].map((k) => 4 + k), mode);
assert.equal(compilation.taille, 7 + largeur, mode);
const { score, contraintes } = ordresNommes(compilation, mode);
assert.deepEqual(score.slice(4, 4 + largeur), attendues[mode], mode);
assert.deepEqual(contraintes.slice(2, 2 + largeur), attendues[mode], mode);
assert.deepEqual(
contraintes.slice(2 + largeur + 1, 2 + largeur + 3),
['ecartAuPlafondAPrioriMax', 'carresEcartsAuPlafondAPriori'],
mode,
);
}
});
test('aucune composante quand les tours hors objectif ne comptent pas, quel que soit le mode', () => {
for (const mode of MODES_OBJECTIFS) {
const compilation = compiler(PRIORITES_PAR_DEFAUT, (nom) => nom !== 'toursHorsObjectif', mode);
assert.equal(compilation.taille, 7, mode);
const noms = nomsDesComposantes(compilation, mode);
assert.ok(!noms.some((nom) => COMPOSANTES_DES_MODES[mode].includes(nom)), mode);
}
});
test('la taille ne dépasse pas dix, atteinte sous minimum avec tout compté', () => {
assert.equal(compiler(PRIORITES_PAR_DEFAUT, () => true, 'minimum').taille, 10);
});
});
describe('criteres : la source du registre', () => {
test("aucune Map, aucun parcours de clés d'objet, aucun code de faute en capitales", () => {
const source = readFileSync(new URL('./criteres.js', import.meta.url), 'utf8');
for (const motif of [/new Map\b/, /Object\.(keys|values|entries)\(/, /\bfor\s*\([^)]*\bin\b/, /faute: '[A-Z]/]) {
assert.doesNotMatch(source, motif);
}
});
});

View file

@ -2080,9 +2080,12 @@ describe('arborescence : un seul propriétaire des valeurs du contrat (types.js)
});
});
// Modules que le § 14.13 tient à 100 % de branches : le module des indicateurs
// et du plafond, et les chemins de reprise du stockage.
// 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.
const MODULES_A_SEUIL = [
'src/moteur/correspondance.js',
'src/moteur/criteres.js',
'src/moteur/indicateurs.js',
'src/moteur/plafond.js',
'src/stockage/depot.js',
@ -2196,7 +2199,7 @@ function releverCouverture(racine, couverture) {
}
// Une mesure conforme au § 14.13, que les données d'épreuve altèrent une
// pièce à la fois : les quatre modules, le script de package.json, la recette
// pièce à la fois : les modules nommés, le script de package.json, la recette
// du Makefile, et la configuration de couverture.
const scriptDeMesure = (script) => JSON.stringify({ scripts: { couverture: script } });
const MODULES_MESURES = Object.freeze(Object.fromEntries(MODULES_A_SEUIL.map((module) => [module, ''])));
@ -2224,6 +2227,8 @@ describe('arborescence : la couverture (§ 14.13)', () => {
thresholds: {
branches: 90,
perFile: true,
'src/moteur/correspondance.js': { branches: 100 },
'src/moteur/criteres.js': { branches: 100 },
'src/moteur/indicateurs.js': { branches: 100 },
'src/moteur/plafond.js': { branches: 95 },
'src/stockage/journal.js': { branches: 100, lines: 100 },
@ -2232,6 +2237,8 @@ describe('arborescence : la couverture (§ 14.13)', () => {
};
const fichiers = {
...COMMANDE_CONFORME,
'src/moteur/correspondance.js': '',
'src/moteur/criteres.js': '',
'src/moteur/indicateurs.js': '',
'src/stockage/depot.js': '',
'src/stockage/journal.js': '',
@ -2248,7 +2255,13 @@ describe('arborescence : la couverture (§ 14.13)', () => {
const sansInclude = { ...couverture, include: undefined, exclude: undefined, thresholds: {} };
assert.deepEqual(
avecArbre(fichiers, (racine) => releverCouverture(racine, sansInclude)).filter((e) => e.endsWith('mesure')),
['src/moteur/indicateurs.js : hors de la mesure', 'src/stockage/depot.js : hors de la mesure', 'src/stockage/journal.js : hors de la mesure'],
[
'src/moteur/correspondance.js : hors de la mesure',
'src/moteur/criteres.js : hors de la mesure',
'src/moteur/indicateurs.js : hors de la mesure',
'src/stockage/depot.js : hors de la mesure',
'src/stockage/journal.js : hors de la mesure',
],
);
assert.throws(() => releverCouverture(RACINE, undefined), /aucune configuration de couverture/);
});

View file

@ -103,10 +103,10 @@ 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é. Quatre modules tiennent 100 % de branches, seuil dur : la
// 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 quatre seuils, ces deux rapports
// test/arborescence.test.js garde ces six seuils, ces deux rapports
// et les deux commandes qui lancent la mesure.
coverage: {
provider: 'v8',
@ -115,6 +115,8 @@ export default defineConfig({
reporter: [['text', { skipFull: false }], 'html'],
reportsDirectory: 'coverage',
thresholds: {
'src/moteur/correspondance.js': { branches: 100 },
'src/moteur/criteres.js': { branches: 100 },
'src/moteur/indicateurs.js': { branches: 100 },
'src/moteur/plafond.js': { branches: 100 },
'src/stockage/depot.js': { branches: 100 },