diff --git a/tests/integration/cochange.test.js b/tests/integration/cochange.test.js index 4b60e893..5dbed8fb 100644 --- a/tests/integration/cochange.test.js +++ b/tests/integration/cochange.test.js @@ -192,6 +192,8 @@ describe('coChangeData + coChangeTopData', () => { // Query for a nonexistent file const data = coChangeData('nonexistent.js', dbPath); expect(data.error).toBeDefined(); + expect(typeof data.error).toBe('string'); + expect(data.error.length).toBeGreaterThan(0); }); test('meta is included in response', () => { diff --git a/tests/integration/context.test.js b/tests/integration/context.test.js index e9b1afc0..14e39b05 100644 --- a/tests/integration/context.test.js +++ b/tests/integration/context.test.js @@ -195,6 +195,8 @@ describe('contextData', () => { // Test callers should be excluded from the callers list // (noTests filters the result nodes themselves, not test callers specifically) expect(r).toBeDefined(); + expect(r.kind).toBe('function'); + expect(r.file).toBeTruthy(); }); test('nonexistent name returns empty results', () => { @@ -227,6 +229,8 @@ describe('contextData', () => { const pfResult = validateData.results.find((r) => r.name === 'processFiles'); // parseOne callee — has no comment above it (blank line separator) expect(pfResult).toBeDefined(); + expect(pfResult.kind).toBe('function'); + expect(pfResult.callees).toBeInstanceOf(Array); }); test('limits results with pagination', () => { diff --git a/tests/integration/dataflow.test.js b/tests/integration/dataflow.test.js index 1659fdce..78eb679d 100644 --- a/tests/integration/dataflow.test.js +++ b/tests/integration/dataflow.test.js @@ -323,10 +323,14 @@ describe('empty dataflow', () => { test('dataflowPathData returns warning', () => { const data = dataflowPathData('lonely', 'lonely', emptyDbPath); expect(data.warning).toBeDefined(); + expect(typeof data.warning).toBe('string'); + expect(data.warning.length).toBeGreaterThan(0); }); test('dataflowImpactData returns warning', () => { const data = dataflowImpactData('lonely', emptyDbPath); expect(data.warning).toBeDefined(); + expect(typeof data.warning).toBe('string'); + expect(data.warning.length).toBeGreaterThan(0); }); }); diff --git a/tests/parsers/javascript.test.js b/tests/parsers/javascript.test.js index 0550ad70..294ba39e 100644 --- a/tests/parsers/javascript.test.js +++ b/tests/parsers/javascript.test.js @@ -196,6 +196,7 @@ describe('JavaScript parser', () => { const symbols = parseJS(`program.command('query ').action(() => { search(); });`); const def = symbols.definitions.find((d) => d.name === 'command:query'); expect(def).toBeDefined(); + expect(def.kind).toBe('function'); }); it('does not extract Commander action with named handler', () => { @@ -223,6 +224,7 @@ describe('JavaScript parser', () => { const symbols = parseJS(`router.post('/api/items', async (req, res) => { save(); });`); const def = symbols.definitions.find((d) => d.name === 'route:POST /api/items'); expect(def).toBeDefined(); + expect(def.kind).toBe('function'); }); it('does not extract Map.get as Express route', () => { @@ -243,6 +245,7 @@ describe('JavaScript parser', () => { const symbols = parseJS(`server.once('listening', () => { log(); });`); const def = symbols.definitions.find((d) => d.name === 'event:listening'); expect(def).toBeDefined(); + expect(def.kind).toBe('function'); }); it('does not extract event with named handler', () => { diff --git a/tests/presentation/colors.test.js b/tests/presentation/colors.test.js new file mode 100644 index 00000000..ed120a0d --- /dev/null +++ b/tests/presentation/colors.test.js @@ -0,0 +1,75 @@ +/** + * Unit tests for presentation/colors.ts — color constant validation. + */ +import { describe, expect, it } from 'vitest'; +import { + COMMUNITY_COLORS, + DEFAULT_NODE_COLORS, + DEFAULT_ROLE_COLORS, +} from '../../src/presentation/colors.js'; + +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +describe('DEFAULT_NODE_COLORS', () => { + const expectedKinds = [ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', + 'file', + 'parameter', + 'property', + 'constant', + ]; + + it('has a color for every node kind', () => { + for (const kind of expectedKinds) { + expect(DEFAULT_NODE_COLORS).toHaveProperty(kind); + } + }); + + it('all values are valid hex colors', () => { + for (const [kind, color] of Object.entries(DEFAULT_NODE_COLORS)) { + expect(color, `${kind} color`).toMatch(HEX_COLOR); + } + }); +}); + +describe('DEFAULT_ROLE_COLORS', () => { + const expectedRoles = ['entry', 'core', 'utility', 'dead', 'leaf']; + + it('has a color for expected roles', () => { + for (const role of expectedRoles) { + expect(DEFAULT_ROLE_COLORS).toHaveProperty(role); + } + }); + + it('all values are valid hex colors', () => { + for (const [role, color] of Object.entries(DEFAULT_ROLE_COLORS)) { + expect(color, `${role} color`).toMatch(HEX_COLOR); + } + }); +}); + +describe('COMMUNITY_COLORS', () => { + it('has at least 12 colors', () => { + expect(COMMUNITY_COLORS.length).toBeGreaterThanOrEqual(12); + }); + + it('all values are valid hex colors', () => { + for (const color of COMMUNITY_COLORS) { + expect(color).toMatch(HEX_COLOR); + } + }); + + it('all colors are unique', () => { + const unique = new Set(COMMUNITY_COLORS); + expect(unique.size).toBe(COMMUNITY_COLORS.length); + }); +}); diff --git a/tests/presentation/queries-cli.test.js b/tests/presentation/queries-cli.test.js new file mode 100644 index 00000000..d5a41ae2 --- /dev/null +++ b/tests/presentation/queries-cli.test.js @@ -0,0 +1,628 @@ +/** + * Unit tests for presentation/queries-cli/ modules. + * + * Mocks the domain layer to supply controlled data, then verifies + * that the CLI formatters produce the expected console output. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mock domain layer ────────────────────────────────────────────── +const mocks = { + whereData: vi.fn(), + queryNameData: vi.fn(), + contextData: vi.fn(), + childrenData: vi.fn(), + explainData: vi.fn(), + implementationsData: vi.fn(), + interfacesData: vi.fn(), + pathData: vi.fn(), + exportsData: vi.fn(), + moduleMapData: vi.fn(), + rolesData: vi.fn(), + statsData: vi.fn(), + kindIcon: vi.fn((kind) => kind.charAt(0)), +}; + +vi.mock('../../src/domain/queries.js', () => mocks); +vi.mock('../../src/infrastructure/result-formatter.js', () => ({ + outputResult: () => false, // never short-circuit — always render CLI output +})); +// Also mock the canonical path so the tests remain correct if imports +// are refactored away from the backward-compat re-export. +vi.mock('../../src/presentation/result-formatter.js', () => ({ + outputResult: () => false, +})); + +// ── Import modules under test ────────────────────────────────────── +const { where, queryName, context, children, explain, implementations, interfaces } = await import( + '../../src/presentation/queries-cli/inspect.js' +); +const { symbolPath } = await import('../../src/presentation/queries-cli/path.js'); +const { fileExports } = await import('../../src/presentation/queries-cli/exports.js'); +const { moduleMap, roles } = await import('../../src/presentation/queries-cli/overview.js'); + +// ── Helpers ──────────────────────────────────────────────────────── +let lines; + +beforeEach(() => { + lines = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { + lines.push(args.map(String).join(' ')); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function output() { + return lines.join('\n'); +} + +// ── inspect.js: where ────────────────────────────────────────────── + +describe('where', () => { + it('prints symbol results with file and line', () => { + mocks.whereData.mockReturnValue({ + mode: 'symbol', + results: [ + { + name: 'foo', + kind: 'function', + role: 'core', + file: 'a.js', + line: 10, + exported: true, + uses: [{ file: 'b.js', line: 5 }], + }, + ], + }); + where('foo', '/db'); + const out = output(); + expect(out).toContain('foo'); + expect(out).toContain('a.js:10'); + expect(out).toContain('(exported)'); + expect(out).toContain('[core]'); + expect(out).toContain('b.js:5'); + }); + + it('prints file results with symbols and imports', () => { + mocks.whereData.mockReturnValue({ + mode: 'file', + results: [ + { + file: 'utils.js', + symbols: [{ name: 'add', line: 1 }], + imports: ['math.js'], + importedBy: ['index.js'], + exported: ['add'], + }, + ], + }); + where('utils.js', '/db'); + const out = output(); + expect(out).toContain('# utils.js'); + expect(out).toContain('add:1'); + expect(out).toContain('Imports: math.js'); + expect(out).toContain('Imported by: index.js'); + expect(out).toContain('Exported: add'); + }); + + it('prints message when no results', () => { + mocks.whereData.mockReturnValue({ mode: 'symbol', results: [] }); + where('ghost', '/db'); + expect(output()).toContain('No symbol matching "ghost"'); + }); + + it('shows "No uses found" when symbol has no uses', () => { + mocks.whereData.mockReturnValue({ + mode: 'symbol', + results: [ + { + name: 'lonely', + kind: 'function', + role: null, + file: 'a.js', + line: 1, + exported: false, + uses: [], + }, + ], + }); + where('lonely', '/db'); + expect(output()).toContain('No uses found'); + }); +}); + +// ── inspect.js: queryName ────────────────────────────────────────── + +describe('queryName', () => { + it('prints callees and callers', () => { + mocks.queryNameData.mockReturnValue({ + results: [ + { + name: 'build', + kind: 'function', + file: 'b.js', + line: 5, + callees: [{ name: 'parse', edgeKind: 'calls', file: 'p.js', line: 1 }], + callers: [{ name: 'main', edgeKind: 'calls', file: 'm.js', line: 10 }], + }, + ], + }); + queryName('build', '/db', {}); + const out = output(); + expect(out).toContain('build'); + expect(out).toContain('calls/uses'); + expect(out).toContain('parse'); + expect(out).toContain('called by'); + expect(out).toContain('main'); + }); + + it('prints "No results" when empty', () => { + mocks.queryNameData.mockReturnValue({ results: [] }); + queryName('ghost', '/db', {}); + expect(output()).toContain('No results for "ghost"'); + }); + + it('truncates at 15 callees with "more" message', () => { + const callees = Array.from({ length: 20 }, (_, i) => ({ + name: `fn${i}`, + edgeKind: 'calls', + file: 'x.js', + line: i, + })); + mocks.queryNameData.mockReturnValue({ + results: [{ name: 'hub', kind: 'function', file: 'h.js', line: 1, callees, callers: [] }], + }); + queryName('hub', '/db', {}); + const out = output(); + expect(out).toContain('and 5 more'); + }); +}); + +// ── inspect.js: context ──────────────────────────────────────────── + +describe('context', () => { + it('renders full context with sections', () => { + mocks.contextData.mockReturnValue({ + results: [ + { + name: 'parse', + kind: 'function', + role: 'core', + file: 'p.js', + line: 1, + endLine: 20, + signature: { params: 'src: string', returnType: 'AST' }, + children: [{ kind: 'parameter', name: 'src', line: 2 }], + complexity: { cognitive: 5, cyclomatic: 3, maxNesting: 2, maintainabilityIndex: 80 }, + source: 'function parse(src) {\n return ast(src);\n}', + callees: [{ kind: 'function', name: 'ast', file: 'a.js', line: 1, summary: 'parse AST' }], + callers: [{ kind: 'function', name: 'main', file: 'm.js', line: 10 }], + implementors: [], + implements: [], + relatedTests: [ + { file: 'test/p.test.js', testCount: 3, testNames: ['parses JS'], source: null }, + ], + }, + ], + }); + context('parse', '/db', {}); + const out = output(); + expect(out).toContain('# parse (function)'); + expect(out).toContain('[core]'); + expect(out).toContain('p.js:1-20'); + expect(out).toContain('Parameters: (src: string)'); + expect(out).toContain('Returns: AST'); + expect(out).toContain('Children (1)'); + expect(out).toContain('Cognitive: 5'); + expect(out).toContain('MI: 80'); + expect(out).toContain('## Source'); + expect(out).toContain('Direct Dependencies (1)'); + expect(out).toContain('parse AST'); + expect(out).toContain('Callers (1)'); + expect(out).toContain('Related Tests'); + expect(out).toContain('3 tests'); + }); + + it('prints message for no matches', () => { + mocks.contextData.mockReturnValue({ results: [] }); + context('ghost', '/db', {}); + expect(output()).toContain('No function/method/class matching "ghost"'); + }); +}); + +// ── inspect.js: children ─────────────────────────────────────────── + +describe('children', () => { + it('renders children list', () => { + mocks.childrenData.mockReturnValue({ + results: [ + { + name: 'Config', + kind: 'class', + file: 'c.js', + line: 1, + children: [ + { kind: 'method', name: 'load', line: 5 }, + { kind: 'property', name: 'path', line: 2 }, + ], + }, + ], + }); + children('Config', '/db', {}); + const out = output(); + expect(out).toContain('Config'); + expect(out).toContain('load'); + expect(out).toContain('path'); + }); + + it('shows "(no children)" for empty list', () => { + mocks.childrenData.mockReturnValue({ + results: [{ name: 'Leaf', kind: 'function', file: 'l.js', line: 1, children: [] }], + }); + children('Leaf', '/db', {}); + expect(output()).toContain('(no children)'); + }); +}); + +// ── inspect.js: explain ──────────────────────────────────────────── + +describe('explain', () => { + it('renders file explanation', () => { + mocks.explainData.mockReturnValue({ + kind: 'file', + results: [ + { + file: 'utils.js', + lineCount: 50, + symbolCount: 3, + publicApi: [ + { + kind: 'function', + name: 'add', + line: 1, + signature: { params: 'a, b' }, + role: 'utility', + summary: 'adds numbers', + }, + ], + internal: [ + { + kind: 'function', + name: 'helper', + line: 30, + signature: null, + role: null, + summary: null, + }, + ], + imports: [{ file: 'math.js' }], + importedBy: [{ file: 'index.js' }], + dataFlow: [{ caller: 'add', callees: ['helper'] }], + }, + ], + }); + explain('utils.js', '/db', {}); + const out = output(); + expect(out).toContain('# utils.js'); + expect(out).toContain('50 lines'); + expect(out).toContain('1 exported'); + expect(out).toContain('1 internal'); + expect(out).toContain('Imports: math.js'); + expect(out).toContain('Imported by: index.js'); + expect(out).toContain('## Exported'); + expect(out).toContain('add'); + expect(out).toContain('adds numbers'); + expect(out).toContain('## Internal'); + expect(out).toContain('helper'); + expect(out).toContain('## Data Flow'); + }); + + it('renders function explanation with depth-based headings', () => { + mocks.explainData.mockReturnValue({ + kind: 'function', + results: [ + { + name: 'main', + kind: 'function', + role: null, + file: 'm.js', + line: 1, + endLine: 10, + lineCount: 10, + summary: 'entry point', + _depth: 0, + signature: { params: '', returnType: 'void' }, + complexity: { cognitive: 2, cyclomatic: 1, maxNesting: 0 }, + callees: [{ kind: 'function', name: 'run', file: 'r.js', line: 1 }], + callers: [], + relatedTests: [], + depDetails: [], + }, + ], + }); + explain('main', '/db', {}); + const out = output(); + expect(out).toContain('# main (function)'); + expect(out).toContain('entry point'); + expect(out).toContain('Calls (1)'); + expect(out).toContain('run'); + }); + + it('prints "no function/symbol" when empty', () => { + mocks.explainData.mockReturnValue({ kind: 'function', results: [] }); + explain('ghost', '/db', {}); + expect(output()).toContain('No function/symbol matching "ghost"'); + }); +}); + +// ── inspect.js: implementations & interfaces ─────────────────────── + +describe('implementations', () => { + it('lists implementors', () => { + mocks.implementationsData.mockReturnValue({ + results: [ + { + name: 'Parser', + kind: 'interface', + file: 'p.ts', + line: 1, + implementors: [{ kind: 'class', name: 'JSParser', file: 'js.ts', line: 5 }], + }, + ], + }); + implementations('Parser', '/db', {}); + const out = output(); + expect(out).toContain('Parser'); + expect(out).toContain('Implementors (1)'); + expect(out).toContain('JSParser'); + }); + + it('shows "(no implementors)" for empty list', () => { + mocks.implementationsData.mockReturnValue({ + results: [{ name: 'Orphan', kind: 'interface', file: 'o.ts', line: 1, implementors: [] }], + }); + implementations('Orphan', '/db', {}); + expect(output()).toContain('(no implementors found)'); + }); +}); + +describe('interfaces', () => { + it('lists interfaces', () => { + mocks.interfacesData.mockReturnValue({ + results: [ + { + name: 'JSParser', + kind: 'class', + file: 'js.ts', + line: 5, + interfaces: [{ kind: 'interface', name: 'Parser', file: 'p.ts', line: 1 }], + }, + ], + }); + interfaces('JSParser', '/db', {}); + const out = output(); + expect(out).toContain('JSParser'); + expect(out).toContain('Implements (1)'); + expect(out).toContain('Parser'); + }); +}); + +// ── path.js: symbolPath ──────────────────────────────────────────── + +describe('symbolPath', () => { + it('prints path steps with edge kinds', () => { + mocks.pathData.mockReturnValue({ + found: true, + hops: 2, + path: [ + { name: 'a', kind: 'function', file: 'a.js', line: 1, edgeKind: null }, + { name: 'b', kind: 'function', file: 'b.js', line: 5, edgeKind: 'calls' }, + { name: 'c', kind: 'function', file: 'c.js', line: 10, edgeKind: 'calls' }, + ], + alternateCount: 1, + }); + symbolPath('a', 'c', '/db'); + const out = output(); + expect(out).toContain('Path from a to c'); + expect(out).toContain('2 hops'); + expect(out).toContain('--[calls]-->'); + expect(out).toContain('1 alternate shortest path'); + }); + + it('handles same-symbol (0 hops)', () => { + mocks.pathData.mockReturnValue({ + found: true, + hops: 0, + path: [{ name: 'x', kind: 'function', file: 'x.js', line: 1, edgeKind: null }], + alternateCount: 0, + }); + symbolPath('x', 'x', '/db'); + expect(output()).toContain('same symbol (0 hops)'); + }); + + it('handles not-found with candidate disambiguation', () => { + mocks.pathData.mockReturnValue({ + found: false, + maxDepth: 5, + reverse: false, + fromCandidates: [ + { name: 'run', file: 'a.js', line: 1 }, + { name: 'run', file: 'b.js', line: 5 }, + ], + toCandidates: [{ name: 'stop', file: 'c.js', line: 1 }], + }); + symbolPath('run', 'stop', '/db'); + const out = output(); + expect(out).toContain('No path from "run" to "stop"'); + expect(out).toContain('matched 2 symbols'); + }); + + it('prints error when present', () => { + mocks.pathData.mockReturnValue({ error: 'Symbol not found' }); + symbolPath('x', 'y', '/db'); + expect(output()).toContain('Symbol not found'); + // ensure we stopped after the error — no path-rendering output + expect(output()).not.toContain('Path from'); + }); +}); + +// ── exports.js: fileExports ──────────────────────────────────────── + +describe('fileExports', () => { + it('renders export list with consumers', () => { + mocks.exportsData.mockReturnValue({ + file: 'math.js', + totalExported: 2, + totalInternal: 1, + totalUnused: 0, + results: [ + { + name: 'add', + kind: 'function', + line: 1, + role: 'utility', + signature: { params: 'a, b' }, + consumers: [{ name: 'main', file: 'index.js', line: 5 }], + }, + { + name: 'subtract', + kind: 'function', + line: 10, + role: null, + signature: null, + consumers: [], + }, + ], + reexportedSymbols: [], + reexports: [], + }); + fileExports('math.js', '/db'); + const out = output(); + expect(out).toContain('math.js'); + expect(out).toContain('2 exported'); + expect(out).toContain('1 internal'); + expect(out).toContain('add(a, b)'); + expect(out).toContain('[utility]'); + expect(out).toContain('main (index.js:5)'); + expect(out).toContain('subtract'); + expect(out).toContain('(no consumers)'); + }); + + it('renders barrel file header when no direct exports', () => { + mocks.exportsData.mockReturnValue({ + file: 'index.js', + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + results: [], + reexportedSymbols: [ + { + name: 'add', + kind: 'function', + line: 1, + role: null, + signature: null, + originFile: 'math.js', + consumers: [], + }, + ], + reexports: [], + }); + fileExports('index.js', '/db'); + const out = output(); + expect(out).toContain('barrel file'); + expect(out).toContain('from math.js'); + }); + + it('prints message when no exports found', () => { + mocks.exportsData.mockReturnValue({ + file: 'empty.js', + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + results: [], + reexportedSymbols: [], + reexports: [], + }); + fileExports('empty.js', '/db'); + expect(output()).toContain('No exported symbols found'); + }); + + it('prints unused header when opts.unused', () => { + mocks.exportsData.mockReturnValue({ + file: 'lib.js', + totalExported: 3, + totalInternal: 0, + totalUnused: 1, + results: [ + { name: 'dead', kind: 'function', line: 5, role: 'dead', signature: null, consumers: [] }, + ], + reexportedSymbols: [], + reexports: [], + }); + fileExports('lib.js', '/db', { unused: true }); + const out = output(); + expect(out).toContain('1 unused export'); + expect(out).toContain('of 3 exported'); + }); +}); + +// ── overview.js: moduleMap ───────────────────────────────────────── + +describe('moduleMap', () => { + it('renders grouped directory output with coupling bars', () => { + mocks.moduleMapData.mockReturnValue({ + topNodes: [ + { file: 'src/parser.js', dir: 'src', inEdges: 10, outEdges: 5 }, + { file: 'src/builder.js', dir: 'src', inEdges: 3, outEdges: 8 }, + { file: 'lib/utils.js', dir: 'lib', inEdges: 2, outEdges: 1 }, + ], + stats: { totalFiles: 20, totalNodes: 100, totalEdges: 200 }, + }); + moduleMap('/db', 20, {}); + const out = output(); + expect(out).toContain('[src/]'); + expect(out).toContain('[lib/]'); + expect(out).toContain('parser.js'); + expect(out).toContain('<- 10'); + expect(out).toContain('-> 5'); + expect(out).toContain('#'); // coupling bar + expect(out).toContain('Total: 20 files'); + }); +}); + +// ── overview.js: roles ───────────────────────────────────────────── + +describe('roles', () => { + it('renders role groups with truncation at 30', () => { + const symbols = Array.from({ length: 35 }, (_, i) => ({ + role: 'core', + kind: 'function', + name: `fn${i}`, + file: 'a.js', + line: i + 1, + })); + mocks.rolesData.mockReturnValue({ + count: 35, + summary: { core: 35 }, + symbols, + }); + roles('/db', {}); + const out = output(); + expect(out).toContain('Node roles (35 symbols)'); + expect(out).toContain('## core (35)'); + expect(out).toContain('fn0'); + expect(out).toContain('fn29'); + expect(out).toContain('and 5 more'); + expect(out).not.toContain('fn30'); + }); + + it('prints message when no symbols classified', () => { + mocks.rolesData.mockReturnValue({ count: 0, summary: {}, symbols: [] }); + roles('/db', {}); + expect(output()).toContain('No classified symbols found'); + }); +}); diff --git a/tests/presentation/result-formatter.test.js b/tests/presentation/result-formatter.test.js new file mode 100644 index 00000000..b524a297 --- /dev/null +++ b/tests/presentation/result-formatter.test.js @@ -0,0 +1,98 @@ +/** + * Unit tests for presentation/result-formatter.js — output dispatch logic. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the config loader so it doesn't touch disk +vi.mock('../../src/infrastructure/config.js', () => ({ + loadConfig: () => ({ display: { maxColWidth: 40 } }), +})); + +// Mock paginate to capture NDJSON calls +const mockPrintNdjson = vi.fn(); +vi.mock('../../src/shared/paginate.js', () => ({ + printNdjson: (...args) => mockPrintNdjson(...args), +})); + +const { outputResult } = await import('../../src/presentation/result-formatter.js'); + +describe('outputResult', () => { + let logSpy; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockPrintNdjson.mockClear(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('returns false when no format option is set', () => { + const result = outputResult({ items: [1, 2] }, 'items', {}); + expect(result).toBe(false); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('handles json option', () => { + const data = { results: [{ name: 'foo' }] }; + const result = outputResult(data, 'results', { json: true }); + expect(result).toBe(true); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(logSpy.mock.calls[0][0]); + expect(output).toEqual(data); + }); + + it('handles ndjson option', () => { + const data = { results: [{ name: 'a' }] }; + const result = outputResult(data, 'results', { ndjson: true }); + expect(result).toBe(true); + expect(mockPrintNdjson).toHaveBeenCalledWith(data, 'results'); + }); + + it('handles csv option with array data', () => { + const data = { + items: [ + { name: 'a', count: 1 }, + { name: 'b', count: 2 }, + ], + }; + const result = outputResult(data, 'items', { csv: true }); + expect(result).toBe(true); + // Header row + 2 data rows + expect(logSpy).toHaveBeenCalledTimes(3); + expect(logSpy.mock.calls[0][0]).toContain('name'); + expect(logSpy.mock.calls[0][0]).toContain('count'); + }); + + it('csv returns false when field is not an array', () => { + const result = outputResult({ scalar: 42 }, 'scalar', { csv: true }); + expect(result).toBe(false); + }); + + it('handles table option', () => { + const data = { items: [{ file: 'a.js', lines: 10 }] }; + const result = outputResult(data, 'items', { table: true }); + expect(result).toBe(true); + expect(logSpy).toHaveBeenCalled(); + // Should contain table formatting (box-drawing chars) + const output = logSpy.mock.calls[0][0]; + expect(output).toContain('\u2500'); + }); + + it('csv escapes commas and quotes in values', () => { + const data = { items: [{ text: 'hello, world', quoted: 'say "hi"' }] }; + outputResult(data, 'items', { csv: true }); + const dataRow = logSpy.mock.calls[1][0]; + expect(dataRow).toContain('"hello, world"'); + expect(dataRow).toContain('"say ""hi"""'); + }); + + it('flattens nested objects for csv/table output', () => { + const data = { items: [{ meta: { score: 5, file: 'a.js' } }] }; + outputResult(data, 'items', { csv: true }); + const header = logSpy.mock.calls[0][0]; + expect(header).toContain('meta.score'); + expect(header).toContain('meta.file'); + }); +}); diff --git a/tests/presentation/table.test.js b/tests/presentation/table.test.js new file mode 100644 index 00000000..c78e7f80 --- /dev/null +++ b/tests/presentation/table.test.js @@ -0,0 +1,125 @@ +/** + * Unit tests for presentation/table.ts — pure formatting functions. + */ +import { describe, expect, it } from 'vitest'; +import { formatTable, truncEnd } from '../../src/presentation/table.js'; + +describe('truncEnd', () => { + it('returns string unchanged when within limit', () => { + expect(truncEnd('hello', 10)).toBe('hello'); + }); + + it('returns string unchanged when exactly at limit', () => { + expect(truncEnd('hello', 5)).toBe('hello'); + }); + + it('truncates and appends ellipsis when over limit', () => { + expect(truncEnd('hello world', 5)).toBe('hell\u2026'); + }); + + it('truncates to single char plus ellipsis for maxLen=2', () => { + expect(truncEnd('abcdef', 2)).toBe('a\u2026'); + }); + + it('handles empty string', () => { + expect(truncEnd('', 5)).toBe(''); + }); +}); + +describe('formatTable', () => { + it('renders header, separator, and data rows', () => { + const result = formatTable({ + columns: [ + { header: 'Name', width: 10 }, + { header: 'Count', width: 5, align: 'right' }, + ], + rows: [ + ['alpha', '42'], + ['beta', '7'], + ], + }); + + const lines = result.split('\n'); + expect(lines).toHaveLength(4); // header + separator + 2 rows + expect(lines[0]).toContain('Name'); + expect(lines[0]).toContain('Count'); + // Separator uses unicode box-drawing char + expect(lines[1]).toContain('\u2500'); + }); + + it('right-aligns numeric columns', () => { + const result = formatTable({ + columns: [{ header: 'Val', width: 6, align: 'right' }], + rows: [['42']], + }); + const dataLine = result.split('\n')[2]; + // '42' right-aligned in 6-char column → 4 leading spaces + expect(dataLine).toMatch(/\s+42/); + }); + + it('left-aligns by default', () => { + const result = formatTable({ + columns: [{ header: 'Name', width: 8 }], + rows: [['hi']], + }); + const dataLine = result.split('\n')[2]; + // 'hi' left-aligned → followed by spaces + expect(dataLine).toContain('hi '); + }); + + it('respects custom indent', () => { + const result = formatTable({ + columns: [{ header: 'X', width: 3 }], + rows: [['a']], + indent: 4, + }); + // Each line starts with 4-space indent + for (const line of result.split('\n')) { + expect(line).toMatch(/^ {4}/); + } + }); + + it('uses default indent of 2', () => { + const result = formatTable({ + columns: [{ header: 'X', width: 3 }], + rows: [], + }); + const lines = result.split('\n'); + expect(lines[0]).toMatch(/^ {2}/); + }); + + it('handles empty rows', () => { + const result = formatTable({ + columns: [{ header: 'A', width: 5 }], + rows: [], + }); + const lines = result.split('\n'); + expect(lines).toHaveLength(2); // header + separator only + }); + + it('handles missing cell values gracefully', () => { + const result = formatTable({ + columns: [ + { header: 'A', width: 5 }, + { header: 'B', width: 5 }, + ], + rows: [['only-a']], // missing second cell + }); + // Should not throw + expect(result).toContain('only-a'); + }); + + it('separator width matches column widths', () => { + const result = formatTable({ + columns: [ + { header: 'Name', width: 10 }, + { header: 'Size', width: 6 }, + ], + rows: [], + }); + const separator = result.split('\n')[1].trim(); + // 10 + 1 (space) + 6 = 17 box-drawing chars and spaces + const boxChars = separator.replace(/ /g, ''); + expect(boxChars.length).toBe(16); // 10 + 6 box chars + }); +}); diff --git a/tests/search/embedder-search.test.js b/tests/search/embedder-search.test.js index f873a33b..6216c986 100644 --- a/tests/search/embedder-search.test.js +++ b/tests/search/embedder-search.test.js @@ -409,6 +409,7 @@ describe('hybridSearchData', () => { expect(data).not.toBeNull(); const fd = data.results.find((r) => r.name === 'formatDate'); expect(fd).toBeDefined(); + expect(fd.rrf).toBeGreaterThan(0); }); test('respects rrfK parameter', async () => {