Files
decision/frontend/tests/engine/triage.spec.ts
T
YvvandClaude Fable 5 f707b5f15d 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>
2026-08-11 09:22:19 +02:00

609 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
})
})