Files
decision/frontend/tests/stores/decisions.spec.ts
T
YvvandClaude Fable 5 d886302b59 v2 : couche données complète + shell + primitives communes
- stores/collective.ts (état local-first, gabarits, import/export, seeds)
  + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→
  sessions→cristallisation→épreuve du réel→révocation)
- data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord,
  5 points de départ, Institution symétrique)
- composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch
  (index unique Cmd+K/Q0)
- shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/
  Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1,
  LdAvatarStack (premier/second lieu), LdCountdown (suspension striée)
- 338 tests vitest verts, npm run build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:30:35 +02:00

608 lines
27 KiB
TypeScript

/**
* Store-layer specs — the decisions façade + the seven templates.
*
* Plain vitest + createPinia (no Nuxt runtime): the stores import defineStore
* from 'pinia' and the engines/persistence relatively, so they run here as-is.
* idb-keyval is mocked with an in-memory Map.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('idb-keyval', () => {
const store = new Map<string, unknown>()
return {
get: async (key: string) => store.get(key),
set: async (key: string, value: unknown) => void store.set(key, value),
del: async (key: string) => void store.delete(key),
keys: async () => [...store.keys()],
}
})
import { useCollectiveStore } from '../../app/stores/collective'
import { useDecisionsStore } from '../../app/stores/decisions'
import type { Refusal } from '../../app/stores/decisions'
import { buildTemplateBundle, TEMPLATE_CARDS } from '../../app/data/templates'
import type { TemplateId } from '../../app/data/templates'
import { validateBundle } from '../../app/data/persistence'
import { hasConsentProtocol, resolveSettings, windowOutcome } from '../../app/engine'
import type {
Decision,
Mandate,
ParamSpec,
Verdict,
VoteSession,
Vote,
} from '../../app/types/domain'
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
const MEMBERS = ['Alice', 'Bakir', 'Chloé', 'Dara']
function isRefusal(value: unknown): value is Refusal {
return (
typeof value === 'object'
&& value !== null
&& 'ok' in value
&& (value as { ok: unknown }).ok === false
)
}
/** Narrow an `X | Refusal` return — throws the French reason on refusal. */
function ok<T>(value: T | Refusal): T {
if (isRefusal(value)) throw new Error(value.reason)
return value
}
async function setup(template: TemplateId = 'association') {
setActivePinia(createPinia())
const col = useCollectiveStore()
const store = useDecisionsStore()
const result = await col.createFromTemplate(template, {
name: 'Le Test',
slug: `test-${crypto.randomUUID().slice(0, 8)}`,
color: '#3b82f6',
icon: 'i-lucide-users',
meName: 'Yvv',
memberNames: MEMBERS,
})
expect(result.state).toBeDefined()
const me = col.me!
const root = col.circles.find(c => c.id === col.current!.collective.rootCircleId)!
const person = (name: string) => col.people.find(p => p.displayName === name)!
const protocolByMethod = (method: string) =>
col.protocols.find(p => p.method === method)!
return { col, store, me, root, person, protocolByMethod }
}
const buildOpts = (slug: string) => ({
name: 'Gabarit',
slug,
color: '#0ea5e9',
icon: 'i-lucide-users',
meName: 'Yvv',
memberNames: MEMBERS,
now: '2026-08-11T10:00:00.000Z',
newId: () => crypto.randomUUID(),
})
// ─────────────────────────────────────────────────────────────
// Solo — 2 gestures, adopted
// ─────────────────────────────────────────────────────────────
describe('solo path', () => {
it('capture → triage → applyPath adopts in two mutations', async () => {
const { store } = await setup()
const decision = ok(store.capture('Acheter une bouilloire pour le local'))
expect(decision.status).toBe('draft')
expect(decision.scope.selfOnly).toBe(true)
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'easy',
weight: 'light',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('solo')
expect(verdict.rule).toBe('R2')
const adopted = ok(store.applyPath(decision, verdict))
expect(adopted.status).toBe('adopted')
expect(adopted.decidedAt).toBeDefined()
expect(adopted.windowEndsAt).toBeUndefined()
})
it('extracts #tags at capture — simple', async () => {
const { store } = await setup()
const decision = ok(store.capture('Réparer le vidéoprojecteur #matériel #salle'))
expect(decision.tags).toEqual(['matériel', 'salle'])
})
})
// ─────────────────────────────────────────────────────────────
// Mandate — window, Assent, windowOutcome
// ─────────────────────────────────────────────────────────────
describe('mandate path', () => {
it('routes under my covering mandate, opens the window, adopts on explicit assent', async () => {
const { col, store, me, root, person } = await setup()
const now = col.now()
const mandate: Mandate = {
id: col.newId(),
collectiveId: col.current!.collective.id,
createdAt: now,
updatedAt: now,
title: 'Intendance du local',
holderId: me.id,
originDecisionId: col.newId(),
domain: { circleIds: [root.id], tags: [] },
startsAt: now,
endsAt: '2100-01-01T00:00:00.000Z',
electorCircleId: root.id,
nominationMethod: 'consent',
reports: [],
status: 'active',
}
col.current!.mandates.push(mandate)
const decision = ok(store.capture('Remplacer la serrure du local'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('mandate')
expect(verdict.rule).toBe('R0b')
expect(verdict.windowHours).toBe(48)
// The tunnel's chips travel through edits (the verdict carries none).
ok(store.applyPath(decision, verdict, { reversibility: 'costly', weight: 'binding' }))
expect(decision.status).toBe('objection')
expect(decision.underMandateId).toBe(mandate.id)
expect(decision.windowEndsAt).toBeDefined()
// Computed concerns: the four members (the author is excluded).
const concerns = col.concerns.filter(c => c.decisionId === decision.id)
expect(concerns).toHaveLength(4)
expect(concerns.every(c => c.origin === 'computed' && c.beforeSnapshot)).toBe(true)
// Costly ⇒ never adopted by pure silence: extend, until a third-party assent.
const ctx = ok(store.transitionContext(decision.id))
expect(windowOutcome(decision, ctx)).toBe('extend')
ok(store.assentTo(decision.id, person('Alice').id))
const ctxAfter = ok(store.transitionContext(decision.id))
expect(windowOutcome(decision, ctxAfter)).toBe('adopt')
const moved = store.transition(decision.id, 'adopted')
expect(moved.ok).toBe(true)
expect(decision.status).toBe('adopted')
})
})
// ─────────────────────────────────────────────────────────────
// Collective consent — objection blocks adoption
// ─────────────────────────────────────────────────────────────
describe('collective consent path', () => {
it('opens a session, and a maintained objection prevents adoption', async () => {
const { col, store, root, person } = await setup()
const decision = ok(store.capture('Organiser une fête de quartier au local'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'light',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('collective')
expect(verdict.rule).toBe('R5')
ok(store.applyPath(decision, verdict))
expect(decision.status).toBe('voting')
const session = col.sessions.find(s => s.decisionId === decision.id)!
// Arrested list: 4 computed concerned + the author.
expect(session.corpusSize).toBe(5)
expect(session.status).toBe('open')
ok(store.objectTo(decision.id, 'content', 'La cour est trop petite.', person('Bakir').id))
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('rejected')
expect(decision.status).not.toBe('adopted')
})
it('boundary objection suspends the window countdown', async () => {
const { store, person } = await setup()
const decision = ok(store.capture('Changer le planning du ménage'))
decision.status = 'objection'
ok(store.objectTo(decision.id, 'boundary', 'Le cercle Cuisine a été oublié.', person('Chloé').id))
expect(decision.windowSuspendedAt).toBeDefined()
const refused = store.transition(decision.id, 'adopted')
expect(refused.ok).toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// Nuanced — full loop, clause application
// ─────────────────────────────────────────────────────────────
describe('nuanced session', () => {
it('votes, tallies, closes and applies the amended clause automatically', async () => {
const { col, store, root, person, protocolByMethod } = await setup()
const nuanced = protocolByMethod('nuanced')
const clauseA1 = col.clauses.find(c => c.code === 'A1')!
const oldVersionId = clauseA1.currentVersionId!
const decision = ok(store.capture('Donner une nouvelle boussole au collectif'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'light',
urgent: false,
}),
) as Verdict
ok(
store.applyPath(decision, verdict, {
protocolId: nuanced.id,
amendsClauseId: clauseA1.id,
body: 'La convivialité rejoint nos deux boussoles.',
}),
)
expect(decision.status).toBe('voting')
const session = col.sessions.find(s => s.decisionId === decision.id)!
// A negative nuance without comment is refused.
const refused = store.castVote(session.id, { value: 1 })
expect(isRefusal(refused)).toBe(true)
ok(store.castVote(session.id, { value: 4 }))
ok(store.castVote(session.id, { value: 5, asPersonId: person('Alice').id }))
ok(store.castVote(session.id, { value: 3, asPersonId: person('Bakir').id }))
const tallied = ok(store.tally(session))
if (tallied.method !== 'nuanced') throw new Error('bad method')
expect(tallied.result.total).toBe(3)
expect(tallied.result.adopted).toBe(true)
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('adopted')
expect(decision.status).toBe('adopted')
// Automatic application: new current version, old one superseded.
const newVersion = col.versions.find(
v => v.clauseId === clauseA1.id && v.status === 'current',
)!
expect(newVersion.decisionId).toBe(decision.id)
expect(newVersion.content).toBe('La convivialité rejoint nos deux boussoles.')
expect(clauseA1.currentVersionId).toBe(newVersion.id)
expect(col.versions.find(v => v.id === oldVersionId)!.status).toBe('superseded')
})
it('re-vote supersedes — only the last active vote counts', async () => {
const { col, store, root, protocolByMethod } = await setup()
const decision = ok(store.capture('Peindre la façade en ocre'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('nuanced').id }))
const session = col.sessions.find(s => s.decisionId === decision.id)!
const first = ok(store.castVote(session.id, { value: 2 })) as Vote
const second = ok(store.castVote(session.id, { value: 5 })) as Vote
expect(second.supersedesVoteId).toBe(first.id)
const active = store.activeVotes(session.id)
expect(active).toHaveLength(1)
expect(active[0]!.value).toBe(5)
})
})
// ─────────────────────────────────────────────────────────────
// Parametric — frozen, then the steward's gesture
// ─────────────────────────────────────────────────────────────
describe('parametric session', () => {
const spec: ParamSpec = {
params: [
{ key: 'ateliers', label: 'Ateliers', kind: 'share', min: 0, max: 100, step: 5, baseline: 40 },
{ key: 'materiel', label: 'Matériel', kind: 'share', min: 0, max: 100, step: 5, baseline: 30 },
{ key: 'reserve', label: 'Réserve', kind: 'share', min: 0, max: 100, step: 5, derived: true },
],
constraint: 'sum100',
}
async function openParametric() {
const context = await setup()
const { col, store, root, protocolByMethod, person } = context
const decision = ok(store.capture('Répartir le budget des ateliers'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(
store.applyPath(decision, verdict, {
protocolId: protocolByMethod('parametric').id,
paramSpec: spec,
stewardIds: [person('Alice').id],
}),
)
const session = col.sessions.find(s => s.decisionId === decision.id)!
return { ...context, decision, session }
}
it('validates votes, freezes at closure, refuses a non-steward, crystallizes by gesture', async () => {
const { store, person, me, decision, session } = await openParametric()
// Off-grid vote refused (step 5).
const bad = store.castVote(session.id, { values: [3, 30] })
expect(isRefusal(bad)).toBe(true)
ok(store.castVote(session.id, { values: [50, 30] }))
ok(store.castVote(session.id, { values: [40, 40], asPersonId: person('Alice').id }))
ok(store.castVote(session.id, { values: [60, 20], asPersonId: person('Bakir').id }))
// Parametric NEVER closes automatically: it freezes and waits.
const frozen = ok(store.closeSession(session))
expect(frozen.status).toBe('frozen')
expect(decision.status).toBe('voting')
// I am not a steward (Alice is) — the gesture is refused.
const refused = store.crystallize(session.id)
expect(isRefusal(refused) && refused.reason.includes('garant')).toBe(true)
// As a steward, the dated human gesture crystallizes the LOW median.
decision.stewardIds = [me.id]
const closed = ok(store.crystallize(session.id))
expect(closed.status).toBe('closed')
expect(closed.outcome).toBe('adopted')
expect(closed.crystallizedById).toBe(me.id)
expect(closed.crystallizedAt).toBeDefined()
expect(decision.status).toBe('adopted')
// Low medians: Ateliers 50, Matériel 30, Réserve derived 20.
expect(decision.body).toContain('Position cristallisée')
expect(decision.body).toContain('Ateliers : 50')
expect(decision.body).toContain('Réserve : 20')
})
it('quorum not reached ⇒ rejected, observed at the same gesture', async () => {
const { store, me, decision, session } = await openParametric()
ok(store.castVote(session.id, { values: [50, 30] })) // 1 < parametricMinParticipants 3
ok(store.closeSession(session))
decision.stewardIds = [me.id]
const closed = ok(store.crystallize(session.id))
expect(closed.outcome).toBe('rejected')
expect(decision.status).toBe('rejected')
})
})
// ─────────────────────────────────────────────────────────────
// Election — blank votes, tie left to humans
// ─────────────────────────────────────────────────────────────
describe('election session', () => {
it('accepts blanks, and a tie is NEVER broken by the tool', async () => {
const { col, store, root, person, protocolByMethod } = await setup()
const decision = ok(store.capture('Confier la trésorerie'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('election').id }))
const session = col.sessions.find(s => s.decisionId === decision.id)!
ok(store.castVote(session.id, { choicePersonId: person('Alice').id }))
ok(store.castVote(session.id, {
choicePersonId: person('Chloé').id, asPersonId: person('Bakir').id,
}))
// A deposited vote WITHOUT designation is a BLANK — participation only.
const blank = ok(store.castVote(session.id, { asPersonId: person('Dara').id })) as Vote
expect(blank.choicePersonId).toBeUndefined()
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('tie')
// The decision waits for the HUMAN runoff — never adopted by the engine.
expect(decision.status).toBe('voting')
const tallied = ok(store.tally(session))
if (tallied.method !== 'election') throw new Error('bad method')
if (tallied.result.outcome !== 'tie') throw new Error('expected tie')
expect(tallied.result.blanks).toBe(1)
expect(tallied.result.participants).toBe(3)
})
})
// ─────────────────────────────────────────────────────────────
// L'épreuve du réel — revise chains a child
// ─────────────────────────────────────────────────────────────
describe('review', () => {
it("'revise' chains a pre-filled child under the original protocol", async () => {
const { col, store } = await setup()
const decision = ok(store.capture('Vendre la vieille imprimante'))
const verdict = ok(
store.runTriage({
title: decision.title, tags: decision.tags, scope: decision.scope,
reversibility: 'irreversible', weight: 'light', urgent: false,
}),
) as Verdict
expect(verdict.reviewRequired).toBe(true)
ok(store.applyPath(decision, verdict))
expect(decision.status).toBe('adopted')
expect(decision.review?.dueAt).toBeDefined()
const child = ok(store.reviewVerdict(decision.id, 'revise', 'Elle sert encore.')) as Decision
expect(decision.review?.verdict).toBe('revise')
expect(child.parentDecisionId).toBe(decision.id)
expect(child.chainKind).toBe('revision')
expect(child.status).toBe('draft')
expect(child.title).toBe(`Réviser — ${decision.title}`)
expect(col.decisions.some(d => d.id === child.id)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// Templates — the seven founding bundles
// ─────────────────────────────────────────────────────────────
describe('templates', () => {
it('exposes the seven cards, blank first, standards re-labelled', () => {
expect(TEMPLATE_CARDS).toHaveLength(7)
expect(TEMPLATE_CARDS[0]!.id).toBe('blank')
expect(TEMPLATE_CARDS[0]!.subtitle).toContain('observatoire')
for (const id of ['informal', 'association', 'cooperative', 'commune', 'free-currency']) {
const card = TEMPLATE_CARDS.find(c => c.id === id)!
expect(card.subtitle).toBe('point de départ — tout est amendable par décision')
}
expect(TEMPLATE_CARDS.find(c => c.id === 'symmetric')!.title).toBe('Institution symétrique')
})
it('blank passes validateBundle and satisfies the consent invariant', () => {
const bundle = buildTemplateBundle('blank', buildOpts('page-blanche'))
const { bundle: validated, issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
expect(validated).toBeDefined()
expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
// Consent ONLY — the invariant satisfied, everything else falls back.
expect(bundle.protocols).toHaveLength(1)
expect(bundle.protocols[0]!.method).toBe('consent')
const settings = resolveSettings(bundle.clauses, bundle.versions)
expect(hasConsentProtocol(settings)).toBe(true)
expect(settings.triage.requireEffects).toBe('none')
expect(settings.protocolByRange.large).toBeUndefined()
})
it('builds the seven templates with every clause founded by an adopted decision', () => {
const cards: TemplateId[] = TEMPLATE_CARDS.map(c => c.id)
for (const id of cards) {
const bundle = buildTemplateBundle(id, buildOpts(`t-${id}`))
const { issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
// Every clause: a 'current' version born of an ADOPTED founding decision.
for (const clause of bundle.clauses) {
expect(clause.currentVersionId).toBeDefined()
const version = bundle.versions.find(v => v.id === clause.currentVersionId)!
expect(version.status).toBe('current')
const foundingDecision = bundle.decisions.find(d => d.id === version.decisionId)!
expect(foundingDecision.status).toBe('adopted')
}
// The first proposed decision, in the infinitive.
expect(bundle.decisions.some(
d => d.title === 'Adopter notre Pacte' && d.status === 'draft' && d.route === 'collective',
)).toBe(true)
const settings = resolveSettings(bundle.clauses, bundle.versions)
expect(hasConsentProtocol(settings)).toBe(true)
}
})
it('wires protocolByRange.large: nuanced for standards, inertial binary for free-currency', () => {
const assoc = buildTemplateBundle('association', buildOpts('assoc'))
const assocSettings = resolveSettings(assoc.clauses, assoc.versions)
const assocLarge = assoc.protocols.find(p => p.id === assocSettings.protocolByRange.large)!
expect(assocLarge.method).toBe('nuanced')
expect(assocSettings.triage.requireEffects).toBe('binding')
const g1 = buildTemplateBundle('free-currency', buildOpts('g1'))
const g1Settings = resolveSettings(g1.clauses, g1.versions)
const g1Large = g1.protocols.find(p => p.id === g1Settings.protocolByRange.large)!
expect(g1Large.method).toBe('binary')
expect(g1Large.modeParams).toBe('D30M50B.1G.2')
expect(g1Large.description).toContain('héritage Toile de Confiance')
const informal = buildTemplateBundle('informal', buildOpts('informel'))
expect(resolveSettings(informal.clauses, informal.versions).triage.requireEffects).toBe('none')
})
it('symmetric seeds the declaration and the founding draft decision', () => {
const bundle = buildTemplateBundle('symmetric', buildOpts('sym'))
expect(bundle.collective.isTransparent).toBe(true)
expect(bundle.docs.some(d => d.role === 'reference' && d.title === 'Déclaration de symétrie')).toBe(true)
expect(bundle.decisions.some(
d => d.title === 'Déclarer notre symétrie' && d.status === 'draft',
)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// Collective store — import path & refusals
// ─────────────────────────────────────────────────────────────
describe('collective store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('refuses an import colliding with an existing collective id', async () => {
const col = useCollectiveStore()
const bundle = buildTemplateBundle('blank', buildOpts('collision'))
const first = await col.importJson(JSON.stringify(bundle), true)
expect(first.state).toBeDefined()
const second = await col.importJson(JSON.stringify(bundle), true)
expect(second.collided).toBe(true)
expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true)
})
it('resolves settings and the me profile from the active state', async () => {
const col = useCollectiveStore()
await col.createFromTemplate('commune', {
name: 'La Commune', slug: `commune-${crypto.randomUUID().slice(0, 8)}`,
color: '#16a34a', icon: 'i-lucide-landmark',
meName: 'Yvv', memberNames: MEMBERS,
})
expect(col.me?.displayName).toBe('Yvv')
expect(col.hasConsent).toBe(true)
expect(col.settings?.triage.requireEffects).toBe('binding')
expect(col.pactDoc?.description).toBe('Notre contrat social — sacralisé, jamais immuable')
expect(col.exportJson()).toContain('"schemaVersion": 2')
})
it('actions refuse politely without an active collective', () => {
const store = useDecisionsStore()
const result = store.capture('Sans collectif')
expect(isRefusal(result)).toBe(true)
})
it('loads the Atelier du Canal seed through the same import path', async () => {
const col = useCollectiveStore()
const result = await col.loadSeed('atelier-du-canal')
// Idempotence guard: an earlier test may have already imported it — a
// collision is then the expected polite refusal, never a crash.
if (result.state) {
expect(col.current?.collective.slug).toBe('atelier-du-canal')
expect(col.hasConsent).toBe(true)
} else {
expect(result.collided).toBe(true)
}
})
})