diff --git a/__tests__/ui-file-model.test.ts b/__tests__/ui-file-model.test.ts new file mode 100644 index 0000000..893d741 --- /dev/null +++ b/__tests__/ui-file-model.test.ts @@ -0,0 +1,304 @@ +/** + * 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('—'); + }); +}); diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts index 156d7c3..dedf8af 100644 --- a/__tests__/ui-server-api.test.ts +++ b/__tests__/ui-server-api.test.ts @@ -845,6 +845,17 @@ describe('GET /api/file/', () => { expect(body.dependencies).toContain('src/types.ts'); }); + it('says whether the file runs anything at its top level', async () => { + // `src/main.ts` instantiates a Service and calls two functions outside + // every definition — code no outline row can show, because it belongs to + // no symbol. `src/cache.ts` only defines things. + const main = await getJson('/api/file/src/main.ts'); + expect(main.topLevel.calls).toBeGreaterThanOrEqual(2); + + const cache = await getJson('/api/file/src/cache.ts'); + expect(cache.topLevel.calls).toBe(0); + }); + it('404s a file that is not in the index and refuses one outside the project', async () => { const missing = await getStatusAndJson('/api/file/src/nope.ts'); expect(missing.status).toBe(404); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 9dfada1..71ab2a3 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -143,6 +143,22 @@ Grid **300px | minmax(480px,1fr) | 300px**: Imported by · outline (source order File rows 12px mono, 5px 14px padding, `--rule-faint` separators; files outside the index in `--ink-3`, not clickable. Header: file glyph, basename as h1, `lang · KB · N symbols · generated`, full path. +**As built (phase 1, CG-46).** The two rails count **dependencies**, not import statements — +`getFileDependencies` / `getFileDependents`, every cross-file edge except `contains`. The prototype +drew `imports` edges alone, and on this repo that understates the answer: `src/graph/traversal.ts` +imports two files and depends on four (it reaches `src/resolution/lru-cache.ts` through a call no +import names). The import rows are still merged in — they carry the symbol NAMES, shown as a count +on the row and in full in its tooltip. Rows sort production-first then alphabetically, tests last. +Imports that resolved to nothing indexed are listed under **Outside the index**, in `--ink-3` and +not clickable, so a file importing `react` and `fs` does not read as having one dependency. +The header's `N symbols` is the OUTLINE's total, not the file record's node count (which includes +the file node and its import declarations). A file that runs code at its top level — an edge out of +the file node — carries a badge ("Runs N calls at the top level — see what it calls") that focuses +the file node, the only place that code can be read. Outline rows are a fixed 28px and the list is +windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); the two constants +live together in `ui/src/lib/file-model.ts`. Keyboard: ↑/↓ within a pane, ←/→ across the three +panes, Enter follows; `?hl=` selects the DEEPEST outline row whose range holds the line. + ### 3.5 Flow strip (`#/flow/`) Header: "Flow" + a `