knowledge-graph-preview / cli /analyzer /layer-detector.test.js
mr4's picture
Upload 136 files
fd8cdf5 verified
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);
}
});
});
});