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>
This commit is contained in:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
@@ -0,0 +1,622 @@
/**
* 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)
})
})