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,168 @@
|
||||
/**
|
||||
* electionResult — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4).
|
||||
*
|
||||
* Contract under test: the caller passes the LAST ACTIVE votes only
|
||||
* (supersedesVoteId chains resolved by the store) — one vote per voter.
|
||||
* Blank vote = no choicePersonId: counts for participation, never for
|
||||
* designation. The engine NEVER breaks a tie.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Vote } from '../../app/types/domain'
|
||||
import { electionResult } from '../../app/engine/threshold'
|
||||
|
||||
let seq = 0
|
||||
|
||||
/** Build a minimal last-active Vote; omit choicePersonId for a blank. */
|
||||
function vote(choicePersonId?: string): Vote {
|
||||
seq++
|
||||
return {
|
||||
id: `vote-${seq}`,
|
||||
collectiveId: 'col-1',
|
||||
createdAt: '2026-08-11T00:00:00Z',
|
||||
updatedAt: '2026-08-11T00:00:00Z',
|
||||
sessionId: 'session-1',
|
||||
voterId: `voter-${seq}`,
|
||||
choicePersonId,
|
||||
}
|
||||
}
|
||||
|
||||
/** n votes for the same person (or n blanks when personId is undefined). */
|
||||
function votes(n: number, personId?: string): Vote[] {
|
||||
return Array.from({ length: n }, () => vote(personId))
|
||||
}
|
||||
|
||||
describe('electionResult — clear plurality', () => {
|
||||
it('elects the most designated person; blanks count in participation only', () => {
|
||||
const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(1)]
|
||||
const result = electionResult(ballot, { electionMinParticipants: 5 })
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'elected',
|
||||
winnerId: 'alice',
|
||||
counts: { alice: 3, bob: 2 },
|
||||
blanks: 1,
|
||||
participants: 6,
|
||||
})
|
||||
})
|
||||
|
||||
it('a tie below the top does not prevent election', () => {
|
||||
const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(2, 'carol')]
|
||||
const result = electionResult(ballot, {})
|
||||
|
||||
expect(result.outcome).toBe('elected')
|
||||
if (result.outcome === 'elected') {
|
||||
expect(result.winnerId).toBe('alice')
|
||||
expect(result.counts).toEqual({ alice: 3, bob: 2, carol: 2 })
|
||||
}
|
||||
})
|
||||
|
||||
it('works without any quorum configured (electionMinParticipants undefined)', () => {
|
||||
const result = electionResult([vote('alice')], {})
|
||||
expect(result.outcome).toBe('elected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('electionResult — tie: the engine NEVER breaks it', () => {
|
||||
it('two-way tie at the top => tie with sorted exAequoIds', () => {
|
||||
const ballot = [...votes(2, 'bob'), ...votes(2, 'alice'), ...votes(1, 'carol')]
|
||||
const result = electionResult(ballot, { electionMinParticipants: 3 })
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'tie',
|
||||
exAequoIds: ['alice', 'bob'], // sorted — deterministic order, not a tie-break
|
||||
counts: { alice: 2, bob: 2, carol: 1 },
|
||||
blanks: 0,
|
||||
participants: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('three-way tie => all three ex aequo, sorted regardless of arrival order', () => {
|
||||
const ballot = [...votes(2, 'carol'), ...votes(2, 'alice'), ...votes(2, 'bob'), ...votes(1)]
|
||||
const result = electionResult(ballot, { electionMinParticipants: 4 })
|
||||
|
||||
expect(result.outcome).toBe('tie')
|
||||
if (result.outcome === 'tie') {
|
||||
expect(result.exAequoIds).toEqual(['alice', 'bob', 'carol'])
|
||||
expect(result.blanks).toBe(1)
|
||||
expect(result.participants).toBe(7)
|
||||
}
|
||||
})
|
||||
|
||||
it('never designates a winner among equals (no random, no first-come)', () => {
|
||||
// Same ballot tallied twice must yield the exact same tie.
|
||||
const ballot = [...votes(1, 'bob'), ...votes(1, 'alice')]
|
||||
const first = electionResult(ballot, {})
|
||||
const second = electionResult(ballot, {})
|
||||
expect(first).toEqual(second)
|
||||
expect(first.outcome).toBe('tie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('electionResult — rejections', () => {
|
||||
it('quorum not reached => rejected with reason quorum and required', () => {
|
||||
const ballot = [...votes(2, 'alice'), ...votes(1)]
|
||||
const result = electionResult(ballot, { electionMinParticipants: 5 })
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
reason: 'quorum',
|
||||
participants: 3,
|
||||
required: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('quorum reached but all blanks => rejected with reason no-designation', () => {
|
||||
const ballot = votes(4)
|
||||
const result = electionResult(ballot, { electionMinParticipants: 3 })
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
reason: 'no-designation',
|
||||
participants: 4,
|
||||
required: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('zero votes without quorum => rejected no-designation (required 0)', () => {
|
||||
const result = electionResult([], {})
|
||||
expect(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
reason: 'no-designation',
|
||||
participants: 0,
|
||||
required: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('electionResult — blanks and participation', () => {
|
||||
it('quorum reached THANKS to blanks: blanks count for participation', () => {
|
||||
// 3 designations alone would miss the quorum of 5; 2 blanks complete it.
|
||||
const ballot = [...votes(3, 'alice'), ...votes(2)]
|
||||
const result = electionResult(ballot, { electionMinParticipants: 5 })
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'elected',
|
||||
winnerId: 'alice',
|
||||
counts: { alice: 3 },
|
||||
blanks: 2,
|
||||
participants: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('exact quorum boundary: participants === required passes', () => {
|
||||
const ballot = [...votes(1, 'alice'), ...votes(1)]
|
||||
const result = electionResult(ballot, { electionMinParticipants: 2 })
|
||||
expect(result.outcome).toBe('elected')
|
||||
})
|
||||
|
||||
it('blanks never appear in counts', () => {
|
||||
const ballot = [...votes(2, 'alice'), ...votes(3)]
|
||||
const result = electionResult(ballot, {})
|
||||
if (result.outcome === 'elected') {
|
||||
expect(Object.keys(result.counts)).toEqual(['alice'])
|
||||
expect(result.blanks).toBe(3)
|
||||
} else {
|
||||
throw new Error(`expected elected, got ${result.outcome}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests du moteur de périmètre — computeConcerned / expandCircleMembers.
|
||||
* Fonctions pures : fixtures assemblées à la main, aucun store.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeConcerned, expandCircleMembers } from '../../app/engine/impact'
|
||||
import type { Circle, Decision, Mandate } from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-01-01T00:00:00Z'
|
||||
const T9 = '2027-01-01T00:00:00Z'
|
||||
const AUTHOR = 'p-author'
|
||||
|
||||
function mkCircle(over: Partial<Circle> = {}): Circle {
|
||||
return {
|
||||
id: 'c1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
name: 'Forgerons',
|
||||
purpose: '',
|
||||
memberIds: [],
|
||||
domains: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkMandate(over: Partial<Mandate> = {}): Mandate {
|
||||
return {
|
||||
id: 'm1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
title: 'Modération',
|
||||
holderId: 'p-holder',
|
||||
originDecisionId: 'd0',
|
||||
domain: { circleIds: ['c1'], tags: [] },
|
||||
startsAt: T0,
|
||||
endsAt: T9,
|
||||
electorCircleId: 'c1',
|
||||
nominationMethod: 'consent',
|
||||
reports: [],
|
||||
status: 'active',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkScope(over: Partial<Decision['scope']> = {}): Decision['scope'] {
|
||||
return { selfOnly: false, circleIds: [], personIds: [], ...over }
|
||||
}
|
||||
|
||||
describe('computeConcerned — l\'union calculée, chaque entrée avec sa raison', () => {
|
||||
it('unit les trois sources : cercles, personnes nommées, titulaires de mandats', () => {
|
||||
const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1', 'p2'] })]
|
||||
const mandates = [mkMandate({ holderId: 'p4', title: 'Modération' })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: ['p3'] })
|
||||
|
||||
const result = computeConcerned(scope, circles, mandates, AUTHOR)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result).toContainEqual({ personId: 'p1', reason: 'membre du cercle Forgerons' })
|
||||
expect(result).toContainEqual({ personId: 'p2', reason: 'membre du cercle Forgerons' })
|
||||
expect(result).toContainEqual({ personId: 'p3', reason: 'nommé·e par l\'auteur' })
|
||||
expect(result).toContainEqual({ personId: 'p4', reason: 'titulaire du mandat Modération' })
|
||||
})
|
||||
|
||||
it('dédoublonne — la première raison gagne, priorité cercle > nommé > mandat', () => {
|
||||
const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1'] })]
|
||||
// p1 est membre du cercle ET nommé ET titulaire d'un mandat intersectant.
|
||||
const mandates = [mkMandate({ holderId: 'p1' })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] })
|
||||
|
||||
const result = computeConcerned(scope, circles, mandates, AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p1', reason: 'membre du cercle Forgerons' }])
|
||||
})
|
||||
|
||||
it('dédoublonne — nommé·e gagne sur titulaire de mandat', () => {
|
||||
const mandates = [mkMandate({ holderId: 'p1', domain: { circleIds: ['c1'], tags: [] } })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] })
|
||||
|
||||
const result = computeConcerned(scope, [], mandates, AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }])
|
||||
})
|
||||
|
||||
it('exclut l\'auteur de toutes les sources', () => {
|
||||
const circles = [mkCircle({ id: 'c1', memberIds: [AUTHOR, 'p1'] })]
|
||||
const mandates = [mkMandate({ holderId: AUTHOR })]
|
||||
const scope = mkScope({ circleIds: ['c1'], personIds: [AUTHOR] })
|
||||
|
||||
const result = computeConcerned(scope, circles, mandates, AUTHOR)
|
||||
|
||||
expect(result.map(e => e.personId)).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('n\'inclut un titulaire que si le domaine du mandat intersecte les cercles du périmètre', () => {
|
||||
const mandates = [
|
||||
mkMandate({ id: 'm1', holderId: 'p4', domain: { circleIds: ['c9'], tags: [] } }),
|
||||
mkMandate({ id: 'm2', holderId: 'p5', title: 'Trésorerie', domain: { circleIds: ['c2', 'c1'], tags: [] } }),
|
||||
]
|
||||
const scope = mkScope({ circleIds: ['c1'] })
|
||||
|
||||
const result = computeConcerned(scope, [], mandates, AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p5', reason: 'titulaire du mandat Trésorerie' }])
|
||||
})
|
||||
|
||||
it('ignore les mandats non actifs', () => {
|
||||
const mandates = [mkMandate({ holderId: 'p4', status: 'expired' })]
|
||||
const scope = mkScope({ circleIds: ['c1'] })
|
||||
|
||||
expect(computeConcerned(scope, [], mandates, AUTHOR)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignore sans erreur un cercle inconnu dans le périmètre', () => {
|
||||
const scope = mkScope({ circleIds: ['c-fantome'], personIds: ['p1'] })
|
||||
|
||||
const result = computeConcerned(scope, [], [], AUTHOR)
|
||||
|
||||
expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandCircleMembers — appartenance nominative, sans propagation', () => {
|
||||
it('réunit les membres directs de plusieurs cercles, dédoublonnés', () => {
|
||||
const circles = [
|
||||
mkCircle({ id: 'c1', memberIds: ['p1', 'p2'] }),
|
||||
mkCircle({ id: 'c2', name: 'Cuisine', memberIds: ['p2', 'p3'] }),
|
||||
]
|
||||
|
||||
const members = expandCircleMembers(['c1', 'c2'], circles)
|
||||
|
||||
expect(members).toEqual(new Set(['p1', 'p2', 'p3']))
|
||||
})
|
||||
|
||||
it('ne propage PAS l\'appartenance des cercles enfants au parent (emboîtement ≠ appartenance)', () => {
|
||||
// v2 : l'appartenance est nominative par cercle ; l'emboîtement
|
||||
// (parentCircleId) sert l'élargissement d'un cran, jamais le calcul.
|
||||
const parent = mkCircle({ id: 'c-parent', name: 'Village', memberIds: ['p1'] })
|
||||
const child = mkCircle({
|
||||
id: 'c-child',
|
||||
name: 'Hameau',
|
||||
memberIds: ['p2'],
|
||||
parentCircleId: 'c-parent',
|
||||
})
|
||||
|
||||
expect(expandCircleMembers(['c-parent'], [parent, child])).toEqual(new Set(['p1']))
|
||||
// Ni dans l'autre sens : l'enfant n'hérite pas des membres du parent.
|
||||
expect(expandCircleMembers(['c-child'], [parent, child])).toEqual(new Set(['p2']))
|
||||
// L'élargissement est un geste explicite : ajouter le cercle au périmètre.
|
||||
expect(expandCircleMembers(['c-parent', 'c-child'], [parent, child]))
|
||||
.toEqual(new Set(['p1', 'p2']))
|
||||
})
|
||||
|
||||
it('ignore un identifiant de cercle inconnu', () => {
|
||||
expect(expandCircleMembers(['c-fantome'], [mkCircle()])).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* resolveSettings — the Pact IS the settings store (Δ10).
|
||||
* Defaults, voted overrides, specified fallbacks, corrupted-bundle resilience.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SETTINGS_DEFAULTS, hasConsentProtocol, resolveSettings } from '../../app/engine/settings'
|
||||
import type { Clause, ClauseVersion, Json } from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-08-01T00:00:00.000Z'
|
||||
const T1 = '2026-08-05T00:00:00.000Z'
|
||||
|
||||
let seq = 0
|
||||
|
||||
function makeClause(settingKey: string, overrides: Partial<Clause> = {}): Clause {
|
||||
seq += 1
|
||||
return {
|
||||
id: `clause-${seq}`,
|
||||
collectiveId: 'col-1',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
docId: 'doc-pact',
|
||||
section: 'Réglages',
|
||||
position: seq,
|
||||
code: `S${seq}`,
|
||||
title: settingKey,
|
||||
inertia: 'standard',
|
||||
settingKey,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeVersion(
|
||||
clauseId: string,
|
||||
settingValue: Json,
|
||||
overrides: Partial<ClauseVersion> = {},
|
||||
): ClauseVersion {
|
||||
seq += 1
|
||||
return {
|
||||
id: `version-${seq}`,
|
||||
collectiveId: 'col-1',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
clauseId,
|
||||
decisionId: 'dec-founding',
|
||||
versionLabel: 'v1',
|
||||
content: 'clause adoptée',
|
||||
settingValue,
|
||||
status: 'current',
|
||||
adoptedAt: T0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a Pact from [settingKey, settingValue] pairs and resolve it. */
|
||||
function resolveFrom(entries: [string, Json][]) {
|
||||
const clauses = entries.map(([key]) => makeClause(key))
|
||||
const versions = clauses.map((clause, index) => makeVersion(clause.id, entries[index]![1]))
|
||||
return resolveSettings(clauses, versions)
|
||||
}
|
||||
|
||||
describe('resolveSettings — les défauts complets', () => {
|
||||
it('sans aucune clause, tous les seuils de triage prennent leur défaut', () => {
|
||||
const settings = resolveSettings([], [])
|
||||
expect(settings.triage).toEqual({
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
})
|
||||
expect(settings.triage).toEqual(SETTINGS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('sans aucune clause, les protocoles optionnels restent indéfinis (repli → consent chez l’appelant)', () => {
|
||||
const settings = resolveSettings([], [])
|
||||
expect(settings.protocolByRange.nuanced).toBeUndefined()
|
||||
expect(settings.protocolByRange.large).toBeUndefined()
|
||||
expect(settings.protocolByRange.parametric).toBeUndefined()
|
||||
expect(settings.protocolByRange.election).toBeUndefined()
|
||||
expect(settings.protocolByRange.clauseByInertia).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSettings — une clause adoptée change une valeur', () => {
|
||||
it('la version courante d’une clause remplace le défaut', () => {
|
||||
const settings = resolveFrom([['triage.smallGroupMax', 8]])
|
||||
expect(settings.triage.smallGroupMax).toBe(8)
|
||||
expect(settings.triage.collectiveMin).toBe(50) // les autres clés gardent leur défaut
|
||||
})
|
||||
|
||||
it('une version seulement proposée ne change rien', () => {
|
||||
const clause = makeClause('triage.framingDays')
|
||||
const version = makeVersion(clause.id, 21, { status: 'proposed', adoptedAt: undefined })
|
||||
expect(resolveSettings([clause], [version]).triage.framingDays).toBe(14)
|
||||
})
|
||||
|
||||
it('une version remplacée ne change rien — seule la courante compte', () => {
|
||||
const clause = makeClause('triage.consentMax')
|
||||
const superseded = makeVersion(clause.id, 3, { status: 'superseded' })
|
||||
const current = makeVersion(clause.id, 9, { adoptedAt: T1 })
|
||||
expect(resolveSettings([clause], [superseded, current]).triage.consentMax).toBe(9)
|
||||
})
|
||||
|
||||
it('deux versions courantes (anomalie) — la plus récemment adoptée gagne', () => {
|
||||
const clause = makeClause('triage.objectionWindowHours')
|
||||
const older = makeVersion(clause.id, 24, { adoptedAt: T0 })
|
||||
const newer = makeVersion(clause.id, 96, { adoptedAt: T1 })
|
||||
expect(resolveSettings([clause], [older, newer]).triage.objectionWindowHours).toBe(96)
|
||||
})
|
||||
|
||||
it('une clause archivée est ignorée', () => {
|
||||
const clause = makeClause('triage.smallGroupMax', { archivedAt: T1 })
|
||||
const version = makeVersion(clause.id, 12)
|
||||
expect(resolveSettings([clause], [version]).triage.smallGroupMax).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSettings — requireEffects résolu', () => {
|
||||
it('« none » voté est appliqué', () => {
|
||||
expect(resolveFrom([['triage.requireEffects', 'none']]).triage.requireEffects).toBe('none')
|
||||
})
|
||||
|
||||
it('« structural » voté est appliqué', () => {
|
||||
expect(resolveFrom([['triage.requireEffects', 'structural']]).triage.requireEffects).toBe(
|
||||
'structural',
|
||||
)
|
||||
})
|
||||
|
||||
it('une valeur inconnue retombe sur « binding »', () => {
|
||||
expect(resolveFrom([['triage.requireEffects', 'everything']]).triage.requireEffects).toBe(
|
||||
'binding',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSettings — le protocole de consentement', () => {
|
||||
it('la clause protocols.consent résout l’identifiant', () => {
|
||||
const settings = resolveFrom([['protocols.consent', 'proto-consent-1']])
|
||||
expect(settings.protocolByRange.consent).toBe('proto-consent-1')
|
||||
expect(hasConsentProtocol(settings)).toBe(true)
|
||||
})
|
||||
|
||||
it('consent introuvable (bundle corrompu) — objet complet, consent vide, jamais de crash', () => {
|
||||
expect(() => resolveSettings([], [])).not.toThrow()
|
||||
const settings = resolveSettings([], [])
|
||||
expect(settings.protocolByRange.consent).toBe('')
|
||||
expect(hasConsentProtocol(settings)).toBe(false)
|
||||
expect(settings.triage.smallGroupMax).toBe(5) // le reste de l’objet est utilisable
|
||||
})
|
||||
|
||||
it('consent d’un mauvais type compte comme introuvable', () => {
|
||||
const settings = resolveFrom([['protocols.consent', 42]])
|
||||
expect(settings.protocolByRange.consent).toBe('')
|
||||
expect(hasConsentProtocol(settings)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSettings — les protocoles optionnels et l’héritage Ğ1', () => {
|
||||
it('nuanced, large, parametric et election se résolvent chacun', () => {
|
||||
const settings = resolveFrom([
|
||||
['protocols.consent', 'p-consent'],
|
||||
['protocols.nuanced', 'p-nuanced'],
|
||||
['protocols.large', 'p-large'],
|
||||
['protocols.parametric', 'p-parametric'],
|
||||
['protocols.election', 'p-election'],
|
||||
])
|
||||
expect(settings.protocolByRange.nuanced).toBe('p-nuanced')
|
||||
expect(settings.protocolByRange.large).toBe('p-large')
|
||||
expect(settings.protocolByRange.parametric).toBe('p-parametric')
|
||||
expect(settings.protocolByRange.election).toBe('p-election')
|
||||
})
|
||||
|
||||
it('clauseByInertia complet (4 presets) est exposé', () => {
|
||||
const settings = resolveFrom([
|
||||
['protocols.clauseByInertia.low', 'p-low'],
|
||||
['protocols.clauseByInertia.standard', 'p-standard'],
|
||||
['protocols.clauseByInertia.high', 'p-high'],
|
||||
['protocols.clauseByInertia.max', 'p-max'],
|
||||
])
|
||||
expect(settings.protocolByRange.clauseByInertia).toEqual({
|
||||
low: 'p-low',
|
||||
standard: 'p-standard',
|
||||
high: 'p-high',
|
||||
max: 'p-max',
|
||||
})
|
||||
})
|
||||
|
||||
it('clauseByInertia partiel reste indéfini — l’appelant retombe sur consent', () => {
|
||||
const settings = resolveFrom([
|
||||
['protocols.clauseByInertia.low', 'p-low'],
|
||||
['protocols.clauseByInertia.standard', 'p-standard'],
|
||||
['protocols.clauseByInertia.high', 'p-high'],
|
||||
])
|
||||
expect(settings.protocolByRange.clauseByInertia).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSettings — les valeurs invalides retombent sur les défauts, en silence', () => {
|
||||
it('un mauvais type sur une clé numérique est ignoré', () => {
|
||||
expect(resolveFrom([['triage.smallGroupMax', 'huit']]).triage.smallGroupMax).toBe(5)
|
||||
})
|
||||
|
||||
it('un nombre négatif est ignoré', () => {
|
||||
expect(resolveFrom([['triage.framingDays', -3]]).triage.framingDays).toBe(14)
|
||||
})
|
||||
|
||||
it('un ratio négatif est ignoré', () => {
|
||||
expect(resolveFrom([['triage.concernEscalateRatio', -0.5]]).triage.concernEscalateRatio).toBe(
|
||||
0.5,
|
||||
)
|
||||
})
|
||||
|
||||
it('null est ignoré', () => {
|
||||
expect(resolveFrom([['triage.reviewDelayDays', null]]).triage.reviewDelayDays).toBe(90)
|
||||
})
|
||||
|
||||
it('un identifiant de protocole vide compte comme non résolu', () => {
|
||||
const settings = resolveFrom([['protocols.nuanced', ' ']])
|
||||
expect(settings.protocolByRange.nuanced).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* canTransition — the ONE state machine of the decision (Δ29).
|
||||
* Every guard (a–i) tested with positive AND negative cases + windowOutcome.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TRANSITIONS, canTransition, windowOutcome } from '../../app/engine/state'
|
||||
import type { TransitionContext } from '../../app/engine/state'
|
||||
import type {
|
||||
Assent,
|
||||
CollectiveSettings,
|
||||
Concern,
|
||||
Decision,
|
||||
Objection,
|
||||
ParamSpec,
|
||||
VoteSession,
|
||||
} from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-08-01T00:00:00.000Z'
|
||||
const NOW = '2026-08-11T12:00:00.000Z'
|
||||
|
||||
let seq = 0
|
||||
|
||||
function makeEntity() {
|
||||
seq += 1
|
||||
return { id: `id-${seq}`, collectiveId: 'col-1', createdAt: T0, updatedAt: T0 }
|
||||
}
|
||||
|
||||
function makeSettings(overrides: Partial<CollectiveSettings['triage']> = {}): CollectiveSettings {
|
||||
return {
|
||||
triage: {
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
...overrides,
|
||||
},
|
||||
protocolByRange: { consent: 'proto-consent' },
|
||||
}
|
||||
}
|
||||
|
||||
function makeDecision(overrides: Partial<Decision> = {}): Decision {
|
||||
return {
|
||||
...makeEntity(),
|
||||
id: 'dec-1',
|
||||
authorId: 'p-author',
|
||||
title: 'Décision de test',
|
||||
tags: [],
|
||||
reversibility: 'easy',
|
||||
weight: 'light',
|
||||
urgent: false,
|
||||
scope: { selfOnly: false, circleIds: ['circle-1'], personIds: [] },
|
||||
route: 'collective',
|
||||
triageRule: 'R5',
|
||||
routeOverridden: false,
|
||||
status: 'draft',
|
||||
stewardIds: [],
|
||||
measurerIds: [],
|
||||
visibility: 'scope',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeConcern(origin: Concern['origin'], overrides: Partial<Concern> = {}): Concern {
|
||||
const entity = makeEntity()
|
||||
return {
|
||||
...entity,
|
||||
decisionId: 'dec-1',
|
||||
personId: `p-${entity.id}`,
|
||||
origin,
|
||||
reason: 'membre du cercle',
|
||||
beforeSnapshot: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssent(personId: string, overrides: Partial<Assent> = {}): Assent {
|
||||
return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides }
|
||||
}
|
||||
|
||||
function makeObjection(status: Objection['status'], overrides: Partial<Objection> = {}): Objection {
|
||||
return {
|
||||
...makeEntity(),
|
||||
decisionId: 'dec-1',
|
||||
personId: 'p-objector',
|
||||
kind: 'content',
|
||||
argument: 'Je maintiens mon désaccord.',
|
||||
status,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<VoteSession> = {}): VoteSession {
|
||||
return {
|
||||
...makeEntity(),
|
||||
decisionId: 'dec-1',
|
||||
protocolId: 'proto-1',
|
||||
corpusPersonIds: ['p-1', 'p-2', 'p-3'],
|
||||
corpusSize: 3,
|
||||
opensAt: T0,
|
||||
closesAt: NOW,
|
||||
status: 'open',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<TransitionContext> = {}): TransitionContext {
|
||||
return {
|
||||
concerns: [],
|
||||
settings: makeSettings(),
|
||||
assents: [],
|
||||
objections: [],
|
||||
now: NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const PARAM_SPEC: ParamSpec = {
|
||||
params: [
|
||||
{ key: 'partA', label: 'Part ateliers', kind: 'share', min: 0, max: 100, step: 1 },
|
||||
{ key: 'partB', label: 'Part réserve', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||||
],
|
||||
constraint: 'sum100',
|
||||
}
|
||||
|
||||
/** 4 computed + 2 declared ⇒ ratio 0.5 atteint (2 ≥ 0.5 × 4). */
|
||||
function influxConcerns(): Concern[] {
|
||||
return [
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('declared'),
|
||||
makeConcern('declared'),
|
||||
]
|
||||
}
|
||||
|
||||
describe('La table des transitions', () => {
|
||||
it('draft ouvre les sept chemins', () => {
|
||||
expect(TRANSITIONS.draft).toEqual([
|
||||
'advice',
|
||||
'objection',
|
||||
'framing',
|
||||
'voting',
|
||||
'adopted',
|
||||
'transmitted',
|
||||
'rejected',
|
||||
])
|
||||
})
|
||||
|
||||
it('chaque état de travail connaît ses sorties', () => {
|
||||
expect(TRANSITIONS.advice).toEqual(['adopted', 'voting'])
|
||||
expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting'])
|
||||
expect(TRANSITIONS.framing).toEqual(['voting', 'closed'])
|
||||
expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected'])
|
||||
expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed'])
|
||||
})
|
||||
|
||||
it('les états terminaux n’ont aucune sortie', () => {
|
||||
expect(TRANSITIONS.rejected).toBeUndefined()
|
||||
expect(TRANSITIONS.revoked).toBeUndefined()
|
||||
expect(TRANSITIONS.closed).toBeUndefined()
|
||||
expect(TRANSITIONS.transmitted).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde a — la transition inconnue est refusée', () => {
|
||||
it('adopted → voting n’existe pas', () => {
|
||||
const result = canTransition(makeDecision({ status: 'adopted' }), 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejected est terminal — aucune sortie', () => {
|
||||
expect(canTransition(makeDecision({ status: 'rejected' }), 'adopted', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('draft → advice existe', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'advice' })
|
||||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('le retour explicite d’une fenêtre à son propre état est permis', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('un état hors fenêtre ne boucle pas sur lui-même', () => {
|
||||
expect(canTransition(makeDecision({ status: 'voting' }), 'voting', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde b — la frontière contestée suspend toute sortie', () => {
|
||||
it('une fenêtre d’objection suspendue ne peut pas adopter', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||||
const result = canTransition(decision, 'adopted', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('frontière')
|
||||
})
|
||||
|
||||
it('une fenêtre d’avis suspendue ne peut pas non plus escalader vers le vote', () => {
|
||||
const decision = makeDecision({ status: 'advice', route: 'advice', windowSuspendedAt: NOW })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('le retour explicite au même état reste possible pendant la suspension', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sans suspension, la fenêtre s’adopte normalement', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la frontière prime sur l’affluence (ordre des gardes)', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||||
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('frontière')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde c — l’affluence exige un traitement du périmètre', () => {
|
||||
it('ratio atteint sans scopeKeptNote — la session ne se clôt pas', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe(
|
||||
'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('le maintien motivé publiquement (scopeKeptNote) débloque la clôture', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'voting',
|
||||
scopeKeptNote: 'Le cercle Ateliers reste le bon périmètre : le budget est le sien.',
|
||||
})
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sous le ratio, la clôture passe', () => {
|
||||
const concerns = [
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('computed'),
|
||||
makeConcern('declared'),
|
||||
]
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sans concerné calculé, la garde ne se déclenche jamais', () => {
|
||||
const concerns = [makeConcern('declared'), makeConcern('declared'), makeConcern('declared')]
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la garde vaut aussi pour la fenêtre d’avis', () => {
|
||||
const decision = makeDecision({ status: 'advice', route: 'advice' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('la garde vaut pour le rejet d’un vote — clore, c’est clore', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('les concernés d’une autre décision ne comptent pas', () => {
|
||||
const concerns = influxConcerns().map((concern) => ({ ...concern, decisionId: 'dec-2' }))
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('l’escalade vers framing ou voting reste ouverte — c’est le traitement, pas la clôture', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||||
expect(canTransition(decision, 'framing', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde d — la matière (requireEffects) à l’ouverture de session collective', () => {
|
||||
const resources = { note: 'Deux heures par semaine pendant un mois' }
|
||||
|
||||
it('« binding » + poids binding sans effet recherché — refus', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
|
||||
const result = canTransition(decision, 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('effet recherché')
|
||||
})
|
||||
|
||||
it('« binding » + poids binding avec un effet recherché — ouverture', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'binding',
|
||||
resources,
|
||||
brief: { effects: [{ label: 'Réduire le temps de réunion' }] },
|
||||
})
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('« binding » + poids structural sans cible mesurable — refus', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'structural',
|
||||
resources,
|
||||
brief: { effects: [{ label: 'Assainir le budget' }] },
|
||||
})
|
||||
const result = canTransition(decision, 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('cible')
|
||||
})
|
||||
|
||||
it('« binding » + poids structural avec une cible — ouverture', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'structural',
|
||||
resources,
|
||||
brief: { effects: [{ label: 'Assainir le budget', target: '≤ 400 € par mois' }] },
|
||||
})
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('« structural » : le poids binding n’est pas sous garde', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
|
||||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
|
||||
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('« structural » : le poids structural sans effet est refusé', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
|
||||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
|
||||
expect(canTransition(decision, 'voting', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('« none » : aucune exigence de matière, même structural', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
|
||||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'none' }) })
|
||||
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('le poids light n’est jamais soumis à la matière', () => {
|
||||
const decision = makeDecision({ status: 'draft', weight: 'light' })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la garde est réservée à la route collective', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding', resources })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('framing → voting est gardé de la même façon', () => {
|
||||
const decision = makeDecision({ status: 'framing', weight: 'structural', resources })
|
||||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde e — « Ce que ça engage » à l’ouverture de fenêtre ou de session', () => {
|
||||
it('poids binding sans note de ressources — la fenêtre d’objection ne s’ouvre pas', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding' })
|
||||
const result = canTransition(decision, 'objection', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('engage')
|
||||
})
|
||||
|
||||
it('avec la note, la fenêtre s’ouvre', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
route: 'mandate',
|
||||
weight: 'binding',
|
||||
resources: { note: 'Une demi-journée de l’équipe accueil' },
|
||||
})
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('une note blanche ne compte pas', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
route: 'mandate',
|
||||
weight: 'binding',
|
||||
resources: { note: ' ' },
|
||||
})
|
||||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('le poids light ouvre sans note', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'advice', weight: 'light' })
|
||||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la route solo est exemptée', () => {
|
||||
const decision = makeDecision({ status: 'draft', route: 'solo', weight: 'binding' })
|
||||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('la garde vit aussi à l’ouverture de session — après la matière', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'draft',
|
||||
weight: 'binding',
|
||||
brief: { effects: [{ label: 'Un effet recherché' }] },
|
||||
})
|
||||
const result = canTransition(decision, 'voting', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('engage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde f — l’accord explicite (Assent) hors du réversible', () => {
|
||||
it('easy : le silence vaut accord, la fenêtre s’adopte sans Assent', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('costly sans Assent — la fenêtre se prolonge', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const result = canTransition(decision, 'adopted', makeCtx())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('Il manque un accord explicite — la fenêtre se prolonge.')
|
||||
}
|
||||
})
|
||||
|
||||
it('costly : l’accord de l’auteur seul ne suffit pas', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-author')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('costly : un accord d’un tiers concerné débloque l’adoption', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-other')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('irreversible sans Assent — refus aussi', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'objection',
|
||||
route: 'mandate',
|
||||
reversibility: 'irreversible',
|
||||
})
|
||||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('l’Assent d’une autre décision ne compte pas', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-other', { decisionId: 'dec-2' })] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde g — jamais d’adoption sur une objection ouverte', () => {
|
||||
it('une objection ouverte bloque l’adoption, même en easy', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
const result = canTransition(decision, 'adopted', makeCtx({ objections: [makeObjection('open')] }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('objection')
|
||||
})
|
||||
|
||||
it('une objection retirée ne bloque plus', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
const ctx = makeCtx({ objections: [makeObjection('withdrawn')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('une objection intégrée ne bloque plus', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||||
const ctx = makeCtx({ objections: [makeObjection('integrated')] })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('l’Assent d’un tiers ne contourne pas une objection ouverte', () => {
|
||||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||||
const ctx = makeCtx({ assents: [makeAssent('p-other')], objections: [makeObjection('open')] })
|
||||
const result = canTransition(decision, 'adopted', ctx)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('objection')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde h — la clôture du dossier découpé', () => {
|
||||
const parent = () => makeDecision({ id: 'dossier-1', status: 'framing' })
|
||||
|
||||
function element(status: Decision['status'], overrides: Partial<Decision> = {}): Decision {
|
||||
const entity = makeEntity()
|
||||
return makeDecision({
|
||||
id: entity.id,
|
||||
parentDecisionId: 'dossier-1',
|
||||
chainKind: 'element',
|
||||
status,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
it('sans élément, le dossier ne se clôt pas', () => {
|
||||
const result = canTransition(parent(), 'closed', makeCtx({ children: [] }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('dossier')
|
||||
})
|
||||
|
||||
it('tous les éléments terminaux — le garant peut clore', () => {
|
||||
const children = [
|
||||
element('adopted'),
|
||||
element('rejected'),
|
||||
element('revoked'),
|
||||
element('closed'),
|
||||
element('transmitted'),
|
||||
]
|
||||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('un élément encore en vote retient le dossier', () => {
|
||||
const children = [element('adopted'), element('voting')]
|
||||
const result = canTransition(parent(), 'closed', makeCtx({ children }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toContain('en cours')
|
||||
})
|
||||
|
||||
it('des enfants non-éléments ne font pas un dossier', () => {
|
||||
const children = [element('adopted', { chainKind: 'revision' })]
|
||||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('l’élément d’un autre dossier ne compte pas', () => {
|
||||
const children = [element('adopted', { parentDecisionId: 'dossier-2' })]
|
||||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('framing → voting du parent reste possible pendant que le dossier vit', () => {
|
||||
const decision = makeDecision({ id: 'dossier-1', status: 'framing', weight: 'light' })
|
||||
expect(canTransition(decision, 'voting', makeCtx({ children: [element('voting')] })).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Garde i — la cristallisation attend le geste du garant', () => {
|
||||
const parametricDecision = () => makeDecision({ status: 'voting', paramSpec: PARAM_SPEC })
|
||||
|
||||
it('session figée — pas d’adoption sans le geste', () => {
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||||
const result = canTransition(parametricDecision(), 'adopted', ctx)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('Les votes sont figés — la cristallisation attend son geste.')
|
||||
}
|
||||
})
|
||||
|
||||
it('session close sans crystallizedById — le moteur ne cristallise jamais', () => {
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
|
||||
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('session close et geste daté — l’adoption passe', () => {
|
||||
const ctx = makeCtx({
|
||||
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
|
||||
})
|
||||
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('le rejet (quorum non atteint) exige le même geste', () => {
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||||
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('le rejet constaté au geste passe', () => {
|
||||
const ctx = makeCtx({
|
||||
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
|
||||
})
|
||||
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('une session figée trahit le paramétrique même sans paramSpec', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('une session non paramétrique se clôt automatiquement — pas de geste exigé', () => {
|
||||
const decision = makeDecision({ status: 'voting' })
|
||||
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
|
||||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('sans session dans le contexte, la garde ne s’applique pas', () => {
|
||||
expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowOutcome — l’échéance des fenêtres d’objection', () => {
|
||||
it('easy — le silence vaut accord, la fenêtre s’adopte', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'easy' })
|
||||
expect(windowOutcome(decision, makeCtx())).toBe('adopt')
|
||||
})
|
||||
|
||||
it('costly sans accord tiers — la fenêtre se prolonge d’un cran', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||||
expect(windowOutcome(decision, makeCtx())).toBe('extend')
|
||||
})
|
||||
|
||||
it('costly avec l’accord d’un tiers — la fenêtre s’adopte', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||||
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt')
|
||||
})
|
||||
|
||||
it('l’accord de l’auteur seul ne compte pas', () => {
|
||||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||||
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-author')] }))).toBe('extend')
|
||||
})
|
||||
|
||||
it('frontière suspendue — la fenêtre attend, même en easy', () => {
|
||||
const decision = makeDecision({
|
||||
status: 'objection',
|
||||
reversibility: 'easy',
|
||||
windowSuspendedAt: NOW,
|
||||
})
|
||||
expect(windowOutcome(decision, makeCtx())).toBe('wait')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* Tests du moteur de routage — « Le chemin ».
|
||||
* Ordre des règles vérifié : R-U, R0a, R0b, R0c, R2, R3, R4, R5, R6.
|
||||
* Fonctions pures : fixtures assemblées à la main, aucun store.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectParametricHint, triage } from '../../app/engine/triage'
|
||||
import {
|
||||
BINARY_COST,
|
||||
PARAMETRIC_ALT,
|
||||
RECORD_ALT,
|
||||
URGENT_REFUSED,
|
||||
} from '../../app/lexicon'
|
||||
import type {
|
||||
Clause,
|
||||
CollectiveSettings,
|
||||
Mandate,
|
||||
TriageContext,
|
||||
TriageInput,
|
||||
} from '../../app/types/domain'
|
||||
|
||||
const T0 = '2026-01-01T00:00:00Z'
|
||||
const T9 = '2027-01-01T00:00:00Z'
|
||||
|
||||
function mkSettings(over: {
|
||||
triage?: Partial<CollectiveSettings['triage']>
|
||||
protocolByRange?: Partial<CollectiveSettings['protocolByRange']>
|
||||
} = {}): CollectiveSettings {
|
||||
return {
|
||||
triage: {
|
||||
smallGroupMax: 5,
|
||||
collectiveMin: 50,
|
||||
consentMax: 7,
|
||||
objectionWindowHours: 48,
|
||||
adviceWindowHours: 72,
|
||||
framingDays: 14,
|
||||
concernEscalateRatio: 0.5,
|
||||
recurrenceThreshold: 3,
|
||||
reviewDelayDays: 90,
|
||||
requireEffects: 'binding',
|
||||
...over.triage,
|
||||
},
|
||||
protocolByRange: { consent: 'proto-consent', ...over.protocolByRange },
|
||||
}
|
||||
}
|
||||
|
||||
function mkInput(over: Partial<TriageInput> = {}): TriageInput {
|
||||
return {
|
||||
title: 'Repeindre la salle',
|
||||
tags: [],
|
||||
scope: { selfOnly: false, circleIds: [], personIds: [] },
|
||||
reversibility: 'costly',
|
||||
weight: 'light',
|
||||
urgent: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkCtx(over: Partial<TriageContext> = {}): TriageContext {
|
||||
return {
|
||||
myActiveMandates: [],
|
||||
otherActiveMandates: [],
|
||||
matchingClauses: [],
|
||||
similarRecentCount: 0,
|
||||
similarRecordedCount: 0,
|
||||
computedConcernedIds: ['p1', 'p2', 'p3'],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkMandate(over: Partial<Mandate> = {}): Mandate {
|
||||
return {
|
||||
id: 'm1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
title: 'Modération',
|
||||
holderId: 'p-holder',
|
||||
originDecisionId: 'd0',
|
||||
domain: { circleIds: ['c1', 'c2'], tags: [] },
|
||||
startsAt: T0,
|
||||
endsAt: T9,
|
||||
electorCircleId: 'c1',
|
||||
nominationMethod: 'consent',
|
||||
reports: [],
|
||||
status: 'active',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mkClause(over: Partial<Clause> = {}): Clause {
|
||||
return {
|
||||
id: 'cl1',
|
||||
collectiveId: 'col',
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
docId: 'doc1',
|
||||
section: 'E',
|
||||
position: 9,
|
||||
code: 'E9',
|
||||
title: 'Acte Forgeron',
|
||||
inertia: 'high',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const scoped = (circleIds: string[]): TriageInput['scope'] =>
|
||||
({ selfOnly: false, circleIds, personIds: [] })
|
||||
|
||||
describe('detectParametricHint — nombre, %, montant dans la phrase', () => {
|
||||
it('détecte un montant en euros', () => {
|
||||
expect(detectParametricHint('Fixer la cotisation à 25 €')).toBe(true)
|
||||
})
|
||||
|
||||
it('ne détecte rien dans une phrase sans chiffre', () => {
|
||||
expect(detectParametricHint('Repeindre la salle')).toBe(false)
|
||||
})
|
||||
|
||||
it('détecte un pourcentage', () => {
|
||||
expect(detectParametricHint('Porter la part commune à douze %')).toBe(true)
|
||||
})
|
||||
|
||||
it('détecte l\'unité DU en majuscules', () => {
|
||||
expect(detectParametricHint('Verser un DU aux nouveaux venus')).toBe(true)
|
||||
})
|
||||
|
||||
it('ne confond pas l\'article « du » avec l\'unité DU', () => {
|
||||
expect(detectParametricHint('Changer du mobilier dans la salle')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R-U — urgence', () => {
|
||||
it('conservatoire : route sous-jacente calculée sans urgence, fenêtre doublée, chaîne posée', () => {
|
||||
// Sans urgence, ce cas serait R4 (easy, 3 ≤ 5, fenêtre 72 h).
|
||||
const verdict = triage(
|
||||
mkInput({ urgent: true, reversibility: 'easy' }),
|
||||
mkCtx(),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R-U')
|
||||
expect(verdict.route).toBe('advice')
|
||||
expect(verdict.conservatoryChain).toBe(true)
|
||||
expect(verdict.windowHours).toBe(144)
|
||||
expect(verdict.explanation).toBe('Décidé en urgence — le collectif ratifie.')
|
||||
})
|
||||
|
||||
it('urgence × irréversible : refus — route normale, fenêtres minimales (jamais doublées)', () => {
|
||||
// Sans urgence, ce cas serait R0b (mandat couvrant, fenêtre 48 h).
|
||||
const verdict = triage(
|
||||
mkInput({ urgent: true, reversibility: 'irreversible', scope: scoped(['c1']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R-U')
|
||||
expect(verdict.route).toBe('mandate')
|
||||
expect(verdict.explanation).toBe(URGENT_REFUSED)
|
||||
expect(verdict.conservatoryChain).toBeUndefined()
|
||||
expect(verdict.windowHours).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R0a — déjà décidé', () => {
|
||||
it('clause en vigueur trouvée : consigner en principal, agir ou contester', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())
|
||||
|
||||
expect(verdict.rule).toBe('R0a')
|
||||
expect(verdict.route).toBe('record')
|
||||
expect(verdict.explanation).toBe(
|
||||
'C\'est déjà décidé (E9, Acte Forgeron) — agis, ou conteste la règle.',
|
||||
)
|
||||
expect(verdict.windowHours).toBeUndefined()
|
||||
})
|
||||
|
||||
it('limite : viser explicitement la clause (amendsClauseId) passe à R3, pas R0a', () => {
|
||||
// Choix documenté : contester la règle EST R3 — R0a ne bloque pas l'amendement.
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1' }),
|
||||
mkCtx({ matchingClauses: [mkClause()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R3')
|
||||
expect(verdict.route).toBe('collective')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R0b — mon mandat couvre', () => {
|
||||
it('périmètre inclus dans le domaine du mandat : décide, c\'est tracé', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0b')
|
||||
expect(verdict.route).toBe('mandate')
|
||||
expect(verdict.windowHours).toBe(48)
|
||||
expect(verdict.explanation).toBe('Ton mandat Modération couvre — décide, c\'est tracé.')
|
||||
})
|
||||
|
||||
it('limite : couverture partielle du périmètre ne suffit pas', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1', 'c3']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate({ domain: { circleIds: ['c1', 'c2'], tags: [] } })] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).not.toBe('R0b')
|
||||
})
|
||||
|
||||
it('ordre : selfOnly avec mandat couvrant ⇒ R0b gagne, car R0b précède R2', () => {
|
||||
// Choix documenté : le devoir de trace du mandat prime sur le solo.
|
||||
const verdict = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: ['c1'], personIds: [] } }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0b')
|
||||
expect(verdict.route).toBe('mandate')
|
||||
})
|
||||
|
||||
it('limite : périmètre sans cercle ⇒ jamais de couverture triviale', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped([]) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).not.toBe('R0b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R0c — le mandat d\'un autre couvre', () => {
|
||||
it('transmets à sa ou son titulaire — le titre du mandat nomme le pouvoir', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ otherActiveMandates: [mkMandate({ holderId: 'p-other' })] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0c')
|
||||
expect(verdict.route).toBe('transmit')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Le mandat Modération couvre — transmets à sa ou son titulaire.',
|
||||
)
|
||||
})
|
||||
|
||||
it('ordre : mon mandat gagne sur celui d\'un autre (R0b avant R0c)', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({
|
||||
myActiveMandates: [mkMandate({ id: 'm-me', title: 'Trésorerie' })],
|
||||
otherActiveMandates: [mkMandate({ id: 'm-other', holderId: 'p-other' })],
|
||||
}),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R0b')
|
||||
})
|
||||
|
||||
it('limite : un mandat expiré ne couvre pas', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ otherActiveMandates: [mkMandate({ status: 'expired' })] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).not.toBe('R0c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R2 — moi seul', () => {
|
||||
it('selfOnly : décide, zéro fenêtre', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
|
||||
mkCtx({ computedConcernedIds: [] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R2')
|
||||
expect(verdict.route).toBe('solo')
|
||||
expect(verdict.explanation).toBe('Personne d\'autre n\'est concerné — décide.')
|
||||
expect(verdict.windowHours).toBeUndefined()
|
||||
expect(verdict.reviewRequired).toBe(false)
|
||||
})
|
||||
|
||||
it('limite : irréversible sur soi ⇒ revoyure suggérée (pré-coche retirable côté UI)', () => {
|
||||
// Le moteur suggère (reviewRequired) ; la pré-coche retirable est un fait
|
||||
// d'UI — sur soi, l'outil ne s'interpose jamais.
|
||||
const verdict = triage(
|
||||
mkInput({
|
||||
scope: { selfOnly: true, circleIds: [], personIds: [] },
|
||||
reversibility: 'irreversible',
|
||||
}),
|
||||
mkCtx({ computedConcernedIds: [] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R2')
|
||||
expect(verdict.reviewRequired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R3 — clause visée', () => {
|
||||
const clauseSettings = mkSettings({
|
||||
protocolByRange: {
|
||||
clauseByInertia: {
|
||||
low: 'proto-low',
|
||||
standard: 'proto-standard',
|
||||
high: 'proto-high',
|
||||
max: 'proto-max',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
it('l\'inertie de la clause choisit le protocole', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1' }),
|
||||
mkCtx({ matchingClauses: [mkClause({ inertia: 'high' })] }),
|
||||
clauseSettings,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R3')
|
||||
expect(verdict.route).toBe('collective')
|
||||
expect(verdict.protocolId).toBe('proto-high')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Tu proposes une version de E9 — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
|
||||
)
|
||||
})
|
||||
|
||||
it('repli : sans clauseByInertia, le consentement s\'applique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1' }),
|
||||
mkCtx({ matchingClauses: [mkClause()] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
})
|
||||
|
||||
it('poids structurel ⇒ fenêtre de formulation posée', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl1', weight: 'structural' }),
|
||||
mkCtx({ matchingClauses: [mkClause()] }),
|
||||
clauseSettings,
|
||||
)
|
||||
|
||||
expect(verdict.framingDays).toBe(14)
|
||||
})
|
||||
|
||||
it('limite : clause introuvable dans l\'index ⇒ consentement et phrase générique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ amendsClauseId: 'cl-inconnue' }),
|
||||
mkCtx({ matchingClauses: [] }),
|
||||
clauseSettings,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R3')
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Tu proposes une version de cette règle — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R4 — petit cercle réversible', () => {
|
||||
it('réversible et 3 personnes : demande leur avis puis décide (72 h)', () => {
|
||||
const verdict = triage(mkInput({ reversibility: 'easy' }), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.rule).toBe('R4')
|
||||
expect(verdict.route).toBe('advice')
|
||||
expect(verdict.windowHours).toBe(72)
|
||||
expect(verdict.explanation).toBe(
|
||||
'Réversible et 3 personnes concernées — demande leur avis puis décide.',
|
||||
)
|
||||
})
|
||||
|
||||
it('limite : accord du singulier pour 1 personne', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ reversibility: 'easy' }),
|
||||
mkCtx({ computedConcernedIds: ['p1'] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.explanation).toBe(
|
||||
'Réversible et 1 personne concernée — demande leur avis puis décide.',
|
||||
)
|
||||
})
|
||||
|
||||
it('limite : au-delà de smallGroupMax, l\'escalade collective s\'applique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ reversibility: 'easy' }),
|
||||
mkCtx({ computedConcernedIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
})
|
||||
|
||||
it('limite : coûteux à défaire ⇒ jamais R4, même à 3 personnes', () => {
|
||||
const verdict = triage(mkInput({ reversibility: 'costly' }), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R5 — collectif, modalité par taille du périmètre', () => {
|
||||
const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `p${i}`)
|
||||
const fullSettings = mkSettings({
|
||||
protocolByRange: { nuanced: 'proto-nuanced', large: 'proto-large' },
|
||||
})
|
||||
|
||||
it('n ≤ consentMax : un tour d\'accord suffit', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ computedConcernedIds: ids(6) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
expect(verdict.route).toBe('collective')
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Vous êtes 6 — un tour d\'accord suffit : sans objection, c\'est adopté.',
|
||||
)
|
||||
})
|
||||
|
||||
it('n ≤ collectiveMin : vote nuancé', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ computedConcernedIds: ids(23) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-nuanced')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Vous êtes 23 — vote nuancé : chacun se prononce en nuances, pas en camps.',
|
||||
)
|
||||
})
|
||||
|
||||
it('repli : sans protocole nuancé, le consentement s\'applique', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(23) }), mkSettings())
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
})
|
||||
|
||||
it('n > collectiveMin : la modalité choisie par le Pacte', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ computedConcernedIds: ids(60) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-large')
|
||||
expect(verdict.explanation).toBe(
|
||||
'Vous êtes 60 — la modalité que votre Pacte a choisie s\'applique.',
|
||||
)
|
||||
})
|
||||
|
||||
it('repli : sans protocole large, le consentement s\'applique', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(60) }), mkSettings())
|
||||
|
||||
expect(verdict.protocolId).toBe('proto-consent')
|
||||
})
|
||||
|
||||
it('poids structurel ⇒ formulation + revoyure + gravure suggérée', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ weight: 'structural' }),
|
||||
mkCtx({ computedConcernedIds: ids(6) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.framingDays).toBe(14)
|
||||
expect(verdict.reviewRequired).toBe(true)
|
||||
expect(verdict.engravingSuggested).toBe(true)
|
||||
})
|
||||
|
||||
it('irréversible ⇒ revoyure exigée, sans gravure automatique', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ reversibility: 'irreversible' }),
|
||||
mkCtx({ computedConcernedIds: ids(6) }),
|
||||
fullSettings,
|
||||
)
|
||||
|
||||
expect(verdict.reviewRequired).toBe(true)
|
||||
expect(verdict.engravingSuggested).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('R6 — récurrence et maturation (surcouche, jamais bloquante)', () => {
|
||||
it('3 décisions similaires récentes ⇒ suggère de réclamer un mandat, sans changer la route', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ tags: ['cuisine', 'achats'] }),
|
||||
mkCtx({ similarRecentCount: 3 }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R5')
|
||||
expect(verdict.suggestion?.kind).toBe('claim-mandate')
|
||||
})
|
||||
|
||||
it('3 consignations similaires ⇒ suggère de protocoliser la pratique', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ similarRecordedCount: 3 }), mkSettings())
|
||||
|
||||
expect(verdict.suggestion?.kind).toBe('protocolize')
|
||||
})
|
||||
|
||||
it('les deux seuils atteints ⇒ réclamer un mandat passe en premier', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ similarRecentCount: 3, similarRecordedCount: 3 }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.suggestion?.kind).toBe('claim-mandate')
|
||||
})
|
||||
|
||||
it('sous le seuil ⇒ aucune suggestion', () => {
|
||||
const verdict = triage(
|
||||
mkInput(),
|
||||
mkCtx({ similarRecentCount: 2, similarRecordedCount: 2 }),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.suggestion).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Repli sans protocole de consentement (bundle corrompu)', () => {
|
||||
const broken = mkSettings({ protocolByRange: { consent: '' } })
|
||||
|
||||
it('route sur avis avec la bannière — prioritaire même sur un mandat couvrant', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: scoped(['c1']) }),
|
||||
mkCtx({ myActiveMandates: [mkMandate()] }),
|
||||
broken,
|
||||
)
|
||||
|
||||
expect(verdict.route).toBe('advice')
|
||||
expect(verdict.explanation).toBe('Aucun protocole — crée-le ou décide sur avis.')
|
||||
expect(verdict.windowHours).toBe(72)
|
||||
})
|
||||
|
||||
it('sauf R2 : moi seul décide sans protocole', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
|
||||
mkCtx(),
|
||||
broken,
|
||||
)
|
||||
|
||||
expect(verdict.rule).toBe('R2')
|
||||
expect(verdict.route).toBe('solo')
|
||||
})
|
||||
|
||||
it('sauf consigner : une clause en vigueur reste consignable sans protocole', () => {
|
||||
const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), broken)
|
||||
|
||||
expect(verdict.rule).toBe('R0a')
|
||||
expect(verdict.route).toBe('record')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Alternatives permanentes — « Je choisis autrement »', () => {
|
||||
it('toujours présentes : réglage, consigner, binaire avec son coût', () => {
|
||||
const verdict = triage(mkInput(), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.alternatives).toHaveLength(3)
|
||||
const labels = verdict.alternatives.map(a => a.label)
|
||||
expect(labels).toContain(PARAMETRIC_ALT)
|
||||
expect(labels).toContain(RECORD_ALT)
|
||||
const binary = verdict.alternatives.find(a => a.cost === BINARY_COST)
|
||||
expect(binary).toBeDefined()
|
||||
expect(binary?.route).toBe('collective')
|
||||
})
|
||||
|
||||
it('présentes sur toutes les routes, solo et consigner comprises', () => {
|
||||
const solo = triage(
|
||||
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
|
||||
mkCtx(),
|
||||
mkSettings(),
|
||||
)
|
||||
const record = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())
|
||||
|
||||
expect(solo.alternatives).toHaveLength(3)
|
||||
expect(record.alternatives).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('montant détecté ⇒ le réglage passe en premier et parametricHint est posé', () => {
|
||||
const verdict = triage(
|
||||
mkInput({ title: 'Fixer la cotisation à 25 €' }),
|
||||
mkCtx(),
|
||||
mkSettings(),
|
||||
)
|
||||
|
||||
expect(verdict.parametricHint).toBe(true)
|
||||
expect(verdict.alternatives[0]?.label).toBe(PARAMETRIC_ALT)
|
||||
})
|
||||
|
||||
it('sans montant ⇒ pas de mise en avant du réglage', () => {
|
||||
const verdict = triage(mkInput(), mkCtx(), mkSettings())
|
||||
|
||||
expect(verdict.parametricHint).toBeUndefined()
|
||||
expect(verdict.alternatives[0]?.label).not.toBe(PARAMETRIC_ALT)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user