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