<!--
---
title: Arabica Sprint 3 代码单元测试样例
description: 为 Arabica Sprint 3 核心服务与控制器层提供标准 Jest 测试用例示例，涵盖文献检索、工具分发、容错保存及本地笔记操作场景。
type: System Development Guide
version: v1.0.0 (Arabica) - Sprint 3
file: arabica-sprint3- code-unit-test-specification.md
author: Gitconomy Research-郭晧
date: 2026-03-11
tags:
  - Project Caffeine
  - Sprint 3
  - Jest
  - Unit Testing
license: CC BY-SA 4.0
status: Active
---
-->
# Arabica Sprint 3 代码单元测试样例

## 1. 测试架构概览

在 Sprint 3 中，系统彻底从“提示词（Prompt）驱动”转向了“工具（Tool）驱动”。我们的测试重点也应该从原来的 Prompts 转移到**核心服务层（Services）和工具控制层（Controllers）**。

当前 Sprint 3 真实需要进行单元测试的核心文件如下：

1. **`src/services/arxivService.ts`**：测试连接 Arxiv API 并解析 XML 的能力。
2. **`src/services/resourceService.ts`**：测试本地笔记的读取、扫描和保存能力。
3. **`src/controllers/toolsController.ts`**：【测试重点】测试五大核心工具（文献、框架、存笔记、查笔记、读笔记）的路由分发和容错机制。

---

## 2. 核心测试场景设计与示例

为了让您的 Jest 测试跑通，以下是针对 Sprint 3 真实文件的测试用例编写指南及代码示例。您可以在 `src/services/__test__/` 和 `src/controllers/__test__/` 目录下创建或修改对应的 `.test.ts` 文件。

### 场景 A：测试文献检索服务 (`arxivService.test.ts`)

**目标**：验证 `searchArxiv` 函数是否能正确发起 HTTP 请求，并从 Arxiv 复杂的 XML 中提取出干净的标题和摘要。


```typescript
// 文件路径: src/services/__test__/arxivService.test.ts
import { searchArxiv } from '../arxivService';

// Mock 全局的 fetch 函数以防真实发起网络请求
global.fetch = jest.fn();

describe('Arxiv Service', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('应该成功解析 Arxiv 的 XML 并返回文献数组', async () => {
    const mockXml = `
      <feed>
        <entry>
          <id>http://arxiv.org/abs/1234.5678</id>
          <title>Quantum Machine Learning</title>
          <summary>This is a summary of QML.</summary>
        </entry>
      </feed>
    `;
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      text: jest.fn().mockResolvedValue(mockXml)
    });

    const results = await searchArxiv('Quantum', 1);
    expect(results).toHaveLength(1);
    expect(results[0].title).toBe('Quantum Machine Learning');
    expect(results[0].id).toBe('http://arxiv.org/abs/1234.5678');
  });

  it('当网络请求失败时应该抛出错误', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 500
    });

    await expect(searchArxiv('Error', 1)).rejects.toThrow('Arxiv API 响应错误: 500');
  });
});
```

### 场景 B：测试工具分发与框架剥离 (`toolsController.test.ts`)

**目标**：测试 `handleToolCall` 是否正确将参数分发给对应函数，**特别要测试 `fetch_framework_template` 是否成功拦截了原始 JSON 并加上了“刹车指令”**。

```typescript
// 文件路径: src/controllers/__test__/toolsController.test.ts
import { handleToolCall } from '../toolsController';
import * as fs from 'fs';

// Mock 文件系统，防止测试时去真实读取本地 JSON
jest.mock('fs');

describe('Tools Controller - Sprint 3', () => {

  it('当调用未知工具时，应返回 isError 为 true', async () => {
    const result = await handleToolCall('unknown_tool', {});
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain('未知工具');
  });

  it('fetch_framework_template 应该正确解析 JSON 并附带刹车指令', async () => {
    // 伪造一个框架 JSON 文件内容
    const mockFrameworkJson = JSON.stringify({
      messages: [
        { role: 'system', content: { text: 'Mock系统指令' } },
        { role: 'user', content: { text: 'Mock用户模板' } }
      ]
    });
    (fs.readFileSync as jest.Mock).mockReturnValue(mockFrameworkJson);

    const result = await handleToolCall('fetch_framework_template', { framework_name: 'swot' });

    expect(result.isError).toBeUndefined(); // 不应报错
    const outputText = result.content[0].text;

    // 断言是否包含了防止死循环的强指令
    expect(outputText).toContain('立即停止调用任何工具');
    // 断言 JSON 壳子被剥离，提取出了纯文本
    expect(outputText).toContain('Mock系统指令');
    expect(outputText).toContain('Mock用户模板');
  });
});
```

