/** * 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 { 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 { 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 { 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 { return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides } } function makeObjection(status: Objection['status'], overrides: Partial = {}): Objection { return { ...makeEntity(), decisionId: 'dec-1', personId: 'p-objector', kind: 'content', argument: 'Je maintiens mon désaccord.', status, ...overrides, } } function makeSession(overrides: Partial = {}): 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 { 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 { 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) }) })