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,168 @@
/**
* electionResult — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4).
*
* Contract under test: the caller passes the LAST ACTIVE votes only
* (supersedesVoteId chains resolved by the store) — one vote per voter.
* Blank vote = no choicePersonId: counts for participation, never for
* designation. The engine NEVER breaks a tie.
*/
import { describe, expect, it } from 'vitest'
import type { Vote } from '../../app/types/domain'
import { electionResult } from '../../app/engine/threshold'
let seq = 0
/** Build a minimal last-active Vote; omit choicePersonId for a blank. */
function vote(choicePersonId?: string): Vote {
seq++
return {
id: `vote-${seq}`,
collectiveId: 'col-1',
createdAt: '2026-08-11T00:00:00Z',
updatedAt: '2026-08-11T00:00:00Z',
sessionId: 'session-1',
voterId: `voter-${seq}`,
choicePersonId,
}
}
/** n votes for the same person (or n blanks when personId is undefined). */
function votes(n: number, personId?: string): Vote[] {
return Array.from({ length: n }, () => vote(personId))
}
describe('electionResult — clear plurality', () => {
it('elects the most designated person; blanks count in participation only', () => {
const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(1)]
const result = electionResult(ballot, { electionMinParticipants: 5 })
expect(result).toEqual({
outcome: 'elected',
winnerId: 'alice',
counts: { alice: 3, bob: 2 },
blanks: 1,
participants: 6,
})
})
it('a tie below the top does not prevent election', () => {
const ballot = [...votes(3, 'alice'), ...votes(2, 'bob'), ...votes(2, 'carol')]
const result = electionResult(ballot, {})
expect(result.outcome).toBe('elected')
if (result.outcome === 'elected') {
expect(result.winnerId).toBe('alice')
expect(result.counts).toEqual({ alice: 3, bob: 2, carol: 2 })
}
})
it('works without any quorum configured (electionMinParticipants undefined)', () => {
const result = electionResult([vote('alice')], {})
expect(result.outcome).toBe('elected')
})
})
describe('electionResult — tie: the engine NEVER breaks it', () => {
it('two-way tie at the top => tie with sorted exAequoIds', () => {
const ballot = [...votes(2, 'bob'), ...votes(2, 'alice'), ...votes(1, 'carol')]
const result = electionResult(ballot, { electionMinParticipants: 3 })
expect(result).toEqual({
outcome: 'tie',
exAequoIds: ['alice', 'bob'], // sorted — deterministic order, not a tie-break
counts: { alice: 2, bob: 2, carol: 1 },
blanks: 0,
participants: 5,
})
})
it('three-way tie => all three ex aequo, sorted regardless of arrival order', () => {
const ballot = [...votes(2, 'carol'), ...votes(2, 'alice'), ...votes(2, 'bob'), ...votes(1)]
const result = electionResult(ballot, { electionMinParticipants: 4 })
expect(result.outcome).toBe('tie')
if (result.outcome === 'tie') {
expect(result.exAequoIds).toEqual(['alice', 'bob', 'carol'])
expect(result.blanks).toBe(1)
expect(result.participants).toBe(7)
}
})
it('never designates a winner among equals (no random, no first-come)', () => {
// Same ballot tallied twice must yield the exact same tie.
const ballot = [...votes(1, 'bob'), ...votes(1, 'alice')]
const first = electionResult(ballot, {})
const second = electionResult(ballot, {})
expect(first).toEqual(second)
expect(first.outcome).toBe('tie')
})
})
describe('electionResult — rejections', () => {
it('quorum not reached => rejected with reason quorum and required', () => {
const ballot = [...votes(2, 'alice'), ...votes(1)]
const result = electionResult(ballot, { electionMinParticipants: 5 })
expect(result).toEqual({
outcome: 'rejected',
reason: 'quorum',
participants: 3,
required: 5,
})
})
it('quorum reached but all blanks => rejected with reason no-designation', () => {
const ballot = votes(4)
const result = electionResult(ballot, { electionMinParticipants: 3 })
expect(result).toEqual({
outcome: 'rejected',
reason: 'no-designation',
participants: 4,
required: 3,
})
})
it('zero votes without quorum => rejected no-designation (required 0)', () => {
const result = electionResult([], {})
expect(result).toEqual({
outcome: 'rejected',
reason: 'no-designation',
participants: 0,
required: 0,
})
})
})
describe('electionResult — blanks and participation', () => {
it('quorum reached THANKS to blanks: blanks count for participation', () => {
// 3 designations alone would miss the quorum of 5; 2 blanks complete it.
const ballot = [...votes(3, 'alice'), ...votes(2)]
const result = electionResult(ballot, { electionMinParticipants: 5 })
expect(result).toEqual({
outcome: 'elected',
winnerId: 'alice',
counts: { alice: 3 },
blanks: 2,
participants: 5,
})
})
it('exact quorum boundary: participants === required passes', () => {
const ballot = [...votes(1, 'alice'), ...votes(1)]
const result = electionResult(ballot, { electionMinParticipants: 2 })
expect(result.outcome).toBe('elected')
})
it('blanks never appear in counts', () => {
const ballot = [...votes(2, 'alice'), ...votes(3)]
const result = electionResult(ballot, {})
if (result.outcome === 'elected') {
expect(Object.keys(result.counts)).toEqual(['alice'])
expect(result.blanks).toBe(3)
} else {
throw new Error(`expected elected, got ${result.outcome}`)
}
})
})
@@ -0,0 +1,158 @@
/**
* Tests du moteur de périmètre — computeConcerned / expandCircleMembers.
* Fonctions pures : fixtures assemblées à la main, aucun store.
*/
import { describe, expect, it } from 'vitest'
import { computeConcerned, expandCircleMembers } from '../../app/engine/impact'
import type { Circle, Decision, Mandate } from '../../app/types/domain'
const T0 = '2026-01-01T00:00:00Z'
const T9 = '2027-01-01T00:00:00Z'
const AUTHOR = 'p-author'
function mkCircle(over: Partial<Circle> = {}): Circle {
return {
id: 'c1',
collectiveId: 'col',
createdAt: T0,
updatedAt: T0,
name: 'Forgerons',
purpose: '',
memberIds: [],
domains: [],
...over,
}
}
function mkMandate(over: Partial<Mandate> = {}): Mandate {
return {
id: 'm1',
collectiveId: 'col',
createdAt: T0,
updatedAt: T0,
title: 'Modération',
holderId: 'p-holder',
originDecisionId: 'd0',
domain: { circleIds: ['c1'], tags: [] },
startsAt: T0,
endsAt: T9,
electorCircleId: 'c1',
nominationMethod: 'consent',
reports: [],
status: 'active',
...over,
}
}
function mkScope(over: Partial<Decision['scope']> = {}): Decision['scope'] {
return { selfOnly: false, circleIds: [], personIds: [], ...over }
}
describe('computeConcerned — l\'union calculée, chaque entrée avec sa raison', () => {
it('unit les trois sources : cercles, personnes nommées, titulaires de mandats', () => {
const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1', 'p2'] })]
const mandates = [mkMandate({ holderId: 'p4', title: 'Modération' })]
const scope = mkScope({ circleIds: ['c1'], personIds: ['p3'] })
const result = computeConcerned(scope, circles, mandates, AUTHOR)
expect(result).toHaveLength(4)
expect(result).toContainEqual({ personId: 'p1', reason: 'membre du cercle Forgerons' })
expect(result).toContainEqual({ personId: 'p2', reason: 'membre du cercle Forgerons' })
expect(result).toContainEqual({ personId: 'p3', reason: 'nommé·e par l\'auteur' })
expect(result).toContainEqual({ personId: 'p4', reason: 'titulaire du mandat Modération' })
})
it('dédoublonne — la première raison gagne, priorité cercle > nommé > mandat', () => {
const circles = [mkCircle({ id: 'c1', name: 'Forgerons', memberIds: ['p1'] })]
// p1 est membre du cercle ET nommé ET titulaire d'un mandat intersectant.
const mandates = [mkMandate({ holderId: 'p1' })]
const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] })
const result = computeConcerned(scope, circles, mandates, AUTHOR)
expect(result).toEqual([{ personId: 'p1', reason: 'membre du cercle Forgerons' }])
})
it('dédoublonne — nommé·e gagne sur titulaire de mandat', () => {
const mandates = [mkMandate({ holderId: 'p1', domain: { circleIds: ['c1'], tags: [] } })]
const scope = mkScope({ circleIds: ['c1'], personIds: ['p1'] })
const result = computeConcerned(scope, [], mandates, AUTHOR)
expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }])
})
it('exclut l\'auteur de toutes les sources', () => {
const circles = [mkCircle({ id: 'c1', memberIds: [AUTHOR, 'p1'] })]
const mandates = [mkMandate({ holderId: AUTHOR })]
const scope = mkScope({ circleIds: ['c1'], personIds: [AUTHOR] })
const result = computeConcerned(scope, circles, mandates, AUTHOR)
expect(result.map(e => e.personId)).toEqual(['p1'])
})
it('n\'inclut un titulaire que si le domaine du mandat intersecte les cercles du périmètre', () => {
const mandates = [
mkMandate({ id: 'm1', holderId: 'p4', domain: { circleIds: ['c9'], tags: [] } }),
mkMandate({ id: 'm2', holderId: 'p5', title: 'Trésorerie', domain: { circleIds: ['c2', 'c1'], tags: [] } }),
]
const scope = mkScope({ circleIds: ['c1'] })
const result = computeConcerned(scope, [], mandates, AUTHOR)
expect(result).toEqual([{ personId: 'p5', reason: 'titulaire du mandat Trésorerie' }])
})
it('ignore les mandats non actifs', () => {
const mandates = [mkMandate({ holderId: 'p4', status: 'expired' })]
const scope = mkScope({ circleIds: ['c1'] })
expect(computeConcerned(scope, [], mandates, AUTHOR)).toEqual([])
})
it('ignore sans erreur un cercle inconnu dans le périmètre', () => {
const scope = mkScope({ circleIds: ['c-fantome'], personIds: ['p1'] })
const result = computeConcerned(scope, [], [], AUTHOR)
expect(result).toEqual([{ personId: 'p1', reason: 'nommé·e par l\'auteur' }])
})
})
describe('expandCircleMembers — appartenance nominative, sans propagation', () => {
it('réunit les membres directs de plusieurs cercles, dédoublonnés', () => {
const circles = [
mkCircle({ id: 'c1', memberIds: ['p1', 'p2'] }),
mkCircle({ id: 'c2', name: 'Cuisine', memberIds: ['p2', 'p3'] }),
]
const members = expandCircleMembers(['c1', 'c2'], circles)
expect(members).toEqual(new Set(['p1', 'p2', 'p3']))
})
it('ne propage PAS l\'appartenance des cercles enfants au parent (emboîtement ≠ appartenance)', () => {
// v2 : l'appartenance est nominative par cercle ; l'emboîtement
// (parentCircleId) sert l'élargissement d'un cran, jamais le calcul.
const parent = mkCircle({ id: 'c-parent', name: 'Village', memberIds: ['p1'] })
const child = mkCircle({
id: 'c-child',
name: 'Hameau',
memberIds: ['p2'],
parentCircleId: 'c-parent',
})
expect(expandCircleMembers(['c-parent'], [parent, child])).toEqual(new Set(['p1']))
// Ni dans l'autre sens : l'enfant n'hérite pas des membres du parent.
expect(expandCircleMembers(['c-child'], [parent, child])).toEqual(new Set(['p2']))
// L'élargissement est un geste explicite : ajouter le cercle au périmètre.
expect(expandCircleMembers(['c-parent', 'c-child'], [parent, child]))
.toEqual(new Set(['p1', 'p2']))
})
it('ignore un identifiant de cercle inconnu', () => {
expect(expandCircleMembers(['c-fantome'], [mkCircle()])).toEqual(new Set())
})
})
@@ -0,0 +1,127 @@
/**
* Ported from backend/app/tests/test_mode_params.py — every pytest case
* exists here with the same expected values, plus tests for
* formatModeParams() (no Python counterpart: defined as the canonical
* inverse of parseModeParams, matching the seed DSL strings).
*/
import { describe, expect, it } from 'vitest'
import { formatModeParams, parseModeParams } from '../../app/engine/modeParams'
describe('parseModeParams', () => {
it('D30M50B.1G.2 => standard Licence G1 params', () => {
const result = parseModeParams('D30M50B.1G.2')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
// Optional criteria absent
expect(result.smith_exponent).toBeNull()
expect(result.techcomm_exponent).toBeNull()
})
it('D30M50B.1G.2S.1 => standard + smith_exponent=0.1', () => {
const result = parseModeParams('D30M50B.1G.2S.1')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
expect(result.smith_exponent).toBe(0.1)
expect(result.techcomm_exponent).toBeNull()
})
it('D30M50B.1G.2T.1 => standard + techcomm_exponent=0.1', () => {
const result = parseModeParams('D30M50B.1G.2T.1')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
expect(result.smith_exponent).toBeNull()
expect(result.techcomm_exponent).toBe(0.1)
})
it('D30M50B1G.5C10 => integer base, gradient=0.5, constant=10', () => {
const result = parseModeParams('D30M50B1G.5C10')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(1.0)
expect(result.gradient_exponent).toBe(0.5)
expect(result.constant_base).toBe(10.0)
})
it('empty string returns all defaults', () => {
const result = parseModeParams('')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
expect(result.constant_base).toBe(0.0)
expect(result.smith_exponent).toBeNull()
expect(result.techcomm_exponent).toBeNull()
expect(result.ratio_multiplier).toBeNull()
expect(result.is_ratio_mode).toBe(false)
})
it('whitespace-only string treated as empty', () => {
const result = parseModeParams(' ')
expect(result.duration_days).toBe(30)
})
it('result exposes exactly the expected keys', () => {
const result = parseModeParams('D30M50B.1G.2')
const expectedKeys = [
'duration_days',
'majority_pct',
'base_exponent',
'gradient_exponent',
'constant_base',
'smith_exponent',
'techcomm_exponent',
'ratio_multiplier',
'is_ratio_mode',
].sort()
expect(Object.keys(result).sort()).toEqual(expectedKeys)
})
it('unknown code letter throws (Python parity)', () => {
expect(() => parseModeParams('D30X5')).toThrow(/inconnu/)
})
it('D7R1N2.5 => ratio mode with multiplier (Python cross-check)', () => {
const result = parseModeParams('D7R1N2.5')
expect(result.duration_days).toBe(7)
expect(result.is_ratio_mode).toBe(true)
expect(result.ratio_multiplier).toBe(2.5)
// Untouched defaults
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
})
})
describe('formatModeParams', () => {
it('defaults format to the canonical standard string', () => {
expect(formatModeParams()).toBe('D30M50B.1G.2')
expect(formatModeParams({})).toBe('D30M50B.1G.2')
})
it('round-trips the seed DSL strings', () => {
for (const s of ['D30M50B.1G.2', 'D30M50B.1G.2S.1', 'D30M50B.1G.2T.1']) {
expect(formatModeParams(parseModeParams(s))).toBe(s)
}
})
it('formats integer base and constant', () => {
expect(formatModeParams(parseModeParams('D30M50B1G.5C10'))).toBe('D30M50B1G.5C10')
})
it('formats ratio mode and multiplier', () => {
expect(formatModeParams(parseModeParams('D7R1N2.5'))).toBe('D7M50B.1G.2N2.5R1')
})
it('parse(format(p)) is identical to p', () => {
for (const s of ['D30M50B.1G.2S.1', 'D30M50B1G.5C10', 'D7R1N2.5', 'D90M66B.2G.3S.2T.1']) {
const p = parseModeParams(s)
expect(parseModeParams(formatModeParams(p))).toEqual(p)
}
})
})
@@ -0,0 +1,118 @@
/**
* Ported from backend/app/tests/test_nuanced.py — every pytest case
* exists here with the same expected values.
*
* Levels: 0-CONTRE, 1-PAS DU TOUT, 2-PAS D'ACCORD, 3-NEUTRE, 4-D'ACCORD, 5-TOUT A FAIT
* Positive = levels 3 + 4 + 5
* Adoption requires: positive_pct >= threshold (80%) AND total >= min_participants (59).
*/
import { describe, expect, it } from 'vitest'
import { nuancedResult } from '../../app/engine/nuanced'
/** Build a votes array from level repetitions, e.g. fill([5, 20], [4, 20]). */
function fill(...groups: Array<[level: number, count: number]>): number[] {
const votes: number[] = []
for (const [level, count] of groups) {
for (let i = 0; i < count; i++) votes.push(level)
}
return votes
}
describe('nuancedResult — adoption', () => {
it('59 positive + 10 negative = 69 total => adopted (85.51% >= 80%)', () => {
const votes = fill([5, 20], [4, 20], [3, 19], [2, 5], [1, 3], [0, 2])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(69)
expect(result.positive_count).toBe(59)
expect(result.positive_pct).toBeCloseTo(85.51, 1)
// Python cross-check: round(59/69*100, 2) == 85.51
expect(result.positive_pct).toBe(85.51)
expect(result.threshold_met).toBe(true)
expect(result.min_participants_met).toBe(true)
expect(result.adopted).toBe(true)
})
it('all 59 voters at level 5 => 100% positive, adopted', () => {
const votes = fill([5, 59])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(59)
expect(result.positive_count).toBe(59)
expect(result.positive_pct).toBe(100.0)
expect(result.adopted).toBe(true)
})
})
describe('nuancedResult — rejection', () => {
it('40 positive + 30 negative = 70 total => threshold not met (57.14% < 80%)', () => {
const votes = fill([5, 15], [4, 15], [3, 10], [2, 10], [1, 10], [0, 10])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(70)
expect(result.positive_count).toBe(40)
expect(result.positive_pct).toBeCloseTo(57.14, 1)
expect(result.threshold_met).toBe(false)
expect(result.min_participants_met).toBe(true) // 70 >= 59
expect(result.adopted).toBe(false)
})
it('55 total < 59 min participants => rejected despite 90.9% positive', () => {
const votes = fill([5, 30], [4, 10], [3, 10], [1, 3], [0, 2])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(55)
expect(result.positive_count).toBe(50)
expect(result.positive_pct).toBeGreaterThan(80)
expect(result.threshold_met).toBe(true)
expect(result.min_participants_met).toBe(false)
expect(result.adopted).toBe(false)
})
})
describe('nuancedResult — edge cases', () => {
it('exactly 80% positive votes passes the threshold', () => {
// 80 positive out of 100 = exactly 80%
const votes = fill([5, 40], [4, 20], [3, 20], [2, 10], [1, 5], [0, 5])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(100)
expect(result.positive_count).toBe(80)
expect(result.positive_pct).toBe(80.0)
expect(result.threshold_met).toBe(true)
expect(result.min_participants_met).toBe(true)
expect(result.adopted).toBe(true)
})
it('79 positive out of 100 = 79% < 80% => rejected', () => {
const votes = fill([5, 39], [4, 20], [3, 20], [2, 11], [1, 5], [0, 5])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(100)
expect(result.positive_count).toBe(79)
expect(result.positive_pct).toBe(79.0)
expect(result.threshold_met).toBe(false)
expect(result.adopted).toBe(false)
})
it('zero votes => not adopted', () => {
const result = nuancedResult([], 80, 59)
expect(result.total).toBe(0)
expect(result.positive_count).toBe(0)
expect(result.positive_pct).toBe(0.0)
expect(result.adopted).toBe(false)
})
it('vote level outside 0-5 throws', () => {
expect(() => nuancedResult([5, 3, 6])).toThrow(/invalide/)
})
it('per-level breakdown is correct', () => {
const votes = [0, 1, 2, 3, 4, 5, 5, 4, 3]
const result = nuancedResult(votes, 50, 1)
expect(result.per_level_counts).toEqual({ 0: 1, 1: 1, 2: 1, 3: 2, 4: 2, 5: 2 })
expect(result.positive_count).toBe(6) // 2+2+2
expect(result.total).toBe(9)
})
})
@@ -0,0 +1,480 @@
/**
* Suite du moteur paramétrique — « Réglage collectif » (BLUEPRINT-V2.md
* Δ2, Δ3, Δ15, Δ16). Specs verrouillées testées ici :
* médiane BASSE élément par élément (pair ⇒ élément central inférieur,
* step respecté par construction), sum100 avec EXACTEMENT une part dérivée,
* clamp + renormalisation proportionnelle à la cristallisation,
* « Pour moi » linear-share (jamais de chiffre inventé), bimodalité
* heuristique jamais bloquante, dégénérescences 0 vote.
*/
import type { ParamSpec } from '../../app/types/domain'
import { describe, expect, it } from 'vitest'
import {
computeMyImpact,
crystallize,
detectBimodality,
medianByElement,
resolveDerived,
validateParamSpec,
validateVote,
} from '../../app/engine/parametric'
// ---------------------------------------------------------------------------
// Specs de référence
// ---------------------------------------------------------------------------
/** Spec sum100 : 2 parts votées + 1 part dérivée large (bornes 0–100). */
function sum100Spec(overrides: Partial<ParamSpec> = {}): ParamSpec {
return {
constraint: 'sum100',
params: [
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
],
...overrides,
}
}
/** Spec sans contrainte : 2 curseurs bornés. */
function sliderSpec(): ParamSpec {
return {
constraint: 'none',
params: [
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1, baseline: 12 },
{ key: 'taux', label: 'Taux (%)', kind: 'slider', min: 0, max: 10, step: 0.5 },
],
}
}
// ---------------------------------------------------------------------------
// medianByElement — médiane basse élément par élément
// ---------------------------------------------------------------------------
describe('medianByElement — médiane basse élément par élément', () => {
it('nombre impair de votes : élément central classique', () => {
expect(medianByElement([[10], [30], [20]])).toEqual([20])
})
it('nombre pair de votes : élément central INFÉRIEUR (médiane basse)', () => {
// Colonne triée [10, 20, 30, 40] : index floor((4-1)/2) = 1 ⇒ 20, pas 25.
expect(medianByElement([[40], [10], [30], [20]])).toEqual([20])
})
it('la médiane paire est la valeur votée inférieure des deux centrales', () => {
const votes = [[15], [25], [40], [60]]
const [median] = medianByElement(votes)
expect(median).toBe(25) // jamais 32.5 (moyenne des centrales)
expect(votes.flat()).toContain(median) // une position que quelqu'un a votée
})
it('step respecté par construction : la médiane appartient à la grille votée', () => {
// Votes conformes au pas 5 : la médiane basse est l'un d'eux, donc conforme.
const votes = [[15], [25], [40], [60]]
const [median] = medianByElement(votes)
expect((median - 0) % 5).toBe(0)
})
it('élément par élément : chaque colonne a sa propre médiane', () => {
const votes = [
[10, 90],
[20, 80],
[30, 70],
]
expect(medianByElement(votes)).toEqual([20, 80])
})
it('deux votes : médiane basse = le plus petit des deux', () => {
expect(medianByElement([[50, 10], [40, 20]])).toEqual([40, 10])
})
it('0 vote ⇒ [] (l’appelant gère la baseline)', () => {
expect(medianByElement([])).toEqual([])
})
})
// ---------------------------------------------------------------------------
// validateParamSpec
// ---------------------------------------------------------------------------
describe('validateParamSpec — validation de la spec à la création', () => {
it('accepte une spec sum100 valide (exactement une part dérivée)', () => {
expect(() => validateParamSpec(sum100Spec())).not.toThrow()
})
it('accepte une spec de curseurs sans contrainte', () => {
expect(() => validateParamSpec(sliderSpec())).not.toThrow()
})
it('rejette plus de 7 paramètres', () => {
const spec: ParamSpec = {
constraint: 'none',
params: Array.from({ length: 8 }, (_, i) => ({
key: `p${i}`,
label: `Paramètre ${i}`,
kind: 'slider' as const,
min: 0,
max: 10,
step: 1,
})),
}
expect(() => validateParamSpec(spec)).toThrow(/maximum 7/)
})
it('rejette une spec sans aucun paramètre', () => {
expect(() => validateParamSpec({ constraint: 'none', params: [] })).toThrow(/Au moins un paramètre/)
})
it('rejette un libellé métier manquant', () => {
const spec = sliderSpec()
spec.params[0].label = ' '
expect(() => validateParamSpec(spec)).toThrow(/Libellé métier manquant/)
})
it('rejette sum100 sans part dérivée (0 derived)', () => {
const spec = sum100Spec()
delete spec.params[2].derived
expect(() => validateParamSpec(spec)).toThrow(/exactement une part dérivée/)
})
it('rejette sum100 avec deux parts dérivées (2 derived)', () => {
const spec = sum100Spec()
spec.params[1].derived = true
expect(() => validateParamSpec(spec)).toThrow(/2 trouvées/)
})
it('rejette un derived posé sur un curseur (kind slider)', () => {
const spec = sum100Spec()
spec.params[2].kind = 'slider'
expect(() => validateParamSpec(spec)).toThrow(/part/)
})
it('rejette un derived sans contrainte pour le résoudre', () => {
const spec = sliderSpec()
spec.params[1].derived = true
expect(() => validateParamSpec(spec)).toThrow(/sans contrainte/)
})
it('rejette des bornes incohérentes (min >= max)', () => {
const spec = sliderSpec()
spec.params[0].min = 24
spec.params[0].max = 24
expect(() => validateParamSpec(spec)).toThrow(/Bornes incohérentes/)
})
it('rejette un pas nul ou négatif (step <= 0)', () => {
const spec = sliderSpec()
spec.params[1].step = 0
expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/)
spec.params[1].step = -1
expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/)
})
it('rejette une baseline hors bornes', () => {
const spec = sliderSpec()
spec.params[0].baseline = 25 // max 24
expect(() => validateParamSpec(spec)).toThrow(/hors bornes/)
})
})
// ---------------------------------------------------------------------------
// validateVote
// ---------------------------------------------------------------------------
describe('validateVote — validation d’un vote', () => {
it('accepte un vote valide (ordre des params hors derived)', () => {
expect(() => validateVote(sum100Spec(), [60, 30])).not.toThrow()
expect(() => validateVote(sliderSpec(), [12, 2.5])).not.toThrow()
})
it('rejette une longueur fausse (le derived ne se vote pas)', () => {
expect(() => validateVote(sum100Spec(), [60, 30, 10])).toThrow(/Nombre de valeurs invalide/)
expect(() => validateVote(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/)
})
it('rejette NaN et Infinity (sanitize)', () => {
expect(() => validateVote(sum100Spec(), [Number.NaN, 30])).toThrow(/nombre fini/)
expect(() => validateVote(sum100Spec(), [60, Number.POSITIVE_INFINITY])).toThrow(/nombre fini/)
expect(() => validateVote(sliderSpec(), [Number.NEGATIVE_INFINITY, 2])).toThrow(/nombre fini/)
})
it('rejette une valeur hors bornes', () => {
expect(() => validateVote(sliderSpec(), [0, 2])).toThrow(/hors bornes/) // min 1
expect(() => validateVote(sliderSpec(), [12, 10.5])).toThrow(/hors bornes/) // max 10
})
it('rejette une valeur non alignée sur le pas', () => {
expect(() => validateVote(sum100Spec(), [62, 30])).toThrow(/pas/) // pas de 5
expect(() => validateVote(sliderSpec(), [12, 2.3])).toThrow(/pas/) // pas de 0.5
})
it('tolérance flottante : 0.1 + 0.2 est accepté sur une grille de 0.1', () => {
const spec: ParamSpec = {
constraint: 'none',
params: [{ key: 't', label: 'Taux', kind: 'slider', min: 0, max: 1, step: 0.1 }],
}
expect(() => validateVote(spec, [0.1 + 0.2])).not.toThrow() // 0.30000000000000004
})
it('sum100 : rejette le vote si le derived résolu sort de ses bornes', () => {
const spec = sum100Spec()
spec.params[2].max = 15 // réserve bornée [0, 15]
// 60 + 30 = 90 ⇒ derived 10 : OK ; 50 + 30 = 80 ⇒ derived 20 : rejet.
expect(() => validateVote(spec, [60, 30])).not.toThrow()
expect(() => validateVote(spec, [50, 30])).toThrow(/sortirait de ses bornes/)
// 105 votés ⇒ derived −5 < min 0 : rejet aussi.
expect(() => validateVote(spec, [70, 35])).toThrow(/sortirait de ses bornes/)
})
it('sum100 : le derived exactement sur sa borne est accepté', () => {
const spec = sum100Spec()
spec.params[2].max = 5
expect(() => validateVote(spec, [65, 30])).not.toThrow() // derived = 5 pile
})
it('sum100 : un curseur hors contrainte ne compte pas dans la somme', () => {
const spec: ParamSpec = {
constraint: 'sum100',
params: [
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
],
}
// Le curseur vaut 24 : s'il comptait, derived = 100 − 24 − 60 = 16 ;
// il ne compte pas ⇒ derived = 40, dans [0, 100].
expect(() => validateVote(spec, [24, 60])).not.toThrow()
})
})
// ---------------------------------------------------------------------------
// resolveDerived
// ---------------------------------------------------------------------------
describe('resolveDerived — résolution linéaire du derived', () => {
it('insère le derived à sa position dans l’ordre de spec.params', () => {
expect(resolveDerived(sum100Spec(), [60, 30])).toEqual([60, 30, 10])
})
it('derived en position centrale : l’ordre est préservé', () => {
const spec: ParamSpec = {
constraint: 'sum100',
params: [
{ key: 'a', label: 'Part A', kind: 'share', min: 0, max: 100, step: 1 },
{ key: 'r', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
{ key: 'b', label: 'Part B', kind: 'share', min: 0, max: 100, step: 1 },
],
}
expect(resolveDerived(spec, [40, 35])).toEqual([40, 25, 35])
})
it('les curseurs hors contrainte passent tels quels', () => {
expect(resolveDerived(sliderSpec(), [12, 2.5])).toEqual([12, 2.5])
})
it('sum100 avec curseur : seul les parts entrent dans le calcul du derived', () => {
const spec: ParamSpec = {
constraint: 'sum100',
params: [
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
],
}
expect(resolveDerived(spec, [24, 60])).toEqual([24, 60, 40])
})
it('rejette une longueur fausse', () => {
expect(() => resolveDerived(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/)
})
})
// ---------------------------------------------------------------------------
// crystallize
// ---------------------------------------------------------------------------
describe('crystallize — médiane basse des parts votées puis résolution', () => {
it('cas nominal : médiane basse puis derived résolu, somme 100', () => {
const votes = [
[50, 20],
[60, 30],
[70, 40],
]
const out = crystallize(sum100Spec(), votes)
expect(out).toEqual([60, 30, 10])
expect(out[0] + out[1] + out[2]).toBe(100)
})
it('derived dans ses bornes : aucun clamp, valeurs médianes intactes', () => {
const spec = sum100Spec()
spec.params[2].max = 15
const out = crystallize(spec, [[60, 30], [65, 30], [55, 35]])
expect(out).toEqual([60, 30, 10]) // derived 10 ≤ 15 : rien à clamper
})
it('cas chiffré exact : derived clampé à 5, parts renormalisées ×95/90', () => {
// Médiane des parts votées = {60, 30} ⇒ derived résolu 10 > max 5.
// Spec verrouillée : clamp à 5, puis parts × (100 − 5)/(60 + 30).
const spec = sum100Spec()
spec.params[2].max = 5
const votes = [
[50, 20],
[60, 30],
[70, 40],
]
const out = crystallize(spec, votes)
expect(out[2]).toBe(5)
expect(out[0]).toBeCloseTo((60 * 95) / 90, 10)
expect(out[1]).toBeCloseTo((30 * 95) / 90, 10)
expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10)
})
it('clamp à la borne min : même renormalisation proportionnelle', () => {
// Médiane {60, 30} ⇒ derived 10 < min 20 : clamp à 20, parts × 80/90.
const spec = sum100Spec()
spec.params[2].min = 20
const out = crystallize(spec, [[60, 30]])
expect(out[2]).toBe(20)
expect(out[0]).toBeCloseTo((60 * 80) / 90, 10)
expect(out[1]).toBeCloseTo((30 * 80) / 90, 10)
expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10)
})
it('les curseurs hors contrainte ne sont jamais renormalisés', () => {
const spec: ParamSpec = {
constraint: 'sum100',
params: [
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 5, step: 1, derived: true },
],
}
const out = crystallize(spec, [[12, 60, 30]])
expect(out[0]).toBe(12) // le curseur passe tel quel
expect(out[3]).toBe(5)
expect(out[1] + out[2] + out[3]).toBeCloseTo(100, 10)
})
it('0 vote ⇒ vecteur des baselines (baseline ?? min)', () => {
const spec = sum100Spec()
spec.params[0].baseline = 50
spec.params[2].baseline = 20
// params[1] sans baseline ⇒ min (0).
expect(crystallize(spec, [])).toEqual([50, 0, 20])
})
it('0 vote sans aucune baseline ⇒ vecteur des min', () => {
expect(crystallize(sliderSpec(), [])).toEqual([12, 0]) // duree a baseline 12, taux min 0
})
it('sans contrainte : médiane basse simple, aucun derived', () => {
const out = crystallize(sliderSpec(), [[6, 2], [12, 4], [18, 3], [24, 5]])
expect(out).toEqual([12, 3]) // médiane basse de chaque colonne
})
})
// ---------------------------------------------------------------------------
// computeMyImpact
// ---------------------------------------------------------------------------
describe('computeMyImpact — carte « Pour moi » (linear-share)', () => {
function impactSpec(): ParamSpec {
const spec = sum100Spec()
spec.impactAttrKey = 'heures/mois'
return spec
}
it('cas nominal chiffré : quote-parts au prorata de l’attribut déclaré', () => {
// 1 200 € ; valeurs [50, 30, 20] ; moi 10 h, corpus [10, 20, 10] ⇒ Σ 40.
// montants : 600, 360, 240 ; ma quote-part = montant × 10/40.
const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10])
expect(result).not.toBeNull()
expect(result!.perParam).toEqual([
{ key: 'bois', label: 'Atelier bois', amount: 150 },
{ key: 'metal', label: 'Atelier métal', amount: 90 },
{ key: 'reserve', label: 'Réserve (calculé)', amount: 60 },
])
expect(result!.total).toBeCloseTo(300, 10)
})
it('le derived est inclus dans les quote-parts (chaque part compte)', () => {
const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10])
expect(result!.perParam.map(l => l.key)).toContain('reserve')
})
it('null si mon attribut est absent — jamais de chiffre inventé', () => {
expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], undefined, [10, 20])).toBeNull()
})
it('null si Σ des attributs du corpus vaut 0', () => {
expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [0, 0, 0])).toBeNull()
expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [])).toBeNull()
})
it('null hors sum100, sans montant ou sans impactAttrKey', () => {
const noConstraint = sliderSpec()
noConstraint.impactAttrKey = 'heures/mois'
expect(computeMyImpact(noConstraint, { amount: 1200 }, [12, 2.5], 10, [10, 20])).toBeNull()
expect(computeMyImpact(impactSpec(), {}, [50, 30, 20], 10, [10, 20])).toBeNull()
const noKey = sum100Spec() // pas d'impactAttrKey
expect(computeMyImpact(noKey, { amount: 1200 }, [50, 30, 20], 10, [10, 20])).toBeNull()
})
it('les curseurs hors contrainte n’ont pas de quote-part', () => {
const spec: ParamSpec = {
constraint: 'sum100',
impactAttrKey: 'heures/mois',
params: [
{ key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
{ key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
{ key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
],
}
const result = computeMyImpact(spec, { amount: 1000 }, [12, 60, 40], 5, [5, 5])
expect(result!.perParam.map(l => l.key)).toEqual(['bois', 'reserve'])
expect(result!.total).toBeCloseTo(500, 10) // (600 + 400) × 5/10
})
})
// ---------------------------------------------------------------------------
// detectBimodality
// ---------------------------------------------------------------------------
describe('detectBimodality — heuristique jamais bloquante', () => {
it('bimodal net : deux paquets séparés par un grand écart ⇒ vrai', () => {
// Triées : [10, 11, 12, 60, 61, 62] — étendue 52, gap max 48 > 40 %.
expect(detectBimodality([10, 60, 11, 61, 12, 62])).toBe(true)
})
it('distribution uniforme ⇒ faux', () => {
// Gaps réguliers de 10 sur une étendue de 40 : 10 < 16.
expect(detectBimodality([10, 20, 30, 40, 50])).toBe(false)
})
it('moins de 4 valeurs ⇒ toujours faux', () => {
expect(detectBimodality([0, 100])).toBe(false)
expect(detectBimodality([0, 50, 100])).toBe(false)
expect(detectBimodality([])).toBe(false)
})
it('un simple outlier (1 seule valeur d’un côté du gap) ⇒ faux', () => {
// Gap max 50 entre 0 et 50, mais une seule valeur à gauche.
expect(detectBimodality([0, 50, 51, 52, 53])).toBe(false)
})
it('valeurs toutes identiques (étendue nulle) ⇒ faux', () => {
expect(detectBimodality([5, 5, 5, 5])).toBe(false)
})
it('deux paires exactement de part et d’autre du gap ⇒ vrai (cas limite n=4)', () => {
expect(detectBimodality([0, 1, 99, 100])).toBe(true)
})
it('jamais bloquante : les valeurs non finies sont ignorées, pas d’exception', () => {
expect(() => detectBimodality([Number.NaN, Number.POSITIVE_INFINITY, 1, 2])).not.toThrow()
expect(detectBimodality([Number.NaN, 0, 1, 99, 100])).toBe(true)
})
})
@@ -0,0 +1,226 @@
/**
* resolveSettings — the Pact IS the settings store (Δ10).
* Defaults, voted overrides, specified fallbacks, corrupted-bundle resilience.
*/
import { describe, expect, it } from 'vitest'
import { SETTINGS_DEFAULTS, hasConsentProtocol, resolveSettings } from '../../app/engine/settings'
import type { Clause, ClauseVersion, Json } from '../../app/types/domain'
const T0 = '2026-08-01T00:00:00.000Z'
const T1 = '2026-08-05T00:00:00.000Z'
let seq = 0
function makeClause(settingKey: string, overrides: Partial<Clause> = {}): Clause {
seq += 1
return {
id: `clause-${seq}`,
collectiveId: 'col-1',
createdAt: T0,
updatedAt: T0,
docId: 'doc-pact',
section: 'Réglages',
position: seq,
code: `S${seq}`,
title: settingKey,
inertia: 'standard',
settingKey,
...overrides,
}
}
function makeVersion(
clauseId: string,
settingValue: Json,
overrides: Partial<ClauseVersion> = {},
): ClauseVersion {
seq += 1
return {
id: `version-${seq}`,
collectiveId: 'col-1',
createdAt: T0,
updatedAt: T0,
clauseId,
decisionId: 'dec-founding',
versionLabel: 'v1',
content: 'clause adoptée',
settingValue,
status: 'current',
adoptedAt: T0,
...overrides,
}
}
/** Build a Pact from [settingKey, settingValue] pairs and resolve it. */
function resolveFrom(entries: [string, Json][]) {
const clauses = entries.map(([key]) => makeClause(key))
const versions = clauses.map((clause, index) => makeVersion(clause.id, entries[index]![1]))
return resolveSettings(clauses, versions)
}
describe('resolveSettings — les défauts complets', () => {
it('sans aucune clause, tous les seuils de triage prennent leur défaut', () => {
const settings = resolveSettings([], [])
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',
})
expect(settings.triage).toEqual(SETTINGS_DEFAULTS)
})
it('sans aucune clause, les protocoles optionnels restent indéfinis (repli → consent chez l’appelant)', () => {
const settings = resolveSettings([], [])
expect(settings.protocolByRange.nuanced).toBeUndefined()
expect(settings.protocolByRange.large).toBeUndefined()
expect(settings.protocolByRange.parametric).toBeUndefined()
expect(settings.protocolByRange.election).toBeUndefined()
expect(settings.protocolByRange.clauseByInertia).toBeUndefined()
})
})
describe('resolveSettings — une clause adoptée change une valeur', () => {
it('la version courante d’une clause remplace le défaut', () => {
const settings = resolveFrom([['triage.smallGroupMax', 8]])
expect(settings.triage.smallGroupMax).toBe(8)
expect(settings.triage.collectiveMin).toBe(50) // les autres clés gardent leur défaut
})
it('une version seulement proposée ne change rien', () => {
const clause = makeClause('triage.framingDays')
const version = makeVersion(clause.id, 21, { status: 'proposed', adoptedAt: undefined })
expect(resolveSettings([clause], [version]).triage.framingDays).toBe(14)
})
it('une version remplacée ne change rien — seule la courante compte', () => {
const clause = makeClause('triage.consentMax')
const superseded = makeVersion(clause.id, 3, { status: 'superseded' })
const current = makeVersion(clause.id, 9, { adoptedAt: T1 })
expect(resolveSettings([clause], [superseded, current]).triage.consentMax).toBe(9)
})
it('deux versions courantes (anomalie) — la plus récemment adoptée gagne', () => {
const clause = makeClause('triage.objectionWindowHours')
const older = makeVersion(clause.id, 24, { adoptedAt: T0 })
const newer = makeVersion(clause.id, 96, { adoptedAt: T1 })
expect(resolveSettings([clause], [older, newer]).triage.objectionWindowHours).toBe(96)
})
it('une clause archivée est ignorée', () => {
const clause = makeClause('triage.smallGroupMax', { archivedAt: T1 })
const version = makeVersion(clause.id, 12)
expect(resolveSettings([clause], [version]).triage.smallGroupMax).toBe(5)
})
})
describe('resolveSettings — requireEffects résolu', () => {
it('« none » voté est appliqué', () => {
expect(resolveFrom([['triage.requireEffects', 'none']]).triage.requireEffects).toBe('none')
})
it('« structural » voté est appliqué', () => {
expect(resolveFrom([['triage.requireEffects', 'structural']]).triage.requireEffects).toBe(
'structural',
)
})
it('une valeur inconnue retombe sur « binding »', () => {
expect(resolveFrom([['triage.requireEffects', 'everything']]).triage.requireEffects).toBe(
'binding',
)
})
})
describe('resolveSettings — le protocole de consentement', () => {
it('la clause protocols.consent résout l’identifiant', () => {
const settings = resolveFrom([['protocols.consent', 'proto-consent-1']])
expect(settings.protocolByRange.consent).toBe('proto-consent-1')
expect(hasConsentProtocol(settings)).toBe(true)
})
it('consent introuvable (bundle corrompu) — objet complet, consent vide, jamais de crash', () => {
expect(() => resolveSettings([], [])).not.toThrow()
const settings = resolveSettings([], [])
expect(settings.protocolByRange.consent).toBe('')
expect(hasConsentProtocol(settings)).toBe(false)
expect(settings.triage.smallGroupMax).toBe(5) // le reste de l’objet est utilisable
})
it('consent d’un mauvais type compte comme introuvable', () => {
const settings = resolveFrom([['protocols.consent', 42]])
expect(settings.protocolByRange.consent).toBe('')
expect(hasConsentProtocol(settings)).toBe(false)
})
})
describe('resolveSettings — les protocoles optionnels et l’héritage Ğ1', () => {
it('nuanced, large, parametric et election se résolvent chacun', () => {
const settings = resolveFrom([
['protocols.consent', 'p-consent'],
['protocols.nuanced', 'p-nuanced'],
['protocols.large', 'p-large'],
['protocols.parametric', 'p-parametric'],
['protocols.election', 'p-election'],
])
expect(settings.protocolByRange.nuanced).toBe('p-nuanced')
expect(settings.protocolByRange.large).toBe('p-large')
expect(settings.protocolByRange.parametric).toBe('p-parametric')
expect(settings.protocolByRange.election).toBe('p-election')
})
it('clauseByInertia complet (4 presets) est exposé', () => {
const settings = resolveFrom([
['protocols.clauseByInertia.low', 'p-low'],
['protocols.clauseByInertia.standard', 'p-standard'],
['protocols.clauseByInertia.high', 'p-high'],
['protocols.clauseByInertia.max', 'p-max'],
])
expect(settings.protocolByRange.clauseByInertia).toEqual({
low: 'p-low',
standard: 'p-standard',
high: 'p-high',
max: 'p-max',
})
})
it('clauseByInertia partiel reste indéfini — l’appelant retombe sur consent', () => {
const settings = resolveFrom([
['protocols.clauseByInertia.low', 'p-low'],
['protocols.clauseByInertia.standard', 'p-standard'],
['protocols.clauseByInertia.high', 'p-high'],
])
expect(settings.protocolByRange.clauseByInertia).toBeUndefined()
})
})
describe('resolveSettings — les valeurs invalides retombent sur les défauts, en silence', () => {
it('un mauvais type sur une clé numérique est ignoré', () => {
expect(resolveFrom([['triage.smallGroupMax', 'huit']]).triage.smallGroupMax).toBe(5)
})
it('un nombre négatif est ignoré', () => {
expect(resolveFrom([['triage.framingDays', -3]]).triage.framingDays).toBe(14)
})
it('un ratio négatif est ignoré', () => {
expect(resolveFrom([['triage.concernEscalateRatio', -0.5]]).triage.concernEscalateRatio).toBe(
0.5,
)
})
it('null est ignoré', () => {
expect(resolveFrom([['triage.reviewDelayDays', null]]).triage.reviewDelayDays).toBe(90)
})
it('un identifiant de protocole vide compte comme non résolu', () => {
const settings = resolveFrom([['protocols.nuanced', ' ']])
expect(settings.protocolByRange.nuanced).toBeUndefined()
})
})
@@ -0,0 +1,650 @@
/**
* canTransition — the ONE state machine of the decision (Δ29).
* Every guard (a–i) tested with positive AND negative cases + windowOutcome.
*/
import { describe, expect, it } from 'vitest'
import { TRANSITIONS, canTransition, windowOutcome } from '../../app/engine/state'
import type { TransitionContext } from '../../app/engine/state'
import type {
Assent,
CollectiveSettings,
Concern,
Decision,
Objection,
ParamSpec,
VoteSession,
} from '../../app/types/domain'
const T0 = '2026-08-01T00:00:00.000Z'
const NOW = '2026-08-11T12:00:00.000Z'
let seq = 0
function makeEntity() {
seq += 1
return { id: `id-${seq}`, collectiveId: 'col-1', createdAt: T0, updatedAt: T0 }
}
function makeSettings(overrides: Partial<CollectiveSettings['triage']> = {}): CollectiveSettings {
return {
triage: {
smallGroupMax: 5,
collectiveMin: 50,
consentMax: 7,
objectionWindowHours: 48,
adviceWindowHours: 72,
framingDays: 14,
concernEscalateRatio: 0.5,
recurrenceThreshold: 3,
reviewDelayDays: 90,
requireEffects: 'binding',
...overrides,
},
protocolByRange: { consent: 'proto-consent' },
}
}
function makeDecision(overrides: Partial<Decision> = {}): Decision {
return {
...makeEntity(),
id: 'dec-1',
authorId: 'p-author',
title: 'Décision de test',
tags: [],
reversibility: 'easy',
weight: 'light',
urgent: false,
scope: { selfOnly: false, circleIds: ['circle-1'], personIds: [] },
route: 'collective',
triageRule: 'R5',
routeOverridden: false,
status: 'draft',
stewardIds: [],
measurerIds: [],
visibility: 'scope',
...overrides,
}
}
function makeConcern(origin: Concern['origin'], overrides: Partial<Concern> = {}): Concern {
const entity = makeEntity()
return {
...entity,
decisionId: 'dec-1',
personId: `p-${entity.id}`,
origin,
reason: 'membre du cercle',
beforeSnapshot: true,
...overrides,
}
}
function makeAssent(personId: string, overrides: Partial<Assent> = {}): Assent {
return { ...makeEntity(), decisionId: 'dec-1', personId, ...overrides }
}
function makeObjection(status: Objection['status'], overrides: Partial<Objection> = {}): Objection {
return {
...makeEntity(),
decisionId: 'dec-1',
personId: 'p-objector',
kind: 'content',
argument: 'Je maintiens mon désaccord.',
status,
...overrides,
}
}
function makeSession(overrides: Partial<VoteSession> = {}): VoteSession {
return {
...makeEntity(),
decisionId: 'dec-1',
protocolId: 'proto-1',
corpusPersonIds: ['p-1', 'p-2', 'p-3'],
corpusSize: 3,
opensAt: T0,
closesAt: NOW,
status: 'open',
...overrides,
}
}
function makeCtx(overrides: Partial<TransitionContext> = {}): TransitionContext {
return {
concerns: [],
settings: makeSettings(),
assents: [],
objections: [],
now: NOW,
...overrides,
}
}
const PARAM_SPEC: ParamSpec = {
params: [
{ key: 'partA', label: 'Part ateliers', kind: 'share', min: 0, max: 100, step: 1 },
{ key: 'partB', label: 'Part réserve', kind: 'share', min: 0, max: 100, step: 1, derived: true },
],
constraint: 'sum100',
}
/** 4 computed + 2 declared ⇒ ratio 0.5 atteint (2 ≥ 0.5 × 4). */
function influxConcerns(): Concern[] {
return [
makeConcern('computed'),
makeConcern('computed'),
makeConcern('computed'),
makeConcern('computed'),
makeConcern('declared'),
makeConcern('declared'),
]
}
describe('La table des transitions', () => {
it('draft ouvre les sept chemins', () => {
expect(TRANSITIONS.draft).toEqual([
'advice',
'objection',
'framing',
'voting',
'adopted',
'transmitted',
'rejected',
])
})
it('chaque état de travail connaît ses sorties', () => {
expect(TRANSITIONS.advice).toEqual(['adopted', 'voting'])
expect(TRANSITIONS.objection).toEqual(['adopted', 'framing', 'voting'])
expect(TRANSITIONS.framing).toEqual(['voting', 'closed'])
expect(TRANSITIONS.voting).toEqual(['adopted', 'rejected', 'framing'])
expect(TRANSITIONS.adopted).toEqual(['revoked', 'closed'])
})
it('les états terminaux n’ont aucune sortie', () => {
expect(TRANSITIONS.rejected).toBeUndefined()
expect(TRANSITIONS.revoked).toBeUndefined()
expect(TRANSITIONS.closed).toBeUndefined()
expect(TRANSITIONS.transmitted).toBeUndefined()
})
})
describe('Garde a — la transition inconnue est refusée', () => {
it('adopted → voting n’existe pas', () => {
const result = canTransition(makeDecision({ status: 'adopted' }), 'voting', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason.length).toBeGreaterThan(0)
})
it('rejected est terminal — aucune sortie', () => {
expect(canTransition(makeDecision({ status: 'rejected' }), 'adopted', makeCtx()).ok).toBe(false)
})
it('draft → advice existe', () => {
const decision = makeDecision({ status: 'draft', route: 'advice' })
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
})
it('le retour explicite d’une fenêtre à son propre état est permis', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate' })
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
})
it('un état hors fenêtre ne boucle pas sur lui-même', () => {
expect(canTransition(makeDecision({ status: 'voting' }), 'voting', makeCtx()).ok).toBe(false)
})
})
describe('Garde b — la frontière contestée suspend toute sortie', () => {
it('une fenêtre d’objection suspendue ne peut pas adopter', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
const result = canTransition(decision, 'adopted', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('frontière')
})
it('une fenêtre d’avis suspendue ne peut pas non plus escalader vers le vote', () => {
const decision = makeDecision({ status: 'advice', route: 'advice', windowSuspendedAt: NOW })
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
})
it('le retour explicite au même état reste possible pendant la suspension', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
})
it('sans suspension, la fenêtre s’adopte normalement', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate' })
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
})
it('la frontière prime sur l’affluence (ordre des gardes)', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', windowSuspendedAt: NOW })
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('frontière')
})
})
describe('Garde c — l’affluence exige un traitement du périmètre', () => {
it('ratio atteint sans scopeKeptNote — la session ne se clôt pas', () => {
const decision = makeDecision({ status: 'voting' })
const result = canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() }))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.reason).toBe(
'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
)
}
})
it('le maintien motivé publiquement (scopeKeptNote) débloque la clôture', () => {
const decision = makeDecision({
status: 'voting',
scopeKeptNote: 'Le cercle Ateliers reste le bon périmètre : le budget est le sien.',
})
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
})
it('sous le ratio, la clôture passe', () => {
const concerns = [
makeConcern('computed'),
makeConcern('computed'),
makeConcern('computed'),
makeConcern('computed'),
makeConcern('declared'),
]
const decision = makeDecision({ status: 'voting' })
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
})
it('sans concerné calculé, la garde ne se déclenche jamais', () => {
const concerns = [makeConcern('declared'), makeConcern('declared'), makeConcern('declared')]
const decision = makeDecision({ status: 'voting' })
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
})
it('la garde vaut aussi pour la fenêtre d’avis', () => {
const decision = makeDecision({ status: 'advice', route: 'advice' })
expect(canTransition(decision, 'adopted', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
})
it('la garde vaut pour le rejet d’un vote — clore, c’est clore', () => {
const decision = makeDecision({ status: 'voting' })
expect(canTransition(decision, 'rejected', makeCtx({ concerns: influxConcerns() })).ok).toBe(false)
})
it('les concernés d’une autre décision ne comptent pas', () => {
const concerns = influxConcerns().map((concern) => ({ ...concern, decisionId: 'dec-2' }))
const decision = makeDecision({ status: 'voting' })
expect(canTransition(decision, 'adopted', makeCtx({ concerns })).ok).toBe(true)
})
it('l’escalade vers framing ou voting reste ouverte — c’est le traitement, pas la clôture', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate' })
expect(canTransition(decision, 'framing', makeCtx({ concerns: influxConcerns() })).ok).toBe(true)
})
})
describe('Garde d — la matière (requireEffects) à l’ouverture de session collective', () => {
const resources = { note: 'Deux heures par semaine pendant un mois' }
it('« binding » + poids binding sans effet recherché — refus', () => {
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
const result = canTransition(decision, 'voting', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('effet recherché')
})
it('« binding » + poids binding avec un effet recherché — ouverture', () => {
const decision = makeDecision({
status: 'draft',
weight: 'binding',
resources,
brief: { effects: [{ label: 'Réduire le temps de réunion' }] },
})
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
})
it('« binding » + poids structural sans cible mesurable — refus', () => {
const decision = makeDecision({
status: 'draft',
weight: 'structural',
resources,
brief: { effects: [{ label: 'Assainir le budget' }] },
})
const result = canTransition(decision, 'voting', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('cible')
})
it('« binding » + poids structural avec une cible — ouverture', () => {
const decision = makeDecision({
status: 'draft',
weight: 'structural',
resources,
brief: { effects: [{ label: 'Assainir le budget', target: '≤ 400 € par mois' }] },
})
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
})
it('« structural » : le poids binding n’est pas sous garde', () => {
const decision = makeDecision({ status: 'draft', weight: 'binding', resources })
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
})
it('« structural » : le poids structural sans effet est refusé', () => {
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'structural' }) })
expect(canTransition(decision, 'voting', ctx).ok).toBe(false)
})
it('« none » : aucune exigence de matière, même structural', () => {
const decision = makeDecision({ status: 'draft', weight: 'structural', resources })
const ctx = makeCtx({ settings: makeSettings({ requireEffects: 'none' }) })
expect(canTransition(decision, 'voting', ctx).ok).toBe(true)
})
it('le poids light n’est jamais soumis à la matière', () => {
const decision = makeDecision({ status: 'draft', weight: 'light' })
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
})
it('la garde est réservée à la route collective', () => {
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding', resources })
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(true)
})
it('framing → voting est gardé de la même façon', () => {
const decision = makeDecision({ status: 'framing', weight: 'structural', resources })
expect(canTransition(decision, 'voting', makeCtx()).ok).toBe(false)
})
})
describe('Garde e — « Ce que ça engage » à l’ouverture de fenêtre ou de session', () => {
it('poids binding sans note de ressources — la fenêtre d’objection ne s’ouvre pas', () => {
const decision = makeDecision({ status: 'draft', route: 'mandate', weight: 'binding' })
const result = canTransition(decision, 'objection', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('engage')
})
it('avec la note, la fenêtre s’ouvre', () => {
const decision = makeDecision({
status: 'draft',
route: 'mandate',
weight: 'binding',
resources: { note: 'Une demi-journée de l’équipe accueil' },
})
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(true)
})
it('une note blanche ne compte pas', () => {
const decision = makeDecision({
status: 'draft',
route: 'mandate',
weight: 'binding',
resources: { note: ' ' },
})
expect(canTransition(decision, 'objection', makeCtx()).ok).toBe(false)
})
it('le poids light ouvre sans note', () => {
const decision = makeDecision({ status: 'draft', route: 'advice', weight: 'light' })
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
})
it('la route solo est exemptée', () => {
const decision = makeDecision({ status: 'draft', route: 'solo', weight: 'binding' })
expect(canTransition(decision, 'advice', makeCtx()).ok).toBe(true)
})
it('la garde vit aussi à l’ouverture de session — après la matière', () => {
const decision = makeDecision({
status: 'draft',
weight: 'binding',
brief: { effects: [{ label: 'Un effet recherché' }] },
})
const result = canTransition(decision, 'voting', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('engage')
})
})
describe('Garde f — l’accord explicite (Assent) hors du réversible', () => {
it('easy : le silence vaut accord, la fenêtre s’adopte sans Assent', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(true)
})
it('costly sans Assent — la fenêtre se prolonge', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
const result = canTransition(decision, 'adopted', makeCtx())
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.reason).toBe('Il manque un accord explicite — la fenêtre se prolonge.')
}
})
it('costly : l’accord de l’auteur seul ne suffit pas', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
const ctx = makeCtx({ assents: [makeAssent('p-author')] })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
})
it('costly : un accord d’un tiers concerné débloque l’adoption', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
const ctx = makeCtx({ assents: [makeAssent('p-other')] })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
})
it('irreversible sans Assent — refus aussi', () => {
const decision = makeDecision({
status: 'objection',
route: 'mandate',
reversibility: 'irreversible',
})
expect(canTransition(decision, 'adopted', makeCtx()).ok).toBe(false)
})
it('l’Assent d’une autre décision ne compte pas', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
const ctx = makeCtx({ assents: [makeAssent('p-other', { decisionId: 'dec-2' })] })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
})
})
describe('Garde g — jamais d’adoption sur une objection ouverte', () => {
it('une objection ouverte bloque l’adoption, même en easy', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
const result = canTransition(decision, 'adopted', makeCtx({ objections: [makeObjection('open')] }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('objection')
})
it('une objection retirée ne bloque plus', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
const ctx = makeCtx({ objections: [makeObjection('withdrawn')] })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
})
it('une objection intégrée ne bloque plus', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'easy' })
const ctx = makeCtx({ objections: [makeObjection('integrated')] })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
})
it('l’Assent d’un tiers ne contourne pas une objection ouverte', () => {
const decision = makeDecision({ status: 'objection', route: 'mandate', reversibility: 'costly' })
const ctx = makeCtx({ assents: [makeAssent('p-other')], objections: [makeObjection('open')] })
const result = canTransition(decision, 'adopted', ctx)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('objection')
})
})
describe('Garde h — la clôture du dossier découpé', () => {
const parent = () => makeDecision({ id: 'dossier-1', status: 'framing' })
function element(status: Decision['status'], overrides: Partial<Decision> = {}): Decision {
const entity = makeEntity()
return makeDecision({
id: entity.id,
parentDecisionId: 'dossier-1',
chainKind: 'element',
status,
...overrides,
})
}
it('sans élément, le dossier ne se clôt pas', () => {
const result = canTransition(parent(), 'closed', makeCtx({ children: [] }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('dossier')
})
it('tous les éléments terminaux — le garant peut clore', () => {
const children = [
element('adopted'),
element('rejected'),
element('revoked'),
element('closed'),
element('transmitted'),
]
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(true)
})
it('un élément encore en vote retient le dossier', () => {
const children = [element('adopted'), element('voting')]
const result = canTransition(parent(), 'closed', makeCtx({ children }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('en cours')
})
it('des enfants non-éléments ne font pas un dossier', () => {
const children = [element('adopted', { chainKind: 'revision' })]
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
})
it('l’élément d’un autre dossier ne compte pas', () => {
const children = [element('adopted', { parentDecisionId: 'dossier-2' })]
expect(canTransition(parent(), 'closed', makeCtx({ children })).ok).toBe(false)
})
it('framing → voting du parent reste possible pendant que le dossier vit', () => {
const decision = makeDecision({ id: 'dossier-1', status: 'framing', weight: 'light' })
expect(canTransition(decision, 'voting', makeCtx({ children: [element('voting')] })).ok).toBe(true)
})
})
describe('Garde i — la cristallisation attend le geste du garant', () => {
const parametricDecision = () => makeDecision({ status: 'voting', paramSpec: PARAM_SPEC })
it('session figée — pas d’adoption sans le geste', () => {
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
const result = canTransition(parametricDecision(), 'adopted', ctx)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.reason).toBe('Les votes sont figés — la cristallisation attend son geste.')
}
})
it('session close sans crystallizedById — le moteur ne cristallise jamais', () => {
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(false)
})
it('session close et geste daté — l’adoption passe', () => {
const ctx = makeCtx({
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
})
expect(canTransition(parametricDecision(), 'adopted', ctx).ok).toBe(true)
})
it('le rejet (quorum non atteint) exige le même geste', () => {
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(false)
})
it('le rejet constaté au geste passe', () => {
const ctx = makeCtx({
session: makeSession({ status: 'closed', crystallizedById: 'p-steward', crystallizedAt: NOW }),
})
expect(canTransition(parametricDecision(), 'rejected', ctx).ok).toBe(true)
})
it('une session figée trahit le paramétrique même sans paramSpec', () => {
const decision = makeDecision({ status: 'voting' })
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(false)
})
it('une session non paramétrique se clôt automatiquement — pas de geste exigé', () => {
const decision = makeDecision({ status: 'voting' })
const ctx = makeCtx({ session: makeSession({ status: 'closed' }) })
expect(canTransition(decision, 'adopted', ctx).ok).toBe(true)
})
it('sans session dans le contexte, la garde ne s’applique pas', () => {
expect(canTransition(makeDecision({ status: 'voting' }), 'adopted', makeCtx()).ok).toBe(true)
})
})
describe('windowOutcome — l’échéance des fenêtres d’objection', () => {
it('easy — le silence vaut accord, la fenêtre s’adopte', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'easy' })
expect(windowOutcome(decision, makeCtx())).toBe('adopt')
})
it('costly sans accord tiers — la fenêtre se prolonge d’un cran', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
expect(windowOutcome(decision, makeCtx())).toBe('extend')
})
it('costly avec l’accord d’un tiers — la fenêtre s’adopte', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-other')] }))).toBe('adopt')
})
it('l’accord de l’auteur seul ne compte pas', () => {
const decision = makeDecision({ status: 'objection', reversibility: 'costly' })
expect(windowOutcome(decision, makeCtx({ assents: [makeAssent('p-author')] }))).toBe('extend')
})
it('frontière suspendue — la fenêtre attend, même en easy', () => {
const decision = makeDecision({
status: 'objection',
reversibility: 'easy',
windowSuspendedAt: NOW,
})
expect(windowOutcome(decision, makeCtx())).toBe('wait')
})
})
describe('Garde j — reformuler (voting→framing) réservé au réglage collectif figé', () => {
it('session figée non cristallisée — reformuler est permis', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({ session: makeSession({ status: 'frozen' }) })
expect(canTransition(decision, 'framing', ctx).ok).toBe(true)
})
it('session ouverte — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({ session: makeSession({ status: 'open' }) })
expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
})
it('session cristallisée — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
const ctx = makeCtx({
session: makeSession({ status: 'closed', crystallizedById: 'p-steward' }),
})
expect(canTransition(decision, 'framing', ctx).ok).toBe(false)
})
it('sans session — reformuler est refusé', () => {
const decision = makeDecision({ status: 'voting', route: 'collective' })
expect(canTransition(decision, 'framing', makeCtx()).ok).toBe(false)
})
})
@@ -0,0 +1,174 @@
/**
* Ported from backend/app/tests/test_threshold.py — every pytest case
* exists here with the same expected values. Exact integer expectations
* were cross-checked by executing the Python engine (backend/.venv).
*
* Real-world reference case:
* Vote Engagement Forgeron v2.0.0 (Feb 2026)
* wotSize=7224, votesFor=97, votesAgainst=23, total=120
* params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
*/
import { describe, expect, it } from 'vitest'
import { smithThreshold, techcommThreshold, wotThreshold } from '../../app/engine/threshold'
// ---------------------------------------------------------------------------
// WoT threshold: real-world vote Forgeron
// ---------------------------------------------------------------------------
describe('wotThreshold — Forgeron reference case', () => {
it('forgeron vote passes (97 for out of 120 total, wot=7224)', () => {
const threshold = wotThreshold(7224, 120, 50, 0.1, 0.2)
// With low participation (120/7224 ~ 1.66%), near-unanimity is required.
// The historical threshold was 94, and 97 >= 94.
expect(97).toBeGreaterThanOrEqual(threshold)
// The threshold should be high relative to total votes (inertia effect)
expect(threshold).toBeGreaterThan(60)
})
it('forgeron threshold value is in a reasonable range', () => {
const threshold = wotThreshold(7224, 120, 50, 0.1, 0.2)
// At ~1.66% participation, inertia should push threshold close to 78-95%
// of total votes.
expect(threshold).toBeGreaterThanOrEqual(80)
expect(threshold).toBeLessThanOrEqual(120)
})
it('forgeron threshold is exactly 94 (Python cross-check)', () => {
expect(wotThreshold(7224, 120, 50, 0.1, 0.2)).toBe(94)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: low participation
// ---------------------------------------------------------------------------
describe('wotThreshold — low participation', () => {
it('10 votes out of 7224 requires near-unanimity', () => {
const threshold = wotThreshold(7224, 10, 50, 0.1, 0.2)
// With participation ratio 10/7224 ~ 0.14%, threshold should be
// very close to totalVotes (near-unanimity).
expect(threshold).toBeGreaterThanOrEqual(9)
expect(threshold).toBeLessThanOrEqual(10)
// Python cross-check: exact value
expect(threshold).toBe(9)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: high participation
// ---------------------------------------------------------------------------
describe('wotThreshold — high participation', () => {
it('3000 votes out of 7224 approaches simple majority', () => {
const threshold = wotThreshold(7224, 3000, 50, 0.1, 0.2)
// With ~42% participation, the inertia factor diminishes.
const simpleMajority = Math.ceil(3000 * 0.5)
expect(threshold).toBeGreaterThanOrEqual(simpleMajority)
// Should be noticeably less than near-unanimity
expect(threshold).toBeLessThan(2700)
// Python cross-check: exact value
expect(threshold).toBe(1742)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: edge cases
// ---------------------------------------------------------------------------
describe('wotThreshold — edge cases', () => {
it('zero total votes => ceil(C + B^W)', () => {
const threshold = wotThreshold(7224, 0, 50, 0.1, 0.2)
// B^W = 0.1^7224 is effectively 0
const expected = Math.ceil(0.0 + 0.1 ** 7224)
expect(threshold).toBe(expected)
expect(threshold).toBe(0)
})
it('throws on wotSize = 0', () => {
expect(() => wotThreshold(0, 10)).toThrow(/wotSize/)
})
it('throws on negative totalVotes', () => {
expect(() => wotThreshold(100, -1)).toThrow(/totalVotes/)
})
it('throws on majorityPct out of range', () => {
expect(() => wotThreshold(100, 10, 150)).toThrow(/majorityPct/)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: Python↔TS parity (values executed from backend/.venv)
// ---------------------------------------------------------------------------
describe('wotThreshold — Python parity cases', () => {
it('T > W (100 wot, 150 votes) => 69', () => {
expect(wotThreshold(100, 150, 50, 0.1, 0.2)).toBe(69)
})
it('T = W (full participation) => simple majority 50', () => {
expect(wotThreshold(100, 100, 50, 0.1, 0.2)).toBe(50)
})
it('constantBase C=10 on Forgeron numbers => 96', () => {
expect(wotThreshold(7224, 120, 50, 0.1, 0.2, 10.0)).toBe(96)
})
it('majorityPct 80 on Forgeron numbers => 110', () => {
expect(wotThreshold(7224, 120, 80, 0.1, 0.2)).toBe(110)
})
it('minimal corpus W=1, T=1 => 1', () => {
expect(wotThreshold(1, 1, 50, 0.1, 0.2)).toBe(1)
})
it('W=10, T=5 => 3', () => {
expect(wotThreshold(10, 5, 50, 0.1, 0.2)).toBe(3)
})
})
// ---------------------------------------------------------------------------
// Smith threshold
// ---------------------------------------------------------------------------
describe('smithThreshold — ceil(smithSize ^ S)', () => {
it('smithSize=20, exponent=0.1 => ceil(20^0.1) = 2', () => {
const result = smithThreshold(20, 0.1)
expect(result).toBe(Math.ceil(20 ** 0.1))
// 20^0.1 ~ 1.35, ceil => 2
expect(result).toBe(2)
})
it('smithSize=1 => ceil(1^0.1) = 1', () => {
expect(smithThreshold(1, 0.1)).toBe(1)
})
it('throws on smithSize = 0', () => {
expect(() => smithThreshold(0)).toThrow()
})
it('smithSize=1000, exponent=0.5 => 32 (Python cross-check)', () => {
expect(smithThreshold(1000, 0.5)).toBe(32)
})
})
// ---------------------------------------------------------------------------
// TechComm threshold
// ---------------------------------------------------------------------------
describe('techcommThreshold — ceil(cotecSize ^ T)', () => {
it('cotecSize=5, exponent=0.1 => ceil(5^0.1) = 2', () => {
const result = techcommThreshold(5, 0.1)
expect(result).toBe(Math.ceil(5 ** 0.1))
// 5^0.1 ~ 1.175, ceil => 2
expect(result).toBe(2)
})
it('cotecSize=1 => 1', () => {
expect(techcommThreshold(1, 0.1)).toBe(1)
})
it('throws on cotecSize = 0', () => {
expect(() => techcommThreshold(0)).toThrow()
})
})
@@ -0,0 +1,608 @@
/**
* Tests du moteur de routage — « Le chemin ».
* Ordre des règles vérifié : R-U, R0a, R0b, R0c, R2, R3, R4, R5, R6.
* Fonctions pures : fixtures assemblées à la main, aucun store.
*/
import { describe, expect, it } from 'vitest'
import { detectParametricHint, triage } from '../../app/engine/triage'
import {
BINARY_COST,
PARAMETRIC_ALT,
RECORD_ALT,
URGENT_REFUSED,
} from '../../app/lexicon'
import type {
Clause,
CollectiveSettings,
Mandate,
TriageContext,
TriageInput,
} from '../../app/types/domain'
const T0 = '2026-01-01T00:00:00Z'
const T9 = '2027-01-01T00:00:00Z'
function mkSettings(over: {
triage?: Partial<CollectiveSettings['triage']>
protocolByRange?: Partial<CollectiveSettings['protocolByRange']>
} = {}): CollectiveSettings {
return {
triage: {
smallGroupMax: 5,
collectiveMin: 50,
consentMax: 7,
objectionWindowHours: 48,
adviceWindowHours: 72,
framingDays: 14,
concernEscalateRatio: 0.5,
recurrenceThreshold: 3,
reviewDelayDays: 90,
requireEffects: 'binding',
...over.triage,
},
protocolByRange: { consent: 'proto-consent', ...over.protocolByRange },
}
}
function mkInput(over: Partial<TriageInput> = {}): TriageInput {
return {
title: 'Repeindre la salle',
tags: [],
scope: { selfOnly: false, circleIds: [], personIds: [] },
reversibility: 'costly',
weight: 'light',
urgent: false,
...over,
}
}
function mkCtx(over: Partial<TriageContext> = {}): TriageContext {
return {
myActiveMandates: [],
otherActiveMandates: [],
matchingClauses: [],
similarRecentCount: 0,
similarRecordedCount: 0,
computedConcernedIds: ['p1', 'p2', 'p3'],
...over,
}
}
function mkMandate(over: Partial<Mandate> = {}): Mandate {
return {
id: 'm1',
collectiveId: 'col',
createdAt: T0,
updatedAt: T0,
title: 'Modération',
holderId: 'p-holder',
originDecisionId: 'd0',
domain: { circleIds: ['c1', 'c2'], tags: [] },
startsAt: T0,
endsAt: T9,
electorCircleId: 'c1',
nominationMethod: 'consent',
reports: [],
status: 'active',
...over,
}
}
function mkClause(over: Partial<Clause> = {}): Clause {
return {
id: 'cl1',
collectiveId: 'col',
createdAt: T0,
updatedAt: T0,
docId: 'doc1',
section: 'E',
position: 9,
code: 'E9',
title: 'Acte Forgeron',
inertia: 'high',
...over,
}
}
const scoped = (circleIds: string[]): TriageInput['scope'] =>
({ selfOnly: false, circleIds, personIds: [] })
describe('detectParametricHint — nombre, %, montant dans la phrase', () => {
it('détecte un montant en euros', () => {
expect(detectParametricHint('Fixer la cotisation à 25 €')).toBe(true)
})
it('ne détecte rien dans une phrase sans chiffre', () => {
expect(detectParametricHint('Repeindre la salle')).toBe(false)
})
it('détecte un pourcentage', () => {
expect(detectParametricHint('Porter la part commune à douze %')).toBe(true)
})
it('détecte l\'unité DU en majuscules', () => {
expect(detectParametricHint('Verser un DU aux nouveaux venus')).toBe(true)
})
it('ne confond pas l\'article « du » avec l\'unité DU', () => {
expect(detectParametricHint('Changer du mobilier dans la salle')).toBe(false)
})
})
describe('R-U — urgence', () => {
it('conservatoire : route sous-jacente calculée sans urgence, fenêtre doublée, chaîne posée', () => {
// Sans urgence, ce cas serait R4 (easy, 3 ≤ 5, fenêtre 72 h).
const verdict = triage(
mkInput({ urgent: true, reversibility: 'easy' }),
mkCtx(),
mkSettings(),
)
expect(verdict.rule).toBe('R-U')
expect(verdict.route).toBe('advice')
expect(verdict.conservatoryChain).toBe(true)
expect(verdict.windowHours).toBe(144)
expect(verdict.explanation).toBe('Décidé en urgence — le collectif ratifie.')
})
it('urgence × irréversible : refus — route normale, fenêtres minimales (jamais doublées)', () => {
// Sans urgence, ce cas serait R0b (mandat couvrant, fenêtre 48 h).
const verdict = triage(
mkInput({ urgent: true, reversibility: 'irreversible', scope: scoped(['c1']) }),
mkCtx({ myActiveMandates: [mkMandate()] }),
mkSettings(),
)
expect(verdict.rule).toBe('R-U')
expect(verdict.route).toBe('mandate')
expect(verdict.explanation).toBe(URGENT_REFUSED)
expect(verdict.conservatoryChain).toBeUndefined()
expect(verdict.windowHours).toBe(48)
})
})
describe('R0a — déjà décidé', () => {
it('clause en vigueur trouvée : consigner en principal, agir ou contester', () => {
const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())
expect(verdict.rule).toBe('R0a')
expect(verdict.route).toBe('record')
expect(verdict.explanation).toBe(
'C\'est déjà décidé (E9, Acte Forgeron) — agis, ou conteste la règle.',
)
expect(verdict.windowHours).toBeUndefined()
})
it('limite : viser explicitement la clause (amendsClauseId) passe à R3, pas R0a', () => {
// Choix documenté : contester la règle EST R3 — R0a ne bloque pas l'amendement.
const verdict = triage(
mkInput({ amendsClauseId: 'cl1' }),
mkCtx({ matchingClauses: [mkClause()] }),
mkSettings(),
)
expect(verdict.rule).toBe('R3')
expect(verdict.route).toBe('collective')
})
})
describe('R0b — mon mandat couvre', () => {
it('périmètre inclus dans le domaine du mandat : décide, c\'est tracé', () => {
const verdict = triage(
mkInput({ scope: scoped(['c1']) }),
mkCtx({ myActiveMandates: [mkMandate()] }),
mkSettings(),
)
expect(verdict.rule).toBe('R0b')
expect(verdict.route).toBe('mandate')
expect(verdict.windowHours).toBe(48)
expect(verdict.explanation).toBe('Ton mandat Modération couvre — décide, c\'est tracé.')
})
it('limite : couverture partielle du périmètre ne suffit pas', () => {
const verdict = triage(
mkInput({ scope: scoped(['c1', 'c3']) }),
mkCtx({ myActiveMandates: [mkMandate({ domain: { circleIds: ['c1', 'c2'], tags: [] } })] }),
mkSettings(),
)
expect(verdict.rule).not.toBe('R0b')
})
it('ordre : selfOnly avec mandat couvrant ⇒ R0b gagne, car R0b précède R2', () => {
// Choix documenté : le devoir de trace du mandat prime sur le solo.
const verdict = triage(
mkInput({ scope: { selfOnly: true, circleIds: ['c1'], personIds: [] } }),
mkCtx({ myActiveMandates: [mkMandate()] }),
mkSettings(),
)
expect(verdict.rule).toBe('R0b')
expect(verdict.route).toBe('mandate')
})
it('limite : périmètre sans cercle ⇒ jamais de couverture triviale', () => {
const verdict = triage(
mkInput({ scope: scoped([]) }),
mkCtx({ myActiveMandates: [mkMandate()] }),
mkSettings(),
)
expect(verdict.rule).not.toBe('R0b')
})
})
describe('R0c — le mandat d\'un autre couvre', () => {
it('transmets à sa ou son titulaire — le titre du mandat nomme le pouvoir', () => {
const verdict = triage(
mkInput({ scope: scoped(['c1']) }),
mkCtx({ otherActiveMandates: [mkMandate({ holderId: 'p-other' })] }),
mkSettings(),
)
expect(verdict.rule).toBe('R0c')
expect(verdict.route).toBe('transmit')
expect(verdict.explanation).toBe(
'Le mandat Modération couvre — transmets à sa ou son titulaire.',
)
})
it('ordre : mon mandat gagne sur celui d\'un autre (R0b avant R0c)', () => {
const verdict = triage(
mkInput({ scope: scoped(['c1']) }),
mkCtx({
myActiveMandates: [mkMandate({ id: 'm-me', title: 'Trésorerie' })],
otherActiveMandates: [mkMandate({ id: 'm-other', holderId: 'p-other' })],
}),
mkSettings(),
)
expect(verdict.rule).toBe('R0b')
})
it('limite : un mandat expiré ne couvre pas', () => {
const verdict = triage(
mkInput({ scope: scoped(['c1']) }),
mkCtx({ otherActiveMandates: [mkMandate({ status: 'expired' })] }),
mkSettings(),
)
expect(verdict.rule).not.toBe('R0c')
})
})
describe('R2 — moi seul', () => {
it('selfOnly : décide, zéro fenêtre', () => {
const verdict = triage(
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
mkCtx({ computedConcernedIds: [] }),
mkSettings(),
)
expect(verdict.rule).toBe('R2')
expect(verdict.route).toBe('solo')
expect(verdict.explanation).toBe('Personne d\'autre n\'est concerné — décide.')
expect(verdict.windowHours).toBeUndefined()
expect(verdict.reviewRequired).toBe(false)
})
it('limite : irréversible sur soi ⇒ revoyure suggérée (pré-coche retirable côté UI)', () => {
// Le moteur suggère (reviewRequired) ; la pré-coche retirable est un fait
// d'UI — sur soi, l'outil ne s'interpose jamais.
const verdict = triage(
mkInput({
scope: { selfOnly: true, circleIds: [], personIds: [] },
reversibility: 'irreversible',
}),
mkCtx({ computedConcernedIds: [] }),
mkSettings(),
)
expect(verdict.rule).toBe('R2')
expect(verdict.reviewRequired).toBe(true)
})
})
describe('R3 — clause visée', () => {
const clauseSettings = mkSettings({
protocolByRange: {
clauseByInertia: {
low: 'proto-low',
standard: 'proto-standard',
high: 'proto-high',
max: 'proto-max',
},
},
})
it('l\'inertie de la clause choisit le protocole', () => {
const verdict = triage(
mkInput({ amendsClauseId: 'cl1' }),
mkCtx({ matchingClauses: [mkClause({ inertia: 'high' })] }),
clauseSettings,
)
expect(verdict.rule).toBe('R3')
expect(verdict.route).toBe('collective')
expect(verdict.protocolId).toBe('proto-high')
expect(verdict.explanation).toBe(
'Tu proposes une version de E9 — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
)
})
it('repli : sans clauseByInertia, le consentement s\'applique', () => {
const verdict = triage(
mkInput({ amendsClauseId: 'cl1' }),
mkCtx({ matchingClauses: [mkClause()] }),
mkSettings(),
)
expect(verdict.protocolId).toBe('proto-consent')
})
it('poids structurel ⇒ fenêtre de formulation posée', () => {
const verdict = triage(
mkInput({ amendsClauseId: 'cl1', weight: 'structural' }),
mkCtx({ matchingClauses: [mkClause()] }),
clauseSettings,
)
expect(verdict.framingDays).toBe(14)
})
it('limite : clause introuvable dans l\'index ⇒ consentement et phrase générique', () => {
const verdict = triage(
mkInput({ amendsClauseId: 'cl-inconnue' }),
mkCtx({ matchingClauses: [] }),
clauseSettings,
)
expect(verdict.rule).toBe('R3')
expect(verdict.protocolId).toBe('proto-consent')
expect(verdict.explanation).toBe(
'Tu proposes une version de cette règle — son inertie s\'applique : vote de ceux qu\'elle gouverne.',
)
})
})
describe('R4 — petit cercle réversible', () => {
it('réversible et 3 personnes : demande leur avis puis décide (72 h)', () => {
const verdict = triage(mkInput({ reversibility: 'easy' }), mkCtx(), mkSettings())
expect(verdict.rule).toBe('R4')
expect(verdict.route).toBe('advice')
expect(verdict.windowHours).toBe(72)
expect(verdict.explanation).toBe(
'Réversible et 3 personnes concernées — demande leur avis puis décide.',
)
})
it('limite : accord du singulier pour 1 personne', () => {
const verdict = triage(
mkInput({ reversibility: 'easy' }),
mkCtx({ computedConcernedIds: ['p1'] }),
mkSettings(),
)
expect(verdict.explanation).toBe(
'Réversible et 1 personne concernée — demande leur avis puis décide.',
)
})
it('limite : au-delà de smallGroupMax, l\'escalade collective s\'applique', () => {
const verdict = triage(
mkInput({ reversibility: 'easy' }),
mkCtx({ computedConcernedIds: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] }),
mkSettings(),
)
expect(verdict.rule).toBe('R5')
})
it('limite : coûteux à défaire ⇒ jamais R4, même à 3 personnes', () => {
const verdict = triage(mkInput({ reversibility: 'costly' }), mkCtx(), mkSettings())
expect(verdict.rule).toBe('R5')
})
})
describe('R5 — collectif, modalité par taille du périmètre', () => {
const ids = (n: number): string[] => Array.from({ length: n }, (_, i) => `p${i}`)
const fullSettings = mkSettings({
protocolByRange: { nuanced: 'proto-nuanced', large: 'proto-large' },
})
it('n ≤ consentMax : un tour d\'accord suffit', () => {
const verdict = triage(
mkInput(),
mkCtx({ computedConcernedIds: ids(6) }),
fullSettings,
)
expect(verdict.rule).toBe('R5')
expect(verdict.route).toBe('collective')
expect(verdict.protocolId).toBe('proto-consent')
expect(verdict.explanation).toBe(
'Vous êtes 6 — un tour d\'accord suffit : sans objection, c\'est adopté.',
)
})
it('n ≤ collectiveMin : vote nuancé', () => {
const verdict = triage(
mkInput(),
mkCtx({ computedConcernedIds: ids(23) }),
fullSettings,
)
expect(verdict.protocolId).toBe('proto-nuanced')
expect(verdict.explanation).toBe(
'Vous êtes 23 — vote nuancé : chacun se prononce en nuances, pas en camps.',
)
})
it('repli : sans protocole nuancé, le consentement s\'applique', () => {
const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(23) }), mkSettings())
expect(verdict.protocolId).toBe('proto-consent')
})
it('n > collectiveMin : la modalité choisie par le Pacte', () => {
const verdict = triage(
mkInput(),
mkCtx({ computedConcernedIds: ids(60) }),
fullSettings,
)
expect(verdict.protocolId).toBe('proto-large')
expect(verdict.explanation).toBe(
'Vous êtes 60 — la modalité que votre Pacte a choisie s\'applique.',
)
})
it('repli : sans protocole large, le consentement s\'applique', () => {
const verdict = triage(mkInput(), mkCtx({ computedConcernedIds: ids(60) }), mkSettings())
expect(verdict.protocolId).toBe('proto-consent')
})
it('poids structurel ⇒ formulation + revoyure + gravure suggérée', () => {
const verdict = triage(
mkInput({ weight: 'structural' }),
mkCtx({ computedConcernedIds: ids(6) }),
fullSettings,
)
expect(verdict.framingDays).toBe(14)
expect(verdict.reviewRequired).toBe(true)
expect(verdict.engravingSuggested).toBe(true)
})
it('irréversible ⇒ revoyure exigée, sans gravure automatique', () => {
const verdict = triage(
mkInput({ reversibility: 'irreversible' }),
mkCtx({ computedConcernedIds: ids(6) }),
fullSettings,
)
expect(verdict.reviewRequired).toBe(true)
expect(verdict.engravingSuggested).toBe(false)
})
})
describe('R6 — récurrence et maturation (surcouche, jamais bloquante)', () => {
it('3 décisions similaires récentes ⇒ suggère de réclamer un mandat, sans changer la route', () => {
const verdict = triage(
mkInput({ tags: ['cuisine', 'achats'] }),
mkCtx({ similarRecentCount: 3 }),
mkSettings(),
)
expect(verdict.rule).toBe('R5')
expect(verdict.suggestion?.kind).toBe('claim-mandate')
})
it('3 consignations similaires ⇒ suggère de protocoliser la pratique', () => {
const verdict = triage(mkInput(), mkCtx({ similarRecordedCount: 3 }), mkSettings())
expect(verdict.suggestion?.kind).toBe('protocolize')
})
it('les deux seuils atteints ⇒ réclamer un mandat passe en premier', () => {
const verdict = triage(
mkInput(),
mkCtx({ similarRecentCount: 3, similarRecordedCount: 3 }),
mkSettings(),
)
expect(verdict.suggestion?.kind).toBe('claim-mandate')
})
it('sous le seuil ⇒ aucune suggestion', () => {
const verdict = triage(
mkInput(),
mkCtx({ similarRecentCount: 2, similarRecordedCount: 2 }),
mkSettings(),
)
expect(verdict.suggestion).toBeUndefined()
})
})
describe('Repli sans protocole de consentement (bundle corrompu)', () => {
const broken = mkSettings({ protocolByRange: { consent: '' } })
it('route sur avis avec la bannière — prioritaire même sur un mandat couvrant', () => {
const verdict = triage(
mkInput({ scope: scoped(['c1']) }),
mkCtx({ myActiveMandates: [mkMandate()] }),
broken,
)
expect(verdict.route).toBe('advice')
expect(verdict.explanation).toBe('Aucun protocole — crée-le ou décide sur avis.')
expect(verdict.windowHours).toBe(72)
})
it('sauf R2 : moi seul décide sans protocole', () => {
const verdict = triage(
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
mkCtx(),
broken,
)
expect(verdict.rule).toBe('R2')
expect(verdict.route).toBe('solo')
})
it('sauf consigner : une clause en vigueur reste consignable sans protocole', () => {
const verdict = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), broken)
expect(verdict.rule).toBe('R0a')
expect(verdict.route).toBe('record')
})
})
describe('Alternatives permanentes — « Je choisis autrement »', () => {
it('toujours présentes : réglage, consigner, binaire avec son coût', () => {
const verdict = triage(mkInput(), mkCtx(), mkSettings())
expect(verdict.alternatives).toHaveLength(3)
const labels = verdict.alternatives.map(a => a.label)
expect(labels).toContain(PARAMETRIC_ALT)
expect(labels).toContain(RECORD_ALT)
const binary = verdict.alternatives.find(a => a.cost === BINARY_COST)
expect(binary).toBeDefined()
expect(binary?.route).toBe('collective')
})
it('présentes sur toutes les routes, solo et consigner comprises', () => {
const solo = triage(
mkInput({ scope: { selfOnly: true, circleIds: [], personIds: [] } }),
mkCtx(),
mkSettings(),
)
const record = triage(mkInput(), mkCtx({ matchingClauses: [mkClause()] }), mkSettings())
expect(solo.alternatives).toHaveLength(3)
expect(record.alternatives).toHaveLength(3)
})
it('montant détecté ⇒ le réglage passe en premier et parametricHint est posé', () => {
const verdict = triage(
mkInput({ title: 'Fixer la cotisation à 25 €' }),
mkCtx(),
mkSettings(),
)
expect(verdict.parametricHint).toBe(true)
expect(verdict.alternatives[0]?.label).toBe(PARAMETRIC_ALT)
})
it('sans montant ⇒ pas de mise en avant du réglage', () => {
const verdict = triage(mkInput(), mkCtx(), mkSettings())
expect(verdict.parametricHint).toBeUndefined()
expect(verdict.alternatives[0]?.label).not.toBe(PARAMETRIC_ALT)
})
})
+142
View File
@@ -0,0 +1,142 @@
/**
* Anti-lexicon guard (BLUEPRINT-V2.md Δ22).
*
* Two sweeps, both case- and accent-insensitive (NFD normalization):
* 1. Every UI-visible VALUE exported by app/lexicon.ts — strings, Record
* values, array items, and the return of template functions called with
* dummy params. Export NAMES and Record KEYS are code identifiers and
* are NOT tested (e.g. REVIEW_VERDICTS is a legal identifier even
* though « verdict » is forbidden in UI text).
* 2. The <template> section of every MARKED .vue file under app/.
* A file is scanned only when it carries the magic marker comment
* <!-- ld-v2 -->. EVERY v2 screen MUST carry this marker (the screen
* agents add it); unmarked v1 leftovers are ignored on purpose — they
* are scheduled for replacement, not for compliance.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import * as lexicon from '../app/lexicon'
import { FORBIDDEN_UI_TERMS } from '../app/lexicon'
const APP_DIR = fileURLToPath(new URL('../app', import.meta.url))
/** Lowercase + strip diacritics: « Délégué » → « delegue ». */
function normalize(text: string): string {
return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
const NORMALIZED_TERMS = FORBIDDEN_UI_TERMS.map(term => ({ term, needle: normalize(term) }))
/** Forbidden terms contained in a text, after normalization. */
function forbiddenTermsIn(text: string): string[] {
const haystack = normalize(text)
return NORMALIZED_TERMS.filter(({ needle }) => haystack.includes(needle)).map(({ term }) => term)
}
interface Violation {
source: string // export path or file path
term: string
excerpt: string
}
/** Call a template function with dummy params (numbers interpolate cleanly
* into template literals AND support toLocaleString; fall back to neutral
* strings for functions that require them). */
function callTemplateFn(fn: (...args: never[]) => unknown): unknown {
const arity = Math.max(fn.length, 1)
try {
return fn(...(Array.from({ length: arity }, (_, i) => i + 1) as never[]))
} catch {
return fn(...(Array.from({ length: arity }, () => 'exemple') as never[]))
}
}
/** Recursively collect forbidden-term violations over exported VALUES only. */
function walkValue(value: unknown, path: string, violations: Violation[]): void {
if (typeof value === 'string') {
for (const term of forbiddenTermsIn(value)) {
violations.push({ source: path, term, excerpt: value })
}
return
}
if (Array.isArray(value)) {
value.forEach((item, i) => walkValue(item, `${path}[${i}]`, violations))
return
}
if (typeof value === 'function') {
walkValue(callTemplateFn(value as (...args: never[]) => unknown), `${path}(…)`, violations)
return
}
if (value !== null && typeof value === 'object') {
// Record: VALUES only — keys are code identifiers.
for (const [key, item] of Object.entries(value)) {
walkValue(item, `${path}.${key}`, violations)
}
}
// numbers / booleans / null / undefined: nothing to check
}
/** Recursive .vue listing under dir. */
function listVueFiles(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) files.push(...listVueFiles(full))
else if (entry.isFile() && entry.name.endsWith('.vue')) files.push(full)
}
return files
}
/**
* Scan the <template> section of every .vue file under `dir` that carries
* the magic marker comment (default <!-- ld-v2 -->). The whole template is
* scanned on purpose — the banned vocabulary must appear NOWHERE in it,
* bound attributes included. Every v2 screen MUST carry the marker.
*/
export function scanVueTemplates(dir: string, marker = 'ld-v2'): Violation[] {
const markerRe = new RegExp(`<!--\\s*${marker}\\s*-->`)
const violations: Violation[] = []
for (const file of listVueFiles(dir)) {
const content = readFileSync(file, 'utf-8')
if (!markerRe.test(content)) continue
const start = content.indexOf('<template')
const end = content.lastIndexOf('</template>')
if (start === -1 || end === -1) continue
const template = content.slice(start, end + '</template>'.length)
for (const term of forbiddenTermsIn(template)) {
violations.push({ source: file, term, excerpt: `terme présent dans <template>` })
}
}
return violations
}
function formatReport(violations: Violation[]): string {
return violations
.map(v => `${v.source} → terme interdit « ${v.term} » : ${v.excerpt}`)
.join('\n')
}
describe('anti-lexique — lexicon.ts', () => {
it('exports at least the known label tables (sanity check of the walk)', () => {
expect(Object.keys(lexicon).length).toBeGreaterThan(10)
expect(typeof lexicon.thresholdSentence).toBe('function')
})
it('no exported UI value contains a forbidden term', () => {
const violations: Violation[] = []
for (const [name, value] of Object.entries(lexicon)) {
if (name === 'FORBIDDEN_UI_TERMS') continue // the ban list itself
walkValue(value, name, violations)
}
expect(formatReport(violations)).toBe('')
})
})
describe('anti-lexique — marked .vue templates (<!-- ld-v2 -->)', () => {
it('no marked template contains a forbidden term', () => {
const violations = scanVueTemplates(APP_DIR)
expect(formatReport(violations)).toBe('')
})
})
@@ -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)
}
})
})
@@ -0,0 +1,625 @@
/**
* Store-layer specs — the decisions façade + the seven templates.
*
* Plain vitest + createPinia (no Nuxt runtime): the stores import defineStore
* from 'pinia' and the engines/persistence relatively, so they run here as-is.
* idb-keyval is mocked with an in-memory Map.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('idb-keyval', () => {
const store = new Map<string, unknown>()
return {
get: async (key: string) => store.get(key),
set: async (key: string, value: unknown) => void store.set(key, value),
del: async (key: string) => void store.delete(key),
keys: async () => [...store.keys()],
}
})
import { useCollectiveStore } from '../../app/stores/collective'
import { useDecisionsStore } from '../../app/stores/decisions'
import type { Refusal } from '../../app/stores/decisions'
import { buildTemplateBundle, TEMPLATE_CARDS } from '../../app/data/templates'
import type { TemplateId } from '../../app/data/templates'
import { validateBundle } from '../../app/data/persistence'
import { hasConsentProtocol, resolveSettings, windowOutcome } from '../../app/engine'
import type {
Decision,
Mandate,
ParamSpec,
Verdict,
VoteSession,
Vote,
} from '../../app/types/domain'
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
const MEMBERS = ['Alice', 'Bakir', 'Chloé', 'Dara']
function isRefusal(value: unknown): value is Refusal {
return (
typeof value === 'object'
&& value !== null
&& 'ok' in value
&& (value as { ok: unknown }).ok === false
)
}
/** Narrow an `X | Refusal` return — throws the French reason on refusal. */
function ok<T>(value: T | Refusal): T {
if (isRefusal(value)) throw new Error(value.reason)
return value
}
async function setup(template: TemplateId = 'association') {
setActivePinia(createPinia())
const col = useCollectiveStore()
const store = useDecisionsStore()
const result = await col.createFromTemplate(template, {
name: 'Le Test',
slug: `test-${crypto.randomUUID().slice(0, 8)}`,
color: '#3b82f6',
icon: 'i-lucide-users',
meName: 'Yvv',
memberNames: MEMBERS,
})
expect(result.state).toBeDefined()
const me = col.me!
const root = col.circles.find(c => c.id === col.current!.collective.rootCircleId)!
const person = (name: string) => col.people.find(p => p.displayName === name)!
const protocolByMethod = (method: string) =>
col.protocols.find(p => p.method === method)!
return { col, store, me, root, person, protocolByMethod }
}
const buildOpts = (slug: string) => ({
name: 'Gabarit',
slug,
color: '#0ea5e9',
icon: 'i-lucide-users',
meName: 'Yvv',
memberNames: MEMBERS,
now: '2026-08-11T10:00:00.000Z',
newId: () => crypto.randomUUID(),
})
// ─────────────────────────────────────────────────────────────
// Solo — 2 gestures, adopted
// ─────────────────────────────────────────────────────────────
describe('solo path', () => {
it('capture → triage → applyPath adopts in two mutations', async () => {
const { store } = await setup()
const decision = ok(store.capture('Acheter une bouilloire pour le local'))
expect(decision.status).toBe('draft')
expect(decision.scope.selfOnly).toBe(true)
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'easy',
weight: 'light',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('solo')
expect(verdict.rule).toBe('R2')
const adopted = ok(store.applyPath(decision, verdict))
expect(adopted.status).toBe('adopted')
expect(adopted.decidedAt).toBeDefined()
expect(adopted.windowEndsAt).toBeUndefined()
})
it('extracts #tags at capture — simple', async () => {
const { store } = await setup()
const decision = ok(store.capture('Réparer le vidéoprojecteur #matériel #salle'))
expect(decision.tags).toEqual(['matériel', 'salle'])
})
})
// ─────────────────────────────────────────────────────────────
// Mandate — window, Assent, windowOutcome
// ─────────────────────────────────────────────────────────────
describe('mandate path', () => {
it('routes under my covering mandate, opens the window, adopts on explicit assent', async () => {
const { col, store, me, root, person } = await setup()
const now = col.now()
const mandate: Mandate = {
id: col.newId(),
collectiveId: col.current!.collective.id,
createdAt: now,
updatedAt: now,
title: 'Intendance du local',
holderId: me.id,
originDecisionId: col.newId(),
domain: { circleIds: [root.id], tags: [] },
startsAt: now,
endsAt: '2100-01-01T00:00:00.000Z',
electorCircleId: root.id,
nominationMethod: 'consent',
reports: [],
status: 'active',
}
col.current!.mandates.push(mandate)
const decision = ok(store.capture('Remplacer la serrure du local'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'binding',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('mandate')
expect(verdict.rule).toBe('R0b')
expect(verdict.windowHours).toBe(48)
// The tunnel's chips travel through edits (the verdict carries none).
ok(store.applyPath(decision, verdict, { reversibility: 'costly', weight: 'binding' }))
expect(decision.status).toBe('objection')
expect(decision.underMandateId).toBe(mandate.id)
expect(decision.windowEndsAt).toBeDefined()
// Computed concerns: the four members (the author is excluded).
const concerns = col.concerns.filter(c => c.decisionId === decision.id)
expect(concerns).toHaveLength(4)
expect(concerns.every(c => c.origin === 'computed' && c.beforeSnapshot)).toBe(true)
// Costly ⇒ never adopted by pure silence: extend, until a third-party assent.
const ctx = ok(store.transitionContext(decision.id))
expect(windowOutcome(decision, ctx)).toBe('extend')
ok(store.assentTo(decision.id, person('Alice').id))
const ctxAfter = ok(store.transitionContext(decision.id))
expect(windowOutcome(decision, ctxAfter)).toBe('adopt')
const moved = store.transition(decision.id, 'adopted')
expect(moved.ok).toBe(true)
expect(decision.status).toBe('adopted')
})
})
// ─────────────────────────────────────────────────────────────
// Collective consent — objection blocks adoption
// ─────────────────────────────────────────────────────────────
describe('collective consent path', () => {
it('opens a session, and a maintained objection prevents adoption', async () => {
const { col, store, root, person } = await setup()
const decision = ok(store.capture('Organiser une fête de quartier au local'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'light',
urgent: false,
}),
) as Verdict
expect(verdict.route).toBe('collective')
expect(verdict.rule).toBe('R5')
ok(store.applyPath(decision, verdict))
expect(decision.status).toBe('voting')
const session = col.sessions.find(s => s.decisionId === decision.id)!
// Arrested list: 4 computed concerned + the author.
expect(session.corpusSize).toBe(5)
expect(session.status).toBe('open')
ok(store.objectTo(decision.id, 'content', 'La cour est trop petite.', person('Bakir').id))
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('rejected')
expect(decision.status).not.toBe('adopted')
})
it('boundary objection suspends the window countdown', async () => {
const { store, person } = await setup()
const decision = ok(store.capture('Changer le planning du ménage'))
decision.status = 'objection'
ok(store.objectTo(decision.id, 'boundary', 'Le cercle Cuisine a été oublié.', person('Chloé').id))
expect(decision.windowSuspendedAt).toBeDefined()
const refused = store.transition(decision.id, 'adopted')
expect(refused.ok).toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// Nuanced — full loop, clause application
// ─────────────────────────────────────────────────────────────
describe('nuanced session', () => {
it('votes, tallies, closes and applies the amended clause automatically', async () => {
const { col, store, root, person, protocolByMethod } = await setup()
const nuanced = protocolByMethod('nuanced')
const clauseA1 = col.clauses.find(c => c.code === 'A1')!
const oldVersionId = clauseA1.currentVersionId!
const decision = ok(store.capture('Donner une nouvelle boussole au collectif'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title,
tags: decision.tags,
scope: decision.scope,
reversibility: 'costly',
weight: 'light',
urgent: false,
}),
) as Verdict
ok(
store.applyPath(decision, verdict, {
protocolId: nuanced.id,
amendsClauseId: clauseA1.id,
body: 'La convivialité rejoint nos deux boussoles.',
}),
)
expect(decision.status).toBe('voting')
const session = col.sessions.find(s => s.decisionId === decision.id)!
// A negative nuance without comment is refused.
const refused = store.castVote(session.id, { value: 1 })
expect(isRefusal(refused)).toBe(true)
ok(store.castVote(session.id, { value: 4 }))
ok(store.castVote(session.id, { value: 5, asPersonId: person('Alice').id }))
ok(store.castVote(session.id, { value: 3, asPersonId: person('Bakir').id }))
const tallied = ok(store.tally(session))
if (tallied.method !== 'nuanced') throw new Error('bad method')
expect(tallied.result.total).toBe(3)
expect(tallied.result.adopted).toBe(true)
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('adopted')
expect(decision.status).toBe('adopted')
// Automatic application: new current version, old one superseded.
const newVersion = col.versions.find(
v => v.clauseId === clauseA1.id && v.status === 'current',
)!
expect(newVersion.decisionId).toBe(decision.id)
expect(newVersion.content).toBe('La convivialité rejoint nos deux boussoles.')
expect(clauseA1.currentVersionId).toBe(newVersion.id)
expect(col.versions.find(v => v.id === oldVersionId)!.status).toBe('superseded')
})
it('re-vote supersedes — only the last active vote counts', async () => {
const { col, store, root, protocolByMethod } = await setup()
const decision = ok(store.capture('Peindre la façade en ocre'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('nuanced').id }))
const session = col.sessions.find(s => s.decisionId === decision.id)!
const first = ok(store.castVote(session.id, { value: 2 })) as Vote
const second = ok(store.castVote(session.id, { value: 5 })) as Vote
expect(second.supersedesVoteId).toBe(first.id)
const active = store.activeVotes(session.id)
expect(active).toHaveLength(1)
expect(active[0]!.value).toBe(5)
})
})
// ─────────────────────────────────────────────────────────────
// Parametric — frozen, then the steward's gesture
// ─────────────────────────────────────────────────────────────
describe('parametric session', () => {
const spec: ParamSpec = {
params: [
{ key: 'ateliers', label: 'Ateliers', kind: 'share', min: 0, max: 100, step: 5, baseline: 40 },
{ key: 'materiel', label: 'Matériel', kind: 'share', min: 0, max: 100, step: 5, baseline: 30 },
{ key: 'reserve', label: 'Réserve', kind: 'share', min: 0, max: 100, step: 5, derived: true },
],
constraint: 'sum100',
}
async function openParametric() {
const context = await setup()
const { col, store, root, protocolByMethod, person } = context
const decision = ok(store.capture('Répartir le budget des ateliers'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(
store.applyPath(decision, verdict, {
protocolId: protocolByMethod('parametric').id,
paramSpec: spec,
stewardIds: [person('Alice').id],
}),
)
const session = col.sessions.find(s => s.decisionId === decision.id)!
return { ...context, decision, session }
}
it('validates votes, freezes at closure, refuses a non-steward, crystallizes by gesture', async () => {
const { store, person, me, decision, session } = await openParametric()
// Off-grid vote refused (step 5).
const bad = store.castVote(session.id, { values: [3, 30] })
expect(isRefusal(bad)).toBe(true)
ok(store.castVote(session.id, { values: [50, 30] }))
ok(store.castVote(session.id, { values: [40, 40], asPersonId: person('Alice').id }))
ok(store.castVote(session.id, { values: [60, 20], asPersonId: person('Bakir').id }))
// Parametric NEVER closes automatically: it freezes and waits.
const frozen = ok(store.closeSession(session))
expect(frozen.status).toBe('frozen')
expect(decision.status).toBe('voting')
// I am not a steward (Alice is) — the gesture is refused.
const refused = store.crystallize(session.id)
expect(isRefusal(refused) && refused.reason.includes('garant')).toBe(true)
// As a steward, the dated human gesture crystallizes the LOW median.
decision.stewardIds = [me.id]
const closed = ok(store.crystallize(session.id))
expect(closed.status).toBe('closed')
expect(closed.outcome).toBe('adopted')
expect(closed.crystallizedById).toBe(me.id)
expect(closed.crystallizedAt).toBeDefined()
expect(decision.status).toBe('adopted')
// Low medians: Ateliers 50, Matériel 30, Réserve derived 20.
expect(decision.body).toContain('Position cristallisée')
expect(decision.body).toContain('Ateliers : 50')
expect(decision.body).toContain('Réserve : 20')
})
it('quorum not reached ⇒ rejected, observed at the same gesture', async () => {
const { store, me, decision, session } = await openParametric()
ok(store.castVote(session.id, { values: [50, 30] })) // 1 < parametricMinParticipants 3
ok(store.closeSession(session))
decision.stewardIds = [me.id]
const closed = ok(store.crystallize(session.id))
expect(closed.outcome).toBe('rejected')
expect(decision.status).toBe('rejected')
})
})
// ─────────────────────────────────────────────────────────────
// Election — blank votes, tie left to humans
// ─────────────────────────────────────────────────────────────
describe('election session', () => {
it('accepts blanks, and a tie is NEVER broken by the tool', async () => {
const { col, store, root, person, protocolByMethod } = await setup()
const decision = ok(store.capture('Confier la trésorerie'))
decision.scope = { selfOnly: false, circleIds: [root.id], personIds: [] }
const verdict = ok(
store.runTriage({
title: decision.title, tags: [], scope: decision.scope,
reversibility: 'costly', weight: 'light', urgent: false,
}),
) as Verdict
ok(store.applyPath(decision, verdict, { protocolId: protocolByMethod('election').id }))
const session = col.sessions.find(s => s.decisionId === decision.id)!
ok(store.castVote(session.id, { choicePersonId: person('Alice').id }))
ok(store.castVote(session.id, {
choicePersonId: person('Chloé').id, asPersonId: person('Bakir').id,
}))
// A deposited vote WITHOUT designation is a BLANK — participation only.
const blank = ok(store.castVote(session.id, { asPersonId: person('Dara').id })) as Vote
expect(blank.choicePersonId).toBeUndefined()
const closed = ok(store.closeSession(session))
expect(closed.outcome).toBe('tie')
// The decision waits for the HUMAN runoff — never adopted by the engine.
expect(decision.status).toBe('voting')
const tallied = ok(store.tally(session))
if (tallied.method !== 'election') throw new Error('bad method')
if (tallied.result.outcome !== 'tie') throw new Error('expected tie')
expect(tallied.result.blanks).toBe(1)
expect(tallied.result.participants).toBe(3)
})
})
// ─────────────────────────────────────────────────────────────
// L'épreuve du réel — revise chains a child
// ─────────────────────────────────────────────────────────────
describe('review', () => {
it("'revise' chains a pre-filled child under the original protocol", async () => {
const { col, store } = await setup()
const decision = ok(store.capture('Vendre la vieille imprimante'))
const verdict = ok(
store.runTriage({
title: decision.title, tags: decision.tags, scope: decision.scope,
reversibility: 'irreversible', weight: 'light', urgent: false,
}),
) as Verdict
expect(verdict.reviewRequired).toBe(true)
ok(store.applyPath(decision, verdict))
expect(decision.status).toBe('adopted')
expect(decision.review?.dueAt).toBeDefined()
const child = ok(store.reviewVerdict(decision.id, 'revise', 'Elle sert encore.')) as Decision
expect(decision.review?.verdict).toBe('revise')
expect(child.parentDecisionId).toBe(decision.id)
expect(child.chainKind).toBe('revision')
expect(child.status).toBe('draft')
expect(child.title).toBe(`Réviser — ${decision.title}`)
expect(col.decisions.some(d => d.id === child.id)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// Templates — the seven founding bundles
// ─────────────────────────────────────────────────────────────
describe('templates', () => {
it('exposes the seven cards, blank first, standards re-labelled', () => {
expect(TEMPLATE_CARDS).toHaveLength(7)
expect(TEMPLATE_CARDS[0]!.id).toBe('blank')
expect(TEMPLATE_CARDS[0]!.subtitle).toContain('observatoire')
for (const id of ['informal', 'association', 'cooperative', 'commune', 'free-currency']) {
const card = TEMPLATE_CARDS.find(c => c.id === id)!
expect(card.subtitle).toBe('point de départ — tout est amendable par décision')
}
expect(TEMPLATE_CARDS.find(c => c.id === 'symmetric')!.title).toBe('Institution symétrique')
})
it('blank passes validateBundle and satisfies the consent invariant', () => {
const bundle = buildTemplateBundle('blank', buildOpts('page-blanche'))
const { bundle: validated, issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
expect(validated).toBeDefined()
expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
// Consent ONLY — the invariant satisfied, everything else falls back.
expect(bundle.protocols).toHaveLength(1)
expect(bundle.protocols[0]!.method).toBe('consent')
const settings = resolveSettings(bundle.clauses, bundle.versions)
expect(hasConsentProtocol(settings)).toBe(true)
expect(settings.triage.requireEffects).toBe('none')
expect(settings.protocolByRange.large).toBeUndefined()
})
it('builds the seven templates with every clause founded by an adopted decision', () => {
const cards: TemplateId[] = TEMPLATE_CARDS.map(c => c.id)
for (const id of cards) {
const bundle = buildTemplateBundle(id, buildOpts(`t-${id}`))
const { issues } = validateBundle(JSON.parse(JSON.stringify(bundle)))
expect(issues.filter(i => i.level === 'error')).toHaveLength(0)
// Every clause: a 'current' version born of an ADOPTED founding decision.
for (const clause of bundle.clauses) {
expect(clause.currentVersionId).toBeDefined()
const version = bundle.versions.find(v => v.id === clause.currentVersionId)!
expect(version.status).toBe('current')
const foundingDecision = bundle.decisions.find(d => d.id === version.decisionId)!
expect(foundingDecision.status).toBe('adopted')
}
// The first proposed decision, in the infinitive.
expect(bundle.decisions.some(
d => d.title === 'Adopter notre Pacte' && d.status === 'draft' && d.route === 'collective',
)).toBe(true)
const settings = resolveSettings(bundle.clauses, bundle.versions)
expect(hasConsentProtocol(settings)).toBe(true)
}
})
it('wires protocolByRange.large: nuanced for standards, inertial binary for free-currency', () => {
const assoc = buildTemplateBundle('association', buildOpts('assoc'))
const assocSettings = resolveSettings(assoc.clauses, assoc.versions)
const assocLarge = assoc.protocols.find(p => p.id === assocSettings.protocolByRange.large)!
expect(assocLarge.method).toBe('nuanced')
expect(assocSettings.triage.requireEffects).toBe('binding')
const g1 = buildTemplateBundle('free-currency', buildOpts('g1'))
const g1Settings = resolveSettings(g1.clauses, g1.versions)
const g1Large = g1.protocols.find(p => p.id === g1Settings.protocolByRange.large)!
expect(g1Large.method).toBe('binary')
expect(g1Large.modeParams).toBe('D30M50B.1G.2')
expect(g1Large.description).toContain('héritage Toile de Confiance')
const informal = buildTemplateBundle('informal', buildOpts('informel'))
expect(resolveSettings(informal.clauses, informal.versions).triage.requireEffects).toBe('none')
})
it('symmetric seeds the declaration and the founding draft decision', () => {
const bundle = buildTemplateBundle('symmetric', buildOpts('sym'))
expect(bundle.collective.isTransparent).toBe(true)
expect(bundle.docs.some(d => d.role === 'reference' && d.title === 'Déclaration de symétrie')).toBe(true)
expect(bundle.decisions.some(
d => d.title === 'Déclarer notre symétrie' && d.status === 'draft',
)).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// Collective store — import path & refusals
// ─────────────────────────────────────────────────────────────
describe('collective store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('refuses an import colliding with an existing collective id', async () => {
const col = useCollectiveStore()
const bundle = buildTemplateBundle('blank', buildOpts('collision'))
const first = await col.importJson(JSON.stringify(bundle), true)
expect(first.state).toBeDefined()
const second = await col.importJson(JSON.stringify(bundle), true)
expect(second.collided).toBe(true)
expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true)
})
it('switches to a remaining collective when the active one is removed', async () => {
const col = useCollectiveStore()
const first = await col.importJson(
JSON.stringify(buildTemplateBundle('blank', buildOpts(`reste-${crypto.randomUUID().slice(0, 8)}`))), true)
const second = await col.importJson(
JSON.stringify(buildTemplateBundle('blank', buildOpts(`actif-${crypto.randomUUID().slice(0, 8)}`))), true)
expect(col.activeId).toBe(second.state!.collective.id)
await col.removeCollective(second.state!.collective.id)
// Jamais d'accueil orphelin : le collectif restant devient l'actif.
expect(col.activeId).toBe(first.state!.collective.id)
expect(col.current?.collective.id).toBe(first.state!.collective.id)
await col.removeCollective(first.state!.collective.id)
expect(col.activeId).toBeNull()
expect(col.index.length).toBe(0)
})
it('resolves settings and the me profile from the active state', async () => {
const col = useCollectiveStore()
await col.createFromTemplate('commune', {
name: 'La Commune', slug: `commune-${crypto.randomUUID().slice(0, 8)}`,
color: '#16a34a', icon: 'i-lucide-landmark',
meName: 'Yvv', memberNames: MEMBERS,
})
expect(col.me?.displayName).toBe('Yvv')
expect(col.hasConsent).toBe(true)
expect(col.settings?.triage.requireEffects).toBe('binding')
expect(col.pactDoc?.description).toBe('Notre contrat social — sacralisé, jamais immuable')
expect(col.exportJson()).toContain('"schemaVersion": 2')
})
it('actions refuse politely without an active collective', () => {
const store = useDecisionsStore()
const result = store.capture('Sans collectif')
expect(isRefusal(result)).toBe(true)
})
it('loads the Atelier du Canal seed through the same import path', async () => {
const col = useCollectiveStore()
const result = await col.loadSeed('atelier-du-canal')
// Idempotence guard: an earlier test may have already imported it — a
// collision is then the expected polite refusal, never a crash.
if (result.state) {
expect(col.current?.collective.slug).toBe('atelier-du-canal')
expect(col.hasConsent).toBe(true)
} else {
expect(result.collided).toBe(true)
}
})
})