Files
sanguo_moziplus_v2/docs/design/18-test-design.md
T
cfdaily f55a037c98
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 1s
[moz] docs: §18 API 聚合重构 + 工具链 Tab 设计文档
- 18-api-refactor-and-toolchain-tab.md: 主设计(9章+实施约束)
  - 后端拆分方案 B(task_routes + task_relation_routes + shared)
  - expand 细粒度聚合(comments/events 带 limit+total_count)
  - 任务列表搜索参数 q
  - 工具链 Tab 设计(仿 MailPanel + 搜索栏)
  - GATE 门控 + 委派原则 + wiki 查询规则
  - 司马懿已审(mail-1781415763066)
- 18-test-design.md: 测试用例详细设计(34 个用例 + CI 集成)
- tests/scripts/verify_api_compat.sh: 路由兼容性验证脚本
2026-06-14 13:53:56 +08:00

16 KiB
Raw Blame History

§18 测试用例详细设计

关联: docs/design/18-api-refactor-and-toolchain-tab.md 日期: 2026-06-14


1. 测试文件规划

文件 类型 测试数 说明
tests/integration/test_api.py 集成 扩展现有 拆分后回归验证
tests/unit/test_task_routes.py 单元 14 task_routes 专项
tests/unit/test_expand_api.py 单元 7 expand 聚合专项
tests/unit/test_task_search.py 单元 4 搜索专项
tests/scripts/verify_api_compat.sh 脚本 1 CI 路由兼容性

总计26 个测试 + 1 个 CI 脚本


2. 预置 Fixture

# tests/conftest.py 新增(如果不存在则补充)

@pytest.fixture
def expand_env(tmp_path):
    """expand 测试环境:1 个 task + 预置关联数据"""
    project_root = tmp_path / "projects"
    project_root.mkdir()
    os.environ["BLACKBOARD_ROOT"] = str(project_root)

    reg = ProjectRegistry(project_root)
    reg.create_project("test-proj", "Test Project", agents=["agent1"])

    bb = Blackboard(project_root / "test-proj" / "blackboard.db")
    bb.create_task(Task(id="t1", title="Expand Test Task", task_type="coding"))

    # 预置 25 条 comment
    for i in range(25):
        bb.add_comment("t1", agent="agent1", comment_type="general",
                       content=f"Comment number {i}")

    # 预置 5 条 output
    for i in range(5):
        bb.write_output("t1", agent="agent1",
                        output_type="code",
                        content=f"output content {i}",
                        filename=f"file_{i}.py")

    # 预置 3 条 review
    for i in range(3):
        bb.add_review("t1", reviewer="agent1",
                      verdict="APPROVE",
                      confidence=0.9,
                      risk_level="low",
                      summary=f"Review {i}")

    # 预置 2 条 decision
    for i in range(2):
        bb.add_decision("t1", agent="agent1",
                        decision_type="scope",
                        rationale=f"Decision {i}")

    # 预置 35 条 event
    from src.blackboard.queries import Queries
    q = Queries(project_root / "test-proj" / "blackboard.db")
    for i in range(35):
        q.add_event("t1", event_type="status_change",
                    detail=f"Event {i}")

    yield project_root
    del os.environ["BLACKBOARD_ROOT"]

3. task_routes.py 测试(test_task_routes.py

"""task_routes.py 路由测试 — 验证拆分后行为不变"""

import pytest
from fastapi.testclient import TestClient
from src.main import app

client = TestClient(app)
pytestmark = pytest.mark.integration


class TestTaskListRoutes:
    """GET /tasks + 搜索"""

    def test_list_tasks_basic(self, project_env):
        """列表基本返回格式不变"""
        resp = client.get("/api/projects/test-proj/tasks")
        assert resp.status_code == 200
        data = resp.json()
        assert "tasks" in data
        assert isinstance(data["tasks"], list)

    def test_list_tasks_with_search(self, project_env):
        """q 参数搜索标题"""
        resp = client.get("/api/projects/test-proj/tasks?q=Existing")
        data = resp.json()
        assert len(data["tasks"]) == 1
        assert "Existing" in data["tasks"][0]["title"]

    def test_list_tasks_search_case_insensitive(self, project_env):
        """大小写不敏感"""
        resp = client.get("/api/projects/test-proj/tasks?q=existing")
        data = resp.json()
        assert len(data["tasks"]) == 1

    def test_list_tasks_search_no_match(self, project_env):
        """无匹配返回空列表"""
        resp = client.get("/api/projects/test-proj/tasks?q=nonexistent_xyz")
        data = resp.json()
        assert len(data["tasks"]) == 0

    def test_list_tasks_search_empty_q(self, project_env):
        """q 为空返回全部"""
        resp = client.get("/api/projects/test-proj/tasks?q=")
        data = resp.json()
        assert len(data["tasks"]) >= 1


class TestTaskDetailRoutes:
    """GET /tasks/{tid} + expand"""

    def test_get_task_basic(self, project_env):
        """无 expand 返回基本 task"""
        resp = client.get("/api/projects/test-proj/tasks/t1")
        assert resp.status_code == 200
        data = resp.json()
        assert data["id"] == "t1"
        assert "comments" not in data  # 无 expand 不含关联数据

    def test_get_task_404(self, project_env):
        """不存在的 task 返回 404"""
        resp = client.get("/api/projects/test-proj/tasks/nonexistent")
        assert resp.status_code == 404


class TestTaskActionRoutes:
    """claim/status/patch/archive 行为不变"""

    def test_claim_task(self, project_env):
        """认领行为不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/claim",
                          json={"agent": "agent1"})
        assert resp.status_code == 200

    def test_update_status(self, project_env):
        """状态流转不变"""
        client.post("/api/projects/test-proj/tasks/t1/claim",
                   json={"agent": "agent1"})
        resp = client.post("/api/projects/test-proj/tasks/t1/status",
                          json={"agent": "agent1", "status": "working"})
        assert resp.status_code == 200

    def test_invalid_status_transition(self, project_env):
        """非法状态转换返回 409"""
        resp = client.post("/api/projects/test-proj/tasks/t1/status",
                          json={"agent": "agent1", "status": "done"})
        assert resp.status_code == 409

    def test_patch_task(self, project_env):
        """PATCH 更新不变"""
        resp = client.patch("/api/projects/test-proj/tasks/t1",
                           json={"priority": 5})
        assert resp.status_code == 200

    def test_archive_task(self, project_env):
        """归档不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/archive",
                          json={"agent": "agent1"})
        assert resp.status_code == 200


