608 lines
24 KiB
Python
608 lines
24 KiB
Python
# apps/api/tests/test_governance_dispatcher.py | 2026-05-03 @ Asia/Taipei
|
||
"""
|
||
Unit Tests — GovernanceDispatcher (Wave 2E)
|
||
|
||
覆蓋範圍:
|
||
1. high confidence (>= 0.85) → decision_path=auto_dispatch → status=pending(dispatch 建立)
|
||
2. mid confidence (0.65-0.85) → decision_path=pending_approval → dispatch 建立(executor=manual)
|
||
3. low confidence (< 0.65) → decision_path=skip → 不寫 dispatch,返回 None
|
||
4. 重複事件:get_active_for_event 有值 → 不重複 dispatch(返回 None)
|
||
5. LLM 失敗 fallback:fusion 拋 Exception → skip + log,不寫 dispatch
|
||
6. _build_decision_context 完整三維欄位驗證
|
||
|
||
測試策略:mock DB / adapter / repo,不依賴真實 Postgres。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone, timedelta
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# ─── 模擬 AiGovernanceEvent(避免 DB 連線)────────────────────────────────
|
||
|
||
TAIPEI = timezone(timedelta(hours=8))
|
||
NOW = datetime(2026, 5, 3, 12, 0, tzinfo=TAIPEI)
|
||
|
||
|
||
def _make_governance_event(
|
||
event_id: str = "evt-001",
|
||
event_type: str = "trust_drift",
|
||
) -> MagicMock:
|
||
"""建立 mock AiGovernanceEvent。"""
|
||
evt = MagicMock()
|
||
evt.id = event_id
|
||
evt.event_type = event_type
|
||
evt.triggered_at = NOW
|
||
evt.resolved = False
|
||
evt.details = {
|
||
"status": "warning",
|
||
"impact": {"drifted_count": 3, "total_playbooks": 10},
|
||
"remediation": {"next_action": "review_playbooks"},
|
||
}
|
||
return evt
|
||
|
||
|
||
# ─── FusedDecision factory ──────────────────────────────────────────────────
|
||
|
||
def _make_fused_decision(
|
||
confidence: float = 0.9,
|
||
playbook_id: str | None = "pb-001",
|
||
) -> MagicMock:
|
||
"""建立 mock FusedDecision。"""
|
||
from src.services.decision_fusion_adapter import FusedDecision
|
||
|
||
if confidence >= 0.85:
|
||
path = "auto_dispatch"
|
||
elif confidence >= 0.65:
|
||
path = "pending_approval"
|
||
else:
|
||
path = "skip"
|
||
|
||
return FusedDecision(
|
||
confidence=confidence,
|
||
recommended_action="啟動 Playbook 信任度修復流程",
|
||
matched_playbook_id=playbook_id,
|
||
playbook_trust=0.7 if playbook_id else None,
|
||
llm_reasoning={"parsed_confidence": confidence, "parsed_action": "review"},
|
||
mcp_snapshot={"autonomy_rate": 0.82, "_meta": {"success_count": 2, "total_queries": 2}},
|
||
decision_path=path,
|
||
llm_score=confidence,
|
||
playbook_score=0.7 if playbook_id else 0.3,
|
||
mcp_score=0.8,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — dispatch_governance_event
|
||
# =============================================================================
|
||
|
||
class TestDispatchGovernanceEvent:
|
||
"""dispatch_governance_event 核心邏輯測試。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_high_confidence_creates_auto_dispatch(self):
|
||
"""confidence >= 0.85 → decision_path=auto_dispatch → dispatch 建立,executor=playbook_executor。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-001"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result == "dispatch-001"
|
||
mock_create.assert_awaited_once()
|
||
call_kwargs = mock_create.call_args
|
||
assert call_kwargs.kwargs["executor_type"] == "playbook_executor"
|
||
assert call_kwargs.kwargs["event_id"] == "evt-001"
|
||
assert call_kwargs.kwargs["event_type"] == "trust_drift"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mid_confidence_creates_pending_approval(self):
|
||
"""0.65 <= confidence < 0.85 → decision_path=pending_approval → executor=manual。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.75)
|
||
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-002"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result == "dispatch-002"
|
||
call_kwargs = mock_create.call_args
|
||
assert call_kwargs.kwargs["executor_type"] == "manual"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_low_confidence_skips_dispatch(self):
|
||
"""confidence < 0.65 → decision_path=skip → 留下 skipped dispatch trail,返回 None。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.40)
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-skipped-001"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
patch(
|
||
"src.services.governance_dispatcher.transition_status",
|
||
new=AsyncMock(),
|
||
) as mock_transition,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_create.assert_awaited_once()
|
||
mock_transition.assert_awaited_once_with(
|
||
"dispatch-skipped-001",
|
||
"pending",
|
||
"skipped",
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_duplicate_event_does_not_dispatch(self):
|
||
"""同一事件已有 active dispatch → 返回 None,不重複 dispatch。"""
|
||
event = _make_governance_event()
|
||
|
||
# 模擬已有活躍 dispatch
|
||
existing_dispatch = MagicMock()
|
||
existing_dispatch.id = "existing-dispatch-001"
|
||
existing_dispatch.dispatch_status = "pending"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=existing_dispatch),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(),
|
||
) as mock_create,
|
||
):
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_create.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_knowledge_degradation_creates_kb_healthcheck_intake(self):
|
||
"""knowledge_degradation 應先建立 non-executing Hermes KB healthcheck work item。"""
|
||
event = _make_governance_event(event_type="knowledge_degradation")
|
||
event.details = {
|
||
"impact": {"stale_count": 1451, "total_count": 1870, "stale_ratio": 0.776},
|
||
"remediation": {"next_action": "run_kb_growth_healthcheck"},
|
||
"ownership": {"lead_agent": "Hermes"},
|
||
}
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-kb-001"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
):
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result == "dispatch-kb-001"
|
||
mock_adapter_factory.assert_not_called()
|
||
mock_create.assert_awaited_once()
|
||
dispatch_kwargs = mock_create.call_args.kwargs
|
||
assert dispatch_kwargs["executor_type"] == "hermes_kb_growth_healthcheck"
|
||
assert dispatch_kwargs["created_by"] == "governance_dispatcher_intake"
|
||
assert dispatch_kwargs["decision_context"]["next_action"] == "run_kb_growth_healthcheck"
|
||
assert dispatch_kwargs["decision_context"]["workflow"]["current_stage"] == "queued_kb_healthcheck"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_knowledge_degradation_intake_ignores_legacy_skip_cooldown(self):
|
||
"""舊 skip cooldown 不可阻止 KM healthcheck 補建 work item。"""
|
||
event = _make_governance_event(event_type="knowledge_degradation")
|
||
event.details = {
|
||
"remediation": {"next_action": "run_kb_growth_healthcheck"},
|
||
"ownership": {"lead_agent": "Hermes"},
|
||
}
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-kb-cooldown"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher._is_skip_cooldown",
|
||
new=AsyncMock(return_value=True),
|
||
) as mock_skip_cooldown,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
):
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result == "dispatch-kb-cooldown"
|
||
mock_skip_cooldown.assert_not_awaited()
|
||
mock_create.assert_awaited_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_knowledge_degradation_does_not_requeue_after_review_draft(self):
|
||
"""同一 event 已有 Hermes review draft 後,不可反覆補 pending dispatch。"""
|
||
event = _make_governance_event(event_type="knowledge_degradation")
|
||
event.details = {
|
||
"remediation": {"next_action": "run_kb_growth_healthcheck"},
|
||
"ownership": {"lead_agent": "Hermes"},
|
||
}
|
||
succeeded_dispatch = MagicMock()
|
||
succeeded_dispatch.executor_type = "hermes_kb_growth_healthcheck"
|
||
succeeded_dispatch.dispatch_status = "succeeded"
|
||
succeeded_dispatch.decision_context = {
|
||
"workflow": {"current_stage": "waiting_owner_review"},
|
||
"worker_result": {"status": "draft_created"},
|
||
}
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.list_by_event",
|
||
new=AsyncMock(return_value=[succeeded_dispatch]),
|
||
) as mock_history,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(),
|
||
) as mock_create,
|
||
):
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_history.assert_awaited_once_with("evt-001")
|
||
mock_create.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_llm_failure_fallback_to_skip(self):
|
||
"""fusion adapter 拋 Exception → fallback skip,不寫 dispatch,返回 None。"""
|
||
event = _make_governance_event()
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(
|
||
side_effect=RuntimeError("Ollama 連線失敗")
|
||
)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_create.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dispatch_already_active_race_condition(self):
|
||
"""並行建立時 DispatchAlreadyActive → 靜默返回 None(冪等)。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
|
||
from src.repositories.governance_remediation_dispatch_repo import DispatchAlreadyActive
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(side_effect=DispatchAlreadyActive("race")),
|
||
),
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — _build_decision_context
|
||
# =============================================================================
|
||
|
||
class TestBuildDecisionContext:
|
||
"""_build_decision_context 完整三維欄位驗證。"""
|
||
|
||
def test_decision_context_has_all_required_fields(self):
|
||
"""decision_context 必須包含完整三維輸入快照。"""
|
||
from src.services.governance_dispatcher import _build_decision_context
|
||
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
|
||
ctx = _build_decision_context(event, decision)
|
||
|
||
# 版本化
|
||
assert ctx["version"] == "v1"
|
||
|
||
# 觸發來源
|
||
assert ctx["trigger_source"] == "governance_dispatcher"
|
||
assert ctx["triggered_metric"] == "trust_drift"
|
||
|
||
# 三維分數均記錄
|
||
fusion = ctx["fusion_scores"]
|
||
assert "llm_score" in fusion
|
||
assert "playbook_score" in fusion
|
||
assert "mcp_score" in fusion
|
||
assert "confidence" in fusion
|
||
assert "weights" in fusion
|
||
|
||
# LLM 推理摘要
|
||
assert "llm_reasoning" in ctx
|
||
assert isinstance(ctx["llm_reasoning"], dict)
|
||
|
||
# MCP 快照
|
||
assert "mcp_snapshot" in ctx
|
||
assert isinstance(ctx["mcp_snapshot"], dict)
|
||
|
||
# 決策路徑
|
||
assert ctx["decision_path"] in ("auto_dispatch", "pending_approval", "skip")
|
||
|
||
def test_decision_context_no_hardcoded_event_type_rules(self):
|
||
"""decision_context 不得含 hardcode event_type → playbook 對應規則。"""
|
||
from src.services.governance_dispatcher import _build_decision_context
|
||
|
||
for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination"):
|
||
event = _make_governance_event(event_type=event_type)
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
ctx = _build_decision_context(event, decision)
|
||
|
||
# 驗證 decision 基於信心度,不是 hardcode event_type 規則
|
||
assert ctx["decision_path"] == decision.decision_path
|
||
assert ctx["fusion_scores"]["confidence"] == round(decision.confidence, 4)
|
||
|
||
def test_knowledge_degradation_context_exposes_kb_work_item(self):
|
||
"""knowledge_degradation 的 next_action / owner / workflow 必須進 dispatch context."""
|
||
from src.services.governance_dispatcher import _build_decision_context
|
||
|
||
event = _make_governance_event(event_type="knowledge_degradation")
|
||
event.details = {
|
||
"impact": {
|
||
"stale_count": 1450,
|
||
"total_count": 1867,
|
||
"stale_ratio": 0.777,
|
||
"threshold": 0.2,
|
||
},
|
||
"remediation": {
|
||
"next_action": "run_kb_growth_healthcheck",
|
||
},
|
||
"ownership": {
|
||
"lead_agent": "Hermes",
|
||
"support_agents": [
|
||
"OpenClaw:提供告警分類、規則匹配與 PlayBook 脈絡摘要,不直接批量改寫 KM。",
|
||
"ElephantAlpha:read-only 稽核高影響 KM 草稿與風險,不執行寫入或通知。",
|
||
],
|
||
"human_owner": "KM owner / SRE owner",
|
||
},
|
||
}
|
||
decision = _make_fused_decision(confidence=0.75)
|
||
|
||
ctx = _build_decision_context(event, decision)
|
||
|
||
assert ctx["next_action"] == "run_kb_growth_healthcheck"
|
||
assert ctx["ownership"]["lead_agent"] == "Hermes"
|
||
assert ctx["workflow"]["work_kind"] == "kb_growth_healthcheck"
|
||
assert ctx["workflow"]["current_stage"] == "queued_kb_healthcheck"
|
||
assert ctx["workflow"]["stage_by_dispatch_status"]["skipped"] == "waiting_owner_review"
|
||
assert ctx["workflow"]["writes_km_without_approval"] is False
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — DecisionFusionAdapter._build_decision_context (adapter 本身單元)
|
||
# =============================================================================
|
||
|
||
class TestDecisionFusionAdapterHelpers:
|
||
"""DecisionFusionAdapter 靜態輔助方法測試。"""
|
||
|
||
def test_summarize_details_with_impact(self):
|
||
"""summarize_details 應提取 impact / status 等關鍵欄位。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
details = {
|
||
"status": "warning",
|
||
"impact": {"drifted_count": 3, "threshold": 0.2},
|
||
"remediation": {"next_action": "run_playbook"},
|
||
}
|
||
summary = DecisionFusionAdapter._summarize_details(details)
|
||
|
||
assert "status" in summary
|
||
assert "warning" in summary
|
||
assert len(summary) <= 300
|
||
|
||
def test_summarize_details_empty(self):
|
||
"""空 details → 返回預設提示,不崩潰。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
summary = DecisionFusionAdapter._summarize_details({})
|
||
assert summary == "(無詳細資訊)"
|
||
|
||
def test_get_mcp_queries_returns_base_for_all_types(self):
|
||
"""所有 event_type 都應包含基礎指標查詢。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination",
|
||
"execution_blast_radius", "governance_slo_data_gap"):
|
||
queries = DecisionFusionAdapter._get_mcp_queries(event_type)
|
||
assert "autonomy_rate" in queries
|
||
assert "decision_accuracy" in queries
|
||
assert len(queries) >= 2
|
||
|
||
def test_extract_keywords_from_details(self):
|
||
"""_extract_keywords 應從 remediation/actionable/impact 中提取關鍵字。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
details = {
|
||
"remediation": {
|
||
"next_action": "run_kb_growth_healthcheck",
|
||
"items": ["check_index", "rebuild_embeddings"],
|
||
},
|
||
}
|
||
keywords = DecisionFusionAdapter._extract_keywords(details)
|
||
assert len(keywords) <= 5
|
||
assert "run_kb_growth_healthcheck" in keywords
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — run_governance_dispatcher_loop(loop 邏輯)
|
||
# =============================================================================
|
||
|
||
class TestRunGovernanceDispatcherLoop:
|
||
"""run_governance_dispatcher_loop 排程迴圈行為測試。"""
|
||
|
||
def test_poll_unresolved_events_excludes_active_dispatch_rows(self):
|
||
"""poll SQL 必須跳過已有 active dispatch 的事件,避免 backlog 餓死。"""
|
||
import inspect
|
||
from src.services import governance_dispatcher
|
||
|
||
source = inspect.getsource(governance_dispatcher._poll_unresolved_events)
|
||
|
||
assert "GovernanceRemediationDispatch" in source
|
||
assert "ACTIVE_STATUSES" in source
|
||
assert ".where(~active_dispatch_exists)" in source
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_loop_processes_events_and_sleeps(self):
|
||
"""loop 一次 cycle 應處理 events 並 sleep。"""
|
||
event = _make_governance_event()
|
||
|
||
call_count = 0
|
||
|
||
async def mock_sleep(seconds):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count >= 2:
|
||
raise asyncio.CancelledError()
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher._poll_unresolved_events",
|
||
new=AsyncMock(return_value=[event]),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.dispatch_governance_event",
|
||
new=AsyncMock(return_value="dispatch-new"),
|
||
),
|
||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||
):
|
||
import asyncio
|
||
from src.services.governance_dispatcher import run_governance_dispatcher_loop
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_governance_dispatcher_loop(interval_seconds=1)
|
||
|
||
# sleep 被呼叫至少一次
|
||
assert call_count >= 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_loop_no_events_does_not_crash(self):
|
||
"""無事件時 loop 應平穩 sleep,不報錯。"""
|
||
call_count = 0
|
||
|
||
async def mock_sleep(seconds):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count >= 1:
|
||
raise asyncio.CancelledError()
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher._poll_unresolved_events",
|
||
new=AsyncMock(return_value=[]),
|
||
),
|
||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||
):
|
||
import asyncio
|
||
from src.services.governance_dispatcher import run_governance_dispatcher_loop
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_governance_dispatcher_loop(interval_seconds=1)
|
||
|
||
assert call_count >= 1
|