diff --git a/src/moteur/correspondance.js b/src/moteur/correspondance.js new file mode 100644 index 0000000..437197d --- /dev/null +++ b/src/moteur/correspondance.js @@ -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); +} diff --git a/src/moteur/correspondance.test.js b/src/moteur/correspondance.test.js new file mode 100644 index 0000000..c324dfd --- /dev/null +++ b/src/moteur/correspondance.test.js @@ -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); + }); +}); diff --git a/src/moteur/criteres.js b/src/moteur/criteres.js new file mode 100644 index 0000000..f9e6d17 --- /dev/null +++ b/src/moteur/criteres.js @@ -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} 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} 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>} liste + * @returns {Array>} + */ +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>} 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, + }; +} diff --git a/src/moteur/criteres.test.js b/src/moteur/criteres.test.js new file mode 100644 index 0000000..7e42456 --- /dev/null +++ b/src/moteur/criteres.test.js @@ -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); + } + }); +}); diff --git a/test/arborescence.test.js b/test/arborescence.test.js index 79b0de4..554f9c0 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -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/); }); diff --git a/vitest.config.js b/vitest.config.js index a11814d..4d6cec6 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -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 },