Files
awoooi/packages/lewooogo-data/tests/test_dual_memory.py
OG T 7478dc0254 feat(phase6-9): Complete modular architecture and Agent Teams
Phase 6.4 - Modular Architecture:
- Add lewooogo-brain adapters for LLM providers
- Add lewooogo-data dual memory (Redis + PostgreSQL)
- Implement consensus engine for multi-agent decisions
- Add incident memory service for historical context

Phase 9 - Agent Teams (Claude Agent SDK):
- Add base agent class with Claude Sonnet 4 integration
- Implement action planner, blast radius, and security agents
- Add agent API endpoints and proposal workflow
- Integrate ADR-009 OpenClaw Agent Teams architecture

DevOps & CI/CD:
- Add GitHub Actions CI/CD workflows (ci.yaml, cd.yaml)
- Add pre-commit hooks and secrets baseline
- Add docker-compose for local development
- Update Kubernetes network policies

Frontend Improvements:
- Add auto-healing error boundary component
- Update i18n messages for agent features
- Enhance dual-state incident card with execution feedback

Documentation:
- Add 7 ADRs covering MCP, design system, architecture decisions
- Update ARCHITECTURE_MEMORY.md with modular design
- Add GLOBAL_RULES.md and SOUL.md for project identity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-23 18:40:36 +08:00

