Files
decision/maquette/frontend/tests/engine/state.spec.ts
YvvandClaude Opus 5.5 67ddee28b2 v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:04:42 +02:00

651 lines
25 KiB
TypeScript
Raw Permalink 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 (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', 'framing'])
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')
})
})
describe('Garde j — reformuler (voting→framing) réservé au réglage collectif figé', () => {
it('session figée non cristallisée — reformuler est permis', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
expect(canTransition(decision, 'framing', ctx).ok).toBe(true)
})
it('session ouverte — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({ session: makeSession({ status: 'open' }) })
expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
})
it('session cristallisée — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({
session: makeSession({ status: 'closed', crystallizedById: 'p-steward' }),
})
expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
})
it('sans session — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
expect(canTransition(decision, 'framing', makeCtx()).ok).toBe(false)
})
})