243 lines
9.6 KiB
Python
243 lines
9.6 KiB
Python
"""Broadcast claim 流程单元测试
|
||
|
||
覆盖 ticker.py 中的 record_broadcast_response 和 BroadcastRound 轮次逻辑。
|
||
场景 B1-B8。
|
||
"""
|
||
|
||
import pytest
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
from src.daemon.ticker import Ticker, BroadcastRound
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助:构造最小 Ticker(只初始化 _broadcast_tracker)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_minimal_ticker() -> MagicMock:
|
||
"""创建只包含 _broadcast_tracker 的 Ticker mock,绑定真实 record_broadcast_response"""
|
||
ticker = MagicMock(spec=Ticker)
|
||
ticker._broadcast_tracker = {}
|
||
# 绑定真实实例方法
|
||
ticker.record_broadcast_response = Ticker.record_broadcast_response.__get__(ticker, Ticker)
|
||
return ticker
|
||
|
||
|
||
def _make_round(task_id: str = "task-1",
|
||
notified: set = None,
|
||
responded: set = None,
|
||
round_number: int = 0) -> BroadcastRound:
|
||
"""快速构造 BroadcastRound"""
|
||
br = BroadcastRound(task_id=task_id)
|
||
br.notified_agents = notified if notified is not None else set()
|
||
br.responded_agents = responded if responded is not None else set()
|
||
br.round_number = round_number
|
||
return br
|
||
|
||
|
||
# ===================================================================
|
||
# B1: agent claimed → tracker 被清理
|
||
# ===================================================================
|
||
|
||
class TestClaimedClearsTracker:
|
||
def test_claimed_removes_tracker(self):
|
||
"""B1: outcome='claimed' 时 tracker 从 dict 中移除"""
|
||
ticker = _make_minimal_ticker()
|
||
ticker._broadcast_tracker["task-1"] = _make_round("task-1")
|
||
|
||
ticker.record_broadcast_response("task-1", "agent-a", "claimed")
|
||
|
||
assert "task-1" not in ticker._broadcast_tracker
|
||
|
||
def test_claimed_ignores_responded(self):
|
||
"""B1 补充:claimed 不往 responded_agents 添加"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a", "agent-b"})
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
ticker.record_broadcast_response("task-1", "agent-a", "claimed")
|
||
|
||
assert "task-1" not in ticker._broadcast_tracker
|
||
|
||
|
||
# ===================================================================
|
||
# B2: agent no_reply → responded 增加
|
||
# ===================================================================
|
||
|
||
class TestNoReplyAddsResponded:
|
||
def test_no_reply_adds_agent(self):
|
||
"""B2: outcome != 'claimed' 时 agent 加入 responded_agents"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a", "agent-b"})
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
ticker.record_broadcast_response("task-1", "agent-a", "no_reply")
|
||
|
||
assert "agent-a" in tracker.responded_agents
|
||
assert "task-1" in ticker._broadcast_tracker # 未清理
|
||
|
||
def test_rejected_adds_agent(self):
|
||
"""B2 补充:'rejected' 也加入 responded(多 agent 避免轮次结束清空)"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a", "agent-b"})
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
ticker.record_broadcast_response("task-1", "agent-a", "rejected")
|
||
|
||
assert "agent-a" in tracker.responded_agents
|
||
assert tracker.round_number == 0 # 还没完成一轮
|
||
|
||
|
||
# ===================================================================
|
||
# B3: 所有 notified 都 responded → round_number +1
|
||
# ===================================================================
|
||
|
||
class TestAllRespondedRoundCompletes:
|
||
def test_all_responded_advances_round(self):
|
||
"""B3: notified_agents ⊆ responded_agents 时 round_number += 1,清空 sets"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a", "agent-b"}, round_number=0)
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
ticker.record_broadcast_response("task-1", "agent-a", "no_reply")
|
||
ticker.record_broadcast_response("task-1", "agent-b", "no_reply")
|
||
|
||
assert tracker.round_number == 1
|
||
assert len(tracker.notified_agents) == 0
|
||
assert len(tracker.responded_agents) == 0
|
||
|
||
def test_superset_responded_also_completes(self):
|
||
"""B3 补充:responded 超过 notified 也完成轮次"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a"}, round_number=0)
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
# agent-a responded → matched → round completes
|
||
ticker.record_broadcast_response("task-1", "agent-a", "no_reply")
|
||
|
||
assert tracker.round_number == 1
|
||
|
||
|
||
# ===================================================================
|
||
# B4: 部分响应 → round 不结束
|
||
# ===================================================================
|
||
|
||
class TestPartialResponseNoRoundAdvance:
|
||
def test_partial_response_keeps_round(self):
|
||
"""B4: 只有一个 agent 响应,另一个未响应 → round 不变"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a", "agent-b", "agent-c"}, round_number=0)
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
ticker.record_broadcast_response("task-1", "agent-a", "no_reply")
|
||
|
||
assert tracker.round_number == 0
|
||
assert "agent-a" in tracker.responded_agents
|
||
assert "task-1" in ticker._broadcast_tracker
|
||
|
||
|
||
# ===================================================================
|
||
# B5: 不存在的 task_id → 静默返回
|
||
# ===================================================================
|
||
|
||
class TestMissingTaskIdSilent:
|
||
def test_missing_task_no_error(self):
|
||
"""B5: task_id 不在 tracker 中 → 不抛异常"""
|
||
ticker = _make_minimal_ticker()
|
||
|
||
# 不应抛异常
|
||
ticker.record_broadcast_response("nonexistent-task", "agent-a", "claimed")
|
||
ticker.record_broadcast_response("nonexistent-task", "agent-a", "no_reply")
|
||
|
||
assert len(ticker._broadcast_tracker) == 0
|
||
|
||
|
||
# ===================================================================
|
||
# B6: round 2 正常完成
|
||
# ===================================================================
|
||
|
||
class TestRoundTwoCompletes:
|
||
def test_second_round_completes(self):
|
||
"""B6: 完成 round 1 后再完成 round 2"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a"}, round_number=0)
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
# Round 1
|
||
ticker.record_broadcast_response("task-1", "agent-a", "no_reply")
|
||
assert tracker.round_number == 1
|
||
|
||
# Simulate: 下一轮 notified 被重新填充
|
||
tracker.notified_agents = {"agent-b"}
|
||
|
||
# Round 2
|
||
ticker.record_broadcast_response("task-1", "agent-b", "no_reply")
|
||
assert tracker.round_number == 2
|
||
|
||
def test_three_rounds_sequentially(self):
|
||
"""B6 补充:连续三轮"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a"}, round_number=0)
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
for expected_round in range(1, 4):
|
||
ticker.record_broadcast_response("task-1", "agent-a", "no_reply")
|
||
assert tracker.round_number == expected_round
|
||
# 重新填充 notified(模拟下一轮广播)
|
||
tracker.notified_agents = {"agent-a"}
|
||
|
||
|
||
# ===================================================================
|
||
# B7: 已 claimed 后其他 agent 仍回调 → 不影响
|
||
# ===================================================================
|
||
|
||
class TestClaimedThenOtherCallback:
|
||
def test_other_agent_callback_after_claimed(self):
|
||
"""B7: agent-a 认领后 agent-b 回调 → tracker 已不存在,静默返回"""
|
||
ticker = _make_minimal_ticker()
|
||
tracker = _make_round("task-1", notified={"agent-a", "agent-b"})
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
# agent-a claimed → tracker removed
|
||
ticker.record_broadcast_response("task-1", "agent-a", "claimed")
|
||
assert "task-1" not in ticker._broadcast_tracker
|
||
|
||
# agent-b late callback → no crash
|
||
ticker.record_broadcast_response("task-1", "agent-b", "no_reply")
|
||
assert "task-1" not in ticker._broadcast_tracker
|
||
|
||
|
||
# ===================================================================
|
||
# B8: 3 轮后 _broadcast_claim 标记 escalated
|
||
# ===================================================================
|
||
|
||
class TestEscalationAfterThreeRounds:
|
||
def test_round_3_triggers_escalation(self):
|
||
"""B8: round_number >= 3 时 _broadcast_claim 将任务放入 escalated 列表
|
||
|
||
验证 tracker.round_number=3 时,任务在 _broadcast_claim 内部
|
||
被识别为 escalated(tracker 被清理)。
|
||
由于 _broadcast_claim 依赖大量基础设施,这里只验证判断条件。
|
||
"""
|
||
ticker = _make_minimal_ticker()
|
||
# 模拟 round_number = 3 的 tracker
|
||
tracker = _make_round("task-1", round_number=3)
|
||
ticker._broadcast_tracker["task-1"] = tracker
|
||
|
||
# 验证条件:round_number >= 3 应被视为 escalated
|
||
assert tracker.round_number >= 3
|
||
|
||
# 模拟 _broadcast_claim 的分支逻辑
|
||
escalated_ids = []
|
||
broadcastable_ids = []
|
||
for tid, tr in ticker._broadcast_tracker.items():
|
||
if tr.round_number >= 3:
|
||
escalated_ids.append(tid)
|
||
else:
|
||
broadcastable_ids.append(tid)
|
||
|
||
assert "task-1" in escalated_ids
|
||
assert "task-1" not in broadcastable_ids
|