Spaces:
Running
Running
| 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); | |
| } | |
| }); | |
| }); | |
| }); | |