- 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>
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
/**
|
|
* 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())
|
|
})
|
|
})
|