knowledge-graph-preview / cli /analyzer /tour-builder.test.js
mr4's picture
Upload 136 files
fd8cdf5 verified
import { describe, it, expect } from 'vitest';
import { buildTour, findEntryPoint } from './tour-builder';
function makeFileNode(id, name, summary, tags) {
return {
id,
type: 'file',
name: name ?? id.split('/').pop(),
summary: summary ?? `Source file: ${id}`,
tags: tags ?? ['typescript'],
};
}
function makeFunctionNode(fileId, funcName) {
return {
id: `${fileId}::${funcName}`,
type: 'function',
name: funcName,
summary: `() → 15 lines`,
tags: ['typescript', 'exported'],
};
}
function makeImportEdge(source, target) {
return { source, target, type: 'imports' };
}
function makeContainsEdge(source, target) {
return { source, target, type: 'contains' };
}
function makeLayer(id, name, nodeIds) {
return { id, name, description: `${name} layer`, nodeIds };
}
describe('findEntryPoint', () => {
it('finds index.ts at root level', () => {
const nodes = [
makeFileNode('index.ts'),
makeFileNode('src/utils.ts'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('index.ts');
});
it('finds index.ts in src/ directory', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/utils.ts'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('src/index.ts');
});
it('finds main.ts when no index.ts exists', () => {
const nodes = [
makeFileNode('src/main.ts', 'main.ts'),
makeFileNode('src/utils.ts'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('src/main.ts');
});
it('finds App.tsx when no index or main exists', () => {
const nodes = [
makeFileNode('src/App.tsx', 'App.tsx'),
makeFileNode('src/utils.ts'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('src/App.tsx');
});
it('finds main.py for Python projects', () => {
const nodes = [
makeFileNode('main.py', 'main.py'),
makeFileNode('utils.py'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('main.py');
});
it('finds main.go for Go projects', () => {
const nodes = [
makeFileNode('main.go', 'main.go'),
makeFileNode('handler.go'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('main.go');
});
it('prioritizes index.ts over main.ts', () => {
const nodes = [
makeFileNode('src/main.ts', 'main.ts'),
makeFileNode('src/index.ts', 'index.ts'),
];
const result = findEntryPoint(nodes, []);
expect(result?.id).toBe('src/index.ts');
});
it('falls back to file with most outgoing import edges', () => {
const nodes = [
makeFileNode('src/app.module.ts'),
makeFileNode('src/service.ts'),
makeFileNode('src/helper.ts'),
];
const edges = [
makeImportEdge('src/app.module.ts', 'src/service.ts'),
makeImportEdge('src/app.module.ts', 'src/helper.ts'),
makeImportEdge('src/service.ts', 'src/helper.ts'),
];
const result = findEntryPoint(nodes, edges);
expect(result?.id).toBe('src/app.module.ts');
});
it('returns undefined for empty nodes', () => {
const result = findEntryPoint([], []);
expect(result).toBeUndefined();
});
});
describe('buildTour', () => {
describe('BFS traversal', () => {
it('builds tour following import edges from entry point', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/app.ts', 'app.ts'),
makeFileNode('src/utils.ts', 'utils.ts'),
];
const edges = [
makeImportEdge('src/index.ts', 'src/app.ts'),
makeImportEdge('src/app.ts', 'src/utils.ts'),
];
const layers = [];
const tour = buildTour(nodes, edges, layers);
expect(tour).toHaveLength(3);
expect(tour[0].order).toBe(1);
expect(tour[0].title).toBe('index.ts');
expect(tour[0].nodeIds).toContain('src/index.ts');
expect(tour[1].order).toBe(2);
expect(tour[1].title).toBe('app.ts');
expect(tour[2].order).toBe(3);
expect(tour[2].title).toBe('utils.ts');
});
it('does not revisit files (no duplicates)', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/a.ts', 'a.ts'),
makeFileNode('src/b.ts', 'b.ts'),
];
const edges = [
makeImportEdge('src/index.ts', 'src/a.ts'),
makeImportEdge('src/index.ts', 'src/b.ts'),
makeImportEdge('src/a.ts', 'src/b.ts'), // b already visited via index
];
const layers = [];
const tour = buildTour(nodes, edges, layers);
const fileIds = tour.map(s => s.nodeIds[0]);
const uniqueIds = new Set(fileIds);
expect(fileIds.length).toBe(uniqueIds.size);
});
it('limits tour to 10 steps maximum', () => {
const nodes = [];
const edges = [];
// Create a chain of 15 files
for (let i = 0; i < 15; i++) {
const id = i === 0 ? 'src/index.ts' : `src/file${i}.ts`;
nodes.push(makeFileNode(id, id.split('/').pop()));
if (i > 0) {
const prevId = i === 1 ? 'src/index.ts' : `src/file${i - 1}.ts`;
edges.push(makeImportEdge(prevId, id));
}
}
const tour = buildTour(nodes, edges, []);
expect(tour.length).toBeLessThanOrEqual(10);
});
});
describe('step generation', () => {
it('generates correct order, title, description, and nodeIds', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts', 'Exports: main, init'),
makeFunctionNode('src/index.ts', 'main'),
];
const edges = [
makeContainsEdge('src/index.ts', 'src/index.ts::main'),
];
const layers = [makeLayer('root', 'Root', ['src/index.ts'])];
const tour = buildTour(nodes, edges, layers);
expect(tour).toHaveLength(1);
expect(tour[0].order).toBe(1);
expect(tour[0].title).toBe('index.ts');
expect(tour[0].description).toContain('Exports: main, init');
expect(tour[0].description).toContain('Root');
expect(tour[0].nodeIds).toContain('src/index.ts');
expect(tour[0].nodeIds).toContain('src/index.ts::main');
});
it('includes child function/class nodes in nodeIds', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFunctionNode('src/index.ts', 'setup'),
makeFunctionNode('src/index.ts', 'teardown'),
];
const edges = [];
const layers = [];
const tour = buildTour(nodes, edges, layers);
expect(tour[0].nodeIds).toContain('src/index.ts');
expect(tour[0].nodeIds).toContain('src/index.ts::setup');
expect(tour[0].nodeIds).toContain('src/index.ts::teardown');
});
it('generates description with layer info when available', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts', 'Entry point'),
];
const edges = [];
const layers = [makeLayer('presentation', 'Presentation', ['src/index.ts'])];
const tour = buildTour(nodes, edges, layers);
expect(tour[0].description).toContain('Presentation');
});
});
describe('layer-based supplementation', () => {
it('supplements with layer representatives when BFS produces fewer than 5 steps', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/components/Button.tsx', 'Button.tsx'),
makeFileNode('src/models/User.ts', 'User.ts'),
makeFileNode('src/utils/format.ts', 'format.ts'),
makeFileNode('src/api/routes.ts', 'routes.ts'),
];
// Only one import edge from entry point (BFS gives 2 steps)
const edges = [
makeImportEdge('src/index.ts', 'src/components/Button.tsx'),
// Give other files some edges so they get picked
makeImportEdge('src/models/User.ts', 'src/utils/format.ts'),
makeImportEdge('src/api/routes.ts', 'src/models/User.ts'),
];
const layers = [
makeLayer('root', 'Root', ['src/index.ts']),
makeLayer('presentation', 'Presentation', ['src/components/Button.tsx']),
makeLayer('data', 'Data', ['src/models/User.ts']),
makeLayer('utilities', 'Utilities', ['src/utils/format.ts']),
makeLayer('api', 'API', ['src/api/routes.ts']),
];
const tour = buildTour(nodes, edges, layers);
// BFS gives index.ts + Button.tsx = 2 steps
// Supplementation should add from uncovered layers (data, utilities, api)
expect(tour.length).toBeGreaterThanOrEqual(4);
const tourFileIds = tour.map(s => s.nodeIds[0]);
expect(tourFileIds).toContain('src/index.ts');
expect(tourFileIds).toContain('src/components/Button.tsx');
});
it('picks most connected file from each uncovered layer', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/utils/a.ts', 'a.ts'),
makeFileNode('src/utils/b.ts', 'b.ts'),
];
const edges = [
// b.ts has more edges than a.ts
makeImportEdge('src/utils/b.ts', 'src/utils/a.ts'),
makeImportEdge('src/index.ts', 'src/utils/b.ts'),
];
const layers = [
makeLayer('root', 'Root', ['src/index.ts']),
makeLayer('utilities', 'Utilities', ['src/utils/a.ts', 'src/utils/b.ts']),
];
const tour = buildTour(nodes, edges, layers);
// BFS from index.ts → b.ts → a.ts (3 steps, all covered)
// Since BFS < 5, supplementation runs but utilities layer is already covered
const tourFileIds = tour.map(s => s.nodeIds[0]);
expect(tourFileIds).toContain('src/index.ts');
expect(tourFileIds).toContain('src/utils/b.ts');
});
it('does not supplement when BFS produces 5 or more steps', () => {
const nodes = [];
const edges = [];
// Create a chain of 6 files from entry point
for (let i = 0; i < 6; i++) {
const id = i === 0 ? 'src/index.ts' : `src/file${i}.ts`;
nodes.push(makeFileNode(id, id.split('/').pop()));
if (i > 0) {
const prevId = i === 1 ? 'src/index.ts' : `src/file${i - 1}.ts`;
edges.push(makeImportEdge(prevId, id));
}
}
// Add an extra file in a different layer that's NOT reachable via BFS
nodes.push(makeFileNode('src/extra/orphan.ts', 'orphan.ts'));
const layers = [
makeLayer('main', 'Main', nodes.slice(0, 6).map(n => n.id)),
makeLayer('extra', 'Extra', ['src/extra/orphan.ts']),
];
const tour = buildTour(nodes, edges, layers);
// BFS gives 6 steps (>= 5), so no supplementation
const tourFileIds = tour.map(s => s.nodeIds[0]);
expect(tourFileIds).not.toContain('src/extra/orphan.ts');
expect(tour).toHaveLength(6);
});
});
describe('edge cases', () => {
it('returns empty array for empty nodes', () => {
const tour = buildTour([], [], []);
expect(tour).toEqual([]);
});
it('returns empty array when no file nodes exist', () => {
const nodes = [
makeFunctionNode('src/index.ts', 'main'),
];
const tour = buildTour(nodes, [], []);
expect(tour).toEqual([]);
});
it('returns one step for a single file', () => {
const nodes = [makeFileNode('src/index.ts', 'index.ts')];
const tour = buildTour(nodes, [], []);
expect(tour).toHaveLength(1);
expect(tour[0].order).toBe(1);
expect(tour[0].title).toBe('index.ts');
expect(tour[0].nodeIds).toContain('src/index.ts');
});
it('handles files with no import edges (isolated nodes)', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/isolated.ts', 'isolated.ts'),
];
const edges = [];
const layers = [];
const tour = buildTour(nodes, edges, layers);
// BFS from index.ts finds only index.ts (no edges)
// With no layers, supplementation doesn't add anything
expect(tour).toHaveLength(1);
expect(tour[0].nodeIds[0]).toBe('src/index.ts');
});
it('handles circular import references gracefully', () => {
const nodes = [
makeFileNode('src/index.ts', 'index.ts'),
makeFileNode('src/a.ts', 'a.ts'),
makeFileNode('src/b.ts', 'b.ts'),
];
const edges = [
makeImportEdge('src/index.ts', 'src/a.ts'),
makeImportEdge('src/a.ts', 'src/b.ts'),
makeImportEdge('src/b.ts', 'src/a.ts'), // circular
];
const tour = buildTour(nodes, edges, []);
// Should not loop infinitely, each file appears once
expect(tour).toHaveLength(3);
const fileIds = tour.map(s => s.nodeIds[0]);
expect(new Set(fileIds).size).toBe(3);
});
});
});