forked from yvv/decision
- 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>
623 lines
27 KiB
TypeScript
623 lines
27 KiB
TypeScript
/**
|
||
* 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 d’habitude')
|
||
})
|
||
})
|
||
|
||
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 d’exemple — « Répartir le budget d’ateliers 2026 »', () => {
|
||
const d = decision('Répartir le budget d’ateliers 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 d’objection — accord explicite et frontière', () => {
|
||
it('l’accord explicite : décision non-easy en fenêtre avec ≥1 Assent d’un 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 d’avis 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 l’accueil')
|
||
|
||
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 d’une 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 d’inertie', () => {
|
||
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é d’une 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 qu’on 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') // l’urgence ne contourne pas l’irré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 — l’histogramme 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)
|
||
})
|
||
})
|