v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal

- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+608
View File
@@ -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)
})
})