590 lines
22 KiB
Python
590 lines
22 KiB
Python
"""Tests for GoalPlanner — 目标分析与计划生成"""
|
||
|
||
import pytest
|
||
|
||
from agentkit.core.goal_planner import GoalPlanner
|
||
from agentkit.core.orchestrator import Orchestrator, SubTask
|
||
from agentkit.core.plan_schema import (
|
||
ExecutionPlan,
|
||
PlanStep,
|
||
PlanStepStatus,
|
||
SkillGap,
|
||
SkillGapLevel,
|
||
)
|
||
from agentkit.core.protocol import TaskMessage, TaskStatus
|
||
from datetime import datetime, timezone
|
||
|
||
|
||
# --- Plan Schema Tests ---
|
||
|
||
|
||
class TestPlanStep:
|
||
"""PlanStep 数据类测试"""
|
||
|
||
def test_default_values(self):
|
||
step = PlanStep(
|
||
step_id="s1",
|
||
name="Test Step",
|
||
description="A test step",
|
||
)
|
||
assert step.dependencies == []
|
||
assert step.parallel_group == 0
|
||
assert step.required_skills == []
|
||
assert step.input_data == {}
|
||
assert step.status == PlanStepStatus.PENDING
|
||
assert step.result is None
|
||
assert step.error is None
|
||
|
||
def test_to_dict(self):
|
||
step = PlanStep(
|
||
step_id="s1",
|
||
name="Test",
|
||
description="Desc",
|
||
dependencies=["s0"],
|
||
parallel_group=1,
|
||
required_skills=["web_search"],
|
||
)
|
||
d = step.to_dict()
|
||
assert d["step_id"] == "s1"
|
||
assert d["dependencies"] == ["s0"]
|
||
assert d["parallel_group"] == 1
|
||
assert d["required_skills"] == ["web_search"]
|
||
assert d["status"] == "pending"
|
||
|
||
|
||
class TestExecutionPlan:
|
||
"""ExecutionPlan 数据类测试"""
|
||
|
||
def test_default_values(self):
|
||
plan = ExecutionPlan(goal="test")
|
||
assert plan.goal == "test"
|
||
assert plan.steps == []
|
||
assert plan.parallel_groups == []
|
||
assert plan.skill_gaps == []
|
||
assert plan.confirmed is False
|
||
|
||
def test_has_skill_gaps(self):
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
skill_gaps=[
|
||
SkillGap(step_name="s1", required_skill="x", level=SkillGapLevel.LOW),
|
||
],
|
||
)
|
||
assert plan.has_skill_gaps is False
|
||
|
||
plan.skill_gaps.append(
|
||
SkillGap(step_name="s2", required_skill="y", level=SkillGapLevel.HIGH),
|
||
)
|
||
assert plan.has_skill_gaps is True
|
||
|
||
def test_get_step(self):
|
||
step = PlanStep(step_id="s1", name="A", description="A step")
|
||
plan = ExecutionPlan(goal="test", steps=[step])
|
||
assert plan.get_step("s1") is step
|
||
assert plan.get_step("nonexistent") is None
|
||
|
||
def test_to_readable(self):
|
||
plan = ExecutionPlan(
|
||
plan_id="p1",
|
||
goal="调研竞品",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="调研 A", description="调研竞品 A", parallel_group=0, required_skills=["web_search"]),
|
||
PlanStep(step_id="s1", name="调研 B", description="调研竞品 B", parallel_group=0, required_skills=["web_search"]),
|
||
PlanStep(step_id="s2", name="汇总", description="汇总报告", dependencies=["s0", "s1"], parallel_group=1, required_skills=["report_generator"]),
|
||
],
|
||
parallel_groups=[["s0", "s1"], ["s2"]],
|
||
)
|
||
readable = plan.to_readable()
|
||
assert "调研竞品" in readable
|
||
assert "并行组 1" in readable
|
||
assert "s0" in readable
|
||
assert "web_search" in readable
|
||
|
||
def test_to_dict(self):
|
||
plan = ExecutionPlan(
|
||
plan_id="p1",
|
||
goal="test",
|
||
steps=[PlanStep(step_id="s0", name="A", description="A")],
|
||
parallel_groups=[["s0"]],
|
||
)
|
||
d = plan.to_dict()
|
||
assert d["plan_id"] == "p1"
|
||
assert len(d["steps"]) == 1
|
||
assert d["parallel_groups"] == [["s0"]]
|
||
|
||
|
||
# --- GoalPlanner Tests ---
|
||
|
||
|
||
class TestGoalPlannerSimpleGoal:
|
||
"""简单目标 → 单步计划"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_simple_goal_generates_single_step(self):
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="查询今天的天气",
|
||
available_skills=["web_search"],
|
||
)
|
||
assert len(plan.steps) == 1
|
||
assert plan.steps[0].parallel_group == 0
|
||
assert len(plan.parallel_groups) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_simple_goal_with_matching_skill(self):
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="搜索最新的 AI 新闻",
|
||
available_skills=["web_search"],
|
||
)
|
||
assert "web_search" in plan.steps[0].required_skills
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_simple_goal_no_matching_skill(self):
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="执行某个未知操作",
|
||
available_skills=["web_search"],
|
||
)
|
||
# 单步任务,无匹配 Skill
|
||
assert len(plan.steps) == 1
|
||
assert plan.steps[0].required_skills == []
|
||
|
||
|
||
class TestGoalPlannerParallelGoal:
|
||
"""复杂目标(并列结构)→ 多步并行计划"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parallel_competitor_research(self):
|
||
"""AE1: 3 个竞品调研自动识别为并行步骤"""
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
available_skills=["web_search", "seo_analyzer", "report_generator"],
|
||
)
|
||
# 应有 4 个步骤:3 个并行调研 + 1 个汇总
|
||
assert len(plan.steps) == 4
|
||
# 前 3 个步骤无依赖,应在同一并行组
|
||
parallel_steps = [s for s in plan.steps if not s.dependencies]
|
||
assert len(parallel_steps) == 3
|
||
# 汇总步骤依赖前 3 个
|
||
summary_step = [s for s in plan.steps if s.dependencies]
|
||
assert len(summary_step) == 1
|
||
assert len(summary_step[0].dependencies) == 3
|
||
# 并行组:第一组 3 个并行,第二组 1 个汇总
|
||
assert len(plan.parallel_groups) == 2
|
||
assert len(plan.parallel_groups[0]) == 3
|
||
assert len(plan.parallel_groups[1]) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parallel_items_with_dunhao(self):
|
||
"""顿号分隔的并列项"""
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研竞品A、竞品B、竞品C的市场策略",
|
||
available_skills=["web_search", "report_generator"],
|
||
)
|
||
assert len(plan.steps) == 4 # 3 个并行 + 1 个汇总
|
||
parallel_steps = [s for s in plan.steps if not s.dependencies]
|
||
assert len(parallel_steps) == 3
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parallel_steps_have_correct_group(self):
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
available_skills=["web_search", "seo_analyzer", "report_generator"],
|
||
)
|
||
# 并行步骤的 parallel_group 应为 0
|
||
for step in plan.steps[:3]:
|
||
assert step.parallel_group == 0
|
||
# 汇总步骤的 parallel_group 应为 1
|
||
assert plan.steps[3].parallel_group == 1
|
||
|
||
|
||
class TestGoalPlannerSequentialGoal:
|
||
"""顺序目标 → 顺序步骤"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sequential_goal_with_bing(self):
|
||
"""'并' 连接的顺序步骤"""
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研市场趋势并生成分析报告",
|
||
available_skills=["web_search", "report_generator"],
|
||
)
|
||
assert len(plan.steps) == 2
|
||
# 第二步依赖第一步
|
||
assert plan.steps[1].dependencies == [plan.steps[0].step_id]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sequential_goal_with_arrow(self):
|
||
"""箭头分隔的顺序步骤"""
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="收集数据→分析数据→生成报告",
|
||
available_skills=["web_search", "data_analyzer", "report_generator"],
|
||
)
|
||
assert len(plan.steps) == 3
|
||
assert plan.steps[0].dependencies == []
|
||
assert plan.steps[1].dependencies == [plan.steps[0].step_id]
|
||
assert plan.steps[2].dependencies == [plan.steps[1].step_id]
|
||
|
||
|
||
class TestGoalPlannerSkillGaps:
|
||
"""能力缺口识别"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_missing_skill_creates_gap(self):
|
||
"""无可用 Skill 的目标 → 计划标注能力缺口"""
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
available_skills=[], # 无可用 Skill
|
||
)
|
||
assert plan.has_skill_gaps is True
|
||
assert len(plan.skill_gaps) > 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_gaps_when_skills_available(self):
|
||
"""所有 Skill 可用时无缺口"""
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="搜索最新的 AI 新闻",
|
||
available_skills=["web_search"],
|
||
)
|
||
# web_search 匹配,不应有 HIGH 级别缺口
|
||
high_gaps = [g for g in plan.skill_gaps if g.level == SkillGapLevel.HIGH]
|
||
assert len(high_gaps) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_gap_includes_suggestion(self):
|
||
planner = GoalPlanner()
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
available_skills=[],
|
||
)
|
||
for gap in plan.skill_gaps:
|
||
if gap.level == SkillGapLevel.HIGH:
|
||
assert gap.suggestion != ""
|
||
|
||
|
||
class TestGoalPlannerUpdatePlan:
|
||
"""用户修改计划"""
|
||
|
||
def test_remove_step(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B", dependencies=["s0"]),
|
||
PlanStep(step_id="s2", name="C", description="C", dependencies=["s1"]),
|
||
],
|
||
parallel_groups=[["s0"], ["s1"], ["s2"]],
|
||
)
|
||
updated = planner.update_plan_from_feedback(plan, {"remove_steps": ["s1"]})
|
||
assert len(updated.steps) == 2
|
||
# s2 的依赖应被清理
|
||
assert "s1" not in updated.steps[1].dependencies
|
||
|
||
def test_update_step(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
],
|
||
parallel_groups=[["s0"]],
|
||
)
|
||
updated = planner.update_plan_from_feedback(
|
||
plan,
|
||
{"update_steps": {"s0": {"name": "Updated A", "description": "Updated description"}}},
|
||
)
|
||
assert updated.steps[0].name == "Updated A"
|
||
assert updated.steps[0].description == "Updated description"
|
||
|
||
def test_add_step(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
],
|
||
parallel_groups=[["s0"]],
|
||
)
|
||
updated = planner.update_plan_from_feedback(
|
||
plan,
|
||
{"add_steps": [{"name": "New Step", "description": "A new step", "dependencies": ["s0"]}]},
|
||
)
|
||
assert len(updated.steps) == 2
|
||
assert updated.steps[1].name == "New Step"
|
||
|
||
def test_update_resets_confirmed(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
confirmed=True,
|
||
steps=[PlanStep(step_id="s0", name="A", description="A")],
|
||
parallel_groups=[["s0"]],
|
||
)
|
||
updated = planner.update_plan_from_feedback(plan, {"update_steps": {"s0": {"name": "B"}}})
|
||
assert updated.confirmed is False
|
||
|
||
def test_update_rebuilds_parallel_groups(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B", dependencies=["s0"]),
|
||
],
|
||
parallel_groups=[["s0"], ["s1"]],
|
||
)
|
||
# 添加一个与 s0 并行的步骤
|
||
updated = planner.update_plan_from_feedback(
|
||
plan,
|
||
{"add_steps": [{"name": "C", "description": "Parallel to A", "dependencies": []}]},
|
||
)
|
||
# 新步骤应与 s0 在同一并行组
|
||
assert len(updated.parallel_groups[0]) == 2
|
||
|
||
|
||
class TestGoalPlannerValidatePlan:
|
||
"""计划验证"""
|
||
|
||
def test_valid_plan(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B", dependencies=["s0"]),
|
||
],
|
||
parallel_groups=[["s0"], ["s1"]],
|
||
)
|
||
errors = planner.validate_plan(plan)
|
||
assert errors == []
|
||
|
||
def test_invalid_dependency(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A", dependencies=["nonexistent"]),
|
||
],
|
||
parallel_groups=[["s0"]],
|
||
)
|
||
errors = planner.validate_plan(plan)
|
||
assert len(errors) > 0
|
||
assert any("nonexistent" in e for e in errors)
|
||
|
||
def test_circular_dependency(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A", dependencies=["s1"]),
|
||
PlanStep(step_id="s1", name="B", description="B", dependencies=["s0"]),
|
||
],
|
||
parallel_groups=[["s0", "s1"]],
|
||
)
|
||
errors = planner.validate_plan(plan)
|
||
assert any("循环依赖" in e for e in errors)
|
||
|
||
def test_ungrouped_steps(self):
|
||
planner = GoalPlanner()
|
||
plan = ExecutionPlan(
|
||
goal="test",
|
||
steps=[
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B"),
|
||
],
|
||
parallel_groups=[["s0"]], # s1 未分组
|
||
)
|
||
errors = planner.validate_plan(plan)
|
||
assert any("未分配" in e for e in errors)
|
||
|
||
|
||
class TestGoalPlannerWithOrchestrator:
|
||
"""GoalPlanner 与 Orchestrator 集成"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orchestrator_with_goal_planner(self):
|
||
"""Orchestrator 使用 GoalPlanner 分解任务"""
|
||
planner = GoalPlanner()
|
||
|
||
class MockAgent:
|
||
def __init__(self, name):
|
||
self.name = name
|
||
self.agent_type = "mock"
|
||
async def execute(self, task):
|
||
from agentkit.core.protocol import TaskResult
|
||
now = datetime.now(timezone.utc)
|
||
return TaskResult(
|
||
task_id=task.task_id,
|
||
agent_name=self.name,
|
||
status=TaskStatus.COMPLETED,
|
||
output_data={"result": f"from {self.name}"},
|
||
error_message=None,
|
||
started_at=now,
|
||
completed_at=now,
|
||
)
|
||
|
||
class MockPool:
|
||
def get_agent(self, name):
|
||
return MockAgent(name)
|
||
def list_agents(self):
|
||
return [
|
||
{"name": "web_search", "agent_type": "search", "description": "Web search agent"},
|
||
{"name": "report_generator", "agent_type": "report", "description": "Report generator"},
|
||
]
|
||
|
||
pool = MockPool()
|
||
orchestrator = Orchestrator(agent_pool=pool, goal_planner=planner)
|
||
|
||
task = TaskMessage(
|
||
task_id="t1",
|
||
agent_name="web_search",
|
||
task_type="research",
|
||
priority=1,
|
||
input_data={"query": "调研 3 个竞品 SEO 策略并生成对比报告"},
|
||
callback_url=None,
|
||
created_at=datetime.now(timezone.utc),
|
||
)
|
||
|
||
plan = await orchestrator._decompose_task(task)
|
||
# GoalPlanner 应将任务分解为 4 个子任务
|
||
assert len(plan.subtasks) == 4
|
||
# 前 3 个无依赖
|
||
assert len(plan.subtasks[0].depends_on) == 0
|
||
assert len(plan.subtasks[1].depends_on) == 0
|
||
assert len(plan.subtasks[2].depends_on) == 0
|
||
# 第 4 个依赖前 3 个
|
||
assert len(plan.subtasks[3].depends_on) == 3
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orchestrator_without_goal_planner_backward_compat(self):
|
||
"""无 GoalPlanner 时 Orchestrator 保持原有行为"""
|
||
class MockPool:
|
||
def get_agent(self, name):
|
||
return None
|
||
def list_agents(self):
|
||
return []
|
||
|
||
pool = MockPool()
|
||
orchestrator = Orchestrator(agent_pool=pool)
|
||
|
||
task = TaskMessage(
|
||
task_id="t1",
|
||
agent_name="worker1",
|
||
task_type="test",
|
||
priority=1,
|
||
input_data={"query": "test"},
|
||
callback_url=None,
|
||
created_at=datetime.now(timezone.utc),
|
||
)
|
||
|
||
plan = await orchestrator._decompose_task(task)
|
||
# Fallback: 单个子任务
|
||
assert len(plan.subtasks) == 1
|
||
assert plan.subtasks[0].task_id.startswith(plan.plan_id)
|
||
|
||
|
||
class TestGoalPlannerLLMRefinement:
|
||
"""LLM 细化计划"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_llm_refinement_called_when_needed(self):
|
||
"""初始方案不够精确时调用 LLM 细化"""
|
||
class MockLLMGateway:
|
||
async def chat(self, messages, model):
|
||
import json
|
||
return type("Response", (), {
|
||
"content": json.dumps([
|
||
{"name": "调研竞品 A", "description": "使用搜索引擎调研竞品 A 的 SEO 策略,包括关键词排名和外链分析", "dependencies": [], "required_skills": ["web_search"]},
|
||
{"name": "调研竞品 B", "description": "使用搜索引擎调研竞品 B 的 SEO 策略,包括关键词排名和外链分析", "dependencies": [], "required_skills": ["web_search"]},
|
||
{"name": "调研竞品 C", "description": "使用搜索引擎调研竞品 C 的 SEO 策略,包括关键词排名和外链分析", "dependencies": [], "required_skills": ["web_search"]},
|
||
{"name": "生成对比报告", "description": "汇总三个竞品的 SEO 策略数据,生成结构化对比分析报告", "dependencies": [0, 1, 2], "required_skills": ["report_generator"]},
|
||
]),
|
||
})()
|
||
|
||
# 使用无可用 Skill 的场景触发 LLM 细化
|
||
planner = GoalPlanner(llm_gateway=MockLLMGateway())
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
available_skills=[], # 无可用 Skill,触发 LLM 细化
|
||
)
|
||
# LLM 细化后步骤描述应更详细
|
||
assert len(plan.steps) == 4
|
||
assert plan.metadata.get("refined_by_llm") is True
|
||
for step in plan.steps:
|
||
assert len(step.description) >= 20
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_llm_failure_falls_back_to_initial(self):
|
||
"""LLM 细化失败时回退到初始方案"""
|
||
class FailingLLMGateway:
|
||
async def chat(self, messages, model):
|
||
raise RuntimeError("LLM unavailable")
|
||
|
||
planner = GoalPlanner(llm_gateway=FailingLLMGateway())
|
||
plan = await planner.generate_plan(
|
||
goal="调研 3 个竞品 SEO 策略并生成对比报告",
|
||
available_skills=["web_search"],
|
||
)
|
||
# 应回退到规则生成的初始方案
|
||
assert len(plan.steps) == 4
|
||
assert plan.metadata.get("refined_by_llm") is None
|
||
|
||
|
||
class TestGoalPlannerBuildParallelGroups:
|
||
"""并行组构建"""
|
||
|
||
def test_simple_parallel(self):
|
||
planner = GoalPlanner()
|
||
steps = [
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B"),
|
||
PlanStep(step_id="s2", name="C", description="C", dependencies=["s0", "s1"]),
|
||
]
|
||
groups = planner._build_parallel_groups(steps)
|
||
assert len(groups) == 2
|
||
assert set(groups[0]) == {"s0", "s1"}
|
||
assert groups[1] == ["s2"]
|
||
|
||
def test_sequential_chain(self):
|
||
planner = GoalPlanner()
|
||
steps = [
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B", dependencies=["s0"]),
|
||
PlanStep(step_id="s2", name="C", description="C", dependencies=["s1"]),
|
||
]
|
||
groups = planner._build_parallel_groups(steps)
|
||
assert len(groups) == 3
|
||
assert groups[0] == ["s0"]
|
||
assert groups[1] == ["s1"]
|
||
assert groups[2] == ["s2"]
|
||
|
||
def test_max_parallel_limit(self):
|
||
planner = GoalPlanner(max_parallel=2)
|
||
steps = [
|
||
PlanStep(step_id="s0", name="A", description="A"),
|
||
PlanStep(step_id="s1", name="B", description="B"),
|
||
PlanStep(step_id="s2", name="C", description="C"),
|
||
]
|
||
groups = planner._build_parallel_groups(steps)
|
||
# 最多 2 个并行
|
||
assert len(groups[0]) <= 2
|
||
|
||
def test_circular_dependency_handling(self):
|
||
planner = GoalPlanner()
|
||
steps = [
|
||
PlanStep(step_id="s0", name="A", description="A", dependencies=["s1"]),
|
||
PlanStep(step_id="s1", name="B", description="B", dependencies=["s0"]),
|
||
]
|
||
groups = planner._build_parallel_groups(steps)
|
||
# 循环依赖时将剩余步骤放入一组
|
||
assert len(groups) == 1
|
||
assert set(groups[0]) == {"s0", "s1"}
|