forked from yvv/decision
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>
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* 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> = {}): 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user