/** * existing.test.ts — near-duplicates, cross-app look-alikes, modify resolution. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadScopeCorpus } from '../corpus.js' import { canonicalPortee, existingActors, existingPermissions, existingRules, existingUseCases, jaccard, normalizeTitle, titleTokens } from '../existing.js' import { UC_PREFIX, writeChangeCorpus } from './fixture.js' let tmp: string let root: string beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'ba-change-existing-')) root = join(tmp, 'ba') writeChangeCorpus(root) }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) const corpus = () => loadScopeCorpus({ baRoot: root, app: 'CRM', module: 'PIPELINE', section: 'opportunites' }) describe('normalisation', () => { it('folds case, accents and punctuation', () => { expect(normalizeTitle('Créer une Opportunité !')).toBe('creer une opportunite') expect([...titleTokens('Créer une opportunité')]).toEqual(['creer', 'opportunite']) expect(jaccard(new Set(['a', 'b']), new Set(['a', 'b']))).toBe(1) expect(jaccard(new Set(['a']), new Set(['b']))).toBe(0) }) }) describe('existingUseCases', () => { it('same title (case/accents-insensitive) → exact; count = section items', () => { const r = existingUseCases(corpus(), 'opportunites', { title: 'creer une opportunite' }, 'add') expect(r.count).toBe(3) expect(r.exact.map((m) => m.code)).toEqual([`${UC_PREFIX}001`]) expect(r.exact[0]!.file).toBe('CRM/PIPELINE/opportunites/use-case.md') }) it('token overlap → similar with a score', () => { const r = existingUseCases(corpus(), 'opportunites', { title: 'Qualifier rapidement une opportunité' }, 'add') expect(r.exact).toEqual([]) expect(r.similar.some((m) => m.code === `${UC_PREFIX}002` && (m.score ?? 0) >= 0.6)).toBe(true) }) it('a look-alike in ANOTHER application lands in crossApp, never in exact', () => { const r = existingUseCases(corpus(), 'opportunites', { title: 'Créer une opportunité' }, 'add') expect(r.crossApp.map((m) => m.code)).toEqual(['UC-HR-RECRUIT-candidats-001']) expect(r.crossApp[0]!.file.startsWith('HR/')).toBe(true) }) it('op=modify resolves the target by code and excludes it from the duplicate check', () => { const r = existingUseCases(corpus(), 'opportunites', { code: `${UC_PREFIX}001`, title: 'Créer une opportunité' }, 'modify') expect(r.resolved?.code).toBe(`${UC_PREFIX}001`) expect(r.exact).toEqual([]) }) it('op=modify with an unknown code leaves resolved undefined', () => { const r = existingUseCases(corpus(), 'opportunites', { code: `${UC_PREFIX}099` }, 'modify') expect(r.resolved).toBeUndefined() }) }) describe('existingRules', () => { it('same title in the owner doc → exact; the same error code → same-error-code', () => { const r = existingRules(corpus(), undefined, { title: 'Remise plafonnée', errorCode: 'pipeline.close.reason' }, 'add') expect(r.count).toBe(2) expect(r.exact.some((m) => m.code === 'BR-001' && m.reason === 'same-title')).toBe(true) expect(r.exact.some((m) => m.code === 'BR-003' && m.reason === 'same-error-code')).toBe(true) }) it('a same-title rule of another application is crossApp only', () => { const r = existingRules(corpus(), undefined, { title: 'Remise plafonnée' }, 'add') expect(r.crossApp.map((m) => m.file)).toEqual(['HR/RECRUIT/règles-métier.md']) }) it('op=modify resolves a section-doc code from the module doc scope (BR codes are doc-scoped)', () => { const r = existingRules(corpus(), undefined, { code: 'BR-003' }, 'modify') expect(r.resolved?.file).toBe('CRM/PIPELINE/opportunites/règles-métier.md') }) }) describe('existingActors — project-scoped identity', () => { it('same label in ANOTHER application is exact (reuse, a Périmètre line), count = this app\'s actors', () => { const r = existingActors(corpus(), { title: 'responsable rh' }, 'add') expect(r.count).toBe(2) expect(r.exact).toEqual([{ code: 'BA-001-AC-005', title: 'Responsable RH', file: 'HR/acteur.md', reason: 'same-title' }]) expect(r.crossApp).toEqual([]) }) it('the same seeded role code (slugifyRoleCode) is exact too — « Le Commercial » = commercial', () => { const r = existingActors(corpus(), { title: 'Le Commercial' }, 'add') expect(r.exact.some((m) => m.code === 'BA-001-AC-001' && m.reason === 'same-name')).toBe(true) }) it('op=modify resolves the code in this app', () => { const r = existingActors(corpus(), { code: 'BA-001-AC-002', title: 'Manager commercial' }, 'modify') expect(r.resolved?.code).toBe('BA-001-AC-002') expect(r.exact).toEqual([]) }) }) describe('existingPermissions — the tuple', () => { it('same (actor, path, portée) → exact same-tuple; another portée → similar same-actor-path', () => { const c = corpus() const exact = existingPermissions(c, { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.read', portee: 'toutes' }, 'add') expect(exact.count).toBe(3) expect(exact.exact.map((m) => m.reason)).toEqual(['same-tuple']) const sibling = existingPermissions(c, { actor: 'Commercial', permissionPath: 'pipeline.opportunites.read', portee: 'les siennes' }, 'add') expect(sibling.exact).toEqual([]) expect(sibling.similar.map((m) => m.reason)).toEqual(['same-actor-path']) }) it('op=modify resolves the row by (actor, path)', () => { const r = existingPermissions(corpus(), { actor: 'BA-001-AC-002', permissionPath: 'pipeline.opportunites.approve' }, 'modify') expect(r.resolved?.reason).toBe('same-actor-path') expect(r.resolved?.title).toContain('équipe') }) it('canonicalPortee folds the vocabulary and rejects the rest', () => { expect(canonicalPortee('')).toBe('all') expect(canonicalPortee('Équipe')).toBe('team') expect(canonicalPortee('les siennes')).toBe('own') expect(canonicalPortee('custom: Region = @user.region')).toBe('custom') expect(canonicalPortee('mine')).toBeNull() }) })