### 场景 C：测试容错版保存工具 (`resourceService.test.ts` 相关逻辑)

**目标**：在 `toolsController` 层测试 `save_note`，验证当大模型错误地传入 JSON 对象而不是字符串时，系统能否自动将其序列化以避免崩溃。

```typescript
import { handleToolCall } from '../../controllers/toolsController'';
import * as resourceService from '../../services/resourceService';
import * as arxivService from '../../services/arxivService';
import * as fs from 'fs';

// ====================================================
// 1. 全局 Mock 外部依赖 (防止测试时发生真实读写和网络请求)
// ====================================================
jest.mock('fs');

jest.mock('../../services/resourceService', () => ({
  saveNote: jest.fn().mockResolvedValue('保存成功'),
  listObsidianNotes: jest.fn().mockResolvedValue(['test-note.md', 'ai-trend.md']),
  readObsidianNote: jest.fn().mockResolvedValue('这是模拟的本地笔记内容')
}));

jest.mock('../../services/arxivService', () => ({
  searchArxiv: jest.fn().mockResolvedValue([
    { id: 'http://arxiv.org/abs/1234', title: 'Test Paper', summary: 'Mock Summary' }
  ])
}));

describe('Tools Controller - Sprint 3 (意图驱动与容错机制)', () => {
  beforeEach(() => {
    // 每次测试前清理 Mock 调用记录
    jest.clearAllMocks();
  });

  // ====================================================
  // 测试: 未知工具防呆
  // ====================================================
  it('当调用未知工具时，应返回 isError 为 true 的友好提示', async () => {
    const result = await handleToolCall('unknown_tool', {});
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain('未知工具: unknown_tool');
  });

  // ====================================================
  // 测试: 意图 3 - 保存笔记工具 (重点测试大模型容错能力)
  // ====================================================
  describe('Tool: save_note (容错版保存工具)', () => {
    it('当大模型老老实实传入【纯文本字符串】时，应直接正常保存', async () => {
      const result = await handleToolCall('save_note', { filename: 'output.md', content: '这是一段完美的Markdown文本' });

      expect(result.isError).toBeUndefined();
      expect(result.content[0].text).toBe('保存成功');
      expect(resourceService.saveNote).toHaveBeenCalledWith('output.md', '这是一段完美的Markdown文本');
    });

    it('🚨 容错测试：当大模型错误地传入【深度嵌套的 JSON 对象】时，应自动将其序列化，而不是崩溃', async () => {
      // 模拟大模型发生幻觉，把整个分析对象当成了参数传进来
      const mockJsonObject = {
        title: "行业分析报告",
        data: { trend: "上升", keywords: ["AI", "Quantum"] }
      };

      const result = await handleToolCall('save_note', { filename: 'report.md', content: mockJsonObject });

      expect(result.isError).toBeUndefined();
      expect(result.content[0].text).toBe('保存成功');

      // 期望底层调用保存时，Controller 已经非常聪明地把 JSON 对象转换为了带缩进的字符串
      const expectedString = JSON.stringify(mockJsonObject, null, 2);
      expect(resourceService.saveNote).toHaveBeenCalledWith('report.md', expectedString);
    });

    it('当缺少必要参数时，应优雅地返回错误信息', async () => {
      const result = await handleToolCall('save_note', { filename: 'error.md' }); // 故意漏掉 content
      expect(result.isError).toBe(true);
      expect(result.content[0].text).toContain('缺少 filename 或 content 参数');
    });
  });

  // ====================================================
  // 测试: 意图 2 - 获取思维框架模板
  // ====================================================
  describe('Tool: fetch_framework_template (抗死循环剥离器)', () => {
    it('应该正确读取 JSON 文件，剥离外壳，并【强制附加刹车指令】防止大模型死循环', async () => {
      // 伪造一个框架 JSON 文件内容
      const mockFrameworkJson = JSON.stringify({
        messages: [
          { role: 'system', content: { text: '我是系统架构师' } },
          { role: 'user', content: { text: '请按 SWOT 分析化验' } }
        ]
      });
      (fs.readFileSync as jest.Mock).mockReturnValue(mockFrameworkJson);

      const result = await handleToolCall('fetch_framework_template', { framework_name: 'swot' });

      expect(result.isError).toBeUndefined();
      const outputText = result.content[0].text;

      // 断言：必须包含这句护身符，阻止大模型不断重复调工具
      expect(outputText).toContain('立即停止调用任何工具');

      // 断言：JSON 外壳已经被剥离，直接透出了内部指导文字
      expect(outputText).toContain('我是系统架构师');
      expect(outputText).toContain('请按 SWOT 分析化验');
    });
  });

  // ====================================================
  // 测试: 意图 1 - 文献检索
  // ====================================================
  describe('Tool: search_arxiv', () => {
    it('应该正确调用服务并格式化返回带 Markdown 语法的文献列表', async () => {
      const result = await handleToolCall('search_arxiv', { query: 'AI' });

      expect(result.isError).toBeUndefined();
      expect(arxivService.searchArxiv).toHaveBeenCalledWith('AI', 5);

      const outputText = result.content[0].text;
      expect(outputText).toContain('找到了关于 "AI" 的相关文献');
      expect(outputText).toContain('Test Paper');
      expect(outputText).toContain('http://arxiv.org/abs/1234');
    });
  });

  // ====================================================
  // 测试: 意图 4 - 本地笔记相关
  // ====================================================
  describe('Local Notes Tools', () => {
    it('list_local_notes 应该正确返回笔记列表', async () => {
      const result = await handleToolCall('list_local_notes', {});
      expect(result.content[0].text).toContain('test-note.md');
      expect(result.content[0].text).toContain('ai-trend.md');
    });

    it('read_local_note 应该正确读取具体笔记内容', async () => {
      const result = await handleToolCall('read_local_note', { filename: 'test-note.md' });
      expect(resourceService.readObsidianNote).toHaveBeenCalledWith('test-note.md');
      expect(result.content[0].text).toContain('这是模拟的本地笔记内容');
    });
  });
});
```

