Phase 6.4g (API 突觸對接):
- lewooogo-brain dependency binding in apps/api/pyproject.toml
- POST /api/v1/incidents/{id}/propose route (proposals.py)
- Guardrails integration (8/8 tests passed)
Phase 6.5a (視覺皮層建置):
- DualStateIncidentCard.tsx with Nothing.tech visual compliance
- Ping radar animation for alert state
- Tier-based decision layer UI (AI 執行中 / 等待親核)
Phase 6.5b (神經網路串接):
- Main warroom page integration (page.tsx)
- IncidentResponse → DualState mapper function
- Empty state: "系統穩定。0 活躍異常。"
Tests:
- test_guardrails.py (8/8)
- test_incident_engine.py (6/6)
- test_skill_loader.py (6/6)
- Frontend build: 0 errors
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
322 lines
10 KiB
Python
322 lines
10 KiB
Python
"""
|
||
IncidentEngine 單元測試
|
||
========================
|
||
|
||
Phase 6.4e 驗證點 2:
|
||
使用 Mock MemoryProvider 驗證 IncidentEngine 能正確處理告警信號
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
# 添加 src 到 Python Path
|
||
src_path = Path(__file__).parent.parent / "src"
|
||
sys.path.insert(0, str(src_path))
|
||
|
||
|
||
# =============================================================================
|
||
# Mock Memory Provider (完全隔離,不依賴外部)
|
||
# =============================================================================
|
||
|
||
class MockIncidentMemory:
|
||
"""Mock 記憶體提供者 - 純記憶體實作"""
|
||
|
||
def __init__(self):
|
||
self._incidents: dict[str, Any] = {}
|
||
self._ns_index: dict[str, str] = {} # namespace → incident_id
|
||
self._target_index: dict[str, str] = {} # target → incident_id
|
||
|
||
async def load_incident(self, incident_id: str):
|
||
"""載入 Incident"""
|
||
return self._incidents.get(incident_id)
|
||
|
||
async def save_incident(self, incident, ttl_seconds: int = 604800) -> bool:
|
||
"""儲存 Incident"""
|
||
self._incidents[incident.incident_id] = incident
|
||
return True
|
||
|
||
async def persist_incident(self, incident) -> bool:
|
||
"""持久化 (Mock 直接返回成功)"""
|
||
return True
|
||
|
||
async def find_related_incident(
|
||
self,
|
||
namespace: str,
|
||
target: str,
|
||
window_minutes: int = 30,
|
||
):
|
||
"""尋找相關 Incident"""
|
||
# 檢查 namespace 索引
|
||
if namespace in self._ns_index:
|
||
incident_id = self._ns_index[namespace]
|
||
incident = self._incidents.get(incident_id)
|
||
if incident and incident.status.value in ["investigating", "mitigating"]:
|
||
return incident
|
||
|
||
# 檢查 target 索引
|
||
if target in self._target_index:
|
||
incident_id = self._target_index[target]
|
||
incident = self._incidents.get(incident_id)
|
||
if incident and incident.status.value in ["investigating", "mitigating"]:
|
||
return incident
|
||
|
||
return None
|
||
|
||
async def update_index(
|
||
self,
|
||
incident_id: str,
|
||
namespace: str,
|
||
target: str,
|
||
) -> bool:
|
||
"""更新索引"""
|
||
self._ns_index[namespace] = incident_id
|
||
self._target_index[target] = incident_id
|
||
return True
|
||
|
||
|
||
class MockBlastRadiusAnalyzer:
|
||
"""Mock 爆炸半徑分析器"""
|
||
|
||
def analyze(self, target: str) -> list[str]:
|
||
"""返回受影響服務 (Mock 固定回應)"""
|
||
return [target, f"{target}-dependent"]
|
||
|
||
|
||
# =============================================================================
|
||
# 測試案例
|
||
# =============================================================================
|
||
|
||
def test_incident_engine_import():
|
||
"""測試:能正確 import IncidentEngine"""
|
||
from lewooogo_brain.engines.incident_engine import IncidentEngine
|
||
from lewooogo_brain.interfaces.incident_processor import IIncidentProcessor
|
||
|
||
assert issubclass(IncidentEngine, IIncidentProcessor)
|
||
print("✅ IncidentEngine import 成功,實作 IIncidentProcessor")
|
||
|
||
|
||
def test_incident_engine_create_incident():
|
||
"""測試:處理新告警時創建 Incident"""
|
||
import asyncio
|
||
from lewooogo_brain.engines.incident_engine import IncidentEngine
|
||
|
||
memory = MockIncidentMemory()
|
||
analyzer = MockBlastRadiusAnalyzer()
|
||
engine = IncidentEngine(memory=memory, blast_analyzer=analyzer)
|
||
|
||
signal_data = {
|
||
"source": "prometheus",
|
||
"alert_name": "HighCPUUsage",
|
||
"severity": "critical",
|
||
"namespace": "awoooi-prod",
|
||
"target": "awoooi-api",
|
||
"message": "CPU usage exceeded 90%",
|
||
"labels": {"app": "awoooi-api"},
|
||
}
|
||
|
||
async def run_test():
|
||
incident = await engine.process_signal(signal_data)
|
||
return incident
|
||
|
||
incident = asyncio.get_event_loop().run_until_complete(run_test())
|
||
|
||
assert incident is not None, "Failed to create incident"
|
||
assert incident.incident_id.startswith("INC-"), f"Invalid incident ID: {incident.incident_id}"
|
||
assert incident.severity.value == "P0", f"Expected P0, got {incident.severity.value}"
|
||
assert len(incident.signals) == 1, f"Expected 1 signal, got {len(incident.signals)}"
|
||
assert "awoooi-api" in incident.affected_services
|
||
|
||
print(f"✅ Incident 創建成功:")
|
||
print(f" - ID: {incident.incident_id}")
|
||
print(f" - Severity: {incident.severity.value}")
|
||
print(f" - Signals: {len(incident.signals)}")
|
||
print(f" - Affected: {incident.affected_services}")
|
||
|
||
|
||
def test_incident_engine_aggregate_signals():
|
||
"""測試:相關告警聚合到同一 Incident"""
|
||
import asyncio
|
||
from lewooogo_brain.engines.incident_engine import IncidentEngine
|
||
|
||
memory = MockIncidentMemory()
|
||
engine = IncidentEngine(memory=memory)
|
||
|
||
# 第一個告警
|
||
signal1 = {
|
||
"source": "prometheus",
|
||
"alert_name": "HighCPUUsage",
|
||
"severity": "warning",
|
||
"namespace": "awoooi-prod",
|
||
"target": "awoooi-api",
|
||
"message": "CPU at 80%",
|
||
}
|
||
|
||
# 相同 namespace/target 的第二個告警
|
||
signal2 = {
|
||
"source": "grafana",
|
||
"alert_name": "HighMemoryUsage",
|
||
"severity": "critical",
|
||
"namespace": "awoooi-prod",
|
||
"target": "awoooi-api",
|
||
"message": "Memory at 95%",
|
||
}
|
||
|
||
async def run_test():
|
||
incident1 = await engine.process_signal(signal1)
|
||
incident2 = await engine.process_signal(signal2)
|
||
return incident1, incident2
|
||
|
||
incident1, incident2 = asyncio.get_event_loop().run_until_complete(run_test())
|
||
|
||
assert incident1 is not None
|
||
assert incident2 is not None
|
||
assert incident1.incident_id == incident2.incident_id, "Signals should aggregate"
|
||
assert len(incident2.signals) == 2, f"Expected 2 signals, got {len(incident2.signals)}"
|
||
# 嚴重度應升級為 P0 (critical)
|
||
assert incident2.severity.value == "P0", f"Severity should escalate to P0"
|
||
|
||
print(f"✅ 告警聚合成功:")
|
||
print(f" - Incident ID: {incident2.incident_id}")
|
||
print(f" - Total Signals: {len(incident2.signals)}")
|
||
print(f" - Final Severity: {incident2.severity.value}")
|
||
|
||
|
||
def test_incident_engine_deduplication():
|
||
"""測試:相同 Fingerprint 的告警去重"""
|
||
import asyncio
|
||
from lewooogo_brain.engines.incident_engine import IncidentEngine
|
||
|
||
memory = MockIncidentMemory()
|
||
engine = IncidentEngine(memory=memory)
|
||
|
||
# 兩個完全相同的告警
|
||
signal = {
|
||
"source": "prometheus",
|
||
"alert_name": "PodCrashLooping",
|
||
"severity": "critical",
|
||
"namespace": "awoooi-prod",
|
||
"target": "awoooi-worker",
|
||
"message": "Pod restart count > 5",
|
||
}
|
||
|
||
async def run_test():
|
||
incident1 = await engine.process_signal(signal)
|
||
incident2 = await engine.process_signal(signal) # 重複
|
||
return incident1, incident2
|
||
|
||
incident1, incident2 = asyncio.get_event_loop().run_until_complete(run_test())
|
||
|
||
assert incident1 is not None
|
||
assert incident2 is not None
|
||
assert incident1.incident_id == incident2.incident_id
|
||
# 重複告警應被去重,signal 數量仍為 1
|
||
assert len(incident2.signals) == 1, f"Expected 1 signal (dedup), got {len(incident2.signals)}"
|
||
|
||
print(f"✅ 告警去重成功:")
|
||
print(f" - Signals after dedup: {len(incident2.signals)}")
|
||
|
||
|
||
def test_incident_engine_update_status():
|
||
"""測試:更新 Incident 狀態"""
|
||
import asyncio
|
||
from lewooogo_brain.engines.incident_engine import IncidentEngine
|
||
from lewooogo_brain.interfaces.incident_processor import IncidentStatus
|
||
|
||
memory = MockIncidentMemory()
|
||
engine = IncidentEngine(memory=memory)
|
||
|
||
signal = {
|
||
"source": "test",
|
||
"alert_name": "TestAlert",
|
||
"severity": "warning",
|
||
"namespace": "test",
|
||
"target": "test-service",
|
||
}
|
||
|
||
async def run_test():
|
||
incident = await engine.process_signal(signal)
|
||
assert incident.status == IncidentStatus.INVESTIGATING
|
||
|
||
success = await engine.update_status(incident.incident_id, IncidentStatus.RESOLVED)
|
||
assert success, "Failed to update status"
|
||
|
||
updated = await engine.get_incident(incident.incident_id)
|
||
return updated
|
||
|
||
updated = asyncio.get_event_loop().run_until_complete(run_test())
|
||
|
||
assert updated is not None
|
||
assert updated.status == IncidentStatus.RESOLVED
|
||
assert updated.resolved_at is not None
|
||
|
||
print(f"✅ 狀態更新成功:")
|
||
print(f" - Status: {updated.status.value}")
|
||
print(f" - Resolved At: {updated.resolved_at}")
|
||
|
||
|
||
def test_incident_engine_no_external_deps():
|
||
"""測試:IncidentEngine 不依賴任何外部模組"""
|
||
import importlib
|
||
import lewooogo_brain.engines.incident_engine as module
|
||
|
||
# 取得所有 import
|
||
source = Path(module.__file__).read_text()
|
||
|
||
# 禁止的 import patterns
|
||
forbidden = [
|
||
"from src.core",
|
||
"from src.db",
|
||
"from src.services",
|
||
"import redis",
|
||
"from redis",
|
||
"import sqlalchemy",
|
||
"from sqlalchemy",
|
||
]
|
||
|
||
violations = []
|
||
for pattern in forbidden:
|
||
if pattern in source:
|
||
violations.append(pattern)
|
||
|
||
assert len(violations) == 0, f"Found forbidden imports: {violations}"
|
||
print("✅ 無外部依賴,完全積木化")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print("=" * 60)
|
||
print("🧪 IncidentEngine 單元測試")
|
||
print("=" * 60)
|
||
|
||
tests = [
|
||
test_incident_engine_import,
|
||
test_incident_engine_create_incident,
|
||
test_incident_engine_aggregate_signals,
|
||
test_incident_engine_deduplication,
|
||
test_incident_engine_update_status,
|
||
test_incident_engine_no_external_deps,
|
||
]
|
||
|
||
passed = 0
|
||
failed = 0
|
||
|
||
for test in tests:
|
||
print(f"\n🔬 {test.__name__}")
|
||
try:
|
||
test()
|
||
passed += 1
|
||
except AssertionError as e:
|
||
print(f"❌ FAILED: {e}")
|
||
failed += 1
|
||
except Exception as e:
|
||
print(f"❌ ERROR: {type(e).__name__}: {e}")
|
||
failed += 1
|
||
|
||
print("\n" + "=" * 60)
|
||
print(f"📊 結果: {passed} 通過, {failed} 失敗")
|
||
print("=" * 60)
|
||
|
||
if failed > 0:
|
||
sys.exit(1)
|