/** * DiffViewerTool 单元测试(v0.7.5 大幅扩充) * * 覆盖:LCS diff 计算(text 模式,不触文件系统)、 * unified diff 格式(hunk 分组 / 行号 / 尾部 context 修剪)、 * 5000 行截断 / 10MB 文件闸门 / 50KB 输出截断 / similarity 计算 / files 模式。 */ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { DiffViewerTool } from '../diff-viewer'; import type { ToolExecutionContext } from '../../../types/metona-tool'; const context: ToolExecutionContext = { sessionId: 'test', workspacePath: process.cwd(), iteration: 1, requestId: 'req_test', }; interface DiffResult { success: boolean; error?: string; diff?: string; diff_lines?: Array; summary?: { files_compared?: number; lines_added: number; lines_removed: number; lines_unchanged: number; total_changes: number; similarity: number; truncated: boolean; }; } function textResult( tool: DiffViewerTool, text_a: string, text_b: string, extra?: Record, ) { return tool.execute({ mode: 'text', text_a, text_b, ...extra }, context) as Promise; } describe('DiffViewerTool — text 模式 LCS 差异', () => { const tool = new DiffViewerTool(); it('两段文本生成统一 diff(成功)', async () => { const result = await textResult(tool, 'line1\nline2\nline3', 'line1\nline2-changed\nline3'); expect(result.success).toBe(true); expect(result.diff).toContain('-line2'); expect(result.diff).toContain('+line2-changed'); expect(result.summary?.total_changes).toBe(2); }); it('相同文本返回无差异', async () => { const result = await textResult(tool, 'same\nsame', 'same\nsame'); expect(result.success).toBe(true); expect(result.summary?.total_changes).toBe(0); expect(result.summary?.similarity).toBe(1); expect(result.diff).toBe('--- text_a\n+++ text_b'); }); it('空文本对比空文本 → 无差异且 similarity=1', async () => { const result = await textResult(tool, '', ''); expect(result.success).toBe(true); expect(result.summary?.total_changes).toBe(0); expect(result.summary?.similarity).toBe(1); }); it('text_a 为空 → 全量 added(空串 split 为单空行 removed 兜底)', async () => { const result = await textResult(tool, '', 'x\ny'); expect(result.summary?.lines_added).toBe(2); expect(result.diff).toContain('+x'); expect(result.diff).toContain('+y'); }); it('text_b 为空 → 全量 removed(空串 split 为单空行)', async () => { const result = await textResult(tool, 'a\nb', ''); expect(result.summary?.lines_removed).toBe(2); expect(result.diff).toContain('-a'); expect(result.diff).toContain('-b'); }); it('完全不同的两段文本:added+removed 各占全部', async () => { const result = await textResult(tool, 'aaa\nbbb', 'xxx\nyyy'); expect(result.summary?.lines_added).toBe(2); expect(result.summary?.lines_removed).toBe(2); expect(result.summary?.total_changes).toBe(4); expect(result.summary?.similarity).toBe(0); }); it('中间插入:仅新增行', async () => { const result = await textResult(tool, 'a\nb\nc', 'a\nx\nb\nc\nd'); expect(result.summary?.lines_added).toBe(2); expect(result.diff).toContain('+x'); expect(result.diff).toContain('+d'); }); it('删除行被标记 - 且行号正确', async () => { const result = await textResult(tool, 'a\nb\nc', 'a\nc'); expect(result.summary?.lines_removed).toBe(1); expect(result.diff).toContain('-b'); }); it('无效 mode 返回错误', async () => { const result = (await tool.execute({ mode: 'invalid' }, context)) as DiffResult; expect(result.success).toBe(false); expect(result.error).toContain('Invalid mode'); }); it('多个变更区(context 间隙 ≤ contextLines)合并为单个 hunk', async () => { const result = await textResult( tool, ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n'), ['a', 'X', 'c', 'd', 'e', 'Y', 'g', 'h'].join('\n'), ); const hunkCount = ((result.diff ?? '').match(/^@@/gm) ?? []).length; expect(hunkCount).toBe(1); // 间隙 context 3 行 ≤ 默认 contextLines=3 → 不拆 hunk }); it('context_lines=0 时相邻变更区被拆分为独立 hunk', async () => { const result = await textResult( tool, ['a', 'b', 'c', 'd', 'e'].join('\n'), ['a', 'X', 'c', 'Y', 'e'].join('\n'), { context_lines: 0 }, ); const hunkCount = ((result.diff ?? '').match(/^@@/gm) ?? []).length; expect(hunkCount).toBe(2); }); it('hunk 头行号从首个变更行开始(change-only 计数)', async () => { const result = await textResult( tool, 'line1\nline2\nCHANGED\nline4', 'line1\nline2\nNEW\nline4', ); expect(result.diff).toContain('@@ -3,1 +3,1 @@'); }); it('hunk 头对局部变更使用精确的行区间', async () => { // 前 3 行相同,第 4 行变更 → hunk 起始行 4 const result = await textResult(tool, 'k1\nk2\nk3\nOLD\nk5', 'k1\nk2\nk3\nNEW\nk5'); expect(result.diff).toContain('@@ -4,1 +4,1 @@'); }); it('context_lines=0 → 输出不含空格 context 行', async () => { const result = await textResult(tool, 'a\nb\nc', 'a\nx\nc', { context_lines: 0 }); const lines = (result.diff ?? '').split('\n'); expect(lines.some((l) => l.startsWith(' '))).toBe(false); expect(lines).toContain('+x'); expect(lines).toContain('-b'); }); it('context_lines 超上限被钳制到 10 且不报错(实况:hunk 仅含变更行)', async () => { const lines = Array.from({ length: 20 }, (_, i) => `L${i}`); const changed = [...lines]; changed[10] = 'CHANGED'; const result = await textResult(tool, lines.join('\n'), changed.join('\n'), { context_lines: 999, }); expect(result.success).toBe(true); const hunk = (result.diff ?? '').split('\n'); const spaceCount = hunk.filter((l) => l.startsWith(' ')).length; expect(spaceCount).toBe(0); }); it('formatUnifiedDiff 实况契约:hunk 只含 +/- 变更行,无前导/尾部 context', async () => { const result = await textResult(tool, 'a\nb\nc\nd\ne', 'a\nb\nX\nd\ne'); const lines = (result.diff ?? '').split('\n'); // 前导 context(a,b)不捕获;尾部 context(d,e)被修剪 expect(lines.some((l) => l.startsWith(' '))).toBe(false); expect(lines).toContain('-c'); expect(lines).toContain('+X'); expect(lines).not.toContain(' d'); }); it('summary 统计 added/removed/unchanged 各自计数', async () => { const result = await textResult(tool, 'a\nb\nc\nd', 'a\nx\nc\ny'); expect(result.summary?.lines_unchanged).toBe(2); // a, c expect(result.summary?.lines_added).toBe(2); // x, y expect(result.summary?.lines_removed).toBe(2); // b, d }); it('similarity 对一半相同文本约为 0.5', async () => { const result = await textResult(tool, 'a\nb', 'a\nx'); expect(result.summary?.similarity).toBeCloseTo(0.5, 1); }); it('空 text_a/text_b 缺省为空串', async () => { const result = (await tool.execute({ mode: 'text' }, context)) as DiffResult; expect(result.success).toBe(true); expect(result.summary?.total_changes).toBe(0); }); it('超过 5000 行 → truncated=true 且 similarity 按截断长度计算', async () => { const a = Array.from({ length: 6000 }, (_, i) => `a${i}`).join('\n'); const b = Array.from({ length: 6000 }, (_, i) => `a${i}`).join('\n'); const result = await textResult(tool, a, b); expect(result.summary?.truncated).toBe(true); expect(result.summary?.similarity).toBe(1); }); it('超长 diff 输出被截断到 50KB 并附标记', async () => { const a = Array.from({ length: 6000 }, (_, i) => `old-line-${i}-${'x'.repeat(60)}`).join('\n'); const b = Array.from({ length: 6000 }, (_, i) => `new-line-${i}-${'y'.repeat(60)}`).join('\n'); const result = await textResult(tool, a, b); expect(result.success).toBe(true); expect(result.diff).toContain('... (diff truncated)'); expect((result.diff ?? '').length).toBeLessThan(50_000 + 200); }); it('diff_lines 返回前 500 条差异行', async () => { const a = Array.from({ length: 6000 }, (_, i) => `a${i}`).join('\n'); const b = Array.from({ length: 6000 }, (_, i) => `b${i}`).join('\n'); const result = await textResult(tool, a, b); expect(result.diff_lines).toHaveLength(500); }); }); describe('DiffViewerTool — files 模式', () => { let ws: string; const tool = new DiffViewerTool(); const fileCtx = (): ToolExecutionContext => ({ sessionId: 't', workspacePath: ws, iteration: 1, requestId: 'r', }); beforeAll(() => { ws = mkdtempSync(join(tmpdir(), 'metona-diff-')); writeFileSync(join(ws, 'a.txt'), 'alpha\nbeta\ngamma'); writeFileSync(join(ws, 'b.txt'), 'alpha\nBETA\ngamma'); writeFileSync(join(ws, 'big.txt'), Buffer.alloc(10 * 1024 * 1024 + 10, 0x61)); }); afterAll(() => { try { rmSync(ws, { recursive: true, force: true }); } catch { /* ignore */ } }); it('两个文件 diff 成功并带文件标签', async () => { const result = (await tool.execute( { mode: 'files', file_a: 'a.txt', file_b: 'b.txt' }, fileCtx(), )) as DiffResult; expect(result.success).toBe(true); expect(result.diff).toContain('--- a.txt'); expect(result.diff).toContain('+++ b.txt'); expect(result.diff).toContain('-beta'); expect(result.diff).toContain('+BETA'); expect(result.summary?.files_compared).toBe(2); }); it('files 模式缺 file_a 或 file_b → 报错', async () => { const missingA = (await tool.execute( { mode: 'files', file_b: 'b.txt' }, fileCtx(), )) as DiffResult; expect(missingA.success).toBe(false); expect(missingA.error).toContain('file_a and file_b are required'); }); it('files 模式文件不存在 → 报错', async () => { const result = (await tool.execute( { mode: 'files', file_a: 'a.txt', file_b: 'nope.txt' }, fileCtx(), )) as DiffResult; expect(result.success).toBe(false); expect(result.error).toContain('Failed to read files'); }); it('files 模式超过 10MB 闸门 → 报错', async () => { const result = (await tool.execute( { mode: 'files', file_a: 'a.txt', file_b: 'big.txt' }, fileCtx(), )) as DiffResult; expect(result.success).toBe(false); expect(result.error).toContain('File too large for diff'); }); it('files 模式路径越界 → 被 safeResolvePath 拒绝', async () => { const outside = process.platform === 'win32' ? 'C:\\Windows\\notepad.exe' : '/etc/passwd'; const result = (await tool.execute( { mode: 'files', file_a: 'a.txt', file_b: outside }, fileCtx(), )) as DiffResult; expect(result.success).toBe(false); }); it('files 模式两文件相同 → 无差异', async () => { writeFileSync(join(ws, 'same1.txt'), 'x\ny'); writeFileSync(join(ws, 'same2.txt'), 'x\ny'); const result = (await tool.execute( { mode: 'files', file_a: 'same1.txt', file_b: 'same2.txt' }, fileCtx(), )) as DiffResult; expect(result.success).toBe(true); expect(result.summary?.total_changes).toBe(0); }); });