class TestTaskCreateRoute:
    """POST /tasks 创建行为不变"""

    def test_create_task(self, project_env):
        """创建格式不变"""
        resp = client.post("/api/projects/test-proj/tasks",
                          json={"title": "New Task", "description": "test"})
        assert resp.status_code == 201
        data = resp.json()
        assert "id" in data

4. expand 聚合测试(test_expand_api.py

"""expand 聚合接口测试"""

import pytest
from fastapi.testclient import TestClient
from src.main import app

client = TestClient(app)
pytestmark = pytest.mark.integration


class TestExpandComments:
    """expand=comments"""

    def test_comments_limit_and_count(self, expand_env):
        """返回最新 20 条 + total_count=25"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=comments")
        data = resp.json()

        comments = data["comments"]
        assert isinstance(comments, dict)
        assert len(comments["items"]) == 20
        assert comments["total_count"] == 25
        assert comments["limit"] == 20

    def test_comments_are_latest(self, expand_env):
        """返回的是最新 20 条(Comment 5-24"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=comments")
        data = resp.json()
        first_content = data["comments"]["items"][0]["content"]
        last_content = data["comments"]["items"][-1]["content"]
        # 最新 20 条 = index 5 到 24
        assert "5" in first_content or "24" in last_content


class TestExpandEvents:
    """expand=events"""

    def test_events_limit_and_count(self, expand_env):
        """返回最新 30 条 + total_count=35"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=events")
        data = resp.json()

        events = data["events"]
        assert isinstance(events, dict)
        assert len(events["items"]) == 30
        assert events["total_count"] == 35
        assert events["limit"] == 30


class TestExpandFullResources:
    """outputs/reviews/decisions 全量返回"""

    def test_expand_outputs_full(self, expand_env):
        """outputs 全量返回(5 条),格式是 list 不是 dict"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=outputs")
        data = resp.json()

        outputs = data["outputs"]
        assert isinstance(outputs, list)
        assert len(outputs) == 5

    def test_expand_reviews_full(self, expand_env):
        """reviews 全量返回(3 条)"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=reviews")
        data = resp.json()

        reviews = data["reviews"]
        assert isinstance(reviews, list)
        assert len(reviews) == 3

    def test_expand_decisions_full(self, expand_env):
        """decisions 全量返回(2 条)"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=decisions")
        data = resp.json()

        decisions = data["decisions"]
        assert isinstance(decisions, list)
        assert len(decisions) == 2


class TestExpandCombinations:
    """组合 expand"""

    def test_expand_multiple_fields(self, expand_env):
        """expand=comments,outputs,reviews 组合"""
        resp = client.get(
            "/api/projects/test-proj/tasks/t1?expand=comments,outputs,reviews"
        )
        data = resp.json()

        assert "comments" in data
        assert "outputs" in data
        assert "reviews" in data
        assert "events" not in data  # 未请求
        assert "decisions" not in data

    def test_expand_all_compat(self, expand_env):
        """expand=all 向后兼容"""
        resp = client.get("/api/projects/test-proj/tasks/t1?expand=all")
        data = resp.json()

        # all 返回所有关联资源
        assert "comments" in data
        assert "outputs" in data
        assert "reviews" in data
        assert "events" in data
        assert "decisions" in data

    def test_no_expand(self, expand_env):
        """不传 expand 只返回基本 task"""
        resp = client.get("/api/projects/test-proj/tasks/t1")
        data = resp.json()

        assert "comments" not in data
        assert "outputs" not in data
        assert data["id"] == "t1"

    def test_expand_invalid_field_ignored(self, expand_env):
        """无效 expand 字段静默忽略"""
        resp = client.get(
            "/api/projects/test-proj/tasks/t1?expand=comments,invalid_field"
        )
        data = resp.json()

        assert "comments" in data
        assert "invalid_field" not in data

