/** * lib/domain-scan — the Domain scan with OWNERS. Pinned: the owner is the * `Entities///` folder, every file of a class name is kept in a * host-independent (sorted) order, `sameSegment` equates the BA folder code with * the generated PascalCase segment (`GESTION_FLOTTE` ≡ `GestionFlotte`), and * `extraAttributes` names what a BA entity declares beyond the class. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { CS_FRAMEWORK_PROPS, csPropertyNames, domainOf, extraAttributes, foldAlnum, ownerOfFile, resetDomainScanCache, sameSegment, scanDomain, } from '../domain-scan.js' let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ss-domain-scan-')) resetDomainScanCache() }) afterEach(() => rmSync(root, { recursive: true, force: true })) function cs(rel: string, cls: string, props: string[] = []): void { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, `namespace X;\npublic class ${cls} : ExtensionBaseEntity\n{\n${props.map((n) => ` public string ${n} { get; set; }\n`).join('')}}\n`, 'utf8') } describe('ownerOfFile', () => { it('reads / after Entities/', () => { expect(ownerOfFile('src/Demo.Domain/Entities/Notes/Memos/Memo.cs')).toEqual({ app: 'Notes', module: 'Memos' }) }) it('a file directly under Entities/ (flat layout) has no owner', () => { expect(ownerOfFile('src/Demo.Domain/Entities/Memo.cs')).toEqual({}) }) it('a file outside Entities/ has no owner', () => { expect(ownerOfFile('src/Demo.Domain/ValueObjects/Money.cs')).toEqual({}) }) it('an app folder without a module folder gives the app only', () => { expect(ownerOfFile('src/Demo.Domain/Entities/Notes/Memo.cs')).toEqual({ app: 'Notes' }) }) it('backslashes are normalised', () => { expect(ownerOfFile('src\\Demo.Domain\\Entities\\Notes\\Memos\\Memo.cs')).toEqual({ app: 'Notes', module: 'Memos' }) }) }) describe('sameSegment / foldAlnum', () => { it('BA folder code ≡ generated segment ≡ kebab code', () => { expect(sameSegment('GESTION_FLOTTE', 'GestionFlotte')).toBe(true) expect(sameSegment('gestion-flotte', 'GestionFlotte')).toBe(true) expect(sameSegment('NOTES', 'Notes')).toBe(true) expect(sameSegment('VÉHICULES', 'Vehicules')).toBe(true) }) it('different segments never match, and an empty segment matches nothing', () => { expect(sameSegment('NOTES', 'Crm')).toBe(false) expect(sameSegment('', '')).toBe(false) }) it('foldAlnum keeps letters and digits only', () => { expect(foldAlnum('Gestion_Flotte-2')).toBe('gestionflotte2') }) }) describe('scanDomain — owners, sorted, cached', () => { it('indexes every file of a class name with its owner, in sorted order', () => { cs('src/Demo.Domain/Entities/Notes/Memos/Contact.cs', 'Contact', ['Nom']) cs('src/Demo.Domain/Entities/Crm/Clients/Contact.cs', 'Contact', ['Nom', 'Email']) cs('src/Demo.Domain/Entities/Contact.cs', 'Contact') const scan = scanDomain(root) expect(scan.filesScanned).toBe(3) const owners = scan.owners.get('Contact')!.map((o) => ({ file: o.file, app: o.app, module: o.module })) expect(owners).toEqual([ { file: 'src/Demo.Domain/Entities/Contact.cs', app: undefined, module: undefined }, { file: 'src/Demo.Domain/Entities/Crm/Clients/Contact.cs', app: 'Crm', module: 'Clients' }, { file: 'src/Demo.Domain/Entities/Notes/Memos/Contact.cs', app: 'Notes', module: 'Memos' }, ]) // The historical single-file maps = the FIRST file in sorted order. expect(scan.classes.get('Contact')).toBe('src/Demo.Domain/Entities/Contact.cs') expect(scan.classSources.get('Contact')).toContain('class Contact') }) it('skips bin/obj/node_modules and non-Domain projects', () => { cs('src/Demo.Domain/bin/Debug/Gen.cs', 'Gen') cs('src/Demo.Api/Controllers/X.cs', 'X') cs('src/Demo.Domain/Entities/A/B/Real.cs', 'Real') const scan = scanDomain(root) expect(scan.filesScanned).toBe(1) expect([...scan.classes.keys()]).toEqual(['Real']) }) it('no src/ → an empty scan (greenfield), never a throw; domainOf(undefined) → null', () => { expect(scanDomain(root).filesScanned).toBe(0) expect(domainOf(undefined)).toBeNull() expect(domainOf(join(root, 'missing'))).toBeNull() }) it('caches per projectRoot until reset', () => { cs('src/Demo.Domain/Entities/A/B/One.cs', 'One') expect(scanDomain(root).filesScanned).toBe(1) cs('src/Demo.Domain/Entities/A/B/Two.cs', 'Two') expect(scanDomain(root).filesScanned).toBe(1) resetDomainScanCache() expect(scanDomain(root).filesScanned).toBe(2) }) }) describe('csPropertyNames / extraAttributes', () => { const src = 'public class V { public Guid Id { get; set; } public string Plaque { get; set; } = ""; public virtual Owner? Owner { get; set; } public required int Km { get; init; } }' it('folds every public property name', () => { expect([...csPropertyNames(src)].sort()).toEqual(['id', 'km', 'owner', 'plaque']) }) it('extraAttributes = declared − class − framework, in declaration order', () => { expect(extraAttributes(['Id', 'Plaque', 'Immatriculation', 'CreatedAt', 'Couleur'], src)).toEqual(['Immatriculation', 'Couleur']) expect(CS_FRAMEWORK_PROPS.has('tenantid')).toBe(true) }) })