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

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

624 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* canTransition — the ONE state machine of the decision (Δ29).
* Every guard (ai) 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 nont 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 nexiste 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 dune 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 dobjection 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 davis 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 sadopte normalement', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate' })
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
})
it('la frontière prime sur laffluence (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 — laffluence 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 dun 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 davis', () => {
const decision = makeDecision({ status: 'advice', route: 'advice' })
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
})
it('la garde vaut pour le rejet dun vote — clore, cest clore', () => {
const decision = makeDecision({ status: 'voting' })
expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
})
it('les concernés dune 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('lescalade vers framing ou voting reste ouverte — cest 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) à louverture 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 nest 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 nest 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 » à louverture de fenêtre ou de session', () => {
it('poids binding sans note de ressources — la fenêtre dobjection ne souvre 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 souvre', () => {
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 à louverture 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 — laccord explicite (Assent) hors du réversible', () => {
it('easy : le silence vaut accord, la fenêtre sadopte 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 : laccord de lauteur 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 dun tiers concerné débloque ladoption', () => {
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('lAssent dune 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 dadoption sur une objection ouverte', () => {
it('une objection ouverte bloque ladoption, 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('lAssent dun 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 dun 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 dadoption 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é — ladoption 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 sapplique pas', () => {
expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true)
})
})
describe('windowOutcome — l’échéance des fenêtres dobjection', () => {
it('easy — le silence vaut accord, la fenêtre sadopte', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'easy' })
expect(windowOutcome(decision, makeCtx())).toBe('adopt')
})
it('costly sans accord tiers — la fenêtre se prolonge dun cran', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
expect(windowOutcome(decision, makeCtx())).toBe('extend')
})
it('costly avec laccord dun tiers — la fenêtre sadopte', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt')
})
it('laccord de lauteur 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')
})
})