import { describe, it, expect } from 'vitest'; import { detectLayers } from './layer-detector'; function makeFileNode(overrides = {}) { return { id: 'src/utils/helper.ts', type: 'file', name: 'helper.ts', summary: 'Exports: formatDate', tags: ['typescript', 'util'], ...overrides, }; } function makeFunctionNode(overrides = {}) { return { id: 'src/utils/helper.ts::formatDate', type: 'function', name: 'formatDate', summary: '(date) → 15 lines', tags: ['typescript', 'exported'], ...overrides, }; } function makeClassNode(overrides = {}) { return { id: 'src/models/User.ts::UserModel', type: 'class', name: 'UserModel', summary: 'Methods: save, delete', tags: ['typescript', 'exported'], ...overrides, }; } const emptyEdges = []; describe('detectLayers', () => { describe('tag-based layer assignment', () => { it('assigns nodes with "component" tag to Presentation layer', () => { const nodes = [ makeFileNode({ id: 'src/components/Button.tsx', tags: ['typescript', 'component'] }), makeFileNode({ id: 'src/components/Header.tsx', tags: ['typescript', 'component'] }), ]; const layers = detectLayers(nodes, emptyEdges); const presentation = layers.find(l => l.id === 'presentation'); expect(presentation).toBeDefined(); expect(presentation.name).toBe('Presentation'); expect(presentation.description).toBe('UI components, views, and pages'); expect(presentation.nodeIds).toContain('src/components/Button.tsx'); expect(presentation.nodeIds).toContain('src/components/Header.tsx'); }); it('assigns nodes with "api" tag to API layer', () => { const nodes = [ makeFileNode({ id: 'src/routes/users.ts', tags: ['typescript', 'api'] }), makeFileNode({ id: 'src/controllers/auth.ts', tags: ['typescript', 'api'] }), ]; const layers = detectLayers(nodes, emptyEdges); const api = layers.find(l => l.id === 'api'); expect(api).toBeDefined(); expect(api.name).toBe('API'); expect(api.nodeIds).toContain('src/routes/users.ts'); expect(api.nodeIds).toContain('src/controllers/auth.ts'); }); it('assigns nodes with "model" tag to Data layer', () => { const nodes = [ makeFileNode({ id: 'src/models/User.ts', tags: ['typescript', 'model'] }), ]; const layers = detectLayers(nodes, emptyEdges); const data = layers.find(l => l.id === 'data'); expect(data).toBeDefined(); expect(data.name).toBe('Data'); expect(data.description).toBe('Models, entities, and data schemas'); expect(data.nodeIds).toContain('src/models/User.ts'); }); it('assigns nodes with "util" tag to Utilities layer', () => { const nodes = [ makeFileNode({ id: 'src/utils/format.ts', tags: ['typescript', 'util'] }), makeFileNode({ id: 'src/lib/crypto.ts', tags: ['typescript', 'util'] }), ]; const layers = detectLayers(nodes, emptyEdges); const utilities = layers.find(l => l.id === 'utilities'); expect(utilities).toBeDefined(); expect(utilities.name).toBe('Utilities'); expect(utilities.nodeIds).toHaveLength(2); }); it('assigns nodes with "test" tag to Testing layer', () => { const nodes = [ makeFileNode({ id: 'src/utils/format.test.ts', tags: ['typescript', 'test'] }), ]; const layers = detectLayers(nodes, emptyEdges); const testing = layers.find(l => l.id === 'testing'); expect(testing).toBeDefined(); expect(testing.name).toBe('Testing'); expect(testing.description).toBe('Test files and test utilities'); expect(testing.nodeIds).toContain('src/utils/format.test.ts'); }); it('prioritizes first matching tag (component over util)', () => { const nodes = [ makeFileNode({ id: 'src/components/utils.tsx', tags: ['typescript', 'component', 'util'] }), ]; const layers = detectLayers(nodes, emptyEdges); expect(layers).toHaveLength(1); expect(layers[0].id).toBe('presentation'); }); }); describe('directory-based fallback grouping', () => { it('groups by first segment after "src/" when no tag matches', () => { const nodes = [ makeFileNode({ id: 'src/services/auth.ts', tags: ['typescript'] }), makeFileNode({ id: 'src/services/payment.ts', tags: ['typescript'] }), ]; const layers = detectLayers(nodes, emptyEdges); const services = layers.find(l => l.id === 'services'); expect(services).toBeDefined(); expect(services.name).toBe('Services'); expect(services.description).toBe('Files in the services/ directory'); expect(services.nodeIds).toHaveLength(2); }); it('groups by first segment for non-src paths', () => { const nodes = [ makeFileNode({ id: 'lib/crypto.ts', tags: ['typescript'] }), makeFileNode({ id: 'lib/hash.ts', tags: ['typescript'] }), ]; const layers = detectLayers(nodes, emptyEdges); const lib = layers.find(l => l.id === 'lib'); expect(lib).toBeDefined(); expect(lib.name).toBe('Lib'); expect(lib.nodeIds).toHaveLength(2); }); it('groups config directory files together', () => { const nodes = [ makeFileNode({ id: 'config/app.json', tags: ['json'] }), makeFileNode({ id: 'config/db.json', tags: ['json'] }), ]; const layers = detectLayers(nodes, emptyEdges); const config = layers.find(l => l.id === 'config'); expect(config).toBeDefined(); expect(config.name).toBe('Config'); expect(config.nodeIds).toHaveLength(2); }); }); describe('root files handling', () => { it('assigns root-level files (no directory) to Root layer', () => { const nodes = [ makeFileNode({ id: 'package.json', tags: ['json'] }), makeFileNode({ id: 'tsconfig.json', tags: ['json'] }), ]; const layers = detectLayers(nodes, emptyEdges); const root = layers.find(l => l.id === 'root'); expect(root).toBeDefined(); expect(root.name).toBe('Root'); expect(root.description).toBe('Root-level files'); expect(root.nodeIds).toContain('package.json'); expect(root.nodeIds).toContain('tsconfig.json'); }); }); describe('function/class nodes assigned to parent file layer', () => { it('assigns function node to same layer as parent file', () => { const nodes = [ makeFileNode({ id: 'src/utils/format.ts', tags: ['typescript', 'util'] }), makeFunctionNode({ id: 'src/utils/format.ts::formatDate', tags: ['typescript', 'exported'] }), ]; const layers = detectLayers(nodes, emptyEdges); const utilities = layers.find(l => l.id === 'utilities'); expect(utilities).toBeDefined(); expect(utilities.nodeIds).toContain('src/utils/format.ts'); expect(utilities.nodeIds).toContain('src/utils/format.ts::formatDate'); }); it('assigns class node to same layer as parent file', () => { const nodes = [ makeFileNode({ id: 'src/models/User.ts', tags: ['typescript', 'model'] }), makeClassNode({ id: 'src/models/User.ts::UserModel', tags: ['typescript', 'exported'] }), ]; const layers = detectLayers(nodes, emptyEdges); const data = layers.find(l => l.id === 'data'); expect(data).toBeDefined(); expect(data.nodeIds).toContain('src/models/User.ts'); expect(data.nodeIds).toContain('src/models/User.ts::UserModel'); }); it('assigns child node using directory fallback when parent file has no matching tags', () => { const nodes = [ makeFileNode({ id: 'src/services/auth.ts', tags: ['typescript'] }), makeFunctionNode({ id: 'src/services/auth.ts::login', tags: ['typescript', 'exported'] }), ]; const layers = detectLayers(nodes, emptyEdges); const services = layers.find(l => l.id === 'services'); expect(services).toBeDefined(); expect(services.nodeIds).toContain('src/services/auth.ts'); expect(services.nodeIds).toContain('src/services/auth.ts::login'); }); it('assigns orphan child node (no parent file in nodes) using path fallback', () => { const nodes = [ makeFunctionNode({ id: 'src/services/auth.ts::login', tags: ['typescript', 'exported'] }), ]; const layers = detectLayers(nodes, emptyEdges); const services = layers.find(l => l.id === 'services'); expect(services).toBeDefined(); expect(services.nodeIds).toContain('src/services/auth.ts::login'); }); }); describe('empty input handling', () => { it('returns empty array for empty nodes list', () => { const layers = detectLayers([], emptyEdges); expect(layers).toEqual([]); }); it('returns empty array when no file or child nodes exist', () => { // Nodes that are neither file type nor have "::" in id const nodes = [ { id: 'some-module', type: 'module', name: 'Module', summary: '', tags: [] }, ]; const layers = detectLayers(nodes, emptyEdges); expect(layers).toEqual([]); }); }); describe('mixed scenarios', () => { it('creates multiple layers from mixed nodes', () => { const nodes = [ makeFileNode({ id: 'src/components/Button.tsx', tags: ['typescript', 'component'] }), makeFileNode({ id: 'src/routes/users.ts', tags: ['typescript', 'api'] }), makeFileNode({ id: 'src/models/User.ts', tags: ['typescript', 'model'] }), makeFileNode({ id: 'src/utils/format.ts', tags: ['typescript', 'util'] }), makeFileNode({ id: 'src/services/auth.ts', tags: ['typescript'] }), makeFileNode({ id: 'package.json', tags: ['json'] }), ]; const layers = detectLayers(nodes, emptyEdges); expect(layers.find(l => l.id === 'presentation')).toBeDefined(); expect(layers.find(l => l.id === 'api')).toBeDefined(); expect(layers.find(l => l.id === 'data')).toBeDefined(); expect(layers.find(l => l.id === 'utilities')).toBeDefined(); expect(layers.find(l => l.id === 'services')).toBeDefined(); expect(layers.find(l => l.id === 'root')).toBeDefined(); }); it('every file node belongs to exactly one layer', () => { const nodes = [ makeFileNode({ id: 'src/components/Button.tsx', tags: ['typescript', 'component'] }), makeFileNode({ id: 'src/routes/users.ts', tags: ['typescript', 'api'] }), makeFileNode({ id: 'src/services/auth.ts', tags: ['typescript'] }), makeFileNode({ id: 'package.json', tags: ['json'] }), ]; const layers = detectLayers(nodes, emptyEdges); const fileNodeIds = nodes.map(n => n.id); for (const fileId of fileNodeIds) { const containingLayers = layers.filter(l => l.nodeIds.includes(fileId)); expect(containingLayers).toHaveLength(1); } }); }); });