- 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>
624 lines
24 KiB
TypeScript
624 lines
24 KiB
TypeScript
/**
|
||
* canTransition — the ONE state machine of the decision (Δ29).
|
||
* Every guard (a–i) tested with positive AND negative cases + windowOutcome.
|
||
*/
|
||
import { describe, expect, it } from 'vitest'
|
||
import { TRANSITIONS, canTransition, windowOutcome } from '../../app/engine/state'
|
||
import type { TransitionContext } from '../../app/engine/state'
|
||
import type {
|
||
Assent,
|
||
CollectiveSettings,
|
||
Concern,
|
||
Decision,
|
||
Objection,
|
||
ParamSpec,
|
||
VoteSession,
|
||
} from '../../app/types/domain'
|
||
|
||
const T0 = '2026-08-01T00:00:00.000Z'
|
||
const NOW = '2026-08-11T12:00:00.000Z'
|
||
|
||
let seq = 0
|
||
|
||
function makeEntity() {
|
||
seq += 1
|
||
return { id: `id-${seq}`, collectiveId: 'col-1', createdAt: T0, updatedAt: T0 }
|
||
}
|
||
|
||
function makeSettings(overrides: Partial<CollectiveSettings['triage']> = {}): CollectiveSettings {
|
||
return {
|
||
triage: {
|
||
smallGroupMax: 5,
|
||
collectiveMin: 50,
|
||
consentMax: 7,
|
||
objectionWindowHours: 48,
|
||
adviceWindowHours: 72,
|
||
framingDays: 14,
|
||
concernEscalateRatio: 0.5,
|
||
recurrenceThreshold: 3,
|
||
reviewDelayDays: 90,
|
||
requireEffects: 'binding',
|
||
...overrides,
|
||
},
|
||
protocolByRange: { consent: 'proto-consent' },
|
||
}
|
||
}
|
||
|
||
function makeDecision(overrides: Partial<Decision> = {}): Decision {
|
||
return {
|
||
...makeEntity(),
|
||
id: 'dec-1',
|
||
authorId: 'p-author',
|
||
title: 'Décision de test',
|
||
tags: [],
|
||
reversibility: 'easy',
|
||
weight: 'light',
|
||
urgent: false,
|
||
scope: { selfOnly: false, circleIds: ['circle-1'], personIds: [] },
|
||
route: 'collective',
|
||
triageRule: 'R5',
|
||
routeOverridden: false,
|
||
status: 'draft',
|
||
stewardIds: [],
|
||
measurerIds: [],
|
||
visibility: 'scope',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeConcern(origin: Concern['origin'], overrides: Partial<Concern> = {}): Concern {
|
||
const entity = makeEntity()
|
||
return {
|
||
...entity,
|
||
decisionId: 'dec-1',
|
||
personId: `p-${entity.id}`,
|
||
origin,
|
||
reason: 'membre du cercle',
|
||
beforeSnapshot: true,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeAssent(personId: string, overrides: Partial<Assent> = {}): Assent {
|
||
return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides }
|
||
}
|
||
|
||
function makeObjection(status: Objection['status'], overrides: Partial<Objection> = {}): Objection {
|
||
return {
|
||
...makeEntity(),
|
||
decisionId: 'dec-1',
|
||
personId: 'p-objector',
|
||
kind: 'content',
|
||
argument: 'Je maintiens mon désaccord.',
|
||
status,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeSession(overrides: Partial<VoteSession> = {}): VoteSession {
|
||
return {
|
||
...makeEntity(),
|
||
decisionId: 'dec-1',
|
||
protocolId: 'proto-1',
|
||
corpusPersonIds: ['p-1', 'p-2', 'p-3'],
|
||
corpusSize: 3,
|
||
opensAt: T0,
|
||
closesAt: NOW,
|
||
status: 'open',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeCtx(overrides: Partial<TransitionContext> = {}): TransitionContext {
|
||
return {
|
||
concerns: [],
|
||
settings: makeSettings(),
|
||
assents: [],
|
||
objections: [],
|
||
now: NOW,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
const PARAM_SPEC: ParamSpec = {
|
||
params: [
|
||
{ key: 'partA', label: 'Part ateliers', kind: 'share', min: 0, max: 100, step: 1 },
|
||
{ key: 'partB', label: 'Part réserve', kind: 'share', min: 0, max: 100, step: 1, derived: true },
|
||
],
|
||
constraint: 'sum100',
|
||
}
|
||
|
||
/** 4 computed + 2 declared ⇒ ratio 0.5 atteint (2 ≥ 0.5 × 4). */
|
||
function influxConcerns(): Concern[] {
|
||
return [
|
||
makeConcern('computed'),
|
||
makeConcern('computed'),
|
||
makeConcern('computed'),
|
||
makeConcern('computed'),
|
||
makeConcern('declared'),
|
||
makeConcern('declared'),
|
||
]
|
||
}
|
||
|
||
describe('La table des transitions', () => {
|
||
it('draft ouvre les sept chemins', () => {
|
||
expect(TRANSITIONS.draft).toEqual([
|
||
'advice',
|
||
'objection',
|
||
'framing',
|
||
'voting',
|
||
'adopted',
|
||
'transmitted',
|
||
'rejected',
|
||
])
|
||
})
|
||
|
||
it('chaque état de travail connaît ses sorties', () => {
|
||
expect(TRANSITIONS.advice).toEqual(['adopted', 'voting'])
|
||
expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting'])
|
||
expect(TRANSITIONS.framing).toEqual(['voting', 'closed'])
|
||
expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected'])
|
||
expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed'])
|
||
})
|
||
|
||
it('les états terminaux n’ont aucune sortie', () => {
|
||
expect(TRANSITIONS.rejected).toBeUndefined()
|
||
expect(TRANSITIONS.revoked).toBeUndefined()
|
||
expect(TRANSITIONS.closed).toBeUndefined()
|
||
expect(TRANSITIONS.transmitted).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('Garde a — la transition inconnue est refusée', () => {
|
||
it('adopted → voting n’existe pas', () => {
|
||
const result = canTransition(makeDecision({ status: 'adopted' }), 'voting', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('rejected est terminal — aucune sortie', () => {
|
||
expect(canTransition(makeDecision({ status: 'rejected' }), 'adopted', makeCtx()).ok).toBe(false)
|
||
})
|
||
|
||
it('draft → advice existe', () => {
|
||
const decision = makeDecision({ status: 'draft', route: 'advice' })
|
||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('le retour explicite d’une fenêtre à son propre état est permis', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('un état hors fenêtre ne boucle pas sur lui-même', () => {
|
||
expect(canTransition(makeDecision({ status: 'voting' }), 'voting', makeCtx()).ok).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('Garde b — la frontière contestée suspend toute sortie', () => {
|
||
it('une fenêtre d’objection suspendue ne peut pas adopter', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||
const result = canTransition(decision, 'adopted', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('frontière')
|
||
})
|
||
|
||
it('une fenêtre d’avis suspendue ne peut pas non plus escalader vers le vote', () => {
|
||
const decision = makeDecision({ status: 'advice', route: 'advice', windowSuspendedAt: NOW })
|
||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
|
||
})
|
||
|
||
it('le retour explicite au même état reste possible pendant la suspension', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('sans suspension, la fenêtre s’adopte normalement', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('la frontière prime sur l’affluence (ordre des gardes)', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
|
||
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('frontière')
|
||
})
|
||
})
|
||
|
||
describe('Garde c — l’affluence exige un traitement du périmètre', () => {
|
||
it('ratio atteint sans scopeKeptNote — la session ne se clôt pas', () => {
|
||
const decision = makeDecision({ status: 'voting' })
|
||
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe(
|
||
'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
|
||
)
|
||
}
|
||
})
|
||
|
||
it('le maintien motivé publiquement (scopeKeptNote) débloque la clôture', () => {
|
||
const decision = makeDecision({
|
||
status: 'voting',
|
||
scopeKeptNote: 'Le cercle Ateliers reste le bon périmètre : le budget est le sien.',
|
||
})
|
||
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
|
||
})
|
||
|
||
it('sous le ratio, la clôture passe', () => {
|
||
const concerns = [
|
||
makeConcern('computed'),
|
||
makeConcern('computed'),
|
||
makeConcern('computed'),
|
||
makeConcern('computed'),
|
||
makeConcern('declared'),
|
||
]
|
||
const decision = makeDecision({ status: 'voting' })
|
||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||
})
|
||
|
||
it('sans concerné calculé, la garde ne se déclenche jamais', () => {
|
||
const concerns = [makeConcern('declared'), makeConcern('declared'), makeConcern('declared')]
|
||
const decision = makeDecision({ status: 'voting' })
|
||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||
})
|
||
|
||
it('la garde vaut aussi pour la fenêtre d’avis', () => {
|
||
const decision = makeDecision({ status: 'advice', route: 'advice' })
|
||
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
|
||
})
|
||
|
||
it('la garde vaut pour le rejet d’un vote — clore, c’est clore', () => {
|
||
const decision = makeDecision({ status: 'voting' })
|
||
expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
|
||
})
|
||
|
||
it('les concernés d’une autre décision ne comptent pas', () => {
|
||
const concerns = influxConcerns().map((concern) => ({ ...concern, decisionId: 'dec-2' }))
|
||
const decision = makeDecision({ status: 'voting' })
|
||
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
|
||
})
|
||
|
||
it('l’escalade vers framing ou voting reste ouverte — c’est le traitement, pas la clôture', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate' })
|
||
expect(canTransition(decision, 'framing', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('Garde d — la matière (requireEffects) à l’ouverture de session collective', () => {
|
||
const resources = { note: 'Deux heures par semaine pendant un mois' }
|
||
|
||
it('« binding » + poids binding sans effet recherché — refus', () => {
|
||
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
|
||
const result = canTransition(decision, 'voting', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('effet recherché')
|
||
})
|
||
|
||
it('« binding » + poids binding avec un effet recherché — ouverture', () => {
|
||
const decision = makeDecision({
|
||
status: 'draft',
|
||
weight: 'binding',
|
||
resources,
|
||
brief: { effects: [{ label: 'Réduire le temps de réunion' }] },
|
||
})
|
||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('« binding » + poids structural sans cible mesurable — refus', () => {
|
||
const decision = makeDecision({
|
||
status: 'draft',
|
||
weight: 'structural',
|
||
resources,
|
||
brief: { effects: [{ label: 'Assainir le budget' }] },
|
||
})
|
||
const result = canTransition(decision, 'voting', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('cible')
|
||
})
|
||
|
||
it('« binding » + poids structural avec une cible — ouverture', () => {
|
||
const decision = makeDecision({
|
||
status: 'draft',
|
||
weight: 'structural',
|
||
resources,
|
||
brief: { effects: [{ label: 'Assainir le budget', target: '≤ 400 € par mois' }] },
|
||
})
|
||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('« structural » : le poids binding n’est pas sous garde', () => {
|
||
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
|
||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
|
||
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('« structural » : le poids structural sans effet est refusé', () => {
|
||
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
|
||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
|
||
expect(canTransition(decision, 'voting', ctx).ok).toBe(false)
|
||
})
|
||
|
||
it('« none » : aucune exigence de matière, même structural', () => {
|
||
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
|
||
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'none' }) })
|
||
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('le poids light n’est jamais soumis à la matière', () => {
|
||
const decision = makeDecision({ status: 'draft', weight: 'light' })
|
||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('la garde est réservée à la route collective', () => {
|
||
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding', resources })
|
||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('framing → voting est gardé de la même façon', () => {
|
||
const decision = makeDecision({ status: 'framing', weight: 'structural', resources })
|
||
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('Garde e — « Ce que ça engage » à l’ouverture de fenêtre ou de session', () => {
|
||
it('poids binding sans note de ressources — la fenêtre d’objection ne s’ouvre pas', () => {
|
||
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding' })
|
||
const result = canTransition(decision, 'objection', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('engage')
|
||
})
|
||
|
||
it('avec la note, la fenêtre s’ouvre', () => {
|
||
const decision = makeDecision({
|
||
status: 'draft',
|
||
route: 'mandate',
|
||
weight: 'binding',
|
||
resources: { note: 'Une demi-journée de l’équipe accueil' },
|
||
})
|
||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('une note blanche ne compte pas', () => {
|
||
const decision = makeDecision({
|
||
status: 'draft',
|
||
route: 'mandate',
|
||
weight: 'binding',
|
||
resources: { note: ' ' },
|
||
})
|
||
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(false)
|
||
})
|
||
|
||
it('le poids light ouvre sans note', () => {
|
||
const decision = makeDecision({ status: 'draft', route: 'advice', weight: 'light' })
|
||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('la route solo est exemptée', () => {
|
||
const decision = makeDecision({ status: 'draft', route: 'solo', weight: 'binding' })
|
||
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('la garde vit aussi à l’ouverture de session — après la matière', () => {
|
||
const decision = makeDecision({
|
||
status: 'draft',
|
||
weight: 'binding',
|
||
brief: { effects: [{ label: 'Un effet recherché' }] },
|
||
})
|
||
const result = canTransition(decision, 'voting', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('engage')
|
||
})
|
||
})
|
||
|
||
describe('Garde f — l’accord explicite (Assent) hors du réversible', () => {
|
||
it('easy : le silence vaut accord, la fenêtre s’adopte sans Assent', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
|
||
})
|
||
|
||
it('costly sans Assent — la fenêtre se prolonge', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||
const result = canTransition(decision, 'adopted', makeCtx())
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe('Il manque un accord explicite — la fenêtre se prolonge.')
|
||
}
|
||
})
|
||
|
||
it('costly : l’accord de l’auteur seul ne suffit pas', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||
const ctx = makeCtx({ assents: [makeAssent('p-author')] })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||
})
|
||
|
||
it('costly : un accord d’un tiers concerné débloque l’adoption', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||
const ctx = makeCtx({ assents: [makeAssent('p-other')] })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('irreversible sans Assent — refus aussi', () => {
|
||
const decision = makeDecision({
|
||
status: 'objection',
|
||
route: 'mandate',
|
||
reversibility: 'irreversible',
|
||
})
|
||
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(false)
|
||
})
|
||
|
||
it('l’Assent d’une autre décision ne compte pas', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||
const ctx = makeCtx({ assents: [makeAssent('p-other', { decisionId: 'dec-2' })] })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('Garde g — jamais d’adoption sur une objection ouverte', () => {
|
||
it('une objection ouverte bloque l’adoption, même en easy', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||
const result = canTransition(decision, 'adopted', makeCtx({ objections: [makeObjection('open')] }))
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('objection')
|
||
})
|
||
|
||
it('une objection retirée ne bloque plus', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||
const ctx = makeCtx({ objections: [makeObjection('withdrawn')] })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('une objection intégrée ne bloque plus', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
|
||
const ctx = makeCtx({ objections: [makeObjection('integrated')] })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('l’Assent d’un tiers ne contourne pas une objection ouverte', () => {
|
||
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
|
||
const ctx = makeCtx({ assents: [makeAssent('p-other')], objections: [makeObjection('open')] })
|
||
const result = canTransition(decision, 'adopted', ctx)
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('objection')
|
||
})
|
||
})
|
||
|
||
describe('Garde h — la clôture du dossier découpé', () => {
|
||
const parent = () => makeDecision({ id: 'dossier-1', status: 'framing' })
|
||
|
||
function element(status: Decision['status'], overrides: Partial<Decision> = {}): Decision {
|
||
const entity = makeEntity()
|
||
return makeDecision({
|
||
id: entity.id,
|
||
parentDecisionId: 'dossier-1',
|
||
chainKind: 'element',
|
||
status,
|
||
...overrides,
|
||
})
|
||
}
|
||
|
||
it('sans élément, le dossier ne se clôt pas', () => {
|
||
const result = canTransition(parent(), 'closed', makeCtx({ children: [] }))
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('dossier')
|
||
})
|
||
|
||
it('tous les éléments terminaux — le garant peut clore', () => {
|
||
const children = [
|
||
element('adopted'),
|
||
element('rejected'),
|
||
element('revoked'),
|
||
element('closed'),
|
||
element('transmitted'),
|
||
]
|
||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(true)
|
||
})
|
||
|
||
it('un élément encore en vote retient le dossier', () => {
|
||
const children = [element('adopted'), element('voting')]
|
||
const result = canTransition(parent(), 'closed', makeCtx({ children }))
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) expect(result.reason).toContain('en cours')
|
||
})
|
||
|
||
it('des enfants non-éléments ne font pas un dossier', () => {
|
||
const children = [element('adopted', { chainKind: 'revision' })]
|
||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
|
||
})
|
||
|
||
it('l’élément d’un autre dossier ne compte pas', () => {
|
||
const children = [element('adopted', { parentDecisionId: 'dossier-2' })]
|
||
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
|
||
})
|
||
|
||
it('framing → voting du parent reste possible pendant que le dossier vit', () => {
|
||
const decision = makeDecision({ id: 'dossier-1', status: 'framing', weight: 'light' })
|
||
expect(canTransition(decision, 'voting', makeCtx({ children: [element('voting')] })).ok).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('Garde i — la cristallisation attend le geste du garant', () => {
|
||
const parametricDecision = () => makeDecision({ status: 'voting', paramSpec: PARAM_SPEC })
|
||
|
||
it('session figée — pas d’adoption sans le geste', () => {
|
||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||
const result = canTransition(parametricDecision(), 'adopted', ctx)
|
||
expect(result.ok).toBe(false)
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe('Les votes sont figés — la cristallisation attend son geste.')
|
||
}
|
||
})
|
||
|
||
it('session close sans crystallizedById — le moteur ne cristallise jamais', () => {
|
||
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
|
||
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(false)
|
||
})
|
||
|
||
it('session close et geste daté — l’adoption passe', () => {
|
||
const ctx = makeCtx({
|
||
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
|
||
})
|
||
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('le rejet (quorum non atteint) exige le même geste', () => {
|
||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(false)
|
||
})
|
||
|
||
it('le rejet constaté au geste passe', () => {
|
||
const ctx = makeCtx({
|
||
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
|
||
})
|
||
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('une session figée trahit le paramétrique même sans paramSpec', () => {
|
||
const decision = makeDecision({ status: 'voting' })
|
||
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
|
||
})
|
||
|
||
it('une session non paramétrique se clôt automatiquement — pas de geste exigé', () => {
|
||
const decision = makeDecision({ status: 'voting' })
|
||
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
|
||
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
|
||
})
|
||
|
||
it('sans session dans le contexte, la garde ne s’applique pas', () => {
|
||
expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('windowOutcome — l’échéance des fenêtres d’objection', () => {
|
||
it('easy — le silence vaut accord, la fenêtre s’adopte', () => {
|
||
const decision = makeDecision({ status: 'objection', reversibility: 'easy' })
|
||
expect(windowOutcome(decision, makeCtx())).toBe('adopt')
|
||
})
|
||
|
||
it('costly sans accord tiers — la fenêtre se prolonge d’un cran', () => {
|
||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||
expect(windowOutcome(decision, makeCtx())).toBe('extend')
|
||
})
|
||
|
||
it('costly avec l’accord d’un tiers — la fenêtre s’adopte', () => {
|
||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt')
|
||
})
|
||
|
||
it('l’accord de l’auteur seul ne compte pas', () => {
|
||
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
|
||
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-author')] }))).toBe('extend')
|
||
})
|
||
|
||
it('frontière suspendue — la fenêtre attend, même en easy', () => {
|
||
const decision = makeDecision({
|
||
status: 'objection',
|
||
reversibility: 'easy',
|
||
windowSuspendedAt: NOW,
|
||
})
|
||
expect(windowOutcome(decision, makeCtx())).toBe('wait')
|
||
})
|
||
})
|