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