feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock process.exit
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
// Mock console.error
|
||||
const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
describe('Config - 配置管理扩展测试', () => {
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 清理环境变量
|
||||
delete process.env.AI_PROVIDER;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.AI_MODEL;
|
||||
delete process.env.AI_MAX_TOKENS;
|
||||
delete process.env.AI_BASE_URL;
|
||||
delete process.env.VISION_PROVIDER;
|
||||
delete process.env.VISION_MODEL;
|
||||
delete process.env.VISION_API_KEY;
|
||||
delete process.env.VISION_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockExit.mockClear();
|
||||
mockConsoleError.mockClear();
|
||||
});
|
||||
|
||||
describe('getConfig - 获取原始配置', () => {
|
||||
it('配置文件不存在返回空对象', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// 需要重新导入以获取最新的 mock
|
||||
vi.resetModules();
|
||||
const { getConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = getConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('配置文件存在返回解析后的内容', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { getConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.provider).toBe('anthropic');
|
||||
expect(config.apiKey).toBe('test-key');
|
||||
});
|
||||
|
||||
it('配置文件解析错误返回空对象', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
|
||||
|
||||
vi.resetModules();
|
||||
const { getConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = getConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig - 加载配置', () => {
|
||||
it('优先使用环境变量中的 API Key', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'env-api-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'file-api-key',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.apiKey).toBe('env-api-key');
|
||||
});
|
||||
|
||||
it('使用配置文件中的 provider', async () => {
|
||||
process.env.DEEPSEEK_API_KEY = 'deepseek-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'deepseek',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.provider).toBe('deepseek');
|
||||
expect(config.apiKey).toBe('deepseek-key');
|
||||
});
|
||||
|
||||
it('OpenAI provider 使用 OPENAI_API_KEY', async () => {
|
||||
process.env.OPENAI_API_KEY = 'openai-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'openai',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.provider).toBe('openai');
|
||||
expect(config.apiKey).toBe('openai-key');
|
||||
});
|
||||
|
||||
it('环境变量 AI_MODEL 覆盖配置文件', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_MODEL = 'claude-opus-4-20250514';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.model).toBe('claude-opus-4-20250514');
|
||||
});
|
||||
|
||||
it('使用默认模型', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('环境变量 AI_BASE_URL 覆盖配置文件', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_BASE_URL = 'https://custom-api.example.com';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
baseUrl: 'https://config-api.example.com',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe('https://custom-api.example.com');
|
||||
});
|
||||
|
||||
it('无 API Key 时退出程序', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
expect(() => loadConfig()).toThrow('process.exit called');
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadVisionConfig - 加载 Vision 配置', () => {
|
||||
it('返回 null 当没有 API Key', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('使用环境变量配置', async () => {
|
||||
process.env.VISION_PROVIDER = 'openai';
|
||||
process.env.VISION_MODEL = 'gpt-4o';
|
||||
process.env.VISION_API_KEY = 'vision-key';
|
||||
process.env.VISION_BASE_URL = 'https://vision-api.example.com';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('openai');
|
||||
expect(config!.model).toBe('gpt-4o');
|
||||
expect(config!.apiKey).toBe('vision-key');
|
||||
expect(config!.baseUrl).toBe('https://vision-api.example.com');
|
||||
});
|
||||
|
||||
it('默认使用 anthropic provider', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('Vision 专用 Key 优先于 provider Key', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-key';
|
||||
process.env.VISION_API_KEY = 'vision-specific-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config!.apiKey).toBe('vision-specific-key');
|
||||
});
|
||||
|
||||
it('从配置文件加载 Vision 设置', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'openai',
|
||||
visionModel: 'gpt-4o',
|
||||
visionApiKey: 'config-vision-key',
|
||||
visionBaseUrl: 'https://config-vision.example.com',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('openai');
|
||||
expect(config!.model).toBe('gpt-4o');
|
||||
expect(config!.apiKey).toBe('config-vision-key');
|
||||
});
|
||||
|
||||
it('DeepSeek provider 回退到 deepseekApiKey', async () => {
|
||||
process.env.DEEPSEEK_API_KEY = 'deepseek-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'deepseek',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.apiKey).toBe('deepseek-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig - 保存配置', () => {
|
||||
it('创建配置目录(如果不存在)', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ provider: 'anthropic' });
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(CONFIG_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('合并现有配置', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'existing-key',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ model: 'claude-opus-4-20250514' });
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
CONFIG_FILE,
|
||||
expect.stringContaining('"model": "claude-opus-4-20250514"')
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
CONFIG_FILE,
|
||||
expect.stringContaining('"provider": "anthropic"')
|
||||
);
|
||||
});
|
||||
|
||||
it('覆盖相同字段', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
model: 'old-model',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ model: 'new-model' });
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string);
|
||||
expect(savedConfig.model).toBe('new-model');
|
||||
});
|
||||
|
||||
it('解析错误时使用空对象合并', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ provider: 'deepseek' });
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
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, loadVisionConfig } 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.OPENAI_API_KEY;
|
||||
delete process.env.AI_MODEL;
|
||||
delete process.env.AI_MAX_TOKENS;
|
||||
delete process.env.AI_BASE_URL;
|
||||
delete process.env.VISION_PROVIDER;
|
||||
delete process.env.VISION_MODEL;
|
||||
delete process.env.VISION_API_KEY;
|
||||
delete process.env.VISION_BASE_URL;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig - baseUrl 支持', () => {
|
||||
it('从环境变量获取 baseUrl', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_BASE_URL = 'https://custom.api.com/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.baseUrl).toBe('https://custom.api.com/v1');
|
||||
});
|
||||
|
||||
it('从配置文件获取 baseUrl', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
baseUrl: 'https://stored.api.com/v1',
|
||||
}));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.baseUrl).toBe('https://stored.api.com/v1');
|
||||
});
|
||||
|
||||
it('环境变量 baseUrl 优先于配置文件', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_BASE_URL = 'https://env.api.com/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
baseUrl: 'https://stored.api.com/v1',
|
||||
}));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.baseUrl).toBe('https://env.api.com/v1');
|
||||
});
|
||||
|
||||
it('OpenAI provider 支持 baseUrl', () => {
|
||||
process.env.AI_PROVIDER = 'openai';
|
||||
process.env.OPENAI_API_KEY = 'test-openai-key';
|
||||
process.env.AI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.provider).toBe('openai');
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadVisionConfig - Vision 配置', () => {
|
||||
it('返回 null 当没有配置 Vision API Key', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('从环境变量获取 Vision 配置', () => {
|
||||
process.env.VISION_PROVIDER = 'openai';
|
||||
process.env.VISION_API_KEY = 'vision-api-key';
|
||||
process.env.VISION_MODEL = 'gpt-4-vision';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).not.toBeNull();
|
||||
expect(visionConfig?.provider).toBe('openai');
|
||||
expect(visionConfig?.apiKey).toBe('vision-api-key');
|
||||
expect(visionConfig?.model).toBe('gpt-4-vision');
|
||||
});
|
||||
|
||||
it('从配置文件获取 Vision 配置', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'anthropic',
|
||||
visionApiKey: 'stored-vision-key',
|
||||
visionModel: 'claude-3-opus',
|
||||
}));
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).not.toBeNull();
|
||||
expect(visionConfig?.provider).toBe('anthropic');
|
||||
expect(visionConfig?.apiKey).toBe('stored-vision-key');
|
||||
expect(visionConfig?.model).toBe('claude-3-opus');
|
||||
});
|
||||
|
||||
it('回退到对应 provider 的 API Key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-main-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).not.toBeNull();
|
||||
expect(visionConfig?.provider).toBe('anthropic'); // 默认
|
||||
expect(visionConfig?.apiKey).toBe('anthropic-main-key'); // 回退
|
||||
});
|
||||
|
||||
it('Vision baseUrl 配置', () => {
|
||||
process.env.VISION_PROVIDER = 'openai';
|
||||
process.env.VISION_API_KEY = 'vision-key';
|
||||
process.env.VISION_BASE_URL = 'https://vision.api.com/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.baseUrl).toBe('https://vision.api.com/v1');
|
||||
});
|
||||
|
||||
it('从配置文件回退到 deepseek API key', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'deepseek',
|
||||
deepseekApiKey: 'deepseek-stored-key',
|
||||
}));
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.provider).toBe('deepseek');
|
||||
expect(visionConfig?.apiKey).toBe('deepseek-stored-key');
|
||||
});
|
||||
|
||||
it('从配置文件回退到 openai API key', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'openai',
|
||||
openaiApiKey: 'openai-stored-key',
|
||||
}));
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.provider).toBe('openai');
|
||||
expect(visionConfig?.apiKey).toBe('openai-stored-key');
|
||||
});
|
||||
|
||||
it('使用默认 Vision 模型', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('Vision 专用 key 优先于 provider key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-main-key';
|
||||
process.env.VISION_API_KEY = 'vision-specific-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.apiKey).toBe('vision-specific-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
computeDiff,
|
||||
formatDiff,
|
||||
countChanges,
|
||||
formatEditDiff,
|
||||
confirmFileChange,
|
||||
} from '../../../src/utils/diff.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock chalk (保留原始功能以便测试输出格式)
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => `[yellow]${s}[/yellow]`,
|
||||
cyan: (s: string) => `[cyan]${s}[/cyan]`,
|
||||
white: (s: string) => `[white]${s}[/white]`,
|
||||
gray: (s: string) => `[gray]${s}[/gray]`,
|
||||
red: (s: string) => `[red]${s}[/red]`,
|
||||
green: (s: string) => `[green]${s}[/green]`,
|
||||
},
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
describe('Diff - 差异比较扩展测试', () => {
|
||||
describe('computeDiff - 计算 diff', () => {
|
||||
it('新文件所有行都是添加', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
|
||||
expect(diff.isNew).toBe(true);
|
||||
expect(diff.oldContent).toBeNull();
|
||||
expect(diff.hunks.length).toBe(1);
|
||||
expect(diff.hunks[0].lines.every(l => l.type === 'add')).toBe(true);
|
||||
expect(diff.hunks[0].newCount).toBe(3);
|
||||
});
|
||||
|
||||
it('相同内容返回空 hunks', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(content, content);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 相同内容可能没有实际变化的 hunks
|
||||
const hasChanges = diff.hunks.some(h =>
|
||||
h.lines.some(l => l.type === 'add' || l.type === 'remove')
|
||||
);
|
||||
expect(hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('检测添加的行', () => {
|
||||
const oldContent = 'line1\nline2';
|
||||
const newContent = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
expect(addedLines.some(l => l.content === 'line3')).toBe(true);
|
||||
});
|
||||
|
||||
it('检测删除的行', () => {
|
||||
const oldContent = 'line1\nline2\nline3';
|
||||
const newContent = 'line1\nline2';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const removedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'remove'));
|
||||
expect(removedLines.some(l => l.content === 'line3')).toBe(true);
|
||||
});
|
||||
|
||||
it('检测修改的行(删除+添加)', () => {
|
||||
const oldContent = 'line1\nold line\nline3';
|
||||
const newContent = 'line1\nnew line\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const removedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'remove'));
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
|
||||
expect(removedLines.some(l => l.content === 'old line')).toBe(true);
|
||||
expect(addedLines.some(l => l.content === 'new line')).toBe(true);
|
||||
});
|
||||
|
||||
it('处理空文件', () => {
|
||||
const diff = computeDiff('', 'new content');
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 空到有内容是添加
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
expect(addedLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('处理多个不连续的变更', () => {
|
||||
const oldContent = 'line1\nkeep1\nold2\nkeep2\nold3\nline6';
|
||||
const newContent = 'line1\nkeep1\nnew2\nkeep2\nnew3\nline6';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
// 应该有变更
|
||||
const changes = diff.hunks.flatMap(h =>
|
||||
h.lines.filter(l => l.type === 'add' || l.type === 'remove')
|
||||
);
|
||||
expect(changes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('处理完全不同的内容', () => {
|
||||
const oldContent = 'completely\ndifferent\ncontent';
|
||||
const newContent = 'totally\nnew\nstuff';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiff - 格式化 diff', () => {
|
||||
it('新文件显示 +++ 新文件标记', () => {
|
||||
const diff = computeDiff(null, 'new content');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('新文件');
|
||||
expect(formatted).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('修改文件显示 --- 和 +++ 标记', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('原文件');
|
||||
expect(formatted).toContain('修改后');
|
||||
});
|
||||
|
||||
it('显示 hunk 头部 @@ 信息', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('@@');
|
||||
});
|
||||
|
||||
it('添加行使用 + 前缀', () => {
|
||||
const diff = computeDiff('', 'added line');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('+ added line');
|
||||
});
|
||||
|
||||
it('删除行使用 - 前缀', () => {
|
||||
const diff = computeDiff('removed line', '');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('- removed line');
|
||||
});
|
||||
});
|
||||
|
||||
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('line1\nline2\nline3', '');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.deletions).toBe(3);
|
||||
});
|
||||
|
||||
it('统计混合变更', () => {
|
||||
const diff = computeDiff('old1\nold2', 'new1\nold2\nnew2');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
// 具体数字取决于 diff 算法实现
|
||||
expect(changes.additions).toBeGreaterThanOrEqual(0);
|
||||
expect(changes.deletions).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('无变更返回零', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions + changes.deletions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEditDiff - 编辑 diff 格式化', () => {
|
||||
it('显示删除和添加内容', () => {
|
||||
const formatted = formatEditDiff('old text', 'new text');
|
||||
|
||||
expect(formatted).toContain('old text');
|
||||
expect(formatted).toContain('new text');
|
||||
expect(formatted).toContain('-');
|
||||
expect(formatted).toContain('+');
|
||||
});
|
||||
|
||||
it('处理多行内容', () => {
|
||||
const formatted = formatEditDiff('old1\nold2', 'new1\nnew2');
|
||||
|
||||
expect(formatted).toContain('old1');
|
||||
expect(formatted).toContain('old2');
|
||||
expect(formatted).toContain('new1');
|
||||
expect(formatted).toContain('new2');
|
||||
});
|
||||
|
||||
it('包含变更内容标题', () => {
|
||||
const formatted = formatEditDiff('old', 'new');
|
||||
|
||||
expect(formatted).toContain('变更内容');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmFileChange - 文件变更确认', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('内容相同直接返回确认', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'same content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('新文件(读取失败)显示 diff', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('用户输入 y 确认写入', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('用户输入 Y 确认并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(true);
|
||||
});
|
||||
|
||||
it('用户输入 n 取消操作', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('用户输入 N 取消并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(true);
|
||||
});
|
||||
|
||||
it('无效输入默认取消', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('显示变更预览信息', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('文件变更预览');
|
||||
expect(output).toContain('写入文件');
|
||||
expect(output).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('显示编辑操作类型', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await confirmFileChange('/test/file.ts', 'new content', 'edit');
|
||||
|
||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('编辑文件');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeDiff, countChanges, formatEditDiff, formatDiff } 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('formatDiff - 格式化 diff 输出', () => {
|
||||
it('新文件显示新增标记', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('新文件');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('修改文件显示原文件和修改后标记', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('原文件');
|
||||
expect(result).toContain('修改后');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('hunk 头部显示正确的行号范围', () => {
|
||||
const diff = computeDiff('line1\nline2', 'line1\nnew\nline2');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('@@');
|
||||
});
|
||||
|
||||
it('新增行显示 + 前缀', () => {
|
||||
const diff = computeDiff(null, 'added line');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('+');
|
||||
expect(result).toContain('added line');
|
||||
});
|
||||
|
||||
it('删除行显示 - 前缀', () => {
|
||||
const diff = computeDiff('removed line', 'new line');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('-');
|
||||
expect(result).toContain('removed line');
|
||||
});
|
||||
|
||||
it('空 diff 返回基本格式', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
// 没有 hunks 时只有头部
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
isImagePath,
|
||||
extractImageReferences,
|
||||
formatFileSize,
|
||||
loadImage,
|
||||
loadImages,
|
||||
IMAGE_EXTENSIONS,
|
||||
} from '../../../src/utils/image.js';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
describe('Image Utils - 图片处理工具', () => {
|
||||
describe('IMAGE_EXTENSIONS', () => {
|
||||
it('包含常见图片扩展名', () => {
|
||||
expect(IMAGE_EXTENSIONS).toContain('.png');
|
||||
expect(IMAGE_EXTENSIONS).toContain('.jpg');
|
||||
expect(IMAGE_EXTENSIONS).toContain('.jpeg');
|
||||
expect(IMAGE_EXTENSIONS).toContain('.gif');
|
||||
expect(IMAGE_EXTENSIONS).toContain('.webp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isImagePath - 判断是否为图片路径', () => {
|
||||
it('识别 PNG 文件', () => {
|
||||
expect(isImagePath('screenshot.png')).toBe(true);
|
||||
expect(isImagePath('path/to/image.PNG')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 JPG/JPEG 文件', () => {
|
||||
expect(isImagePath('photo.jpg')).toBe(true);
|
||||
expect(isImagePath('photo.jpeg')).toBe(true);
|
||||
expect(isImagePath('photo.JPG')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 GIF 文件', () => {
|
||||
expect(isImagePath('animation.gif')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 WebP 文件', () => {
|
||||
expect(isImagePath('modern.webp')).toBe(true);
|
||||
});
|
||||
|
||||
it('不识别非图片文件', () => {
|
||||
expect(isImagePath('document.txt')).toBe(false);
|
||||
expect(isImagePath('script.ts')).toBe(false);
|
||||
expect(isImagePath('data.json')).toBe(false);
|
||||
expect(isImagePath('readme.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('处理没有扩展名的文件', () => {
|
||||
expect(isImagePath('noextension')).toBe(false);
|
||||
expect(isImagePath('Makefile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImageReferences - 提取图片引用', () => {
|
||||
it('提取单个 @ 引用', () => {
|
||||
const result = extractImageReferences('请分析这张图片 @screenshot.png');
|
||||
expect(result.imagePaths).toEqual(['screenshot.png']);
|
||||
expect(result.textContent).toBe('请分析这张图片');
|
||||
});
|
||||
|
||||
it('提取多个 @ 引用', () => {
|
||||
const result = extractImageReferences('对比 @before.png 和 @after.jpg');
|
||||
expect(result.imagePaths).toEqual(['before.png', 'after.jpg']);
|
||||
expect(result.textContent).toBe('对比 和');
|
||||
});
|
||||
|
||||
it('提取带路径的图片引用', () => {
|
||||
const result = extractImageReferences('分析 @./images/test.png');
|
||||
expect(result.imagePaths).toEqual(['./images/test.png']);
|
||||
});
|
||||
|
||||
it('提取绝对路径图片引用', () => {
|
||||
const result = extractImageReferences('查看 @/tmp/screenshot.png');
|
||||
expect(result.imagePaths).toEqual(['/tmp/screenshot.png']);
|
||||
});
|
||||
|
||||
it('提取带空格的绝对路径(自动匹配到扩展名)', () => {
|
||||
const result = extractImageReferences('这张图片内容是什么?@/Users/xd/Adobe Express - file.png');
|
||||
expect(result.imagePaths).toEqual(['/Users/xd/Adobe Express - file.png']);
|
||||
expect(result.textContent).toBe('这张图片内容是什么?');
|
||||
});
|
||||
|
||||
it('提取带引号的路径(支持空格)', () => {
|
||||
const result = extractImageReferences('分析 @"./my images/test photo.png"');
|
||||
expect(result.imagePaths).toEqual(['./my images/test photo.png']);
|
||||
expect(result.textContent).toBe('分析');
|
||||
});
|
||||
|
||||
it('提取带单引号的路径', () => {
|
||||
const result = extractImageReferences("查看 @'./path with spaces/image.jpg'");
|
||||
expect(result.imagePaths).toEqual(['./path with spaces/image.jpg']);
|
||||
expect(result.textContent).toBe('查看');
|
||||
});
|
||||
|
||||
it('忽略非图片的 @ 引用', () => {
|
||||
const result = extractImageReferences('请查看 @readme.md 文件');
|
||||
expect(result.imagePaths).toEqual([]);
|
||||
expect(result.textContent).toBe('请查看 @readme.md 文件');
|
||||
});
|
||||
|
||||
it('忽略邮箱地址', () => {
|
||||
const result = extractImageReferences('联系 user@example.com 了解详情');
|
||||
expect(result.imagePaths).toEqual([]);
|
||||
expect(result.textContent).toBe('联系 user@example.com 了解详情');
|
||||
});
|
||||
|
||||
it('混合图片和非图片引用', () => {
|
||||
const result = extractImageReferences(
|
||||
'查看 @screenshot.png 和 @config.json'
|
||||
);
|
||||
expect(result.imagePaths).toEqual(['screenshot.png']);
|
||||
expect(result.textContent).toBe('查看 和 @config.json');
|
||||
});
|
||||
|
||||
it('没有引用时返回原文本', () => {
|
||||
const result = extractImageReferences('这是一段普通文本');
|
||||
expect(result.imagePaths).toEqual([]);
|
||||
expect(result.textContent).toBe('这是一段普通文本');
|
||||
});
|
||||
|
||||
it('只有图片引用时文本内容为空', () => {
|
||||
const result = extractImageReferences('@image.png');
|
||||
expect(result.imagePaths).toEqual(['image.png']);
|
||||
expect(result.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('处理多个空格', () => {
|
||||
const result = extractImageReferences(' @test.png 描述 ');
|
||||
expect(result.imagePaths).toEqual(['test.png']);
|
||||
expect(result.textContent.trim()).toBe('描述');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize - 格式化文件大小', () => {
|
||||
it('格式化字节', () => {
|
||||
expect(formatFileSize(0)).toBe('0B');
|
||||
expect(formatFileSize(100)).toBe('100B');
|
||||
expect(formatFileSize(1023)).toBe('1023B');
|
||||
});
|
||||
|
||||
it('格式化 KB', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5KB');
|
||||
expect(formatFileSize(10240)).toBe('10.0KB');
|
||||
});
|
||||
|
||||
it('格式化 MB', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0MB');
|
||||
expect(formatFileSize(1024 * 1024 * 2.5)).toBe('2.5MB');
|
||||
});
|
||||
|
||||
it('大文件以 MB 为单位', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1024.0MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadImage - 加载图片文件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('成功加载 PNG 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake image data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/image.png');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image).toBeDefined();
|
||||
expect(result.image?.filename).toBe('image.png');
|
||||
expect(result.image?.extension).toBe('.png');
|
||||
expect(result.image?.mimeType).toBe('image/png');
|
||||
expect(result.image?.base64).toBe(mockBuffer.toString('base64'));
|
||||
});
|
||||
|
||||
it('成功加载 JPG 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake jpg data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/photo.jpg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('成功加载 JPEG 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake jpeg data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/photo.jpeg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('成功加载 GIF 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake gif data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/animation.gif');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('成功加载 WebP 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake webp data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/modern.webp');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('返回正确的 dataUrl', async () => {
|
||||
const mockBuffer = Buffer.from('test data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/image.png');
|
||||
|
||||
expect(result.image?.dataUrl).toBe(
|
||||
`data:image/png;base64,${mockBuffer.toString('base64')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('不支持的格式返回错误', async () => {
|
||||
const result = await loadImage('/test/document.txt');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('不支持的图片格式');
|
||||
expect(result.error).toContain('.txt');
|
||||
});
|
||||
|
||||
it('文件不存在返回错误', async () => {
|
||||
const error = new Error('File not found');
|
||||
(error as any).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await loadImage('/nonexistent/image.png');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('图片文件不存在');
|
||||
});
|
||||
|
||||
it('读取文件错误返回错误信息', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await loadImage('/test/image.png');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('加载图片失败');
|
||||
expect(result.error).toContain('Permission denied');
|
||||
});
|
||||
|
||||
it('处理相对路径', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('./relative/image.png', '/workdir');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('relative/image.png')
|
||||
);
|
||||
});
|
||||
|
||||
it('处理大写扩展名', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/image.PNG');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadImages - 批量加载图片', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('成功加载多张图片', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImages(['/test/a.png', '/test/b.jpg']);
|
||||
|
||||
expect(result.images).toHaveLength(2);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('部分加载失败返回错误列表', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockImplementation((path) => {
|
||||
if (String(path).includes('good')) {
|
||||
return Promise.resolve(mockBuffer);
|
||||
}
|
||||
const error = new Error('Not found');
|
||||
(error as any).code = 'ENOENT';
|
||||
return Promise.reject(error);
|
||||
});
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImages(['/test/good.png', '/test/bad.png']);
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].path).toBe('/test/bad.png');
|
||||
});
|
||||
|
||||
it('空列表返回空结果', async () => {
|
||||
const result = await loadImages([]);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('全部加载失败时 images 为空', async () => {
|
||||
const error = new Error('Not found');
|
||||
(error as any).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await loadImages(['/test/a.png', '/test/b.png']);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('使用自定义工作目录', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
await loadImages(['./image.png'], '/custom/workdir');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('custom/workdir')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user