v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 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 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)
})
})
@@ -0,0 +1,367 @@
/**
* Test de NON-PERTE du seed Ğ1 — BLUEPRINT-V2.md « Seeds » (bloquant).
*
* Vérifie que frontend/app/data/seeds/duniter-g1.bundle.json (émis par
* backend/scripts/export_seed_bundle.py depuis backend/seed.py) conserve
* l'intégralité du contenu réel Ğ1 :
* - 33 clauses Certification + 59 clauses Forgeron, exactement ;
* - presets d'inertie conservés (les 4 valeurs présentes sur chaque doc,
* répartition exacte du seed v1 : cert 8/19/4/2, forgeron 4/49/4/2) ;
* - provenance.voteRecord == { for: 97, against: 23, invalid: 19,
* wotSize: 7224, thresholdRequired: 97 }, modeParams 'D30M50B.1G.2S.1' ;
* - 11 personnes dont 5 smiths ; 3 mandats ; ≥ 7 protocoles dont le
* Consentement (invariant) ;
* - chaque clause courante chaînée à sa décision fondatrice adoptée ;
* - chaque entité porte collectiveId / createdAt / updatedAt.
*/
import { describe, expect, it } from 'vitest'
import { resolveSettings } from '~/engine/settings'
import type {
Bundle,
Clause,
ClauseVersion,
Decision,
InertiaPreset,
Mandate,
Person,
Protocol,
TextDoc,
Vote,
VoteSession,
} from '~/types/domain'
import raw from '../../app/data/seeds/duniter-g1.bundle.json'
const bundle = raw as unknown as Bundle
const docBySlug = (slug: string): TextDoc => {
const doc = bundle.docs.find((d) => d.slug === slug)
expect(doc, `doc ${slug}`).toBeDefined()
return doc as TextDoc
}
const clausesOf = (doc: TextDoc): Clause[] => bundle.clauses.filter((c) => c.docId === doc.id)
const versionById = new Map<string, ClauseVersion>(bundle.versions.map((v) => [v.id, v]))
const decisionById = new Map<string, Decision>(bundle.decisions.map((d) => [d.id, d]))
const protocolById = new Map<string, Protocol>(bundle.protocols.map((p) => [p.id, p]))
const personByName = new Map<string, Person>(bundle.people.map((p) => [p.displayName, p]))
const inertiaDistribution = (doc: TextDoc): Record<string, number> => {
const dist: Record<string, number> = {}
for (const clause of clausesOf(doc)) dist[clause.inertia] = (dist[clause.inertia] ?? 0) + 1
return dist
}
describe('bundle duniter-g1 — schéma v2', () => {
it('est un Bundle schemaVersion 2 avec assents:[]', () => {
expect(bundle.schemaVersion).toBe(2)
expect(bundle.exportedAt).toBeTruthy()
expect(bundle.assents).toEqual([])
expect(bundle.concerns).toEqual([])
expect(bundle.objections).toEqual([])
expect(bundle.advices).toEqual([])
})
it('collectif : slug/template/transparence/couleur/icône de la spec Seeds', () => {
const c = bundle.collective
expect(c.slug).toBe('duniter-g1')
expect(c.template).toBe('free-currency')
expect(c.isTransparent).toBe(true)
expect(c.color).toBe('#22c55e')
expect(c.icon).toBe('i-lucide-network')
expect(bundle.docs.find((d) => d.id === c.pactDocId)?.role).toBe('pact')
expect(bundle.circles.find((ci) => ci.id === c.rootCircleId)?.name).toBe('Toile de confiance')
})
it('chaque entité porte collectiveId, createdAt et updatedAt', () => {
const collections = [
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,
] as { id: string; collectiveId: string; createdAt: string; updatedAt: string }[][]
for (const collection of collections) {
for (const item of collection) {
expect(item.collectiveId, `collectiveId de ${item.id}`).toBe(bundle.collective.id)
expect(item.createdAt, `createdAt de ${item.id}`).toBeTruthy()
expect(item.updatedAt, `updatedAt de ${item.id}`).toBeTruthy()
}
}
expect(bundle.collective.createdAt).toBeTruthy()
expect(bundle.collective.updatedAt).toBeTruthy()
})
})
describe('non-perte — Acte d’engagement Certification (33 items)', () => {
const doc = docBySlug('engagement-certification')
const clauses = clausesOf(doc)
it('contient exactement 33 clauses aux codes uniques', () => {
expect(clauses).toHaveLength(33)
expect(new Set(clauses.map((c) => c.code)).size).toBe(33)
})
it('conserve la répartition des 4 presets d’inertie du seed v1 (8/19/4/2)', () => {
expect(inertiaDistribution(doc)).toEqual({ low: 8, standard: 19, high: 4, max: 2 })
})
it('conserve le texte voté (échantillon E1 — unicité du compte)', () => {
const e1 = clauses.find((c) => c.code === 'E1')
expect(e1).toBeDefined()
const version = versionById.get(e1!.currentVersionId!)
expect(version?.content).toContain('un seul et unique compte cocréateur de monnaie G1')
})
it('porte la provenance v1 (sources + contributeurs)', () => {
expect(doc.provenance?.sources.length).toBeGreaterThanOrEqual(6)
expect(doc.provenance?.contributors?.join(' ')).toContain('1000i100')
})
})
describe('non-perte — Acte d’engagement forgeron (59 items v2.0.0-fr)', () => {
const doc = docBySlug('engagement-forgeron')
const clauses = clausesOf(doc)
it('contient exactement 59 clauses aux codes uniques, en versionLabel 2.0.0-fr', () => {
expect(clauses).toHaveLength(59)
expect(new Set(clauses.map((c) => c.code)).size).toBe(59)
for (const clause of clauses) {
expect(versionById.get(clause.currentVersionId!)?.versionLabel).toBe('2.0.0-fr')
}
})
it('conserve les 18 clauses aspirant (A1..A18) et les 16 certificateur (C1..C16)', () => {
const codes = new Set(clauses.map((c) => c.code))
for (let i = 1; i <= 18; i++) expect(codes.has(`A${i}`), `A${i}`).toBe(true)
for (let i = 1; i <= 16; i++) expect(codes.has(`C${i}`), `C${i}`).toBe(true)
})
it('conserve la répartition des 4 presets d’inertie du seed v1 (4/49/4/2)', () => {
expect(inertiaDistribution(doc)).toEqual({ low: 4, standard: 49, high: 4, max: 2 })
})
it('voteRecord === {for:97, against:23, invalid:19, wotSize:7224, thresholdRequired:97}', () => {
const record = doc.provenance?.voteRecord
expect(record).toBeDefined()
expect(record!.result).toMatchObject({
for: 97,
against: 23,
invalid: 19,
wotSize: 7224,
thresholdRequired: 97,
})
expect(record!.modeParams).toBe('D30M50B.1G.2S.1')
expect(record!.period).toBe('2026-01-07 → 2026-02-06')
expect(record!.url).toBe(
'https://forum.monnaie-libre.fr/t/vote-engagement-forgeron-v2-0-0/33165',
)
})
})
describe('non-perte — Comité Technique et Pacte', () => {
it('engagement-comite-tech : clauses présentes, source du vote 33293 tracée', () => {
const doc = docBySlug('engagement-comite-tech')
const clauses = clausesOf(doc)
expect(clauses.length).toBeGreaterThanOrEqual(4)
expect(doc.provenance?.sources.some((s) => s.url.includes('33293'))).toBe(true)
// Contenu reconstruit (vote en cours au moment de la recherche) — noté :
expect(doc.provenance?.notes).toContain('research_duniter_forum.md')
})
it('pacte-duniter-g1 : rôle pact, description sacralisée, A1 « Notre finalité »', () => {
const pact = docBySlug('pacte-duniter-g1')
expect(pact.role).toBe('pact')
expect(pact.description).toBe('Notre contrat social — sacralisé, jamais immuable')
const a1 = clausesOf(pact).find((c) => c.code === 'A1')
expect(a1?.title).toBe('Notre finalité')
const content = versionById.get(a1!.currentVersionId!)?.content ?? ''
for (const word of ['Autonomie', 'Équilibre', 'Liaison', 'toile de confiance Ğ1']) {
expect(content).toContain(word)
}
})
it('le Pacte résout les réglages v2 (resolveSettings) avec les seuils du seed', () => {
const pact = docBySlug('pacte-duniter-g1')
const settings = resolveSettings(clausesOf(pact), bundle.versions)
expect(settings.triage).toEqual({
smallGroupMax: 5,
collectiveMin: 50,
consentMax: 7,
objectionWindowHours: 48,
adviceWindowHours: 72,
framingDays: 14,
concernEscalateRatio: 0.5,
recurrenceThreshold: 3,
reviewDelayDays: 90,
requireEffects: 'binding',
})
// consent OBLIGATOIRE + large = binaire inertiel (héritage free-currency)
expect(protocolById.get(settings.protocolByRange.consent)?.method).toBe('consent')
expect(protocolById.get(settings.protocolByRange.large!)?.name).toBe('Vote WoT standard')
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('clauseByInertia : les 4 presets pointent les formules v1 (M/G intacts)', () => {
const pact = docBySlug('pacte-duniter-g1')
const settings = resolveSettings(clausesOf(pact), bundle.versions)
const byInertia = settings.protocolByRange.clauseByInertia
expect(byInertia).toBeDefined()
const expected: Record<InertiaPreset, { M: number; G: number }> = {
low: { M: 50, G: 0.1 },
standard: { M: 50, G: 0.2 },
high: { M: 60, G: 0.4 },
max: { M: 66, G: 0.6 },
}
for (const preset of Object.keys(expected) as InertiaPreset[]) {
const protocol = protocolById.get(byInertia![preset])
expect(protocol, preset).toBeDefined()
expect(protocol!.formula.majorityPct, preset).toBe(expected[preset].M)
expect(protocol!.formula.gradientExponent, preset).toBe(expected[preset].G)
expect(protocol!.formula.baseExponent, preset).toBe(0.1)
}
// La clause qui régit l'inertie est elle-même en preset 'max' (méta-protection)
const metaClauses = clausesOf(pact).filter((c) =>
c.settingKey?.startsWith('protocols.clauseByInertia.'),
)
expect(metaClauses.length).toBe(4)
for (const clause of metaClauses) expect(clause.inertia, clause.code).toBe('max')
})
})
describe('non-perte — personnes, protocoles, mandats', () => {
it('11 personnes dont exactement 5 smiths', () => {
expect(bundle.people).toHaveLength(11)
expect(bundle.people.filter((p) => p.wotStatus === 'smith')).toHaveLength(5)
for (const name of ['Moul', 'Poka', 'Hugo', 'Elois', 'Cgeek']) {
expect(personByName.get(name)?.wotStatus, name).toBe('smith')
}
})
it('≥ 7 protocoles, tous ballot open, avec le Consentement OBLIGATOIRE', () => {
expect(bundle.protocols.length).toBeGreaterThanOrEqual(7)
for (const p of bundle.protocols) expect(p.ballot, p.name).toBe('open')
expect(bundle.protocols.filter((p) => p.method === 'consent')).toHaveLength(1)
const methods = new Set(bundle.protocols.map((p) => p.method))
for (const m of ['consent', 'binary', 'nuanced', 'parametric', 'election']) {
expect(methods.has(m as Protocol['method']), m).toBe(true)
}
})
it('formules v1 intactes : WoT standard, Smith S.1, ComTech T.1, nuancé 59/80', () => {
const wot = bundle.protocols.find((p) => p.name === 'Vote WoT standard')!
expect(wot.modeParams).toBe('D30M50B.1G.2')
expect(wot.formula).toMatchObject({ majorityPct: 50, baseExponent: 0.1, gradientExponent: 0.2 })
expect(wot.description).toContain('dernier recours des très grands corps')
expect(wot.description).toContain('héritage Toile de Confiance')
const smith = bundle.protocols.find((p) => p.name === 'Vote forgeron (Smith)')!
expect(smith.modeParams).toBe('D30M50B.1G.2S.1')
expect(smith.formula.smithExponent).toBe(0.1)
const tech = bundle.protocols.find((p) => p.name === 'Vote Comité Technique')!
expect(tech.modeParams).toBe('D30M50B.1G.2T.1')
expect(tech.formula.techcommExponent).toBe(0.1)
const nuanced = bundle.protocols.find((p) => p.method === 'nuanced')!
expect(nuanced.durationDays).toBe(30)
expect(nuanced.formula.nuancedMinParticipants).toBe(59)
expect(nuanced.formula.nuancedThresholdPct).toBe(80)
const parametric = bundle.protocols.find((p) => p.method === 'parametric')!
expect(parametric.durationDays).toBe(14)
expect(parametric.formula.parametricMinParticipants).toBe(5)
const election = bundle.protocols.find((p) => p.method === 'election')!
expect(election.durationDays).toBe(14)
expect(election.formula.electionMinParticipants).toBe(5)
expect(election.formula.tieBreak).toBe('runoff')
})
it('3 mandats : ComTech actif (Cgeek, 1 rapport dû) · Admin Forgerons proposé · Modération active', () => {
expect(bundle.mandates).toHaveLength(3)
const comtech = bundle.mandates.find((m) => m.title.includes('Comité Technique')) as Mandate
expect(comtech.status).toBe('active')
expect(comtech.holderId).toBe(personByName.get('Cgeek')!.id)
const due = comtech.reports.filter((r) => !r.deliveredAt)
expect(due.length).toBeGreaterThanOrEqual(1)
const admin = bundle.mandates.find((m) => m.title.includes('Administrateur')) as Mandate
expect(admin.status).toBe('proposed')
const forum = bundle.mandates.find((m) => m.title.includes('Modération')) as Mandate
expect(forum.status).toBe('active')
expect(forum.domain.tags).toEqual(['forum', 'moderation'])
})
it('le mandat proposé est EN vote nuancé : session ouverte + 2-3 votes', () => {
const admin = bundle.mandates.find((m) => m.title.includes('Administrateur')) as Mandate
const origin = decisionById.get(admin.originDecisionId)!
expect(origin.status).toBe('voting')
expect(origin.createsMandate).toBeDefined()
expect(protocolById.get(origin.protocolId!)?.method).toBe('nuanced')
const session = bundle.sessions.find((s) => s.decisionId === origin.id) as VoteSession
expect(session).toBeDefined()
expect(session.status).toBe('open')
expect(session.corpusSize).toBe(session.corpusPersonIds.length)
const sessionVotes = bundle.votes.filter((v) => v.sessionId === session.id) as Vote[]
expect(sessionVotes.length).toBeGreaterThanOrEqual(2)
expect(sessionVotes.length).toBeLessThanOrEqual(3)
for (const vote of sessionVotes) {
expect(typeof vote.value).toBe('number')
expect(bundle.people.some((p) => p.id === vote.voterId)).toBe(true)
}
})
})
describe('invariant — la décision est le pivot', () => {
it('chaque clause courante a sa décision fondatrice adoptée, chaînée', () => {
for (const clause of bundle.clauses) {
expect(clause.currentVersionId, `currentVersionId de ${clause.code}`).toBeTruthy()
const version = versionById.get(clause.currentVersionId!)!
expect(version.status, `version de ${clause.code}`).toBe('current')
const decision = decisionById.get(version.decisionId)!
expect(decision, `décision fondatrice de ${clause.code}`).toBeDefined()
expect(decision.status).toBe('adopted')
expect(decision.route).toBe('collective')
expect(decision.title).toBe(`Adoption fondatrice — ${clause.code}`)
expect(decision.amendsClauseId).toBe(clause.id)
expect(decision.decidedAt).toBeTruthy()
}
})
it('décisions historiques : Runtime Upgrade 1100 gravée + licence v0.4.0 en formulation', () => {
const runtime = bundle.decisions.find((d) => d.title === 'Runtime Upgrade 1100')!
expect(runtime.status).toBe('adopted')
expect(runtime.weight).toBe('structural')
expect(runtime.engraving?.proofLevel).toBe('local')
expect(runtime.engraving?.sha256).toMatch(/^[0-9a-f]{64}$/)
expect(runtime.review?.dueAt).toBeTruthy()
expect(runtime.brief?.effects.length).toBeGreaterThanOrEqual(1)
expect(runtime.brief?.effects.some((e) => e.target && e.measured)).toBe(true)
const licence = bundle.decisions.find((d) => d.title === 'Évolution Licence G1 v0.4.0')!
expect(licence.status).toBe('framing')
const proposed = bundle.versions.filter(
(v) => v.decisionId === licence.id && v.status === 'proposed',
)
expect(proposed.length).toBeGreaterThanOrEqual(1)
const certDoc = docBySlug('engagement-certification')
const certClauseIds = new Set(clausesOf(certDoc).map((c) => c.id))
for (const version of proposed) {
expect(certClauseIds.has(version.clauseId), 'version proposée liée à une clause cert').toBe(true)
}
})
})