5. task_relation_routes.py 回归测试

"""task_relation_routes.py 路由回归 — 验证拆分后行为不变"""

import pytest
from fastapi.testclient import TestClient
from src.main import app

client = TestClient(app)
pytestmark = pytest.mark.integration


class TestRelationRoutesRegression:
    """关联路由回归测试"""

    def test_comments_crud(self, project_env):
        """GET/POST comments 不变"""
        # POST
        resp = client.post("/api/projects/test-proj/tasks/t1/comments",
                          json={"agent": "a1", "comment_type": "general",
                                "content": "test comment"})
        assert resp.status_code == 201

        # GET
        resp = client.get("/api/projects/test-proj/tasks/t1/comments")
        assert resp.status_code == 200
        assert len(resp.json()["comments"]) >= 1

    def test_outputs_crud(self, project_env):
        """GET/POST outputs 不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
                          json={"agent": "a1", "type": "code",
                                "content": "print('hello')"})
        assert resp.status_code == 201

        resp = client.get("/api/projects/test-proj/tasks/t1/outputs")
        assert resp.status_code == 200

    def test_write_output_with_filename(self, project_env):
        """output 含 filename 的文件写入不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
                          json={"agent": "a1", "type": "code",
                                "content": "x = 1",
                                "filename": "test.py"})
        assert resp.status_code == 201

    def test_write_output_invalid_type(self, project_env):
        """output 无效 type 返回 422"""
        resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
                          json={"agent": "a1", "type": "invalid_type",
                                "content": "x"})
        assert resp.status_code == 422

    def test_reviews_crud(self, project_env):
        """GET/POST reviews 不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/reviews",
                          json={"reviewer": "a1", "verdict": "APPROVE",
                                "confidence": 0.9, "risk_level": "low",
                                "summary": "LGTM"})
        assert resp.status_code == 201

        resp = client.get("/api/projects/test-proj/tasks/t1/reviews")
        assert resp.status_code == 200

    def test_decisions_crud(self, project_env):
        """GET/POST decisions 不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/decisions",
                          json={"agent": "a1", "decision_type": "scope",
                                "rationale": "test"})
        assert resp.status_code == 201

        resp = client.get("/api/projects/test-proj/tasks/t1/decisions")
        assert resp.status_code == 200

    def test_observations_add(self, project_env):
        """POST observations 不变"""
        resp = client.post("/api/projects/test-proj/tasks/t1/observations",
                          json={"agent": "a1", "observation_type": "note",
                                "content": "observed"})
        assert resp.status_code == 201

    def test_events_list(self, project_env):
        """GET events 不变"""
        resp = client.get("/api/projects/test-proj/tasks/t1/events")
        assert resp.status_code == 200
        assert "events" in resp.json()

    def test_project_events(self, project_env):
        """GET /events 项目级事件不变"""
        resp = client.get("/api/projects/test-proj/events")
        assert resp.status_code == 200

    def test_summary(self, project_env):
        """GET /summary 不变"""
        resp = client.get("/api/projects/test-proj/summary")
        assert resp.status_code == 200

6. CI 集成

.gitea/workflows/ci.yml 新增步骤

  test:
    runs-on: ubuntu-latest
    steps:
      # ... 现有步骤 ...

      # API 兼容性验证
      - name: Verify API Compatibility
        run: |
          cd src/frontend && npm run build
          bash tests/scripts/verify_api_compat.sh

      # 新增测试
      - name: Run API Tests
        run: |
          pytest tests/unit/test_task_routes.py \
                 tests/unit/test_expand_api.py \
                 tests/integration/test_api.py \
                 -m "not e2e" -v

本地开发验证流程

# 1. 改完代码后先跑兼容性验证
bash tests/scripts/verify_api_compat.sh

# 2. 跑全量测试
pytest -m "not e2e" -v

# 3. 跑新增专项测试
pytest tests/unit/test_task_routes.py tests/unit/test_expand_api.py -v

7. 测试覆盖矩阵

设计文档章节 测试文件 测试类 用例数
§2 路由拆分(兼容性) verify_api_compat.sh 1
§3.1 基本详情 test_task_routes.py TestTaskDetailRoutes 2
§3.2 搜索 test_task_routes.py TestTaskListRoutes 5
§3.3 动作路由 test_task_routes.py TestTaskActionRoutes 5
§3.4 创建 test_task_routes.py TestTaskCreateRoute 1
§4 expand comments test_expand_api.py TestExpandComments 2
§4 expand events test_expand_api.py TestExpandEvents 1
§4 expand 全量 test_expand_api.py TestExpandFullResources 3
§4 expand 组合 test_expand_api.py TestExpandCombinations 4
§5.2 关联回归 test_api.py TestRelationRoutesRegression 10
合计 34