/** * 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 protocolByRange?: Partial } = {}): 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 { return { title: 'Repeindre la salle', tags: [], scope: { selfOnly: false, circleIds: [], personIds: [] }, reversibility: 'costly', weight: 'light', urgent: false, ...over, } } function mkCtx(over: Partial = {}): TriageContext { return { myActiveMandates: [], otherActiveMandates: [], matchingClauses: [], similarRecentCount: 0, similarRecordedCount: 0, computedConcernedIds: ['p1', 'p2', 'p3'], ...over, } } function mkMandate(over: Partial = {}): 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 { 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) }) })