431 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
DualMemoryProvider 單元測試
=============================
測試案例:
- test_cache_aside_hit: Working Memory 命中
- test_cache_aside_miss_backfill: Working missEpisodic 命中並回填
- test_dual_write: 雙層寫入
- test_graceful_degradation: 優雅降級 (單層失敗不影響整體)
- test_delete: 雙層刪除
- test_exists: 存在檢查
- test_sync: 手動同步
"""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
from pydantic import BaseModel
from lewooogo_data.providers.dual_memory import DualMemoryProvider
from lewooogo_data.providers.redis_memory import RedisMemoryProvider
from lewooogo_data.providers.pg_memory import PgMemoryProvider
class SampleModel(BaseModel):
"""測試用的 Pydantic Model"""
id: str
name: str
value: int = 0
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_working():
"""Mock Working Memory (Redis)"""
mock = AsyncMock(spec=RedisMemoryProvider)
mock._storage = {}
async def mock_load(key):
return mock._storage.get(key)
async def mock_save(key, data, ttl=None):
mock._storage[key] = data
return True
async def mock_delete(key):
if key in mock._storage:
del mock._storage[key]
return True
return False
async def mock_exists(key):
return key in mock._storage
mock.load = mock_load
mock.save = mock_save
mock.delete = mock_delete
mock.exists = mock_exists
return mock
@pytest.fixture
def mock_episodic():
"""Mock Episodic Memory (PostgreSQL)"""
mock = AsyncMock(spec=PgMemoryProvider)
mock._storage = {}
async def mock_load(key):
return mock._storage.get(key)
async def mock_save(key, data, ttl=None):
mock._storage[key] = data
return True
async def mock_delete(key):
if key in mock._storage:
del mock._storage[key]
return True
return False
async def mock_exists(key):
return key in mock._storage
mock.load = mock_load
mock.save = mock_save
mock.delete = mock_delete
mock.exists = mock_exists
return mock
@pytest.fixture
def dual_provider(mock_working, mock_episodic):
"""建立已 mock 的 DualMemoryProvider"""
provider = DualMemoryProvider(
model_class=SampleModel,
key_prefix="test",
working_ttl=604800,
)
# 替換內部 provider
provider._working = mock_working
provider._episodic = mock_episodic
return provider
# =============================================================================
# Cache-Aside 模式測試
# =============================================================================
class TestDualMemoryCacheAside:
"""Cache-Aside 模式測試"""
@pytest.mark.asyncio
async def test_cache_aside_hit(self, dual_provider, mock_working, mock_episodic):
"""
測試 Working Memory 命中
策略: Working 有資料 → 直接返回,不查 Episodic
"""
# Arrange
data = SampleModel(id="hit-1", name="Cache Hit", value=100)
mock_working._storage["hit-1"] = data
# Act
result = await dual_provider.load("hit-1")
# Assert
assert result is not None
assert result.id == "hit-1"
assert result.name == "Cache Hit"
# Episodic 不應被查詢 (透過檢查 storage 未被訪問)
assert "hit-1" not in mock_episodic._storage
@pytest.mark.asyncio
async def test_cache_aside_miss_backfill(self, dual_provider, mock_working, mock_episodic):
"""
測試 Working missEpisodic 命中並回填
策略:
1. Working miss
2. 查 Episodic命中
3. 回填到 Working
"""
# Arrange - 只在 Episodic 有資料
data = SampleModel(id="miss-1", name="From PG", value=200)
mock_episodic._storage["miss-1"] = data
# Working 是空的
assert "miss-1" not in mock_working._storage
# Act
result = await dual_provider.load("miss-1")
# Assert
assert result is not None
assert result.id == "miss-1"
assert result.name == "From PG"
# 驗證回填到 Working
assert "miss-1" in mock_working._storage
assert mock_working._storage["miss-1"].name == "From PG"
@pytest.mark.asyncio
async def test_cache_aside_total_miss(self, dual_provider, mock_working, mock_episodic):
"""測試兩層都沒有資料"""
# Act
result = await dual_provider.load("nonexistent")
# Assert
assert result is None
# =============================================================================
# 雙層寫入測試
# =============================================================================
class TestDualMemoryWrite:
"""雙層寫入測試"""
@pytest.mark.asyncio
async def test_dual_write(self, dual_provider, mock_working, mock_episodic):
"""
測試雙層同步寫入
統帥鐵律: Working + Episodic 必須同步寫入
"""
# Arrange
data = SampleModel(id="write-1", name="Dual Write", value=300)
# Act
result = await dual_provider.save("write-1", data)
# Assert
assert result is True
# 驗證兩層都有資料
assert "write-1" in mock_working._storage
assert "write-1" in mock_episodic._storage
assert mock_working._storage["write-1"].name == "Dual Write"
assert mock_episodic._storage["write-1"].name == "Dual Write"
# =============================================================================
# 優雅降級測試
# =============================================================================
class TestDualMemoryGracefulDegradation:
"""優雅降級測試"""
@pytest.mark.asyncio
async def test_working_failure_continues(self, dual_provider, mock_working, mock_episodic):
"""
測試 Working 失敗但 Episodic 成功
統帥鐵律: 任一層失敗不影響整體
"""
# Arrange - Working save 會失敗
async def failing_save(key, data, ttl=None):
raise Exception("Redis connection failed")
mock_working.save = failing_save
data = SampleModel(id="degrade-1", name="Degraded", value=400)
# Act
result = await dual_provider.save("degrade-1", data)
# Assert - 至少 Episodic 成功,整體算成功
assert result is True
assert "degrade-1" in mock_episodic._storage
@pytest.mark.asyncio
async def test_episodic_failure_continues(self, dual_provider, mock_working, mock_episodic):
"""測試 Episodic 失敗但 Working 成功"""
# Arrange - Episodic save 會失敗
async def failing_save(key, data, ttl=None):
raise Exception("PostgreSQL connection failed")
mock_episodic.save = failing_save
data = SampleModel(id="degrade-2", name="Degraded 2", value=500)
# Act
result = await dual_provider.save("degrade-2", data)
# Assert - 至少 Working 成功,整體算成功
assert result is True
assert "degrade-2" in mock_working._storage
@pytest.mark.asyncio
async def test_both_failure(self, dual_provider, mock_working, mock_episodic):
"""測試兩層都失敗"""
# Arrange - 兩層都會失敗
async def failing_save(key, data, ttl=None):
return False
mock_working.save = failing_save
mock_episodic.save = failing_save
data = SampleModel(id="fail-all", name="All Failed", value=0)
# Act
result = await dual_provider.save("fail-all", data)
# Assert
assert result is False
# =============================================================================
# 刪除測試
# =============================================================================
class TestDualMemoryDelete:
"""刪除相關測試"""
@pytest.mark.asyncio
async def test_delete_both_layers(self, dual_provider, mock_working, mock_episodic):
"""測試雙層刪除"""
# Arrange - 兩層都有資料
data = SampleModel(id="del-1", name="To Delete", value=0)
mock_working._storage["del-1"] = data
mock_episodic._storage["del-1"] = data
# Act
result = await dual_provider.delete("del-1")
# Assert
assert result is True
assert "del-1" not in mock_working._storage
assert "del-1" not in mock_episodic._storage
@pytest.mark.asyncio
async def test_delete_only_working(self, dual_provider, mock_working, mock_episodic):
"""測試只有 Working 有資料時刪除"""
data = SampleModel(id="del-2", name="Only Working", value=1)
mock_working._storage["del-2"] = data
# Act
result = await dual_provider.delete("del-2")
# Assert - 至少一層成功
assert result is True
assert "del-2" not in mock_working._storage
@pytest.mark.asyncio
async def test_delete_nonexistent(self, dual_provider, mock_working, mock_episodic):
"""測試刪除不存在的資料"""
# Act
result = await dual_provider.delete("nonexistent")
# Assert
assert result is False
# =============================================================================
# 存在檢查測試
# =============================================================================
class TestDualMemoryExists:
"""存在檢查測試"""
@pytest.mark.asyncio
async def test_exists_in_working(self, dual_provider, mock_working, mock_episodic):
"""測試只在 Working 有資料"""
data = SampleModel(id="exist-1", name="In Working", value=1)
mock_working._storage["exist-1"] = data
# Act
result = await dual_provider.exists("exist-1")
# Assert
assert result is True
@pytest.mark.asyncio
async def test_exists_in_episodic(self, dual_provider, mock_working, mock_episodic):
"""測試只在 Episodic 有資料"""
data = SampleModel(id="exist-2", name="In Episodic", value=2)
mock_episodic._storage["exist-2"] = data
# Act
result = await dual_provider.exists("exist-2")
# Assert
assert result is True
@pytest.mark.asyncio
async def test_exists_nowhere(self, dual_provider, mock_working, mock_episodic):
"""測試兩層都沒有"""
# Act
result = await dual_provider.exists("nonexistent")
# Assert
assert result is False
# =============================================================================
# 同步測試
# =============================================================================
class TestDualMemorySync:
"""同步相關測試"""
@pytest.mark.asyncio
async def test_sync_to_working(self, dual_provider, mock_working, mock_episodic):
"""測試從 Episodic 同步到 Working"""
# Arrange - 只在 Episodic 有資料
data = SampleModel(id="sync-1", name="To Sync", value=100)
mock_episodic._storage["sync-1"] = data
# Act
result = await dual_provider.sync_to_working("sync-1")
# Assert
assert result is True
assert "sync-1" in mock_working._storage
assert mock_working._storage["sync-1"].name == "To Sync"
@pytest.mark.asyncio
async def test_sync_to_working_nonexistent(self, dual_provider, mock_working, mock_episodic):
"""測試同步不存在的資料到 Working"""
# Act
result = await dual_provider.sync_to_working("nonexistent")
# Assert
assert result is False
@pytest.mark.asyncio
async def test_sync_to_episodic(self, dual_provider, mock_working, mock_episodic):
"""測試從 Working 同步到 Episodic"""
# Arrange - 只在 Working 有資料
data = SampleModel(id="sync-2", name="To Persist", value=200)
mock_working._storage["sync-2"] = data
# Act
result = await dual_provider.sync_to_episodic("sync-2")
# Assert
assert result is True
assert "sync-2" in mock_episodic._storage
assert mock_episodic._storage["sync-2"].name == "To Persist"
@pytest.mark.asyncio
async def test_sync_to_episodic_nonexistent(self, dual_provider, mock_working, mock_episodic):
"""測試同步不存在的資料到 Episodic"""
# Act
result = await dual_provider.sync_to_episodic("nonexistent")
# Assert
assert result is False
# =============================================================================
# Property 測試
# =============================================================================
class TestDualMemoryProperties:
"""Property 測試"""
def test_working_property(self, dual_provider, mock_working):
"""測試 working property"""
assert dual_provider.working is mock_working
def test_episodic_property(self, dual_provider, mock_episodic):
"""測試 episodic property"""
assert dual_provider.episodic is mock_episodic