984 lines
37 KiB
Python
984 lines
37 KiB
Python
"""TeamPlan / SubTask / PlanPhase 数据模型单元测试 (hub-and-spoke + 流水线模式)"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from agentkit.experts.plan import (
|
||
CollaborationContract,
|
||
MergeStrategy,
|
||
PhaseStatus,
|
||
PhaseType,
|
||
PlanPhase,
|
||
PlanStatus,
|
||
SubTask,
|
||
SubTaskStatus,
|
||
TeamPlan,
|
||
)
|
||
|
||
|
||
# ── 辅助函数 ──────────────────────────────────────────────
|
||
|
||
|
||
def _make_subtask(
|
||
id: str = "subtask_1",
|
||
description: str = "分析数据",
|
||
assigned_expert: str = "analyst",
|
||
status: SubTaskStatus = SubTaskStatus.PENDING,
|
||
result: dict | None = None,
|
||
) -> SubTask:
|
||
"""创建测试用 SubTask 实例"""
|
||
return SubTask(
|
||
id=id,
|
||
description=description,
|
||
assigned_expert=assigned_expert,
|
||
status=status,
|
||
result=result,
|
||
)
|
||
|
||
|
||
def _make_valid_plan() -> TeamPlan:
|
||
"""创建一个有效的 hub-and-spoke 执行计划"""
|
||
subtasks = [
|
||
_make_subtask(id="s1", description="分析需求", assigned_expert="analyst"),
|
||
_make_subtask(id="s2", description="设计架构", assigned_expert="architect"),
|
||
_make_subtask(id="s3", description="编写代码", assigned_expert="coder"),
|
||
]
|
||
return TeamPlan(
|
||
id="plan_001",
|
||
task="实现用户登录功能",
|
||
subtasks=subtasks,
|
||
status=PlanStatus.DRAFT,
|
||
lead_expert="architect",
|
||
)
|
||
|
||
|
||
# ── MergeStrategy 测试 ────────────────────────────────────
|
||
|
||
|
||
class TestMergeStrategy:
|
||
"""MergeStrategy 枚举测试"""
|
||
|
||
def test_only_best_strategy_exists(self):
|
||
"""hub-and-spoke 模式仅保留 BEST 策略"""
|
||
assert MergeStrategy.BEST == "best"
|
||
# 确保只有 BEST 一个值
|
||
assert len(list(MergeStrategy)) == 1
|
||
|
||
def test_no_vote_or_fusion(self):
|
||
"""VOTE 和 FUSION 已被移除"""
|
||
assert not hasattr(MergeStrategy, "VOTE")
|
||
assert not hasattr(MergeStrategy, "FUSION")
|
||
|
||
|
||
# ── PlanStatus 测试 ───────────────────────────────────────
|
||
|
||
|
||
class TestPlanStatus:
|
||
"""PlanStatus 枚举测试"""
|
||
|
||
def test_statuses_exist(self):
|
||
"""必要的计划状态都存在"""
|
||
assert PlanStatus.DRAFT == "draft"
|
||
assert PlanStatus.EXECUTING == "executing"
|
||
assert PlanStatus.COMPLETED == "completed"
|
||
assert PlanStatus.FAILED == "failed"
|
||
assert PlanStatus.FALLBACK == "fallback"
|
||
|
||
def test_no_confirmed_status(self):
|
||
"""CONFIRMED 状态已被移除(hub-and-spoke 无需确认阶段)"""
|
||
assert not hasattr(PlanStatus, "CONFIRMED")
|
||
|
||
|
||
# ── SubTaskStatus 测试 ────────────────────────────────────
|
||
|
||
|
||
class TestSubTaskStatus:
|
||
"""SubTaskStatus 枚举测试"""
|
||
|
||
def test_statuses_exist(self):
|
||
"""子任务状态都存在"""
|
||
assert SubTaskStatus.PENDING == "pending"
|
||
assert SubTaskStatus.RUNNING == "running"
|
||
assert SubTaskStatus.COMPLETED == "completed"
|
||
assert SubTaskStatus.FAILED == "failed"
|
||
|
||
|
||
# ── SubTask 测试 ──────────────────────────────────────────
|
||
|
||
|
||
class TestSubTask:
|
||
"""SubTask 数据模型测试"""
|
||
|
||
def test_creation_with_all_fields(self):
|
||
"""创建 SubTask 并设置所有字段"""
|
||
subtask = SubTask(
|
||
id="subtask_a",
|
||
description="竞品分析",
|
||
assigned_expert="analyst",
|
||
status=SubTaskStatus.RUNNING,
|
||
result={"report": "竞品分析报告"},
|
||
)
|
||
assert subtask.id == "subtask_a"
|
||
assert subtask.description == "竞品分析"
|
||
assert subtask.assigned_expert == "analyst"
|
||
assert subtask.status == SubTaskStatus.RUNNING
|
||
assert subtask.result == {"report": "竞品分析报告"}
|
||
|
||
def test_default_values(self):
|
||
"""默认值:自动生成 id,PENDING 状态"""
|
||
subtask = SubTask(description="测试任务")
|
||
assert subtask.id is not None
|
||
assert len(subtask.id) > 0
|
||
assert subtask.description == "测试任务"
|
||
assert subtask.assigned_expert == ""
|
||
assert subtask.status == SubTaskStatus.PENDING
|
||
assert subtask.result is None
|
||
|
||
def test_to_dict_from_dict_roundtrip(self):
|
||
"""to_dict / from_dict 往返序列化"""
|
||
subtask = SubTask(
|
||
id="roundtrip_subtask",
|
||
description="往返测试",
|
||
assigned_expert="tester",
|
||
status=SubTaskStatus.COMPLETED,
|
||
result={"key": "value"},
|
||
)
|
||
d = subtask.to_dict()
|
||
restored = SubTask.from_dict(d)
|
||
|
||
assert restored.id == subtask.id
|
||
assert restored.description == subtask.description
|
||
assert restored.assigned_expert == subtask.assigned_expert
|
||
assert restored.status == subtask.status
|
||
assert restored.result == subtask.result
|
||
|
||
def test_to_dict_structure(self):
|
||
"""to_dict 返回正确的字典结构"""
|
||
subtask = _make_subtask(
|
||
id="struct_test",
|
||
description="结构测试",
|
||
assigned_expert="dev",
|
||
status=SubTaskStatus.RUNNING,
|
||
result={"output": "data"},
|
||
)
|
||
d = subtask.to_dict()
|
||
assert d["id"] == "struct_test"
|
||
assert d["description"] == "结构测试"
|
||
assert d["assigned_expert"] == "dev"
|
||
assert d["status"] == "running"
|
||
assert d["result"] == {"output": "data"}
|
||
|
||
|
||
# ── TeamPlan 测试 ─────────────────────────────────────────
|
||
|
||
|
||
class TestTeamPlan:
|
||
"""TeamPlan 数据模型测试"""
|
||
|
||
def test_creation(self):
|
||
"""创建 TeamPlan"""
|
||
plan = _make_valid_plan()
|
||
assert plan.id == "plan_001"
|
||
assert plan.task == "实现用户登录功能"
|
||
assert len(plan.subtasks) == 3
|
||
assert plan.status == PlanStatus.DRAFT
|
||
assert plan.lead_expert == "architect"
|
||
|
||
def test_default_values(self):
|
||
"""默认值:自动生成 id,空子任务列表"""
|
||
plan = TeamPlan(task="测试任务")
|
||
assert plan.id is not None
|
||
assert plan.task == "测试任务"
|
||
assert plan.subtasks == []
|
||
assert plan.status == PlanStatus.DRAFT
|
||
assert plan.lead_expert == ""
|
||
|
||
def test_to_dict_from_dict_roundtrip(self):
|
||
"""to_dict / from_dict 往返序列化"""
|
||
plan = _make_valid_plan()
|
||
d = plan.to_dict()
|
||
restored = TeamPlan.from_dict(d)
|
||
|
||
assert restored.id == plan.id
|
||
assert restored.task == plan.task
|
||
assert len(restored.subtasks) == len(plan.subtasks)
|
||
assert restored.status == plan.status
|
||
assert restored.lead_expert == plan.lead_expert
|
||
|
||
for original, restored_st in zip(plan.subtasks, restored.subtasks):
|
||
assert restored_st.id == original.id
|
||
assert restored_st.description == original.description
|
||
assert restored_st.assigned_expert == original.assigned_expert
|
||
assert restored_st.status == original.status
|
||
|
||
def test_get_subtask_by_id(self):
|
||
"""get_subtask 根据 ID 获取子任务"""
|
||
plan = _make_valid_plan()
|
||
st = plan.get_subtask("s2")
|
||
assert st is not None
|
||
assert st.id == "s2"
|
||
assert st.description == "设计架构"
|
||
|
||
def test_get_subtask_with_nonexistent_id_returns_none(self):
|
||
"""get_subtask 对不存在的 ID 返回 None"""
|
||
plan = _make_valid_plan()
|
||
assert plan.get_subtask("nonexistent") is None
|
||
|
||
def test_update_subtask_status(self):
|
||
"""update_subtask_status 更新子任务状态和结果"""
|
||
plan = _make_valid_plan()
|
||
plan.update_subtask_status("s1", SubTaskStatus.COMPLETED, {"output": "分析完成"})
|
||
st = plan.get_subtask("s1")
|
||
assert st is not None
|
||
assert st.status == SubTaskStatus.COMPLETED
|
||
assert st.result == {"output": "分析完成"}
|
||
|
||
def test_update_subtask_status_without_result(self):
|
||
"""update_subtask_status 不传 result 时不覆盖已有 result"""
|
||
plan = _make_valid_plan()
|
||
plan.update_subtask_status("s1", SubTaskStatus.COMPLETED, {"output": "done"})
|
||
plan.update_subtask_status("s1", SubTaskStatus.RUNNING)
|
||
st = plan.get_subtask("s1")
|
||
assert st is not None
|
||
assert st.status == SubTaskStatus.RUNNING
|
||
assert st.result == {"output": "done"}
|
||
|
||
def test_completed_subtasks_property(self):
|
||
"""completed_subtasks 返回已完成的子任务"""
|
||
plan = _make_valid_plan()
|
||
plan.update_subtask_status("s1", SubTaskStatus.COMPLETED)
|
||
plan.update_subtask_status("s2", SubTaskStatus.COMPLETED)
|
||
plan.update_subtask_status("s3", SubTaskStatus.FAILED)
|
||
|
||
completed = plan.completed_subtasks
|
||
assert len(completed) == 2
|
||
assert {st.id for st in completed} == {"s1", "s2"}
|
||
|
||
def test_failed_subtasks_property(self):
|
||
"""failed_subtasks 返回失败的子任务"""
|
||
plan = _make_valid_plan()
|
||
plan.update_subtask_status("s1", SubTaskStatus.COMPLETED)
|
||
plan.update_subtask_status("s2", SubTaskStatus.FAILED)
|
||
plan.update_subtask_status("s3", SubTaskStatus.FAILED)
|
||
|
||
failed = plan.failed_subtasks
|
||
assert len(failed) == 2
|
||
assert {st.id for st in failed} == {"s2", "s3"}
|
||
|
||
def test_all_done_property_when_all_completed(self):
|
||
"""all_done 当所有子任务完成时返回 True"""
|
||
plan = _make_valid_plan()
|
||
for st in plan.subtasks:
|
||
plan.update_subtask_status(st.id, SubTaskStatus.COMPLETED)
|
||
assert plan.all_done is True
|
||
|
||
def test_all_done_property_when_some_failed(self):
|
||
"""all_done 当所有子任务完成或失败时返回 True"""
|
||
plan = _make_valid_plan()
|
||
plan.update_subtask_status("s1", SubTaskStatus.COMPLETED)
|
||
plan.update_subtask_status("s2", SubTaskStatus.FAILED)
|
||
plan.update_subtask_status("s3", SubTaskStatus.COMPLETED)
|
||
assert plan.all_done is True
|
||
|
||
def test_all_done_property_when_pending(self):
|
||
"""all_done 当有子任务未完成时返回 False"""
|
||
plan = _make_valid_plan()
|
||
plan.update_subtask_status("s1", SubTaskStatus.COMPLETED)
|
||
# s2 and s3 still pending
|
||
assert plan.all_done is False
|
||
|
||
def test_all_done_property_empty_plan(self):
|
||
"""all_done 当没有子任务时返回 True(vacuous truth)"""
|
||
plan = TeamPlan(task="空计划")
|
||
assert plan.all_done is True
|
||
|
||
|
||
# ── PlanPhase 测试(流水线模式)──────────────────────────
|
||
|
||
|
||
def _make_phase(
|
||
id: str = "phase_1",
|
||
name: str = "规划",
|
||
assigned_expert: str = "tech_lead",
|
||
task_description: str = "分析需求并制定计划",
|
||
depends_on: list[str] | None = None,
|
||
status: PhaseStatus = PhaseStatus.PENDING,
|
||
result: dict | None = None,
|
||
) -> PlanPhase:
|
||
"""创建测试用 PlanPhase 实例"""
|
||
return PlanPhase(
|
||
id=id,
|
||
name=name,
|
||
assigned_expert=assigned_expert,
|
||
task_description=task_description,
|
||
depends_on=depends_on or [],
|
||
status=status,
|
||
result=result,
|
||
)
|
||
|
||
|
||
def _make_pipeline_plan() -> TeamPlan:
|
||
"""创建一个流水线模式的执行计划
|
||
|
||
结构:
|
||
Layer 0: [规划] (无依赖)
|
||
Layer 1: [前端, 后端] (依赖规划)
|
||
Layer 2: [QA] (依赖前端+后端)
|
||
Layer 3: [评审] (依赖QA)
|
||
"""
|
||
phases = [
|
||
_make_phase(id="p1", name="规划", assigned_expert="tech_lead", depends_on=[]),
|
||
_make_phase(id="p2", name="前端", assigned_expert="frontend_engineer", depends_on=["p1"]),
|
||
_make_phase(id="p3", name="后端", assigned_expert="backend_engineer", depends_on=["p1"]),
|
||
_make_phase(id="p4", name="QA", assigned_expert="qa_engineer", depends_on=["p2", "p3"]),
|
||
_make_phase(id="p5", name="评审", assigned_expert="code_reviewer", depends_on=["p4"]),
|
||
]
|
||
return TeamPlan(
|
||
id="pipeline_001",
|
||
task="开发用户登录功能",
|
||
phases=phases,
|
||
status=PlanStatus.DRAFT,
|
||
lead_expert="tech_lead",
|
||
)
|
||
|
||
|
||
class TestPhaseStatus:
|
||
"""PhaseStatus 枚举测试"""
|
||
|
||
def test_statuses_exist(self):
|
||
"""阶段状态都存在"""
|
||
assert PhaseStatus.PENDING == "pending"
|
||
assert PhaseStatus.RUNNING == "in_progress"
|
||
assert PhaseStatus.COMPLETED == "completed"
|
||
assert PhaseStatus.FAILED == "failed"
|
||
|
||
|
||
class TestPhaseType:
|
||
"""PhaseType 枚举测试"""
|
||
|
||
def test_types_exist(self):
|
||
"""阶段类型都存在"""
|
||
assert PhaseType.EXECUTION == "execution"
|
||
assert PhaseType.DEBATE == "debate"
|
||
|
||
def test_only_two_types(self):
|
||
"""只有 EXECUTION 和 DEBATE 两种类型"""
|
||
assert len(list(PhaseType)) == 2
|
||
|
||
|
||
class TestPlanPhase:
|
||
"""PlanPhase 数据模型测试"""
|
||
|
||
def test_creation_with_all_fields(self):
|
||
"""创建 PlanPhase 并设置所有字段"""
|
||
phase = PlanPhase(
|
||
id="phase_a",
|
||
name="前端开发",
|
||
assigned_expert="frontend_engineer",
|
||
task_description="实现登录页面",
|
||
depends_on=["phase_planning"],
|
||
status=PhaseStatus.RUNNING,
|
||
result={"component": "LoginForm"},
|
||
)
|
||
assert phase.id == "phase_a"
|
||
assert phase.name == "前端开发"
|
||
assert phase.assigned_expert == "frontend_engineer"
|
||
assert phase.task_description == "实现登录页面"
|
||
assert phase.depends_on == ["phase_planning"]
|
||
assert phase.status == PhaseStatus.RUNNING
|
||
assert phase.result == {"component": "LoginForm"}
|
||
|
||
def test_default_values(self):
|
||
"""默认值:自动生成 id,空 depends_on,PENDING 状态"""
|
||
phase = PlanPhase(name="测试阶段")
|
||
assert phase.id is not None
|
||
assert len(phase.id) > 0
|
||
assert phase.name == "测试阶段"
|
||
assert phase.assigned_expert == ""
|
||
assert phase.task_description == ""
|
||
assert phase.depends_on == []
|
||
assert phase.status == PhaseStatus.PENDING
|
||
assert phase.result is None
|
||
|
||
def test_to_dict_from_dict_roundtrip(self):
|
||
"""to_dict / from_dict 往返序列化
|
||
|
||
Note: to_dict() serializes result to string for frontend compatibility,
|
||
so the roundtrip is lossy for the result field (dict → string).
|
||
"""
|
||
phase = _make_phase(
|
||
id="roundtrip_phase",
|
||
name="往返测试",
|
||
assigned_expert="tester",
|
||
task_description="测试序列化",
|
||
depends_on=["dep1", "dep2"],
|
||
status=PhaseStatus.COMPLETED,
|
||
result={"content": "value"},
|
||
)
|
||
d = phase.to_dict()
|
||
restored = PlanPhase.from_dict(d)
|
||
|
||
assert restored.id == phase.id
|
||
assert restored.name == phase.name
|
||
assert restored.assigned_expert == phase.assigned_expert
|
||
assert restored.task_description == phase.task_description
|
||
assert restored.depends_on == phase.depends_on
|
||
assert restored.status == phase.status
|
||
# result is serialized to string in to_dict(); from_dict reads it back as string
|
||
assert restored.result == "value"
|
||
|
||
def test_to_dict_structure(self):
|
||
"""to_dict 返回正确的字典结构"""
|
||
phase = _make_phase(
|
||
id="struct_test",
|
||
name="结构测试",
|
||
assigned_expert="dev",
|
||
task_description="测试结构",
|
||
depends_on=["d1"],
|
||
status=PhaseStatus.RUNNING,
|
||
result={"content": "phase output data"},
|
||
)
|
||
d = phase.to_dict()
|
||
assert d["id"] == "struct_test"
|
||
assert d["name"] == "结构测试"
|
||
assert d["assigned_expert"] == "dev"
|
||
assert d["task_description"] == "测试结构"
|
||
assert d["depends_on"] == ["d1"]
|
||
assert d["status"] == "in_progress"
|
||
# result is serialized to string to match frontend ITeamPlanPhase.result type
|
||
assert d["result"] == "phase output data"
|
||
|
||
def test_default_phase_type_is_execution(self):
|
||
"""默认 phase_type 为 EXECUTION"""
|
||
phase = PlanPhase(name="测试阶段")
|
||
assert phase.phase_type == PhaseType.EXECUTION
|
||
assert phase.debate_config is None
|
||
|
||
def test_debate_phase_creation(self):
|
||
"""创建 DEBATE 类型阶段"""
|
||
debate_config = {
|
||
"topic": "前端框架选型:React vs Vue",
|
||
"participants": ["frontend_engineer", "tech_lead"],
|
||
"max_rounds": 2,
|
||
}
|
||
phase = PlanPhase(
|
||
name="框架选型辩论",
|
||
assigned_expert="tech_lead",
|
||
task_description="就前端框架选型进行辩论",
|
||
phase_type=PhaseType.DEBATE,
|
||
debate_config=debate_config,
|
||
)
|
||
assert phase.phase_type == PhaseType.DEBATE
|
||
assert phase.debate_config == debate_config
|
||
assert phase.debate_config["topic"] == "前端框架选型:React vs Vue"
|
||
assert phase.debate_config["participants"] == ["frontend_engineer", "tech_lead"]
|
||
assert phase.debate_config["max_rounds"] == 2
|
||
|
||
def test_debate_phase_serialization_roundtrip(self):
|
||
"""DEBATE 阶段序列化往返"""
|
||
debate_config = {
|
||
"topic": "微服务 vs 单体",
|
||
"participants": ["backend_engineer", "tech_lead"],
|
||
"max_rounds": 3,
|
||
}
|
||
phase = PlanPhase(
|
||
id="debate_1",
|
||
name="架构辩论",
|
||
assigned_expert="tech_lead",
|
||
task_description="架构选型辩论",
|
||
phase_type=PhaseType.DEBATE,
|
||
debate_config=debate_config,
|
||
)
|
||
d = phase.to_dict()
|
||
assert d["phase_type"] == "debate"
|
||
assert d["debate_config"] == debate_config
|
||
|
||
restored = PlanPhase.from_dict(d)
|
||
assert restored.phase_type == PhaseType.DEBATE
|
||
assert restored.debate_config == debate_config
|
||
assert restored.debate_config["topic"] == "微服务 vs 单体"
|
||
|
||
def test_backward_compatibility_no_phase_type(self):
|
||
"""向后兼容:不带 phase_type 的旧 dict 默认为 EXECUTION"""
|
||
old_dict = {
|
||
"id": "old_phase",
|
||
"name": "旧阶段",
|
||
"assigned_expert": "dev",
|
||
"task_description": "旧任务",
|
||
"depends_on": [],
|
||
"status": "pending",
|
||
"result": None,
|
||
}
|
||
phase = PlanPhase.from_dict(old_dict)
|
||
assert phase.phase_type == PhaseType.EXECUTION
|
||
assert phase.debate_config is None
|
||
|
||
def test_debate_config_none_for_execution(self):
|
||
"""EXECUTION 阶段的 debate_config 为 None"""
|
||
phase = PlanPhase(name="执行阶段", phase_type=PhaseType.EXECUTION)
|
||
assert phase.debate_config is None
|
||
d = phase.to_dict()
|
||
assert d["phase_type"] == "execution"
|
||
assert d["debate_config"] is None
|
||
|
||
def test_default_collaboration_contracts_empty(self):
|
||
"""默认 collaboration_contracts 为空列表"""
|
||
phase = PlanPhase(name="测试阶段")
|
||
assert phase.collaboration_contracts == []
|
||
d = phase.to_dict()
|
||
assert d["collaboration_contracts"] == []
|
||
|
||
def test_plan_phase_with_contracts(self):
|
||
"""PlanPhase 携带 collaboration_contracts 序列化/反序列化正确"""
|
||
contracts = [
|
||
CollaborationContract(
|
||
from_expert="backend",
|
||
to_expert="frontend",
|
||
content_description="API 定义",
|
||
status="delivered",
|
||
),
|
||
CollaborationContract(
|
||
from_expert="tech_lead",
|
||
to_expert="backend",
|
||
content_description="数据模型",
|
||
),
|
||
]
|
||
phase = PlanPhase(
|
||
id="contract_phase",
|
||
name="后端开发",
|
||
assigned_expert="backend_engineer",
|
||
task_description="实现 API",
|
||
collaboration_contracts=contracts,
|
||
)
|
||
d = phase.to_dict()
|
||
assert len(d["collaboration_contracts"]) == 2
|
||
assert d["collaboration_contracts"][0]["from_expert"] == "backend"
|
||
assert d["collaboration_contracts"][0]["to_expert"] == "frontend"
|
||
assert d["collaboration_contracts"][0]["content_description"] == "API 定义"
|
||
assert d["collaboration_contracts"][0]["status"] == "delivered"
|
||
|
||
# 往返序列化
|
||
restored = PlanPhase.from_dict(d)
|
||
assert len(restored.collaboration_contracts) == 2
|
||
assert restored.collaboration_contracts[0].from_expert == "backend"
|
||
assert restored.collaboration_contracts[0].to_expert == "frontend"
|
||
assert restored.collaboration_contracts[0].content_description == "API 定义"
|
||
assert restored.collaboration_contracts[0].status == "delivered"
|
||
assert restored.collaboration_contracts[1].from_expert == "tech_lead"
|
||
assert restored.collaboration_contracts[1].status == "pending"
|
||
|
||
def test_plan_phase_empty_contracts(self):
|
||
"""协作契约为空列表时正常工作"""
|
||
phase = PlanPhase(
|
||
id="empty_contract_phase",
|
||
name="独立阶段",
|
||
assigned_expert="solo_expert",
|
||
collaboration_contracts=[],
|
||
)
|
||
d = phase.to_dict()
|
||
assert d["collaboration_contracts"] == []
|
||
restored = PlanPhase.from_dict(d)
|
||
assert restored.collaboration_contracts == []
|
||
|
||
def test_backward_compatibility_no_contracts_field(self):
|
||
"""向后兼容:不带 collaboration_contracts 的旧 dict 默认为空列表"""
|
||
old_dict = {
|
||
"id": "old_phase",
|
||
"name": "旧阶段",
|
||
"assigned_expert": "dev",
|
||
"task_description": "旧任务",
|
||
"depends_on": [],
|
||
"status": "pending",
|
||
"result": None,
|
||
}
|
||
phase = PlanPhase.from_dict(old_dict)
|
||
assert phase.collaboration_contracts == []
|
||
|
||
|
||
class TestCollaborationContract:
|
||
"""CollaborationContract 数据模型测试"""
|
||
|
||
def test_default_values(self):
|
||
"""默认值:空字符串字段,status 为 pending"""
|
||
contract = CollaborationContract()
|
||
assert contract.from_expert == ""
|
||
assert contract.to_expert == ""
|
||
assert contract.content_description == ""
|
||
assert contract.status == "pending"
|
||
|
||
def test_creation_with_all_fields(self):
|
||
"""创建 CollaborationContract 并设置所有字段"""
|
||
contract = CollaborationContract(
|
||
from_expert="backend",
|
||
to_expert="frontend",
|
||
content_description="API 定义",
|
||
status="delivered",
|
||
)
|
||
assert contract.from_expert == "backend"
|
||
assert contract.to_expert == "frontend"
|
||
assert contract.content_description == "API 定义"
|
||
assert contract.status == "delivered"
|
||
|
||
def test_collaboration_contract_serialization(self):
|
||
"""CollaborationContract 序列化/反序列化正确"""
|
||
contract = CollaborationContract(
|
||
from_expert="tech_lead",
|
||
to_expert="qa_engineer",
|
||
content_description="测试用例规范",
|
||
status="received",
|
||
)
|
||
d = contract.to_dict()
|
||
assert d == {
|
||
"from_expert": "tech_lead",
|
||
"to_expert": "qa_engineer",
|
||
"content_description": "测试用例规范",
|
||
"status": "received",
|
||
}
|
||
|
||
restored = CollaborationContract.from_dict(d)
|
||
assert restored.from_expert == contract.from_expert
|
||
assert restored.to_expert == contract.to_expert
|
||
assert restored.content_description == contract.content_description
|
||
assert restored.status == contract.status
|
||
|
||
def test_from_dict_missing_fields_uses_defaults(self):
|
||
"""from_dict 对缺失字段使用默认值"""
|
||
restored = CollaborationContract.from_dict({"from_expert": "backend"})
|
||
assert restored.from_expert == "backend"
|
||
assert restored.to_expert == ""
|
||
assert restored.content_description == ""
|
||
assert restored.status == "pending"
|
||
|
||
def test_from_dict_empty_dict(self):
|
||
"""from_dict 对空字典返回全默认值"""
|
||
restored = CollaborationContract.from_dict({})
|
||
assert restored.from_expert == ""
|
||
assert restored.to_expert == ""
|
||
assert restored.content_description == ""
|
||
assert restored.status == "pending"
|
||
|
||
|
||
class TestTeamPlanPhases:
|
||
"""TeamPlan 流水线模式(phases)测试"""
|
||
|
||
def test_pipeline_plan_creation(self):
|
||
"""创建流水线模式的 TeamPlan"""
|
||
plan = _make_pipeline_plan()
|
||
assert plan.id == "pipeline_001"
|
||
assert plan.task == "开发用户登录功能"
|
||
assert len(plan.phases) == 5
|
||
assert plan.lead_expert == "tech_lead"
|
||
|
||
def test_get_phase_by_id(self):
|
||
"""get_phase 根据 ID 获取阶段"""
|
||
plan = _make_pipeline_plan()
|
||
ph = plan.get_phase("p3")
|
||
assert ph is not None
|
||
assert ph.id == "p3"
|
||
assert ph.name == "后端"
|
||
|
||
def test_get_phase_nonexistent_returns_none(self):
|
||
"""get_phase 对不存在的 ID 返回 None"""
|
||
plan = _make_pipeline_plan()
|
||
assert plan.get_phase("nonexistent") is None
|
||
|
||
def test_update_phase_status(self):
|
||
"""update_phase_status 更新阶段状态和结果"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED, {"plan": "设计文档"})
|
||
ph = plan.get_phase("p1")
|
||
assert ph is not None
|
||
assert ph.status == PhaseStatus.COMPLETED
|
||
assert ph.result == {"plan": "设计文档"}
|
||
|
||
def test_update_phase_status_without_result(self):
|
||
"""update_phase_status 不传 result 时不覆盖已有 result"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED, {"plan": "文档"})
|
||
plan.update_phase_status("p1", PhaseStatus.RUNNING)
|
||
ph = plan.get_phase("p1")
|
||
assert ph is not None
|
||
assert ph.status == PhaseStatus.RUNNING
|
||
assert ph.result == {"plan": "文档"}
|
||
|
||
def test_completed_phases_property(self):
|
||
"""completed_phases 返回已完成的阶段"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p2", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p3", PhaseStatus.FAILED)
|
||
|
||
completed = plan.completed_phases
|
||
assert len(completed) == 2
|
||
assert {ph.id for ph in completed} == {"p1", "p2"}
|
||
|
||
def test_failed_phases_property(self):
|
||
"""failed_phases 返回失败的阶段"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p2", PhaseStatus.FAILED)
|
||
plan.update_phase_status("p3", PhaseStatus.FAILED)
|
||
|
||
failed = plan.failed_phases
|
||
assert len(failed) == 2
|
||
assert {ph.id for ph in failed} == {"p2", "p3"}
|
||
|
||
def test_all_phases_done_when_all_completed(self):
|
||
"""all_phases_done 当所有阶段完成时返回 True"""
|
||
plan = _make_pipeline_plan()
|
||
for ph in plan.phases:
|
||
plan.update_phase_status(ph.id, PhaseStatus.COMPLETED)
|
||
assert plan.all_phases_done is True
|
||
|
||
def test_all_phases_done_when_some_failed(self):
|
||
"""all_phases_done 当所有阶段完成或失败时返回 True"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p2", PhaseStatus.FAILED)
|
||
plan.update_phase_status("p3", PhaseStatus.FAILED)
|
||
plan.update_phase_status("p4", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p5", PhaseStatus.COMPLETED)
|
||
assert plan.all_phases_done is True
|
||
|
||
def test_all_phases_done_when_pending(self):
|
||
"""all_phases_done 当有阶段未完成时返回 False"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
# p2-p5 still pending
|
||
assert plan.all_phases_done is False
|
||
|
||
def test_all_phases_done_empty_plan(self):
|
||
"""all_phases_done 当没有阶段时返回 True"""
|
||
plan = TeamPlan(task="空计划")
|
||
assert plan.all_phases_done is True
|
||
|
||
|
||
class TestTopologicalSort:
|
||
"""topological_sort 拓扑排序测试"""
|
||
|
||
def test_linear_dependency(self):
|
||
"""线性依赖:A→B→C 排序为 [[A], [B], [C]]"""
|
||
plan = TeamPlan(
|
||
task="线性任务",
|
||
phases=[
|
||
_make_phase(id="a", name="A", depends_on=[]),
|
||
_make_phase(id="b", name="B", depends_on=["a"]),
|
||
_make_phase(id="c", name="C", depends_on=["b"]),
|
||
],
|
||
)
|
||
layers = plan.topological_sort()
|
||
assert len(layers) == 3
|
||
assert [ph.id for ph in layers[0]] == ["a"]
|
||
assert [ph.id for ph in layers[1]] == ["b"]
|
||
assert [ph.id for ph in layers[2]] == ["c"]
|
||
|
||
def test_parallel_phases(self):
|
||
"""并行阶段:A→{B,C}→D 排序为 [[A], [B,C], [D]]"""
|
||
plan = TeamPlan(
|
||
task="并行任务",
|
||
phases=[
|
||
_make_phase(id="a", name="A", depends_on=[]),
|
||
_make_phase(id="b", name="B", depends_on=["a"]),
|
||
_make_phase(id="c", name="C", depends_on=["a"]),
|
||
_make_phase(id="d", name="D", depends_on=["b", "c"]),
|
||
],
|
||
)
|
||
layers = plan.topological_sort()
|
||
assert len(layers) == 3
|
||
assert [ph.id for ph in layers[0]] == ["a"]
|
||
assert set(ph.id for ph in layers[1]) == {"b", "c"}
|
||
assert [ph.id for ph in layers[2]] == ["d"]
|
||
|
||
def test_full_pipeline_structure(self):
|
||
"""完整流水线结构:规划→{前端,后端}→QA→评审"""
|
||
plan = _make_pipeline_plan()
|
||
layers = plan.topological_sort()
|
||
assert len(layers) == 4
|
||
assert [ph.id for ph in layers[0]] == ["p1"]
|
||
assert set(ph.id for ph in layers[1]) == {"p2", "p3"}
|
||
assert [ph.id for ph in layers[2]] == ["p4"]
|
||
assert [ph.id for ph in layers[3]] == ["p5"]
|
||
|
||
def test_no_dependencies(self):
|
||
"""无依赖:所有阶段在同一层"""
|
||
plan = TeamPlan(
|
||
task="无依赖任务",
|
||
phases=[
|
||
_make_phase(id="a", name="A", depends_on=[]),
|
||
_make_phase(id="b", name="B", depends_on=[]),
|
||
_make_phase(id="c", name="C", depends_on=[]),
|
||
],
|
||
)
|
||
layers = plan.topological_sort()
|
||
assert len(layers) == 1
|
||
assert set(ph.id for ph in layers[0]) == {"a", "b", "c"}
|
||
|
||
def test_empty_phases(self):
|
||
"""空阶段列表返回空列表"""
|
||
plan = TeamPlan(task="空计划")
|
||
assert plan.topological_sort() == []
|
||
|
||
def test_circular_dependency_raises(self):
|
||
"""循环依赖 A→B→A 抛出 ValueError"""
|
||
plan = TeamPlan(
|
||
task="循环依赖任务",
|
||
phases=[
|
||
_make_phase(id="a", name="A", depends_on=["b"]),
|
||
_make_phase(id="b", name="B", depends_on=["a"]),
|
||
],
|
||
)
|
||
with pytest.raises(ValueError, match="Circular dependency"):
|
||
plan.topological_sort()
|
||
|
||
def test_self_dependency_raises(self):
|
||
"""自循环依赖 A→A 抛出 ValueError"""
|
||
plan = TeamPlan(
|
||
task="自循环任务",
|
||
phases=[
|
||
_make_phase(id="a", name="A", depends_on=["a"]),
|
||
],
|
||
)
|
||
with pytest.raises(ValueError, match="Circular dependency"):
|
||
plan.topological_sort()
|
||
|
||
def test_invalid_dependency_reference_raises(self):
|
||
"""依赖不存在的阶段 ID 抛出 ValueError"""
|
||
plan = TeamPlan(
|
||
task="无效依赖任务",
|
||
phases=[
|
||
_make_phase(id="a", name="A", depends_on=["nonexistent"]),
|
||
],
|
||
)
|
||
with pytest.raises(ValueError, match="non-existent phase"):
|
||
plan.topological_sort()
|
||
|
||
def test_mixed_execution_and_debate_phases(self):
|
||
"""混合 EXECUTION + DEBATE 阶段的拓扑排序
|
||
|
||
结构:
|
||
Layer 0: [规划] (EXECUTION)
|
||
Layer 1: [前端, 后端] (EXECUTION, 依赖规划)
|
||
Layer 2: [架构辩论] (DEBATE, 依赖前端+后端)
|
||
Layer 3: [QA] (EXECUTION, 依赖架构辩论)
|
||
"""
|
||
plan = TeamPlan(
|
||
task="混合模式任务",
|
||
phases=[
|
||
PlanPhase(id="p1", name="规划", assigned_expert="tech_lead", depends_on=[]),
|
||
PlanPhase(id="p2", name="前端", assigned_expert="frontend", depends_on=["p1"]),
|
||
PlanPhase(id="p3", name="后端", assigned_expert="backend", depends_on=["p1"]),
|
||
PlanPhase(
|
||
id="d1",
|
||
name="架构辩论",
|
||
assigned_expert="tech_lead",
|
||
depends_on=["p2", "p3"],
|
||
phase_type=PhaseType.DEBATE,
|
||
debate_config={
|
||
"topic": "前后端接口设计",
|
||
"participants": ["frontend", "backend"],
|
||
"max_rounds": 2,
|
||
},
|
||
),
|
||
PlanPhase(id="p4", name="QA", assigned_expert="qa", depends_on=["d1"]),
|
||
],
|
||
)
|
||
layers = plan.topological_sort()
|
||
assert len(layers) == 4
|
||
assert [ph.id for ph in layers[0]] == ["p1"]
|
||
assert set(ph.id for ph in layers[1]) == {"p2", "p3"}
|
||
assert [ph.id for ph in layers[2]] == ["d1"]
|
||
assert [ph.id for ph in layers[3]] == ["p4"]
|
||
# Verify the debate phase is correctly typed
|
||
debate_phase = plan.get_phase("d1")
|
||
assert debate_phase is not None
|
||
assert debate_phase.phase_type == PhaseType.DEBATE
|
||
assert debate_phase.debate_config is not None
|
||
|
||
|
||
class TestGetReadyPhases:
|
||
"""get_ready_phases 就绪阶段测试"""
|
||
|
||
def test_initial_ready_phases(self):
|
||
"""初始时只有无依赖的阶段就绪"""
|
||
plan = _make_pipeline_plan()
|
||
ready = plan.get_ready_phases()
|
||
assert len(ready) == 1
|
||
assert ready[0].id == "p1"
|
||
|
||
def test_ready_after_first_layer_completes(self):
|
||
"""第一层完成后,第二层阶段就绪"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
ready = plan.get_ready_phases()
|
||
assert len(ready) == 2
|
||
assert {ph.id for ph in ready} == {"p2", "p3"}
|
||
|
||
def test_ready_after_partial_completion(self):
|
||
"""部分完成:p1 完成后 p2 完成,p3 仍 pending → p4 不就绪"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p2", PhaseStatus.COMPLETED)
|
||
# p3 still pending, so p4 (depends on p2 AND p3) is not ready
|
||
ready = plan.get_ready_phases()
|
||
assert len(ready) == 1
|
||
assert ready[0].id == "p3"
|
||
|
||
def test_ready_excludes_running_phases(self):
|
||
"""运行中的阶段不被视为就绪"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.RUNNING)
|
||
ready = plan.get_ready_phases()
|
||
assert len(ready) == 0
|
||
|
||
def test_ready_excludes_completed_phases(self):
|
||
"""已完成的阶段不被视为就绪"""
|
||
plan = _make_pipeline_plan()
|
||
plan.update_phase_status("p1", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p2", PhaseStatus.COMPLETED)
|
||
plan.update_phase_status("p3", PhaseStatus.COMPLETED)
|
||
ready = plan.get_ready_phases()
|
||
assert len(ready) == 1
|
||
assert ready[0].id == "p4"
|
||
|
||
|
||
class TestTeamPlanPhasesSerialization:
|
||
"""TeamPlan phases 序列化测试"""
|
||
|
||
def test_to_dict_includes_phases(self):
|
||
"""to_dict 包含 phases 字段"""
|
||
plan = _make_pipeline_plan()
|
||
d = plan.to_dict()
|
||
assert "phases" in d
|
||
assert len(d["phases"]) == 5
|
||
assert d["phases"][0]["id"] == "p1"
|
||
assert d["phases"][0]["depends_on"] == []
|
||
|
||
def test_from_dict_restores_phases(self):
|
||
"""from_dict 恢复 phases"""
|
||
plan = _make_pipeline_plan()
|
||
d = plan.to_dict()
|
||
restored = TeamPlan.from_dict(d)
|
||
|
||
assert len(restored.phases) == len(plan.phases)
|
||
for original, restored_ph in zip(plan.phases, restored.phases):
|
||
assert restored_ph.id == original.id
|
||
assert restored_ph.name == original.name
|
||
assert restored_ph.assigned_expert == original.assigned_expert
|
||
assert restored_ph.task_description == original.task_description
|
||
assert restored_ph.depends_on == original.depends_on
|
||
assert restored_ph.status == original.status
|
||
|
||
def test_both_subtasks_and_phases_coexist(self):
|
||
"""subtasks 和 phases 可以共存"""
|
||
plan = TeamPlan(
|
||
task="混合模式",
|
||
subtasks=[SubTask(id="s1", description="子任务1")],
|
||
phases=[_make_phase(id="p1", name="阶段1")],
|
||
)
|
||
d = plan.to_dict()
|
||
assert len(d["subtasks"]) == 1
|
||
assert len(d["phases"]) == 1
|
||
assert d["subtasks"][0]["id"] == "s1"
|
||
assert d["phases"][0]["id"] == "p1"
|