Files
decision/frontend/tests/engine/parametric.spec.ts
T
YvvandClaude Fable 5 f707b5f15d v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:22:19 +02:00

481 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 0100). */
function sum100Spec(overrides: Partial<ParamSpec> = {}): 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 ⇒ [] (lappelant 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 dun 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 lordre de spec.params', () => {
expect(resolveDerived(sum100Spec(), [60, 30])).toEqual([60, 30, 10])
})
it('derived en position centrale : lordre 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 lattribut 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 nont 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 dun 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 dautre 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 dexception', () => {
expect(() => detectBimodality([Number.NaN, Number.POSITIVE_INFINITY, 1, 2])).not.toThrow()
expect(detectBimodality([Number.NaN, 0, 1, 99, 100])).toBe(true)
})
})