/** * scaffold-screen-controller — `legacyPaths` computation + the guarded legacy * sweep (lib/guarded-rm) through the REAL index.ts: the pre-classification * screen controller (`.Api/Controllers//Screens/…`) and screen * DTOs (`.Application//DTOs/Screens/…`) are removed on re-run and * traced in `filesRemoved`; an `@customised` legacy file survives and is * warned; nothing is reported when nothing was there (audit 2026-09-24, * A-WARN-11 / A-WARN-12). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { runCli } from '../../../../../../lib/__tests__/helpers/run-cli.js' import { generate, legacyPaths } from '../generate.js' import { ScaffoldScreenControllerSpecSchema, type PageSpec } from '../types.js' const INDEX = resolve(dirname(fileURLToPath(import.meta.url)), '../index.ts') const CUSTOMISED_HEAD = '// @customised — hand-written, the developer owns it\n' const norm = (p: string): string => p.replace(/\\/g, '/') const LIST_PAGESPEC = { screenCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-001', appCode: 'crm', module: 'pipeline', section: 'opportunites', entity: 'Opportunity', view: 'list', permission: 'pipeline.opportunites.read', columns: [], actions: [], } const CURRENT_CONTROLLER = 'src/Crm.Api/Controllers/Crm/Pipeline/Opportunites/OpportunitiesScreenController.cs' const LEGACY_CONTROLLER = 'src/Crm.Api/Controllers/Pipeline/Screens/OpportunitiesScreenController.cs' const CURRENT_DTO = 'src/Crm.Application/Crm/Pipeline/DTOs/Screens/OpportunityListScreenDto.cs' const LEGACY_DTO = 'src/Crm.Application/Pipeline/DTOs/Screens/OpportunityListScreenDto.cs' let root: string let moduleDir: string let projectPath: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ss-screen-legacy-')) moduleDir = join(root, 'ba', 'CRM', 'PIPELINE') projectPath = join(root, 'app') mkdirSync(join(moduleDir, 'pagespecs'), { recursive: true }) mkdirSync(projectPath, { recursive: true }) writeFileSync(join(moduleDir, 'pagespecs', 'Opportunity.list.md'), '# List\n\n```json\n' + JSON.stringify(LIST_PAGESPEC) + '\n```\n', 'utf-8') }) afterEach(() => rmSync(root, { recursive: true, force: true })) const spec = () => ({ section: 'opportunites', entity: 'Opportunity', module: 'pipeline', appCode: 'crm', namespace: 'Crm', moduleDir, projectPath }) function plant(rel: string, content: string): string { const abs = join(projectPath, rel) mkdirSync(dirname(abs), { recursive: true }) writeFileSync(abs, content, 'utf-8') return abs } function run() { const r = runCli(INDEX, ['--spec', JSON.stringify(spec())]) expect(r.envelope, r.stderr).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.status).toBe(0) return r } describe('scaffold-screen-controller / legacyPaths — explicit computation', () => { const parsed = () => ScaffoldScreenControllerSpecSchema.parse(spec()) const pagespec = () => ({ ...LIST_PAGESPEC, linkedUseCases: [], linkedBusinessRules: [], filters: [], widgets: [] }) as unknown as PageSpec it('maps the classified controller folder to the module-only Screens/ bucket', () => { const { files } = generate(parsed(), [pagespec()]) expect(files.map(f => f.path)).toContain(CURRENT_CONTROLLER) expect(legacyPaths(parsed(), files)).toContain(LEGACY_CONTROLLER) }) it('maps the classified DTOs/Screens folder to the module-only one', () => { const { files } = generate(parsed(), [pagespec()]) expect(files.map(f => f.path)).toContain(CURRENT_DTO) expect(legacyPaths(parsed(), files)).toContain(LEGACY_DTO) }) it('is positional (one legacy path per generated file, same order) and never names a current output', () => { const { files } = generate(parsed(), [pagespec()]) const legacy = legacyPaths(parsed(), files) expect(legacy).toHaveLength(files.length) const current = new Set(files.map(f => f.path)) for (const p of legacy) expect(current.has(p), `legacyPaths would delete a current output: ${p}`).toBe(false) }) it('a file outside both classified folders passes through unchanged (nothing to relocate)', () => { const foreign = { path: 'src/Crm.Api/Controllers/Crm/Other/Thing.cs', content: '' } expect(legacyPaths(parsed(), [foreign])).toEqual([foreign.path]) }) it('PascalCases multi-word module/section codes on both sides of the mapping', () => { const s = ScaffoldScreenControllerSpecSchema.parse({ ...spec(), module: 'human-resources', section: 'types-affaire' }) const ctrl = { path: 'src/Crm.Api/Controllers/Crm/HumanResources/TypesAffaire/X.cs', content: '' } const dto = { path: 'src/Crm.Application/Crm/HumanResources/DTOs/Screens/Y.cs', content: '' } expect(legacyPaths(s, [ctrl, dto])).toEqual([ 'src/Crm.Api/Controllers/HumanResources/Screens/X.cs', 'src/Crm.Application/HumanResources/DTOs/Screens/Y.cs', ]) }) }) describe('scaffold-screen-controller / guarded legacy sweep', () => { it('removes an unmarked legacy DTO (traced in filesRemoved) and keeps a @customised legacy controller with a warning', () => { const bespoke = `${CUSTOMISED_HEAD}public class OpportunitiesScreenController { /* hand-tuned */ }\n` const ctrlAbs = plant(LEGACY_CONTROLLER, bespoke) const dtoAbs = plant(LEGACY_DTO, 'public record OpportunityListScreenDto;\n') const r = run() expect(existsSync(dtoAbs)).toBe(false) expect(r.envelope.filesRemoved).toEqual([LEGACY_DTO]) expect(existsSync(ctrlAbs)).toBe(true) expect(readFileSync(ctrlAbs, 'utf-8')).toBe(bespoke) expect(r.envelope.warnings).toContain(`legacy path ${LEGACY_CONTROLLER} kept: marked @customised`) // The classified outputs land regardless. expect(existsSync(join(projectPath, CURRENT_CONTROLLER))).toBe(true) expect(existsSync(join(projectPath, CURRENT_DTO))).toBe(true) expect(r.envelope.filesGenerated).toBe(2) expect(r.envelope.skipped).toEqual([]) }) it('with no legacy file on disk the sweep reports nothing and warns nothing', () => { const r = run() expect(r.envelope.filesRemoved).toEqual([]) expect(r.envelope.warnings.some((w: string) => /legacy path/.test(w))).toBe(false) }) it('a re-run after the relocate is a clean no-op on the sweep', () => { plant(LEGACY_CONTROLLER, 'public class OpportunitiesScreenController { }\n') expect(run().envelope.filesRemoved).toEqual([LEGACY_CONTROLLER]) expect(run().envelope.filesRemoved).toEqual([]) }) it('a @customised CURRENT output is never overwritten (skipped + warning, filesGenerated excludes it)', () => { const bespoke = `${CUSTOMISED_HEAD}public class OpportunitiesScreenController { /* mine */ }\n` const abs = plant(CURRENT_CONTROLLER, bespoke) const r = run() expect(readFileSync(abs, 'utf-8')).toBe(bespoke) expect(r.envelope.skipped.map(norm)).toEqual([norm(abs)]) expect(r.envelope.filesGenerated).toBe(1) expect(r.envelope.warnings.some((w: string) => w.includes('kept: marked @customised — not overwritten'))).toBe(true) }) })