/** * Suite du moteur paramétrique — « Réglage collectif » (BLUEPRINT-V2.md * Δ2, Δ3, Δ15, Δ16). Specs verrouillées testées ici : * médiane BASSE élément par élément (pair ⇒ élément central inférieur, * step respecté par construction), sum100 avec EXACTEMENT une part dérivée, * clamp + renormalisation proportionnelle à la cristallisation, * « Pour moi » linear-share (jamais de chiffre inventé), bimodalité * heuristique jamais bloquante, dégénérescences 0 vote. */ import type { ParamSpec } from '../../app/types/domain' import { describe, expect, it } from 'vitest' import { computeMyImpact, crystallize, detectBimodality, medianByElement, resolveDerived, validateParamSpec, validateVote, } from '../../app/engine/parametric' // --------------------------------------------------------------------------- // Specs de référence // --------------------------------------------------------------------------- /** Spec sum100 : 2 parts votées + 1 part dérivée large (bornes 0–100). */ function sum100Spec(overrides: Partial = {}): ParamSpec { return { constraint: 'sum100', params: [ { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, ], ...overrides, } } /** Spec sans contrainte : 2 curseurs bornés. */ function sliderSpec(): ParamSpec { return { constraint: 'none', params: [ { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1, baseline: 12 }, { key: 'taux', label: 'Taux (%)', kind: 'slider', min: 0, max: 10, step: 0.5 }, ], } } // --------------------------------------------------------------------------- // medianByElement — médiane basse élément par élément // --------------------------------------------------------------------------- describe('medianByElement — médiane basse élément par élément', () => { it('nombre impair de votes : élément central classique', () => { expect(medianByElement([[10], [30], [20]])).toEqual([20]) }) it('nombre pair de votes : élément central INFÉRIEUR (médiane basse)', () => { // Colonne triée [10, 20, 30, 40] : index floor((4-1)/2) = 1 ⇒ 20, pas 25. expect(medianByElement([[40], [10], [30], [20]])).toEqual([20]) }) it('la médiane paire est la valeur votée inférieure des deux centrales', () => { const votes = [[15], [25], [40], [60]] const [median] = medianByElement(votes) expect(median).toBe(25) // jamais 32.5 (moyenne des centrales) expect(votes.flat()).toContain(median) // une position que quelqu'un a votée }) it('step respecté par construction : la médiane appartient à la grille votée', () => { // Votes conformes au pas 5 : la médiane basse est l'un d'eux, donc conforme. const votes = [[15], [25], [40], [60]] const [median] = medianByElement(votes) expect((median - 0) % 5).toBe(0) }) it('élément par élément : chaque colonne a sa propre médiane', () => { const votes = [ [10, 90], [20, 80], [30, 70], ] expect(medianByElement(votes)).toEqual([20, 80]) }) it('deux votes : médiane basse = le plus petit des deux', () => { expect(medianByElement([[50, 10], [40, 20]])).toEqual([40, 10]) }) it('0 vote ⇒ [] (l’appelant gère la baseline)', () => { expect(medianByElement([])).toEqual([]) }) }) // --------------------------------------------------------------------------- // validateParamSpec // --------------------------------------------------------------------------- describe('validateParamSpec — validation de la spec à la création', () => { it('accepte une spec sum100 valide (exactement une part dérivée)', () => { expect(() => validateParamSpec(sum100Spec())).not.toThrow() }) it('accepte une spec de curseurs sans contrainte', () => { expect(() => validateParamSpec(sliderSpec())).not.toThrow() }) it('rejette plus de 7 paramètres', () => { const spec: ParamSpec = { constraint: 'none', params: Array.from({ length: 8 }, (_, i) => ({ key: `p${i}`, label: `Paramètre ${i}`, kind: 'slider' as const, min: 0, max: 10, step: 1, })), } expect(() => validateParamSpec(spec)).toThrow(/maximum 7/) }) it('rejette une spec sans aucun paramètre', () => { expect(() => validateParamSpec({ constraint: 'none', params: [] })).toThrow(/Au moins un paramètre/) }) it('rejette un libellé métier manquant', () => { const spec = sliderSpec() spec.params[0].label = ' ' expect(() => validateParamSpec(spec)).toThrow(/Libellé métier manquant/) }) it('rejette sum100 sans part dérivée (0 derived)', () => { const spec = sum100Spec() delete spec.params[2].derived expect(() => validateParamSpec(spec)).toThrow(/exactement une part dérivée/) }) it('rejette sum100 avec deux parts dérivées (2 derived)', () => { const spec = sum100Spec() spec.params[1].derived = true expect(() => validateParamSpec(spec)).toThrow(/2 trouvées/) }) it('rejette un derived posé sur un curseur (kind slider)', () => { const spec = sum100Spec() spec.params[2].kind = 'slider' expect(() => validateParamSpec(spec)).toThrow(/part/) }) it('rejette un derived sans contrainte pour le résoudre', () => { const spec = sliderSpec() spec.params[1].derived = true expect(() => validateParamSpec(spec)).toThrow(/sans contrainte/) }) it('rejette des bornes incohérentes (min >= max)', () => { const spec = sliderSpec() spec.params[0].min = 24 spec.params[0].max = 24 expect(() => validateParamSpec(spec)).toThrow(/Bornes incohérentes/) }) it('rejette un pas nul ou négatif (step <= 0)', () => { const spec = sliderSpec() spec.params[1].step = 0 expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/) spec.params[1].step = -1 expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/) }) it('rejette une baseline hors bornes', () => { const spec = sliderSpec() spec.params[0].baseline = 25 // max 24 expect(() => validateParamSpec(spec)).toThrow(/hors bornes/) }) }) // --------------------------------------------------------------------------- // validateVote // --------------------------------------------------------------------------- describe('validateVote — validation d’un vote', () => { it('accepte un vote valide (ordre des params hors derived)', () => { expect(() => validateVote(sum100Spec(), [60, 30])).not.toThrow() expect(() => validateVote(sliderSpec(), [12, 2.5])).not.toThrow() }) it('rejette une longueur fausse (le derived ne se vote pas)', () => { expect(() => validateVote(sum100Spec(), [60, 30, 10])).toThrow(/Nombre de valeurs invalide/) expect(() => validateVote(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/) }) it('rejette NaN et Infinity (sanitize)', () => { expect(() => validateVote(sum100Spec(), [Number.NaN, 30])).toThrow(/nombre fini/) expect(() => validateVote(sum100Spec(), [60, Number.POSITIVE_INFINITY])).toThrow(/nombre fini/) expect(() => validateVote(sliderSpec(), [Number.NEGATIVE_INFINITY, 2])).toThrow(/nombre fini/) }) it('rejette une valeur hors bornes', () => { expect(() => validateVote(sliderSpec(), [0, 2])).toThrow(/hors bornes/) // min 1 expect(() => validateVote(sliderSpec(), [12, 10.5])).toThrow(/hors bornes/) // max 10 }) it('rejette une valeur non alignée sur le pas', () => { expect(() => validateVote(sum100Spec(), [62, 30])).toThrow(/pas/) // pas de 5 expect(() => validateVote(sliderSpec(), [12, 2.3])).toThrow(/pas/) // pas de 0.5 }) it('tolérance flottante : 0.1 + 0.2 est accepté sur une grille de 0.1', () => { const spec: ParamSpec = { constraint: 'none', params: [{ key: 't', label: 'Taux', kind: 'slider', min: 0, max: 1, step: 0.1 }], } expect(() => validateVote(spec, [0.1 + 0.2])).not.toThrow() // 0.30000000000000004 }) it('sum100 : rejette le vote si le derived résolu sort de ses bornes', () => { const spec = sum100Spec() spec.params[2].max = 15 // réserve bornée [0, 15] // 60 + 30 = 90 ⇒ derived 10 : OK ; 50 + 30 = 80 ⇒ derived 20 : rejet. expect(() => validateVote(spec, [60, 30])).not.toThrow() expect(() => validateVote(spec, [50, 30])).toThrow(/sortirait de ses bornes/) // 105 votés ⇒ derived −5 < min 0 : rejet aussi. expect(() => validateVote(spec, [70, 35])).toThrow(/sortirait de ses bornes/) }) it('sum100 : le derived exactement sur sa borne est accepté', () => { const spec = sum100Spec() spec.params[2].max = 5 expect(() => validateVote(spec, [65, 30])).not.toThrow() // derived = 5 pile }) it('sum100 : un curseur hors contrainte ne compte pas dans la somme', () => { const spec: ParamSpec = { constraint: 'sum100', params: [ { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, ], } // Le curseur vaut 24 : s'il comptait, derived = 100 − 24 − 60 = 16 ; // il ne compte pas ⇒ derived = 40, dans [0, 100]. expect(() => validateVote(spec, [24, 60])).not.toThrow() }) }) // --------------------------------------------------------------------------- // resolveDerived // --------------------------------------------------------------------------- describe('resolveDerived — résolution linéaire du derived', () => { it('insère le derived à sa position dans l’ordre de spec.params', () => { expect(resolveDerived(sum100Spec(), [60, 30])).toEqual([60, 30, 10]) }) it('derived en position centrale : l’ordre est préservé', () => { const spec: ParamSpec = { constraint: 'sum100', params: [ { key: 'a', label: 'Part A', kind: 'share', min: 0, max: 100, step: 1 }, { key: 'r', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, { key: 'b', label: 'Part B', kind: 'share', min: 0, max: 100, step: 1 }, ], } expect(resolveDerived(spec, [40, 35])).toEqual([40, 25, 35]) }) it('les curseurs hors contrainte passent tels quels', () => { expect(resolveDerived(sliderSpec(), [12, 2.5])).toEqual([12, 2.5]) }) it('sum100 avec curseur : seul les parts entrent dans le calcul du derived', () => { const spec: ParamSpec = { constraint: 'sum100', params: [ { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, ], } expect(resolveDerived(spec, [24, 60])).toEqual([24, 60, 40]) }) it('rejette une longueur fausse', () => { expect(() => resolveDerived(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/) }) }) // --------------------------------------------------------------------------- // crystallize // --------------------------------------------------------------------------- describe('crystallize — médiane basse des parts votées puis résolution', () => { it('cas nominal : médiane basse puis derived résolu, somme 100', () => { const votes = [ [50, 20], [60, 30], [70, 40], ] const out = crystallize(sum100Spec(), votes) expect(out).toEqual([60, 30, 10]) expect(out[0] + out[1] + out[2]).toBe(100) }) it('derived dans ses bornes : aucun clamp, valeurs médianes intactes', () => { const spec = sum100Spec() spec.params[2].max = 15 const out = crystallize(spec, [[60, 30], [65, 30], [55, 35]]) expect(out).toEqual([60, 30, 10]) // derived 10 ≤ 15 : rien à clamper }) it('cas chiffré exact : derived clampé à 5, parts renormalisées ×95/90', () => { // Médiane des parts votées = {60, 30} ⇒ derived résolu 10 > max 5. // Spec verrouillée : clamp à 5, puis parts × (100 − 5)/(60 + 30). const spec = sum100Spec() spec.params[2].max = 5 const votes = [ [50, 20], [60, 30], [70, 40], ] const out = crystallize(spec, votes) expect(out[2]).toBe(5) expect(out[0]).toBeCloseTo((60 * 95) / 90, 10) expect(out[1]).toBeCloseTo((30 * 95) / 90, 10) expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10) }) it('clamp à la borne min : même renormalisation proportionnelle', () => { // Médiane {60, 30} ⇒ derived 10 < min 20 : clamp à 20, parts × 80/90. const spec = sum100Spec() spec.params[2].min = 20 const out = crystallize(spec, [[60, 30]]) expect(out[2]).toBe(20) expect(out[0]).toBeCloseTo((60 * 80) / 90, 10) expect(out[1]).toBeCloseTo((30 * 80) / 90, 10) expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10) }) it('les curseurs hors contrainte ne sont jamais renormalisés', () => { const spec: ParamSpec = { constraint: 'sum100', params: [ { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 5, step: 1, derived: true }, ], } const out = crystallize(spec, [[12, 60, 30]]) expect(out[0]).toBe(12) // le curseur passe tel quel expect(out[3]).toBe(5) expect(out[1] + out[2] + out[3]).toBeCloseTo(100, 10) }) it('0 vote ⇒ vecteur des baselines (baseline ?? min)', () => { const spec = sum100Spec() spec.params[0].baseline = 50 spec.params[2].baseline = 20 // params[1] sans baseline ⇒ min (0). expect(crystallize(spec, [])).toEqual([50, 0, 20]) }) it('0 vote sans aucune baseline ⇒ vecteur des min', () => { expect(crystallize(sliderSpec(), [])).toEqual([12, 0]) // duree a baseline 12, taux min 0 }) it('sans contrainte : médiane basse simple, aucun derived', () => { const out = crystallize(sliderSpec(), [[6, 2], [12, 4], [18, 3], [24, 5]]) expect(out).toEqual([12, 3]) // médiane basse de chaque colonne }) }) // --------------------------------------------------------------------------- // computeMyImpact // --------------------------------------------------------------------------- describe('computeMyImpact — carte « Pour moi » (linear-share)', () => { function impactSpec(): ParamSpec { const spec = sum100Spec() spec.impactAttrKey = 'heures/mois' return spec } it('cas nominal chiffré : quote-parts au prorata de l’attribut déclaré', () => { // 1 200 € ; valeurs [50, 30, 20] ; moi 10 h, corpus [10, 20, 10] ⇒ Σ 40. // montants : 600, 360, 240 ; ma quote-part = montant × 10/40. const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10]) expect(result).not.toBeNull() expect(result!.perParam).toEqual([ { key: 'bois', label: 'Atelier bois', amount: 150 }, { key: 'metal', label: 'Atelier métal', amount: 90 }, { key: 'reserve', label: 'Réserve (calculé)', amount: 60 }, ]) expect(result!.total).toBeCloseTo(300, 10) }) it('le derived est inclus dans les quote-parts (chaque part compte)', () => { const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10]) expect(result!.perParam.map(l => l.key)).toContain('reserve') }) it('null si mon attribut est absent — jamais de chiffre inventé', () => { expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], undefined, [10, 20])).toBeNull() }) it('null si Σ des attributs du corpus vaut 0', () => { expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [0, 0, 0])).toBeNull() expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [])).toBeNull() }) it('null hors sum100, sans montant ou sans impactAttrKey', () => { const noConstraint = sliderSpec() noConstraint.impactAttrKey = 'heures/mois' expect(computeMyImpact(noConstraint, { amount: 1200 }, [12, 2.5], 10, [10, 20])).toBeNull() expect(computeMyImpact(impactSpec(), {}, [50, 30, 20], 10, [10, 20])).toBeNull() const noKey = sum100Spec() // pas d'impactAttrKey expect(computeMyImpact(noKey, { amount: 1200 }, [50, 30, 20], 10, [10, 20])).toBeNull() }) it('les curseurs hors contrainte n’ont pas de quote-part', () => { const spec: ParamSpec = { constraint: 'sum100', impactAttrKey: 'heures/mois', params: [ { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 }, { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 }, { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true }, ], } const result = computeMyImpact(spec, { amount: 1000 }, [12, 60, 40], 5, [5, 5]) expect(result!.perParam.map(l => l.key)).toEqual(['bois', 'reserve']) expect(result!.total).toBeCloseTo(500, 10) // (600 + 400) × 5/10 }) }) // --------------------------------------------------------------------------- // detectBimodality // --------------------------------------------------------------------------- describe('detectBimodality — heuristique jamais bloquante', () => { it('bimodal net : deux paquets séparés par un grand écart ⇒ vrai', () => { // Triées : [10, 11, 12, 60, 61, 62] — étendue 52, gap max 48 > 40 %. expect(detectBimodality([10, 60, 11, 61, 12, 62])).toBe(true) }) it('distribution uniforme ⇒ faux', () => { // Gaps réguliers de 10 sur une étendue de 40 : 10 < 16. expect(detectBimodality([10, 20, 30, 40, 50])).toBe(false) }) it('moins de 4 valeurs ⇒ toujours faux', () => { expect(detectBimodality([0, 100])).toBe(false) expect(detectBimodality([0, 50, 100])).toBe(false) expect(detectBimodality([])).toBe(false) }) it('un simple outlier (1 seule valeur d’un côté du gap) ⇒ faux', () => { // Gap max 50 entre 0 et 50, mais une seule valeur à gauche. expect(detectBimodality([0, 50, 51, 52, 53])).toBe(false) }) it('valeurs toutes identiques (étendue nulle) ⇒ faux', () => { expect(detectBimodality([5, 5, 5, 5])).toBe(false) }) it('deux paires exactement de part et d’autre du gap ⇒ vrai (cas limite n=4)', () => { expect(detectBimodality([0, 1, 99, 100])).toBe(true) }) it('jamais bloquante : les valeurs non finies sont ignorées, pas d’exception', () => { expect(() => detectBimodality([Number.NaN, Number.POSITIVE_INFINITY, 1, 2])).not.toThrow() expect(detectBimodality([Number.NaN, 0, 1, 99, 100])).toBe(true) }) })