/** * The File view's models, without a browser (CG-46). * * The decision under test throughout is the rails' source of truth: they are * built from `dependencies` / `dependents` — the engine's own * `getFileDependencies` / `getFileDependents` — and merely *decorated* with * the `imports` rows. Getting that backwards is not a cosmetic bug: it silently * understates what a change to the file would reach, which is the only reason * the screen exists. * * The geometry-free sibling of `ui-symbol-model.test.ts` and * `ui-search-model.test.ts`. */ import { describe, it, expect } from 'vitest'; import { buildFileOutline, buildFileRail, fileMetaLine, formatBytes, looksLikeTest, OUTLINE_ROW_HEIGHT, OUTLINE_VIRTUAL_THRESHOLD, } from '../ui/src/lib/file-model'; import type { WireFilePayload, WireImportRow, WireOutlineEntry } from '../ui/src/lib/api'; /* ------------------------------------------------------------- fixtures -- */ function importRow(over: Partial = {}): WireImportRow { const symbols = over.symbols ?? [ { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 }, ]; return { file: over.file ?? 'src/db/queries.ts', test: over.test ?? false, symbols, symbolCount: over.symbolCount ?? symbols.length, }; } function entry(over: Partial = {}): WireOutlineEntry { return { id: over.id ?? 'method:x', kind: 'method', name: 'traverseBFS', qualifiedName: 'GraphTraverser.traverseBFS', file: 'src/graph/traversal.ts', line: 48, endLine: 150, language: 'typescript', test: false, parentId: 'class:GraphTraverser', depth: 1, fanIn: 3, fanOut: 7, ...over, } as WireOutlineEntry; } function payload(over: Partial = {}): WireFilePayload { return { file: { path: 'src/graph/traversal.ts', language: 'typescript', size: 24216, modifiedAt: 1, indexedAt: 2, contentHash: 'abc', nodeCount: 26, generated: false, test: false, errors: [], id: 'file:src/graph/traversal.ts', }, topLevel: { calls: 0 }, drift: false, outline: { total: 0, shown: 0, truncated: false, items: [] }, imports: { total: 0, shown: 0, truncated: false, items: [] }, importedBy: { total: 0, shown: 0, truncated: false, items: [] }, unresolvedImports: [], dependencies: [], dependents: [], ...over, } as WireFilePayload; } /* ----------------------------------------------------------------- rail -- */ describe('the import rails', () => { it('counts every dependency, not just the ones an import statement named', () => { // The real shape on this repo: traversal.ts imports two files and depends // on four — it reaches the LRU cache through a call with no import. const rail = buildFileRail( [ 'src/db/queries.ts', 'src/resolution/lru-cache.ts', 'src/types.ts', 'scripts/agent-eval/probe.mjs', ], [importRow({ file: 'src/db/queries.ts' }), importRow({ file: 'src/types.ts' })] ); expect(rail.total).toBe(4); expect(rail.rows).toHaveLength(4); expect(rail.rows.filter((r) => r.imported).map((r) => r.path)).toEqual([ 'src/db/queries.ts', 'src/types.ts', ]); expect(rail.rows.find((r) => r.path === 'src/resolution/lru-cache.ts')?.imported).toBe(false); }); it('names the symbols an import row carries, on the row for that file', () => { const rail = buildFileRail( ['src/db/queries.ts'], [ importRow({ symbols: [ { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 }, { id: 'iface:R', name: 'Row', kind: 'interface', line: 12 }, ], }), ] ); expect(rail.rows[0]?.symbols.map((s) => s.name)).toEqual(['QueryBuilder', 'Row']); expect(rail.rows[0]?.symbolCount).toBe(2); }); it('does not count a file node as a named symbol', () => { // An `importedBy` edge's far end is the importing file's own file node, so // its "symbols" repeat the path already in the row. A `1` there would be a // count of nothing. const rail = buildFileRail( ['src/index.ts'], [ importRow({ file: 'src/index.ts', symbols: [{ id: 'file:src/index.ts', name: 'index.ts', kind: 'file', line: 1 }], }), ] ); expect(rail.rows[0]?.symbolCount).toBe(0); expect(rail.rows[0]?.imported).toBe(true); }); it('sorts production files before tests, each alphabetically', () => { const rail = buildFileRail( ['src/z.ts', '__tests__/graph.test.ts', 'src/a.ts', '__tests__/a.test.ts'], [] ); expect(rail.rows.map((r) => r.path)).toEqual([ 'src/a.ts', 'src/z.ts', '__tests__/a.test.ts', '__tests__/graph.test.ts', ]); expect(rail.testCount).toBe(2); }); it('trusts the server about what is a test, and falls back to the path', () => { const rail = buildFileRail( ['src/looks-normal.ts', 'src/other.ts'], // The server can see more than a path; a row it marks wins. [importRow({ file: 'src/looks-normal.ts', test: true })] ); expect(rail.rows[0]?.path).toBe('src/other.ts'); expect(rail.rows[1]?.test).toBe(true); }); it('de-duplicates a file the engine listed twice', () => { const rail = buildFileRail(['src/a.ts', 'src/a.ts'], []); expect(rail.rows).toHaveLength(1); expect(rail.total).toBe(1); }); it('folds unresolved imports by name, keeping every line', () => { const rail = buildFileRail( [], [], [ { name: 'node:fs', line: 12 }, { name: 'react', line: 3 }, { name: 'node:fs', line: 4 }, ] ); expect(rail.outside).toEqual([ { name: 'node:fs', lines: [4, 12] }, { name: 'react', lines: [3] }, ]); // Outside-index rows never inflate the dependency count. expect(rail.total).toBe(0); }); }); describe('looksLikeTest', () => { it('recognises the shapes an unnamed dependency can arrive in', () => { expect(looksLikeTest('__tests__/graph.test.ts')).toBe(true); expect(looksLikeTest('src/service.spec.ts')).toBe(true); expect(looksLikeTest('test/helper.go')).toBe(true); expect(looksLikeTest('__tests__/fixtures/app/main.ts')).toBe(true); }); it('errs towards production — misfiling a real file is the worse mistake', () => { expect(looksLikeTest('src/latest.ts')).toBe(false); expect(looksLikeTest('src/protest/index.ts')).toBe(false); expect(looksLikeTest('src/testing-library.ts')).toBe(false); }); }); /* -------------------------------------------------------------- outline -- */ describe('the file outline', () => { it('keeps the server order and indents by depth', () => { const rows = buildFileOutline( payload({ outline: { total: 3, shown: 3, truncated: false, items: [ entry({ id: 'class:C', kind: 'class', name: 'GraphTraverser', depth: 0, line: 34 }), entry({ id: 'method:m', depth: 1, line: 48 }), entry({ id: 'prop:p', kind: 'property', name: 'queries', depth: 1, line: 35 }), ], }, }) ); expect(rows.map((r) => r.entry.id)).toEqual(['class:C', 'method:m', 'prop:p']); expect(rows.map((r) => r.indent)).toEqual([0, 1, 1]); }); it('dims data rather than behaviour', () => { const rows = buildFileOutline( payload({ outline: { total: 4, shown: 4, truncated: false, items: [ entry({ id: 'a', kind: 'property' }), entry({ id: 'b', kind: 'enum_member' }), entry({ id: 'c', kind: 'method' }), entry({ id: 'd', kind: 'class' }), ], }, }) ); expect(rows.map((r) => r.dimmed)).toEqual([true, true, false, false]); }); it('clamps the indent so a deeply nested closure stays in its column', () => { const rows = buildFileOutline( payload({ outline: { total: 1, shown: 1, truncated: false, items: [entry({ depth: 9 })], }, }) ); expect(rows[0]?.indent).toBe(3); }); it('windows past a threshold that leaves ordinary files alone', () => { // 135 symbols in this repo's biggest hand-written file (src/mcp/tools.ts); // 1,681 in the generated fixture that motivated the window. expect(OUTLINE_VIRTUAL_THRESHOLD).toBeGreaterThan(135); expect(OUTLINE_ROW_HEIGHT).toBeGreaterThan(0); }); }); /* --------------------------------------------------------------- header -- */ describe('the header line', () => { it('counts the outline, not the file record', () => { // nodeCount includes the file node and its import declarations; neither is // a row, and a header disagreeing with the list under it is unresolvable. const line = fileMetaLine( payload({ file: { ...payload().file, nodeCount: 26 }, outline: { total: 23, shown: 23, truncated: false, items: [] }, }) ); expect(line).toBe('typescript · 23.6 KB · 23 symbols'); }); it('tags a generated file and a test file', () => { const line = fileMetaLine( payload({ file: { ...payload().file, generated: true, test: true, size: 1024 }, outline: { total: 1, shown: 1, truncated: false, items: [] }, }) ); expect(line).toBe('typescript · 1.0 KB · 1 symbol · generated · test'); }); it('formats sizes for scale, never for accounting', () => { expect(formatBytes(0)).toBe('0 B'); expect(formatBytes(999)).toBe('999 B'); expect(formatBytes(24216)).toBe('23.6 KB'); expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); expect(formatBytes(Number.NaN)).toBe('—'); }); });