---

## 3. 测试执行说明

在您配置好上述的测试文件后，请在终端使用项目中 `package.json` 原有配置的命令执行测试：

1. **执行基础测试**

    ```bash
    npm run test
    ```

2. **执行并查看覆盖率（Coverage）**

    ```bash
    npm run test:coverage
    ```

    执行完成后，请检查终端输出的表格，重点关注 `arxivService.ts` 和 `toolsController.ts` 的 **Stmts (语句覆盖率)** 和 **Branch (分支覆盖率)** 是否达到预期（推荐 > 80%）。

3. **运行单个测试文件**

如果您只想跑 `toolsController.test.ts` 这一个文件，可以使用以下命令：


```bash
# 方式 A (推荐，使用 npx 直接调用 jest)
npx jest src/controllers/__test__/toolsController.test.ts

# 方式 B (使用 npm 脚本透传参数，注意中间的 `--`)
npm run test -- src/controllers/__test__/toolsController.test.ts
```

4. **监听模式**

在写测试或改 Bug 时，我们经常需要修改完代码后自动重新跑那个报错的测试文件。您可以加上 `--watch` 参数：

```bash
npx jest src/controllers/__test__/toolsController.test.ts --watch
```

这样您每次保存代码，Jest 就会自动为您重新运行这个文件的测试，极大提升开发效率！

---

## 许可声明

本文档采用 **知识共享署名--相同方式共享 4.0 国际许可协议 (CC BY--SA 4.0)** 进行许可，© 2025-2026 Gitconomy Research。
