Files
decision/frontend/tests/seeds/atelier-du-canal.spec.ts
T
YvvandClaude Fable 5 f707b5f15d v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- 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>
2026-08-11 09:22:19 +02:00

623 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Seed « Atelier du Canal » — LE test permanent de la marque blanche
* (BLUEPRINT-V2.md § Seeds, DEUXIÈME COLLECTIF).
*
* Chaque état de l'UI v2 doit y trouver sa donnée vivante : tous les
* DecisionStatus (y compris 'transmitted'), une session open ET une session
* frozen à cristalliser, une consignation, un dossier découpé pondéré,
* le paramétrique d'exemple calculable (validé par le moteur réel),
* l'accord explicite (Assent), la frontière suspensive, et un Pacte à
* seuils DIFFÉRENTS des défauts — résolu par resolveSettings.
*
* Repère temporel : bundle.exportedAt (2026-08-11) — les comparaisons de
* dates sont lexicales sur ISO 8601 UTC, donc déterministes pour toujours.
*/
import type {
Bundle,
Decision,
DecisionRoute,
DecisionStatus,
ParamSpec,
Reversibility,
TriageRule,
Vote,
VoteSession,
Weight,
} from '../../app/types/domain'
import { describe, expect, it } from 'vitest'
import raw from '../../app/data/seeds/atelier-du-canal.bundle.json'
import {
crystallize,
medianByElement,
resolveDerived,
validateParamSpec,
validateVote,
} from '../../app/engine/parametric'
import { resolveSettings } from '../../app/engine/settings'
const bundle = raw as unknown as Bundle
const REF = bundle.exportedAt // '2026-08-11T12:00:00.000Z'
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
const ALL_STATUSES: DecisionStatus[] = [
'draft', 'advice', 'objection', 'framing', 'voting',
'adopted', 'rejected', 'revoked', 'transmitted', 'closed',
]
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const REVERSIBILITIES: Reversibility[] = ['easy', 'costly', 'irreversible']
const WEIGHTS: Weight[] = ['light', 'binding', 'structural']
const TRIAGE_RULES: TriageRule[] = ['R-U', 'R0a', 'R0b', 'R0c', 'R2', 'R3', 'R4', 'R5', 'R6']
const personIds = new Set(bundle.people.map(p => p.id))
const circleIds = new Set(bundle.circles.map(c => c.id))
const decisionById = new Map(bundle.decisions.map(d => [d.id, d]))
const protocolById = new Map(bundle.protocols.map(p => [p.id, p]))
const clauseById = new Map(bundle.clauses.map(c => [c.id, c]))
function decision(titlePart: string): Decision {
const found = bundle.decisions.find(d => d.title.includes(titlePart))
expect(found, `décision « ${titlePart} » présente`).toBeDefined()
return found as Decision
}
function sessionsOf(decisionId: string): VoteSession[] {
return bundle.sessions.filter(s => s.decisionId === decisionId)
}
function votesOf(sessionId: string): Vote[] {
return bundle.votes.filter(v => v.sessionId === sessionId)
}
describe('socle du bundle', () => {
it('schemaVersion 2, exportedAt, toutes les collections présentes', () => {
expect(bundle.schemaVersion).toBe(2)
expect(bundle.exportedAt).toMatch(ISO_RE)
const collections = [
'people', 'circles', 'decisions', 'concerns', 'objections', 'advices',
'assents', 'mandates', 'docs', 'clauses', 'versions', 'protocols',
'sessions', 'votes',
] as const
for (const key of collections) {
expect(Array.isArray(bundle[key]), `${key} est un tableau`).toBe(true)
}
})
it('le collectif : slug, gabarit association, transparent, Pacte et racine reliés', () => {
const c = bundle.collective
expect(c.slug).toBe('atelier-du-canal')
expect(c.template).toBe('association')
expect(c.isTransparent).toBe(true)
const pact = bundle.docs.find(d => d.id === c.pactDocId)
expect(pact?.role).toBe('pact')
expect(bundle.circles.some(circle => circle.id === c.rootCircleId)).toBe(true)
})
})
describe('identifiants et horloges', () => {
const entities = [
bundle.collective,
...bundle.people, ...bundle.circles, ...bundle.decisions, ...bundle.concerns,
...bundle.objections, ...bundle.advices, ...bundle.assents, ...bundle.mandates,
...bundle.docs, ...bundle.clauses, ...bundle.versions, ...bundle.protocols,
...bundle.sessions, ...bundle.votes,
]
it('tous les ids sont des UUID v4-like, sans préfixe lisible, uniques', () => {
const ids = entities.map(e => e.id)
for (const id of ids) expect(id).toMatch(UUID_V4_RE)
expect(new Set(ids).size).toBe(ids.length)
})
it('collectiveId partout, createdAt/updatedAt partout', () => {
for (const e of entities) {
if (e !== bundle.collective) {
expect((e as { collectiveId: string }).collectiveId).toBe(bundle.collective.id)
}
expect(e.createdAt).toMatch(ISO_RE)
expect(e.updatedAt).toMatch(ISO_RE)
expect(e.createdAt <= e.updatedAt).toBe(true)
}
})
})
describe('personnes et cercles', () => {
it('12 personnes, isMe sur AUCUNE, 3-4 attributs heures/mois', () => {
expect(bundle.people).toHaveLength(12)
expect(bundle.people.every(p => p.isMe === false)).toBe(true)
const withHours = bundle.people.filter(p => typeof p.attributes?.['heures/mois'] === 'number')
expect(withHours.length).toBeGreaterThanOrEqual(3)
expect(withHours.length).toBeLessThanOrEqual(4)
})
it('cercles typés : racine complète, team ×4, theme ×7, place ×5, membres valides', () => {
const root = bundle.circles.find(c => c.id === bundle.collective.rootCircleId)
expect(root?.memberIds).toHaveLength(12)
const byKind = (kind: string) => bundle.circles.filter(c => c.kind === kind)
expect(byKind('team')).toHaveLength(1)
expect(byKind('team')[0].memberIds).toHaveLength(4)
expect(byKind('theme')).toHaveLength(1)
expect(byKind('theme')[0].memberIds).toHaveLength(7)
expect(byKind('place')).toHaveLength(1)
expect(byKind('place')[0].memberIds).toHaveLength(5)
for (const circle of bundle.circles) {
for (const m of circle.memberIds) expect(personIds.has(m)).toBe(true)
if (circle.id !== bundle.collective.rootCircleId) {
expect(circle.parentCircleId).toBe(bundle.collective.rootCircleId)
}
}
})
})
describe('décisions — enums, références, couverture des états', () => {
it('chaque décision est structurellement valide', () => {
for (const d of bundle.decisions) {
expect(personIds.has(d.authorId), `auteur de « ${d.title} »`).toBe(true)
expect(ROUTES).toContain(d.route)
expect(ALL_STATUSES).toContain(d.status)
expect(REVERSIBILITIES).toContain(d.reversibility)
expect(WEIGHTS).toContain(d.weight)
expect(TRIAGE_RULES).toContain(d.triageRule)
expect(['private', 'scope', 'collective']).toContain(d.visibility)
expect(Array.isArray(d.tags)).toBe(true)
expect(Array.isArray(d.stewardIds)).toBe(true)
expect(Array.isArray(d.measurerIds)).toBe(true)
for (const cid of d.scope.circleIds) expect(circleIds.has(cid)).toBe(true)
for (const pid of d.scope.personIds) expect(personIds.has(pid)).toBe(true)
if (d.parentDecisionId) {
expect(decisionById.has(d.parentDecisionId)).toBe(true)
expect(d.chainKind).toBeDefined()
}
if (d.protocolId) expect(protocolById.has(d.protocolId)).toBe(true)
if (d.amendsClauseId) expect(clauseById.has(d.amendsClauseId)).toBe(true)
if (d.status === 'adopted' || d.status === 'closed') {
expect(d.decidedAt, `decidedAt de « ${d.title} »`).toBeDefined()
}
}
})
it('TOUS les DecisionStatus sont couverts, transmitted inclus', () => {
const present = new Set(bundle.decisions.map(d => d.status))
for (const status of ALL_STATUSES) {
expect(present.has(status), `état « ${status} » couvert`).toBe(true)
}
})
it('au moins une consignation route record, adoptée immédiatement, decidedHow posé', () => {
const records = bundle.decisions.filter(d => d.route === 'record')
expect(records.length).toBeGreaterThanOrEqual(1)
for (const r of records) {
expect(r.status).toBe('adopted')
expect(r.decidedHow?.length).toBeGreaterThan(0)
}
expect(decision('marché du samedi').decidedHow).toContain('comme dhabitude')
})
})
describe('sessions — corpus, fenêtres, cohérence des états', () => {
it('une session open et une session frozen existent', () => {
expect(bundle.sessions.filter(s => s.status === 'open').length).toBeGreaterThanOrEqual(1)
expect(bundle.sessions.filter(s => s.status === 'frozen')).toHaveLength(1)
})
it('chaque session : W = |corpus|, corpus ⊆ personnes, concerné·es beforeSnapshot', () => {
for (const s of bundle.sessions) {
expect(s.corpusSize).toBe(s.corpusPersonIds.length)
expect(s.corpusSize).toBeGreaterThan(0)
expect(protocolById.has(s.protocolId)).toBe(true)
expect(decisionById.has(s.decisionId)).toBe(true)
expect(s.opensAt < s.closesAt).toBe(true)
for (const pid of s.corpusPersonIds) {
expect(personIds.has(pid)).toBe(true)
const concern = bundle.concerns.find(
c => c.decisionId === s.decisionId && c.personId === pid && c.beforeSnapshot,
)
expect(concern, `concern beforeSnapshot pour ${pid}`).toBeDefined()
expect(concern!.reason.length).toBeGreaterThan(0)
}
}
})
it('chaque vote appartient au corpus arrêté de sa session', () => {
for (const v of bundle.votes) {
const s = bundle.sessions.find(sess => sess.id === v.sessionId)
expect(s, 'session du vote').toBeDefined()
expect(s!.corpusPersonIds).toContain(v.voterId)
}
})
it('statut des décisions cohérent avec leurs sessions', () => {
for (const s of bundle.sessions) {
const d = decisionById.get(s.decisionId)!
if (s.status === 'open') {
expect(s.closesAt > REF, `session ouverte non échue (« ${d.title} »)`).toBe(true)
expect(d.status).toBe('voting')
}
if (s.status === 'frozen') {
expect(s.closesAt < REF).toBe(true)
expect(d.status).toBe('voting')
}
if (s.status === 'closed') {
expect(s.outcome).toBeDefined()
expect(['adopted', 'rejected', 'closed', 'revoked']).toContain(d.status)
}
}
for (const d of bundle.decisions.filter(dd => dd.status === 'voting')) {
const alive = sessionsOf(d.id).filter(s => s.status === 'open' || s.status === 'frozen')
expect(alive, ${d.title} » en voting a sa session vivante`).toHaveLength(1)
}
})
it('votes : exactement un de value/values/choicePersonId — sauf blanc d’élection', () => {
for (const v of bundle.votes) {
const method = protocolById.get(bundle.sessions.find(s => s.id === v.sessionId)!.protocolId)!.method
const set = [v.value !== undefined, v.values !== undefined, v.choicePersonId !== undefined]
.filter(Boolean).length
if (set === 0) {
expect(method, 'seul un blanc d’élection peut ne rien porter').toBe('election')
}
else {
expect(set).toBe(1)
}
if (v.value === 0 || v.value === 1 || v.value === 'against') {
expect(v.comment, 'commentaire obligatoire sur vote négatif').toBeDefined()
expect(v.comment!.length).toBeGreaterThan(0)
}
}
})
})
describe('le paramétrique dexemple — « Répartir le budget dateliers 2026 »', () => {
const d = decision('Répartir le budget dateliers 2026')
const spec = d.paramSpec as ParamSpec
const session = sessionsOf(d.id)[0]
const votes = votesOf(session.id)
it('spec sum100 : 3 parts votées nommées + EXACTEMENT 1 part dérivée « Réserve » [0,30]', () => {
expect(spec.constraint).toBe('sum100')
expect(() => validateParamSpec(spec)).not.toThrow()
const derived = spec.params.filter(p => p.derived === true)
expect(derived).toHaveLength(1)
expect(derived[0].label).toBe('Réserve')
expect(derived[0].min).toBe(0)
expect(derived[0].max).toBe(30)
const voted = spec.params.filter(p => !p.derived)
expect(voted).toHaveLength(3)
expect(voted.every(p => p.kind === 'share')).toBe(true)
expect(spec.impactAttrKey).toBe('heures/mois')
})
it('la décision connexe de ressources et la session ouverte sont posées', () => {
expect(d.resources).toEqual({ note: 'budget annuel ateliers', amount: 1200, unit: '€' })
expect(session.status).toBe('open')
expect(session.closesAt > REF).toBe(true)
expect(d.status).toBe('voting')
})
it('5-6 votes VALIDES de 3 valeurs — la réserve dérivée reste dans [0,30] pour CHAQUE vote', () => {
expect(votes.length).toBeGreaterThanOrEqual(5)
expect(votes.length).toBeLessThanOrEqual(6)
for (const v of votes) {
expect(v.values).toHaveLength(3)
expect(() => validateVote(spec, v.values!)).not.toThrow()
const full = resolveDerived(spec, v.values!)
const reserve = full[spec.params.findIndex(p => p.derived === true)]
expect(reserve).toBeGreaterThanOrEqual(0)
expect(reserve).toBeLessThanOrEqual(30)
expect(full.reduce((a, b) => a + b, 0)).toBeCloseTo(100)
}
})
it('faisceau, médiane basse et cristallisation sont calculables', () => {
const values = votes.map(v => v.values!)
const median = medianByElement(values)
expect(median).toHaveLength(3)
// Médiane basse = une position réellement votée, curseur par curseur.
median.forEach((m, j) => {
expect(values.some(v => v[j] === m)).toBe(true)
})
const crystal = crystallize(spec, values)
expect(crystal).toHaveLength(4)
expect(crystal.reduce((a, b) => a + b, 0)).toBeCloseTo(100)
const reserve = crystal[spec.params.findIndex(p => p.derived === true)]
expect(reserve).toBeGreaterThanOrEqual(0)
expect(reserve).toBeLessThanOrEqual(30)
// « Pour moi » : l'attribut déclaré existe chez au moins un membre du corpus.
const corpusAttrs = session.corpusPersonIds
.map(pid => bundle.people.find(p => p.id === pid)?.attributes?.['heures/mois'])
.filter((h): h is number => typeof h === 'number')
expect(corpusAttrs.length).toBeGreaterThan(0)
})
})
describe('la session figée — la carte « À cristalliser » du Fil', () => {
const frozen = bundle.sessions.find(s => s.status === 'frozen')!
const d = decisionById.get(frozen.decisionId)!
it('closesAt passé, votes figés valides, PAS de crystallizedById, garant posé', () => {
expect(frozen.closesAt < REF).toBe(true)
expect(frozen.crystallizedById).toBeUndefined()
expect(frozen.crystallizedAt).toBeUndefined()
expect(d.stewardIds.length).toBeGreaterThanOrEqual(1)
expect(protocolById.get(frozen.protocolId)!.method).toBe('parametric')
const votes = votesOf(frozen.id)
expect(votes.length).toBeGreaterThanOrEqual(3)
const spec = d.paramSpec as ParamSpec
for (const v of votes) {
expect(() => validateVote(spec, v.values!)).not.toThrow()
}
expect(medianByElement(votes.map(v => v.values!))).toHaveLength(spec.params.length)
})
})
describe('fenêtres dobjection — accord explicite et frontière', () => {
it('laccord explicite : décision non-easy en fenêtre avec ≥1 Assent dun concerné ≠ auteur', () => {
const d = decision('lave-linge')
expect(d.status).toBe('objection')
expect(d.reversibility).not.toBe('easy')
expect(d.windowEndsAt! > REF).toBe(true)
const assents = bundle.assents.filter(a => a.decisionId === d.id && a.personId !== d.authorId)
expect(assents.length).toBeGreaterThanOrEqual(1)
const concerned = bundle.concerns.some(
c => c.decisionId === d.id && c.personId === assents[0].personId,
)
expect(concerned).toBe(true)
expect(bundle.assents.length).toBeGreaterThanOrEqual(1)
})
it('la frontière suspend : windowSuspendedAt posé + objection boundary ouverte', () => {
const d = decision('serrure connectée')
expect(d.status).toBe('objection')
expect(d.windowSuspendedAt).toBeDefined()
const boundary = bundle.objections.find(
o => o.decisionId === d.id && o.kind === 'boundary' && o.status === 'open',
)
expect(boundary).toBeDefined()
expect(boundary!.argument.length).toBeGreaterThan(0)
})
it('la fenêtre davis est ouverte avec 2 avis déposés', () => {
const d = decision('façade')
expect(d.status).toBe('advice')
expect(d.windowEndsAt! > REF).toBe(true)
const advices = bundle.advices.filter(a => a.decisionId === d.id)
expect(advices).toHaveLength(2)
expect(new Set(advices.map(a => a.position)).size).toBeGreaterThan(1)
})
})
describe('formulation — 2 contre-propositions avec diff sur le Règlement', () => {
const d = decision('Réviser laccueil')
it('framing, amendsClauseId sur une clause du Règlement intérieur, 2 versions proposées distinctes', () => {
expect(d.status).toBe('framing')
const clause = clauseById.get(d.amendsClauseId!)!
const ri = bundle.docs.find(doc => doc.role === 'reference')!
expect(clause.docId).toBe(ri.id)
const proposed = bundle.versions.filter(v => v.decisionId === d.id && v.status === 'proposed')
expect(proposed).toHaveLength(2)
expect(proposed[0].clauseId).toBe(clause.id)
expect(proposed[1].clauseId).toBe(clause.id)
expect(proposed[0].content).not.toBe(proposed[1].content)
const current = bundle.versions.find(v => v.id === clause.currentVersionId)!
expect(proposed.every(p => p.content !== current.content)).toBe(true)
})
})
describe('le Pacte résolu — des seuils DIFFÉRENTS des défauts', () => {
const settings = resolveSettings(bundle.clauses, bundle.versions)
it('seuils propres : smallGroupMax 4, fenêtres 24/48, requireEffects binding', () => {
expect(settings.triage.smallGroupMax).toBe(4)
expect(settings.triage.objectionWindowHours).toBe(24)
expect(settings.triage.adviceWindowHours).toBe(48)
expect(settings.triage.requireEffects).toBe('binding')
// Les autres clés restent aux défauts, mais VOTÉES (clauses présentes).
expect(settings.triage.collectiveMin).toBe(50)
expect(settings.triage.consentMax).toBe(7)
expect(settings.triage.framingDays).toBe(14)
expect(settings.triage.reviewDelayDays).toBe(90)
})
it('protocolByRange.large → le protocole NUANCÉ (la démonstration anti-binaire)', () => {
const large = protocolById.get(settings.protocolByRange.large!)
expect(large?.method).toBe('nuanced')
expect(bundle.protocols.some(p => p.method === 'binary')).toBe(false)
})
it('le protocole Consentement OBLIGATOIRE est résolu, tous les autres aussi', () => {
const consent = protocolById.get(settings.protocolByRange.consent)
expect(consent?.method).toBe('consent')
expect(consent?.durationDays).toBe(7)
expect(protocolById.get(settings.protocolByRange.nuanced!)?.method).toBe('nuanced')
expect(protocolById.get(settings.protocolByRange.parametric!)?.method).toBe('parametric')
expect(protocolById.get(settings.protocolByRange.election!)?.method).toBe('election')
})
it('les protocoles portent les seuils demandés, tous ballot open', () => {
const nuanced = bundle.protocols.find(p => p.method === 'nuanced')!
expect(nuanced.formula.nuancedMinParticipants).toBe(4)
expect(nuanced.formula.nuancedThresholdPct).toBe(60)
const parametric = bundle.protocols.find(p => p.method === 'parametric')!
expect(parametric.formula.parametricMinParticipants).toBe(4)
const election = bundle.protocols.find(p => p.method === 'election')!
expect(election.formula.electionMinParticipants).toBe(5)
expect(election.formula.tieBreak).toBe('runoff')
expect(bundle.protocols.every(p => p.ballot === 'open')).toBe(true)
})
})
describe('les textes — toute clause naît dune décision', () => {
it('chaque clause courante a sa version current ET sa décision fondatrice adoptée', () => {
for (const clause of bundle.clauses) {
const current = bundle.versions.find(
v => v.id === clause.currentVersionId && v.clauseId === clause.id && v.status === 'current',
)
expect(current, `version courante de ${clause.code}`).toBeDefined()
const founding = decisionById.get(current!.decisionId)
expect(founding, `décision fondatrice de ${clause.code}`).toBeDefined()
expect(['adopted', 'closed']).toContain(founding!.status)
expect(current!.adoptedAt).toBeDefined()
}
})
it('le Règlement intérieur : 8 clauses réparties sur les 4 presets dinertie', () => {
const ri = bundle.docs.find(doc => doc.role === 'reference')!
const riClauses = bundle.clauses.filter(c => c.docId === ri.id)
expect(riClauses).toHaveLength(8)
const presets = new Set(riClauses.map(c => c.inertia))
expect(presets).toEqual(new Set(['low', 'standard', 'high', 'max']))
})
it('le Pacte : A1, préambule, et les 10 clés de triage en settingKey', () => {
const pactClauses = bundle.clauses.filter(c => c.docId === bundle.collective.pactDocId)
expect(pactClauses.some(c => c.code === 'A1' && c.title === 'Notre finalité')).toBe(true)
expect(pactClauses.some(c => c.section === 'Préambule')).toBe(true)
const keys = new Set(pactClauses.map(c => c.settingKey).filter(Boolean))
for (const k of [
'triage.smallGroupMax', 'triage.collectiveMin', 'triage.consentMax',
'triage.objectionWindowHours', 'triage.adviceWindowHours', 'triage.framingDays',
'triage.concernEscalateRatio', 'triage.recurrenceThreshold', 'triage.reviewDelayDays',
'triage.requireEffects',
]) {
expect(keys.has(k), `clé ${k} votée au Pacte`).toBe(true)
}
})
})
describe('le mandat Trésorerie — les feux de la rampe', () => {
const mandate = bundle.mandates.find(m => m.title === 'Trésorerie')!
it('actif, titulaire au Bureau, cercle électeur = racine, né dune décision adoptée', () => {
expect(mandate.status).toBe('active')
const bureau = bundle.circles.find(c => c.kind === 'team')!
expect(bureau.memberIds).toContain(mandate.holderId)
expect(mandate.electorCircleId).toBe(bundle.collective.rootCircleId)
const origin = decisionById.get(mandate.originDecisionId)!
expect(origin.status).toBe('adopted')
expect(origin.createsMandate?.title).toBe('Trésorerie')
expect(mandate.nominationMethod).toBe('election-no-candidate')
})
it('1 rapport rendu + 1 rapport dû non rendu', () => {
const delivered = mandate.reports.filter(r => r.deliveredAt)
const due = mandate.reports.filter(r => !r.deliveredAt && r.dueAt < REF)
expect(delivered).toHaveLength(1)
expect(delivered[0].content!.length).toBeGreaterThan(0)
expect(due).toHaveLength(1)
})
it('2 décisions prises sous mandat : une adoptée à fenêtre passée, une en fenêtre', () => {
const under = bundle.decisions.filter(d => d.underMandateId === mandate.id)
expect(under).toHaveLength(2)
const adopted = under.find(d => d.status === 'adopted')!
expect(adopted.windowEndsAt! < REF).toBe(true)
expect(adopted.decidedAt).toBeDefined()
const inWindow = under.find(d => d.status === 'objection')!
expect(inWindow.windowEndsAt! > REF).toBe(true)
expect(under.every(d => d.route === 'mandate')).toBe(true)
})
it('la décision créatrice a été élue sans candidat, blanc compris', () => {
const origin = decisionById.get(mandate.originDecisionId)!
const session = sessionsOf(origin.id)[0]
expect(session.status).toBe('closed')
expect(session.outcome).toBe('adopted')
const votes = votesOf(session.id)
expect(votes.length).toBeGreaterThanOrEqual(5) // quorum electionMinParticipants
const blank = votes.filter(v => !v.value && !v.values && !v.choicePersonId)
expect(blank).toHaveLength(1)
const designations = votes.filter(v => v.choicePersonId === mandate.holderId)
expect(designations.length).toBeGreaterThan(votes.length / 2)
})
})
describe('le dossier découpé — décision incrémentielle', () => {
const parent = decision('Réaménager le local du quai')
const elements = bundle.decisions.filter(
d => d.parentDecisionId === parent.id && d.chainKind === 'element',
)
it('parent structural en framing, 2 éléments : 1 adopté (terminal), 1 en voting', () => {
expect(parent.status).toBe('framing')
expect(parent.weight).toBe('structural')
expect(parent.stewardIds.length).toBeGreaterThanOrEqual(1) // le garant qui clora
expect(elements).toHaveLength(2)
expect(elements.filter(e => e.status === 'adopted')).toHaveLength(1)
expect(elements.filter(e => e.status === 'voting')).toHaveLength(1)
})
it('les enjeux sont pondérés : Concern.priority 0-3 posés par 3-4 personnes et par élément', () => {
for (const element of elements) {
const weighted = bundle.concerns.filter(
c => c.decisionId === element.id && c.priority !== undefined,
)
expect(weighted.length).toBeGreaterThanOrEqual(3)
expect(weighted.length).toBeLessThanOrEqual(4)
for (const c of weighted) {
expect([0, 1, 2, 3]).toContain(c.priority)
}
}
// La palette 0-3 est réellement utilisée à travers le dossier.
const used = new Set(
bundle.concerns
.filter(c => elements.some(e => e.id === c.decisionId) && c.priority !== undefined)
.map(c => c.priority),
)
expect(used).toEqual(new Set([0, 1, 2, 3]))
})
})
describe('la boucle apprenante', () => {
it('l’épreuve du réel DUE : review échue sans verdict, matière ciblée, ressources, paire garant/mesureur', () => {
const d = decision('poêle à granulés')
expect(d.status).toBe('adopted')
expect(d.review?.dueAt).toBeDefined()
expect(d.review!.dueAt < REF).toBe(true)
expect(d.review!.verdict).toBeUndefined()
expect(d.brief!.effects.length).toBeGreaterThanOrEqual(1)
expect(d.brief!.effects.some(e => e.target)).toBe(true) // structural ⇒ cible mesurable
expect(d.resources).toMatchObject({ amount: 2400, unit: '€' })
expect(d.stewardIds.length).toBeGreaterThanOrEqual(1)
expect(d.measurerIds.length).toBeGreaterThanOrEqual(1)
})
it('révoquée avec sa chaîne : enfant revocation adoptée — « ce quon en a appris »', () => {
const revoked = bundle.decisions.find(d => d.status === 'revoked')!
const child = bundle.decisions.find(
d => d.parentDecisionId === revoked.id && d.chainKind === 'revocation',
)!
expect(child.status).toBe('adopted')
expect(revoked.review?.verdict).toBe('revoke')
})
it('conservatoire ratifiée : urgent:true adoptée + enfant ratification adopté', () => {
const urgent = bundle.decisions.find(d => d.urgent && d.status === 'adopted')!
const ratification = bundle.decisions.find(
d => d.parentDecisionId === urgent.id && d.chainKind === 'ratification',
)!
expect(ratification.status).toBe('adopted')
expect(urgent.reversibility).not.toBe('irreversible') // lurgence ne contourne pas lirréversible
})
it('une décision close par l’épreuve du réel confirmée', () => {
const closed = bundle.decisions.find(d => d.status === 'closed')!
expect(closed.review?.verdict).toBe('confirmed')
expect(closed.brief?.effects.some(e => e.measured)).toBe(true)
})
})
describe('le vote nuancé en cours — lhistogramme vivant', () => {
it('5 votes 0-5, commentaires obligatoires posés sur les 0-1', () => {
const d = decision('réseau des ateliers partagés')
const session = sessionsOf(d.id).find(s => s.status === 'open')!
const votes = votesOf(session.id)
expect(votes).toHaveLength(5)
for (const v of votes) {
expect([0, 1, 2, 3, 4, 5]).toContain(v.value)
if (v.value === 0 || v.value === 1) expect(v.comment!.length).toBeGreaterThan(0)
}
// Distribution nuancée réelle : au moins 4 niveaux distincts.
expect(new Set(votes.map(v => v.value)).size).toBeGreaterThanOrEqual(4)
})
})