/** * 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 { return { id: 'c1', collectiveId: 'col', createdAt: T0, updatedAt: T0, name: 'Forgerons', purpose: '', memberIds: [], domains: [], ...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'], tags: [] }, startsAt: T0, endsAt: T9, electorCircleId: 'c1', nominationMethod: 'consent', reports: [], status: 'active', ...over, } } function mkScope(over: Partial = {}): 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()) }) })