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
+158
View File
@@ -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())
})
})