/** * 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() 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(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) } }) })