feat: 添加完整的单元测试套件
- 新增 vitest 测试框架配置 - 添加 54 个测试文件,共 951 个测试用例 - 覆盖核心模块: - Agent: executor, registry, config-loader, permission-merger - Context: manager, compaction, prune, token-counter - Permission: manager, bash/file/git/web checkers, wildcard - Session: manager, storage - Tools: filesystem (12个), git (10个), web, shell, todo, task - LSP: client, server, language - Utils: config, diff - UI: terminal
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { getConfig, loadConfig, saveConfig } from '../../../src/utils/config.js';
|
||||
|
||||
describe('Config - 配置管理', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 清理环境变量
|
||||
delete process.env.AI_PROVIDER;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.AI_MODEL;
|
||||
delete process.env.AI_MAX_TOKENS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 恢复环境变量
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe('getConfig - 获取原始配置', () => {
|
||||
it('配置文件存在时返回内容', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-3-opus',
|
||||
}));
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
expect(config.provider).toBe('anthropic');
|
||||
expect(config.apiKey).toBe('test-key');
|
||||
expect(config.model).toBe('claude-3-opus');
|
||||
});
|
||||
|
||||
it('配置文件不存在时返回空对象', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('配置文件解析错误时返回空对象', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig - 加载完整配置', () => {
|
||||
it('从环境变量获取 Anthropic 配置', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'env-anthropic-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.provider).toBe('anthropic');
|
||||
expect(config.apiKey).toBe('env-anthropic-key');
|
||||
expect(config.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('从环境变量获取 DeepSeek 配置', () => {
|
||||
process.env.AI_PROVIDER = 'deepseek';
|
||||
process.env.DEEPSEEK_API_KEY = 'env-deepseek-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.provider).toBe('deepseek');
|
||||
expect(config.apiKey).toBe('env-deepseek-key');
|
||||
expect(config.model).toBe('deepseek-chat');
|
||||
});
|
||||
|
||||
it('配置文件优先级高于默认值', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'env-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
model: 'custom-model',
|
||||
maxTokens: 8192,
|
||||
}));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.model).toBe('custom-model');
|
||||
expect(config.maxTokens).toBe(8192);
|
||||
});
|
||||
|
||||
it('配置文件中的 provider 优先', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-key';
|
||||
process.env.DEEPSEEK_API_KEY = 'deepseek-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'deepseek',
|
||||
deepseekApiKey: 'stored-deepseek-key',
|
||||
}));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.provider).toBe('deepseek');
|
||||
// 使用环境变量中的 API Key(优先级更高)
|
||||
expect(config.apiKey).toBe('deepseek-key');
|
||||
});
|
||||
|
||||
it('包含系统提示词', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.systemPrompt).toBeDefined();
|
||||
expect(config.systemPrompt).toContain('终端');
|
||||
});
|
||||
|
||||
it('默认 maxTokens 为 4096', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.maxTokens).toBe(4096);
|
||||
});
|
||||
|
||||
it('环境变量设置的 maxTokens', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_MAX_TOKENS = '16384';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.maxTokens).toBe(16384);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig - 保存配置', () => {
|
||||
it('创建目录并保存配置', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
saveConfig({ provider: 'anthropic', apiKey: 'new-key' });
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true }
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('anthropic')
|
||||
);
|
||||
});
|
||||
|
||||
it('合并现有配置', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'old-key',
|
||||
model: 'old-model',
|
||||
}));
|
||||
|
||||
saveConfig({ apiKey: 'new-key' });
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(savedConfig.provider).toBe('anthropic'); // 保留
|
||||
expect(savedConfig.apiKey).toBe('new-key'); // 更新
|
||||
expect(savedConfig.model).toBe('old-model'); // 保留
|
||||
});
|
||||
|
||||
it('目录已存在时不重新创建', () => {
|
||||
vi.mocked(fs.existsSync)
|
||||
.mockReturnValueOnce(true) // 目录存在
|
||||
.mockReturnValueOnce(true); // 配置文件存在
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
|
||||
saveConfig({ apiKey: 'test' });
|
||||
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeDiff, countChanges, formatEditDiff } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('computeDiff - 计算文件差异', () => {
|
||||
describe('新文件', () => {
|
||||
it('新文件所有行标记为新增', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
|
||||
expect(diff.isNew).toBe(true);
|
||||
expect(diff.oldContent).toBeNull();
|
||||
expect(diff.hunks).toHaveLength(1);
|
||||
|
||||
const hunk = diff.hunks[0];
|
||||
expect(hunk.oldStart).toBe(0);
|
||||
expect(hunk.oldCount).toBe(0);
|
||||
expect(hunk.newStart).toBe(1);
|
||||
expect(hunk.newCount).toBe(3);
|
||||
|
||||
expect(hunk.lines).toHaveLength(3);
|
||||
expect(hunk.lines.every((l) => l.type === 'add')).toBe(true);
|
||||
});
|
||||
|
||||
it('空新文件', () => {
|
||||
const diff = computeDiff(null, '');
|
||||
|
||||
expect(diff.isNew).toBe(true);
|
||||
expect(diff.hunks).toHaveLength(1);
|
||||
expect(diff.hunks[0].lines).toHaveLength(1); // 空行也是一行
|
||||
});
|
||||
});
|
||||
|
||||
describe('修改文件', () => {
|
||||
it('相同内容无变化', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(content, content);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('单行修改', () => {
|
||||
const oldContent = 'line1\nline2\nline3';
|
||||
const newContent = 'line1\nmodified\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
|
||||
// 应该有删除和新增
|
||||
const allLines = diff.hunks.flatMap((h) => h.lines);
|
||||
expect(allLines.some((l) => l.type === 'remove' && l.content === 'line2')).toBe(true);
|
||||
expect(allLines.some((l) => l.type === 'add' && l.content === 'modified')).toBe(true);
|
||||
});
|
||||
|
||||
it('添加行', () => {
|
||||
const oldContent = 'line1\nline3';
|
||||
const newContent = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
const allLines = diff.hunks.flatMap((h) => h.lines);
|
||||
expect(allLines.some((l) => l.type === 'add' && l.content === 'line2')).toBe(true);
|
||||
});
|
||||
|
||||
it('删除行', () => {
|
||||
const oldContent = 'line1\nline2\nline3';
|
||||
const newContent = 'line1\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
const allLines = diff.hunks.flatMap((h) => h.lines);
|
||||
expect(allLines.some((l) => l.type === 'remove' && l.content === 'line2')).toBe(true);
|
||||
});
|
||||
|
||||
it('全部替换', () => {
|
||||
const oldContent = 'old1\nold2\nold3';
|
||||
const newContent = 'new1\nnew2';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
|
||||
const changes = countChanges(diff);
|
||||
expect(changes.deletions).toBeGreaterThan(0);
|
||||
expect(changes.additions).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('特殊情况', () => {
|
||||
it('空文件变为非空', () => {
|
||||
const diff = computeDiff('', 'new content');
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('非空文件变为空', () => {
|
||||
const diff = computeDiff('old content', '');
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('包含空行的内容', () => {
|
||||
const oldContent = 'line1\n\nline3';
|
||||
const newContent = 'line1\nline2\n\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 应该正确处理空行
|
||||
});
|
||||
|
||||
it('单行文件', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('行号正确性', () => {
|
||||
it('新增行有正确的行号', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
|
||||
const lines = diff.hunks[0].lines;
|
||||
expect(lines[0].lineNumber).toBe(1);
|
||||
expect(lines[1].lineNumber).toBe(2);
|
||||
expect(lines[2].lineNumber).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('countChanges - 统计变更数量', () => {
|
||||
it('新文件计算新增行', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions).toBe(3);
|
||||
expect(changes.deletions).toBe(0);
|
||||
});
|
||||
|
||||
it('修改文件计算增删', () => {
|
||||
const diff = computeDiff('old1\nold2', 'new1\nold2\nnew2');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions).toBeGreaterThan(0);
|
||||
expect(changes.deletions).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('空 diff 返回零', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions).toBe(0);
|
||||
expect(changes.deletions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEditDiff - 格式化编辑差异', () => {
|
||||
it('显示删除和新增内容', () => {
|
||||
const result = formatEditDiff('old text', 'new text');
|
||||
|
||||
expect(result).toContain('变更内容');
|
||||
expect(result).toContain('old text');
|
||||
expect(result).toContain('new text');
|
||||
});
|
||||
|
||||
it('多行内容正确显示', () => {
|
||||
const result = formatEditDiff('line1\nline2', 'new1\nnew2\nnew3');
|
||||
|
||||
expect(result).toContain('line1');
|
||||
expect(result).toContain('line2');
|
||||
expect(result).toContain('new1');
|
||||
expect(result).toContain('new2');
|
||||
expect(result).toContain('new3');
|
||||
});
|
||||
|
||||
it('空内容处理', () => {
|
||||
const result = formatEditDiff('', 'new');
|
||||
|
||||
expect(result).toContain('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiffResult 结构', () => {
|
||||
it('包含所有必要字段', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
|
||||
expect(diff).toHaveProperty('oldContent');
|
||||
expect(diff).toHaveProperty('newContent');
|
||||
expect(diff).toHaveProperty('isNew');
|
||||
expect(diff).toHaveProperty('hunks');
|
||||
});
|
||||
|
||||
it('hunk 包含所有必要字段', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
|
||||
if (diff.hunks.length > 0) {
|
||||
const hunk = diff.hunks[0];
|
||||
expect(hunk).toHaveProperty('oldStart');
|
||||
expect(hunk).toHaveProperty('oldCount');
|
||||
expect(hunk).toHaveProperty('newStart');
|
||||
expect(hunk).toHaveProperty('newCount');
|
||||
expect(hunk).toHaveProperty('lines');
|
||||
}
|
||||
});
|
||||
|
||||
it('line 包含所有必要字段', () => {
|
||||
const diff = computeDiff(null, 'content');
|
||||
|
||||
const line = diff.hunks[0].lines[0];
|
||||
expect(line).toHaveProperty('type');
|
||||
expect(line).toHaveProperty('lineNumber');
|
||||
expect(line).toHaveProperty('content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LCS 算法测试', () => {
|
||||
it('相同前缀保留', () => {
|
||||
const oldContent = 'prefix\ncommon\nold';
|
||||
const newContent = 'prefix\ncommon\nnew';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
// common 行应该保持为上下文
|
||||
const contextLines = diff.hunks.flatMap((h) =>
|
||||
h.lines.filter((l) => l.type === 'context')
|
||||
);
|
||||
// prefix 和 common 可能作为上下文保留
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('相同后缀保留', () => {
|
||||
const oldContent = 'old\ncommon\nsuffix';
|
||||
const newContent = 'new\ncommon\nsuffix';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('完全不同的内容', () => {
|
||||
const oldContent = 'a\nb\nc';
|
||||
const newContent = 'x\ny\nz';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const changes = countChanges(diff);
|
||||
expect(changes.additions).toBe(3);
|
||||
expect(changes.deletions).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('实际代码场景', () => {
|
||||
it('函数修改', () => {
|
||||
const oldContent = `function hello() {
|
||||
console.log("Hello");
|
||||
}`;
|
||||
|
||||
const newContent = `function hello() {
|
||||
console.log("Hello World");
|
||||
return true;
|
||||
}`;
|
||||
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
|
||||
const changes = countChanges(diff);
|
||||
expect(changes.additions).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('导入语句添加', () => {
|
||||
const oldContent = `import { a } from 'module';
|
||||
|
||||
export function test() {}`;
|
||||
|
||||
const newContent = `import { a } from 'module';
|
||||
import { b } from 'another';
|
||||
|
||||
export function test() {}`;
|
||||
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const changes = countChanges(diff);
|
||||
expect(changes.additions).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('配置文件修改', () => {
|
||||
const oldContent = `{
|
||||
"name": "test",
|
||||
"version": "1.0.0"
|
||||
}`;
|
||||
|
||||
const newContent = `{
|
||||
"name": "test",
|
||||
"version": "1.1.0",
|
||||
"description": "Added description"
|
||||
}`;
|
||||
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user