GitNexus/gitnexus/test/unit/eval-formatters.test.ts
Kevin Rajan 5e6b79deba
fix(cli): do not call zero-symbol detect-changes a clean tree (#3138)
* fix(cli): do not call zero-symbol detect-changes a clean tree

Print backend summary.message and distinguish a parsed diff with no overlapping indexed symbols from an empty git diff. Pin --color=never so color.ui=always cannot hide +++ b/ headers.

* fix(cli): localize clean-tree detect-changes and skip no-overlap when partial

Production empty diffs carry English summary.message; route that through t() so zh-CN fires. Do not claim no indexed-symbol overlap on queryDegraded partial results. Pin formatter tests to en and cover the production payload shapes.

* fix(cli): prettier detect-changes-format and degraded eval assertion

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-02 18:59:53 +01:00

791 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* P1 Unit Tests: Eval Server Formatters
*
* Tests: formatQueryResult, formatContextResult, formatImpactResult,
* formatCypherResult, formatDetectChangesResult, formatListReposResult, MAX_BODY_SIZE
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
formatQueryResult,
formatContextResult,
formatImpactResult,
formatCypherResult,
formatDetectChangesResult,
formatListReposResult,
MAX_BODY_SIZE,
validateHost,
} from '../../src/cli/eval-server.js';
import { formatSymbolLine } from '../../src/cli/format-symbol.js';
// ─── validateHost ────────────────────────────────────────────────────
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv('GITNEXUS_LANG', 'en');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('validateHost', () => {
it('passes "localhost" through unchanged', () => {
expect(validateHost('localhost')).toBe('localhost');
});
it('accepts valid IPv4 addresses', () => {
expect(validateHost('127.0.0.1')).toBe('127.0.0.1');
expect(validateHost('0.0.0.0')).toBe('0.0.0.0');
expect(validateHost('192.168.1.5')).toBe('192.168.1.5');
expect(validateHost('10.0.0.1')).toBe('10.0.0.1');
});
it('accepts valid IPv6 addresses', () => {
expect(validateHost('::1')).toBe('::1');
expect(validateHost('::')).toBe('::');
expect(validateHost('2001:db8::1')).toBe('2001:db8::1');
});
it('returns null for a non-IP hostname', () => {
expect(validateHost('foo.bar')).toBeNull();
expect(validateHost('myhost.local')).toBeNull();
expect(validateHost('example.com')).toBeNull();
});
it('returns null for out-of-range IPv4 octets', () => {
expect(validateHost('999.999.999.999')).toBeNull();
expect(validateHost('192.168.1.256')).toBeNull();
});
it('returns null for incomplete IPv4 addresses', () => {
expect(validateHost('192.168.1')).toBeNull();
expect(validateHost('192.168')).toBeNull();
});
it('returns null for an empty string', () => {
expect(validateHost('')).toBeNull();
});
it('returns null for whitespace or padded IPs', () => {
expect(validateHost(' ')).toBeNull();
expect(validateHost(' 127.0.0.1')).toBeNull();
expect(validateHost('127.0.0.1 ')).toBeNull();
});
});
// ─── MAX_BODY_SIZE ───────────────────────────────────────────────────
describe('MAX_BODY_SIZE', () => {
it('is 1MB', () => {
expect(MAX_BODY_SIZE).toBe(1024 * 1024);
});
});
// ─── formatQueryResult ───────────────────────────────────────────────
describe('formatQueryResult', () => {
it('returns error message for error input', () => {
expect(formatQueryResult({ error: 'something failed' })).toBe('Error: something failed');
});
it('returns no-match message for empty results', () => {
const result = formatQueryResult({ processes: [], definitions: [] });
expect(result).toContain('No matching execution flows');
});
it('formats processes with symbols', () => {
const result = formatQueryResult({
processes: [{ id: 'p1', summary: 'User Login Flow', step_count: 3, symbol_count: 2 }],
process_symbols: [
{
process_id: 'p1',
type: 'Function',
name: 'login',
filePath: 'src/auth.ts',
startLine: 10,
},
{
process_id: 'p1',
type: 'Function',
name: 'validate',
filePath: 'src/auth.ts',
startLine: 20,
},
],
definitions: [],
});
expect(result).toContain('1 execution flow');
expect(result).toContain('User Login Flow');
expect(result).toContain('login');
expect(result).toContain(':10');
});
it('truncates symbols per process at 6', () => {
const symbols = Array.from({ length: 10 }, (_, i) => ({
process_id: 'p1',
type: 'Function',
name: `fn${i}`,
filePath: 'src/test.ts',
}));
const result = formatQueryResult({
processes: [{ id: 'p1', summary: 'Flow', step_count: 10, symbol_count: 10 }],
process_symbols: symbols,
definitions: [],
});
expect(result).toContain('and 4 more');
});
it('formats standalone definitions', () => {
const result = formatQueryResult({
processes: [],
definitions: [{ type: 'Interface', name: 'Config', filePath: 'src/types.ts' }],
});
expect(result).toContain('Standalone definitions');
expect(result).toContain('Config');
});
it('truncates definitions at 8', () => {
const defs = Array.from({ length: 12 }, (_, i) => ({
type: 'Interface',
name: `Type${i}`,
filePath: 'src/types.ts',
}));
const result = formatQueryResult({ processes: [], definitions: defs });
expect(result).toContain('and 4 more');
});
});
// ─── formatContextResult ─────────────────────────────────────────────
describe('formatContextResult', () => {
it('returns error message for error input', () => {
expect(formatContextResult({ error: 'not found' })).toBe('Error: not found');
});
it('handles ambiguous results', () => {
const result = formatContextResult({
status: 'ambiguous',
candidates: [
{ name: 'foo', kind: 'Function', filePath: 'src/a.ts', line: 10, uid: 'uid1' },
{ name: 'foo', kind: 'Function', filePath: 'src/b.ts', line: 5, uid: 'uid2' },
],
});
expect(result).toContain('Multiple symbols');
expect(result).toContain('uid1');
expect(result).toContain('uid2');
});
it('returns "Symbol not found" when no symbol', () => {
expect(formatContextResult({})).toBe('Symbol not found.');
});
it('formats symbol with incoming/outgoing refs', () => {
const result = formatContextResult({
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts', startLine: 1, endLine: 10 },
incoming: {
CALLS: [{ kind: 'Function', name: 'bar', filePath: 'src/b.ts' }],
},
outgoing: {
IMPORTS: [{ kind: 'Module', name: 'utils', filePath: 'src/utils.ts' }],
},
processes: [],
});
expect(result).toContain('Function foo');
expect(result).toContain('Called/imported by (1)');
expect(result).toContain('Calls/imports (1)');
});
it('formats process participation', () => {
const result = formatContextResult({
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts' },
incoming: {},
outgoing: {},
processes: [{ name: 'Auth Flow', step_index: 2, step_count: 5 }],
});
expect(result).toContain('1 execution flow');
expect(result).toContain('Auth Flow');
});
});
// ─── formatImpactResult ──────────────────────────────────────────────
describe('formatImpactResult', () => {
it('returns error message for error input', () => {
expect(formatImpactResult({ error: 'bad request' })).toContain('Error: bad request');
});
it('surfaces per-candidate blast radius for an ambiguous result, never the "isolated" headline (#2129)', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'classifyCard' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 3,
maxRisk: 'MEDIUM',
candidates: [
{
uid: 'Function:src/sync-logic.ts:classifyCard',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/sync-logic.ts',
line: 1,
impactedCount: 3,
risk: 'MEDIUM',
},
{
uid: 'Function:src/ui-helpers.ts:classifyCard',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/ui-helpers.ts',
line: 1,
impactedCount: 1,
risk: 'LOW',
},
],
});
// Must NOT print the false-safe "isolated" headline.
expect(result).not.toContain('isolated');
expect(result).toContain('AMBIGUOUS');
expect(result).toContain('Max blast radius 3');
// Both candidates + their real counts are visible.
expect(result).toContain('src/sync-logic.ts');
expect(result).toContain('[3 upstream');
expect(result).toContain('--uid');
// No probe failed here → no lower-bound warning.
expect(result).not.toContain('candidate probes failed');
});
it('warns that the max is a lower bound when a candidate probe failed (#2129 review F1)', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'classifyCard' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 2,
maxRisk: 'LOW',
partialProbe: true,
candidates: [
{
uid: 'A',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/a.ts',
line: 1,
impactedCount: 2,
risk: 'LOW',
},
{
uid: 'B',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/b.ts',
line: 1,
impactedCount: 0,
risk: 'UNKNOWN',
},
],
});
expect(result).not.toContain('isolated');
expect(result).toContain('candidate probes failed');
expect(result).toContain('lower bound');
// The honest max is still shown.
expect(result).toContain('Max blast radius 2');
});
it('reports the full match count when the candidate list is truncated (#2129 review F11)', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'handle' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 5,
maxRisk: 'HIGH',
totalCandidates: 9,
candidatesTruncated: true,
candidates: Array.from({ length: 6 }, (_, i) => ({
uid: `U${i}`,
name: 'handle',
kind: 'Function',
filePath: `src/h${i}.ts`,
line: 1,
impactedCount: i,
risk: 'LOW',
})),
});
// Full count (9), not the truncated array length (6).
expect(result).toContain('9 symbols');
expect(result).toContain('showing 6');
});
it('shows a plain count when the candidate list is not truncated', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'foo' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 1,
maxRisk: 'LOW',
totalCandidates: 2,
candidates: [
{
uid: 'A',
name: 'foo',
kind: 'Function',
filePath: 'src/a.ts',
line: 1,
impactedCount: 1,
risk: 'LOW',
},
{
uid: 'B',
name: 'foo',
kind: 'Function',
filePath: 'src/b.ts',
line: 1,
impactedCount: 0,
risk: 'LOW',
},
],
});
expect(result).toContain('2 symbols');
expect(result).not.toContain('showing');
});
it('surfaces the lower-bound boundary note when epistemic is lower-bound (#1858)', () => {
const result = formatImpactResult({
target: { kind: 'Class', name: 'EmailLogger' },
direction: 'upstream',
impactedCount: 0,
risk: 'LOW',
epistemic: 'lower-bound',
boundaries: ['Logger is an interface with 2 implementations; callers bind via DI.'],
byDepth: {},
});
expect(result).not.toContain('isolated');
expect(result.toLowerCase()).toContain('lower bound');
expect(result).toContain('Logger is an interface');
});
it('returns error with suggestion when provided', () => {
const result = formatImpactResult({
error: 'Impact analysis failed',
suggestion: 'Try gitnexus context <symbol> as a fallback',
});
expect(result).toContain('Error: Impact analysis failed');
expect(result).toContain('Suggestion: Try gitnexus context');
});
it('shows partial warning when traversal was interrupted', () => {
const result = formatImpactResult({
target: { kind: 'Function', name: 'foo' },
direction: 'upstream',
impactedCount: 2,
partial: true,
byDepth: {
1: [
{
type: 'Function',
name: 'caller1',
filePath: 'src/a.ts',
relationType: 'CALLS',
confidence: 1,
},
{
type: 'Function',
name: 'caller2',
filePath: 'src/b.ts',
relationType: 'CALLS',
confidence: 1,
},
],
},
});
expect(result).toContain('Partial results');
expect(result).toContain('caller1');
expect(result).toContain('caller2');
});
it('prints the shared-axes comparison when a target has unavailable risk axes', () => {
const result = formatImpactResult({
target: { kind: 'File', name: 'crypto.ts' },
direction: 'upstream',
impactedCount: 13,
risk: 'MEDIUM',
riskSharedAxes: 'MEDIUM',
riskScale: {
comparableAcrossKinds: false,
unusedAxes: [
{
axis: 'processes',
reason: 'file-nodes-have-no-process-or-community-membership',
},
{
axis: 'modules',
reason: 'file-nodes-have-no-process-or-community-membership',
},
],
},
byDepthCounts: { 1: 13 },
});
expect(result).toContain('Risk: MEDIUM');
expect(result).toContain('Shared-axes risk: MEDIUM');
expect(result).toContain('process/module axes are unavailable');
expect(result).toContain('do not use this to waive a HIGH/CRITICAL risk warning');
});
it('handles zero impact', () => {
const result = formatImpactResult({
target: { name: 'foo' },
direction: 'upstream',
impactedCount: 0,
byDepth: {},
});
expect(result).toContain('No upstream callers resolved');
expect(result).not.toContain('appears isolated');
});
it('prints UNKNOWN and riskNote for an empty upstream walk', () => {
const result = formatImpactResult({
target: { name: 'foo' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
riskNote: 'safe to change is a claim about callers and there were none to reason about',
byDepth: {},
});
expect(result).toContain('Risk: UNKNOWN');
expect(result).toContain('safe to change is a claim about callers');
expect(result).not.toContain('appears isolated');
});
it('formats impact by depth', () => {
const result = formatImpactResult({
target: { kind: 'Function', name: 'foo' },
direction: 'upstream',
impactedCount: 3,
byDepth: {
1: [
{
type: 'Function',
name: 'caller1',
filePath: 'src/a.ts',
relationType: 'CALLS',
confidence: 1,
},
{
type: 'Function',
name: 'caller2',
filePath: 'src/b.ts',
relationType: 'CALLS',
confidence: 0.8,
},
],
2: [
{
type: 'Class',
name: 'App',
filePath: 'src/app.ts',
relationType: 'IMPORTS',
confidence: 1,
},
],
},
});
expect(result).toContain('Blast radius');
expect(result).toContain('WILL BREAK');
expect(result).toContain('caller1');
expect(result).toContain('conf: 0.8');
expect(result).toContain('LIKELY AFFECTED');
});
it('truncates items per depth at 12', () => {
const items = Array.from({ length: 15 }, (_, i) => ({
type: 'Function',
name: `fn${i}`,
filePath: 'src/test.ts',
relationType: 'CALLS',
confidence: 1,
}));
const result = formatImpactResult({
target: { kind: 'Function', name: 'foo' },
direction: 'upstream',
impactedCount: 15,
byDepth: { 1: items },
});
expect(result).toContain('and 3 more');
});
});
// ─── formatCypherResult ──────────────────────────────────────────────
describe('formatCypherResult', () => {
it('returns error message for error input', () => {
expect(formatCypherResult({ error: 'syntax error' })).toBe('Error: syntax error');
});
it('handles empty array', () => {
expect(formatCypherResult([])).toBe('Query returned 0 rows.');
});
it('formats array of objects as table', () => {
const result = formatCypherResult([
{ name: 'foo', filePath: 'src/a.ts' },
{ name: 'bar', filePath: 'src/b.ts' },
]);
expect(result).toContain('2 row(s)');
expect(result).toContain('name: foo');
expect(result).toContain('name: bar');
});
it('truncates at 30 rows', () => {
const rows = Array.from({ length: 35 }, (_, i) => ({ id: i }));
const result = formatCypherResult(rows);
expect(result).toContain('5 more rows');
});
it('handles string result', () => {
expect(formatCypherResult('some text')).toBe('some text');
});
});
// ─── formatSymbolLine ────────────────────────────────────────────────
describe('formatSymbolLine', () => {
// `||`, not `??`, on every field: a node whose label came back as an EMPTY
// STRING (several node types do) still needs the placeholder — a `??` here
// would render " login → src/auth.ts" instead of " Symbol login → ...".
it.each<[string | undefined, string | undefined, string | undefined, string]>([
['Function', 'login', 'src/auth.ts', ' Function login → src/auth.ts'],
['', 'login', 'src/auth.ts', ' Symbol login → src/auth.ts'],
[undefined, 'login', 'src/auth.ts', ' Symbol login → src/auth.ts'],
['Function', '', 'src/auth.ts', ' Function ? → src/auth.ts'],
['Function', undefined, 'src/auth.ts', ' Function ? → src/auth.ts'],
['Function', 'login', '', ' Function login → ?'],
['Function', 'login', undefined, ' Function login → ?'],
[undefined, undefined, undefined, ' Symbol ? → ?'],
])('renders type=%s name=%s path=%s as "%s"', (type, name, filePath, expected) => {
expect(formatSymbolLine(type, name, filePath)).toBe(expected);
});
it('is the line both consumers render (detect_changes + query definitions)', () => {
const detectChanges = formatDetectChangesResult({
summary: { changed_files: 1, changed_count: 1, affected_count: 0, risk_level: 'LOW' },
changed_symbols: [{ type: '', name: 'foo', filePath: 'src/a.ts' }],
});
expect(detectChanges).toContain(formatSymbolLine('', 'foo', 'src/a.ts'));
const query = formatQueryResult({
processes: [],
definitions: [{ type: '', name: '', filePath: '' }],
});
expect(query).toContain(formatSymbolLine('', '', ''));
});
});
// ─── formatDetectChangesResult ───────────────────────────────────────
describe('formatDetectChangesResult', () => {
it('returns error message for error input', () => {
expect(formatDetectChangesResult({ error: 'git error' })).toBe('Error: git error');
});
it('handles no changes', () => {
const result = formatDetectChangesResult({ summary: { changed_count: 0 } });
expect(result).toBe('No changes detected.');
});
it('flags a degraded run instead of printing a clean bill of health (#2283)', () => {
// The backend sets `partial` when a graph query is swallowed, and leaves the
// counts at zero. Without the note the pre-commit gate reads as "clean".
const result = formatDetectChangesResult({ partial: true, summary: { changed_count: 0 } });
expect(result).toContain('PARTIAL RESULT');
expect(result).toContain('a graph query failed');
expect(result).not.toContain('No changes detected.');
});
it('flags a degraded run that still found symbols', () => {
const result = formatDetectChangesResult({
partial: true,
summary: { changed_files: 1, changed_count: 1, affected_count: 0, risk_level: 'LOW' },
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
});
expect(result).toContain('PARTIAL RESULT');
expect(result).toContain('foo');
});
it('flags a capped listing, so a short list is not read as a short diff', () => {
// `truncated` is `partial`'s sibling and NOT the same claim: the counts and
// risk level still cover every changed symbol, only the names were capped.
const result = formatDetectChangesResult({
truncated: true,
summary: { changed_files: 40, changed_count: 500, affected_count: 0, risk_level: 'HIGH' },
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
});
expect(result).toContain('LISTING CAPPED');
expect(result).not.toContain('PARTIAL RESULT');
expect(result).toContain('foo');
});
it('leads with both notes when a run was degraded AND capped', () => {
const result = formatDetectChangesResult({
partial: true,
truncated: true,
summary: { changed_files: 40, changed_count: 500, affected_count: 0, risk_level: 'HIGH' },
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
});
// A caveat printed after the summary is read too late, so both notes lead.
expect(result.indexOf('PARTIAL RESULT')).toBe(0);
expect(result.indexOf('LISTING CAPPED')).toBeGreaterThan(0);
expect(result.indexOf('LISTING CAPPED')).toBeLessThan(result.indexOf('Changes: 40 files'));
// And it must NOT keep the truncated-only reassurance that the counts are
// whole: `changed_count` was summed from the batches that succeeded, so with
// `partial` it is a floor. Claiming otherwise here contradicts the note above
// it and the tool description.
expect(result).toContain('lower bound');
expect(result).not.toContain('still cover all of them');
});
it('flags a capped listing that found nothing, alongside the no-changes line', () => {
const result = formatDetectChangesResult({ truncated: true, summary: { changed_count: 0 } });
expect(result).toContain('LISTING CAPPED');
expect(result).toContain('No changes detected.');
});
it('reports the overflow count once — the capped note carries no number of its own', () => {
const result = formatDetectChangesResult({
truncated: true,
summary: { changed_files: 40, changed_count: 500, affected_count: 0, risk_level: 'HIGH' },
changed_symbols: Array.from({ length: 15 }, (_, i) => ({
type: 'Function',
name: `fn${i}`,
filePath: 'src/test.ts',
})),
});
// Splitting on a needle yields (occurrences + 1) pieces.
expect(result.split('... and 485 more')).toHaveLength(2);
expect(result.split('LISTING CAPPED')).toHaveLength(2);
expect(result.match(/485/g)).toEqual(['485']);
});
it('formats changes with affected processes', () => {
const result = formatDetectChangesResult({
summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' },
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
affected_processes: [
{ name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] },
],
});
expect(result).toContain('2 files');
expect(result).toContain('MEDIUM');
expect(result).toContain('Auth Flow');
});
it('truncates changed symbols at 15', () => {
const symbols = Array.from({ length: 20 }, (_, i) => ({
type: 'Function',
name: `fn${i}`,
filePath: 'src/test.ts',
}));
const result = formatDetectChangesResult({
summary: { changed_files: 1, changed_count: 20, affected_count: 0, risk_level: 'HIGH' },
changed_symbols: symbols,
affected_processes: [],
});
expect(result).toContain('and 5 more');
});
it('localizes detect_changes labels for Simplified Chinese', () => {
vi.stubEnv('GITNEXUS_LANG', 'zh-CN');
const result = formatDetectChangesResult({
summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' },
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
affected_processes: [
{ name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] },
],
});
expect(result).toContain('变更2 个文件3 个符号');
expect(result).toContain('受影响流程1');
expect(result).toContain('风险等级MEDIUM');
expect(result).toContain('已变更符号:');
expect(result).toContain('受影响执行流程:');
expect(result).toContain('Auth Flow (5 步) — 已变更foo');
});
});
// ─── formatListReposResult ───────────────────────────────────────────
describe('formatListReposResult', () => {
it('handles an empty page (no pagination)', () => {
expect(formatListReposResult({ repositories: [] })).toBe('No indexed repositories.');
});
it('formats a repo list (no pagination → no footer)', () => {
const result = formatListReposResult({
repositories: [
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: { nodes: 100, edges: 200, processes: 10 },
},
],
});
expect(result).toContain('Indexed repositories');
expect(result).toContain('my-project');
expect(result).toContain('100 symbols');
expect(result).not.toContain('Showing'); // no pagination → no footer
});
it('formats a paginated { repositories, pagination } result with a continuation footer', () => {
const result = formatListReposResult({
repositories: [
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: { nodes: 100, edges: 200, processes: 10 },
},
],
pagination: { total: 437, limit: 50, offset: 0, returned: 1, hasMore: true, nextOffset: 50 },
});
expect(result).toContain('Indexed repositories');
expect(result).toContain('my-project');
expect(result).toContain('Showing 1 of 437');
expect(result).toContain('offset 50'); // continuation hint
});
it('formats the final page (hasMore false) without a continuation hint', () => {
const result = formatListReposResult({
repositories: [
{
name: 'only',
path: '/p/only',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: {},
},
],
pagination: { total: 1, limit: 50, offset: 0, returned: 1, hasMore: false },
});
expect(result).toContain('Showing 1 of 1');
expect(result).not.toContain('More available');
});
it('reports an empty page using pagination metadata', () => {
const result = formatListReposResult({
repositories: [],
pagination: { total: 437, limit: 50, offset: 1000, returned: 0, hasMore: false },
});
expect(result).toContain('No repositories on this page');
expect(result).toContain('437');
});
});