Files
decision/frontend/tests/engine/state.spec.ts
T
YvvandClaude Fable 5 e164b5f6c1 v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes,
  Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation)
- Registre + fiche décision (timeline, périmètre premier/second lieu auditable,
  affluence non-ignorable, S'instruire condensé, éléments + cartographie de
  clôture, épreuve du réel, Remettre en question, PV A4, gravure)
- Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité
  avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse,
  Pour moi, Explorer, cristallisation-geste —, élection à départage humain)
- Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des
  formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux
  de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser)
- Onboarding 7 gabarits + Données locales (export/import, attributs, atelier)
- voting→framing gardé (Reformuler d'un réglage figé non cristallisé)
- Pages v1 supprimées (login, documents, mandates, protocols, sanctuary,
  tools, decisions/new) — 342 tests verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:47:20 +02:00

651 lines
25 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', 'framing'])
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')
})
})
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)
})
})