|
|
|
|
@@ -0,0 +1,183 @@
|
|
|
|
|
"""Tests for the mempalace skill (skills/memory/mempalace.py).
|
|
|
|
|
|
|
|
|
|
Validates PalaceRoom, Mempalace class, factory constructors,
|
|
|
|
|
and the analyse_issues entry-point.
|
|
|
|
|
|
|
|
|
|
Refs: hermes-agent PR #191, Issue #190
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Import guard — the skill lives in skills/memory/mempalace.py which may
|
|
|
|
|
# not be on sys.path in every CI layout. We add a fallback so the tests
|
|
|
|
|
# are importable even before the skill is merged to main.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
try:
|
|
|
|
|
from skills.memory.mempalace import Mempalace, PalaceRoom, analyse_issues
|
|
|
|
|
except ImportError:
|
|
|
|
|
pytest.skip("mempalace skill not yet installed", allow_module_level=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── PalaceRoom unit tests ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestPalaceRoom:
|
|
|
|
|
def test_store_and_retrieve(self):
|
|
|
|
|
room = PalaceRoom(name="test", label="Test Room")
|
|
|
|
|
room.store("key1", 42)
|
|
|
|
|
assert room.retrieve("key1") == 42
|
|
|
|
|
|
|
|
|
|
def test_retrieve_default(self):
|
|
|
|
|
room = PalaceRoom(name="test", label="Test Room")
|
|
|
|
|
assert room.retrieve("missing") is None
|
|
|
|
|
assert room.retrieve("missing", "fallback") == "fallback"
|
|
|
|
|
|
|
|
|
|
def test_summary_format(self):
|
|
|
|
|
room = PalaceRoom(name="test", label="Test Room")
|
|
|
|
|
room.store("repos", 5)
|
|
|
|
|
summary = room.summary()
|
|
|
|
|
assert "## Test Room" in summary
|
|
|
|
|
assert "repos: 5" in summary
|
|
|
|
|
|
|
|
|
|
def test_contents_default_factory_isolation(self):
|
|
|
|
|
"""Each room gets its own dict — no shared mutable default."""
|
|
|
|
|
r1 = PalaceRoom(name="a", label="A")
|
|
|
|
|
r2 = PalaceRoom(name="b", label="B")
|
|
|
|
|
r1.store("x", 1)
|
|
|
|
|
assert r2.retrieve("x") is None
|
|
|
|
|
|
|
|
|
|
def test_entered_at_is_recent(self):
|
|
|
|
|
before = time.time()
|
|
|
|
|
room = PalaceRoom(name="t", label="T")
|
|
|
|
|
after = time.time()
|
|
|
|
|
assert before <= room.entered_at <= after
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Mempalace core tests ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestMempalace:
|
|
|
|
|
def test_add_and_enter_room(self):
|
|
|
|
|
p = Mempalace(domain="test")
|
|
|
|
|
p.add_room("r1", "Room 1")
|
|
|
|
|
room = p.enter("r1")
|
|
|
|
|
assert room.name == "r1"
|
|
|
|
|
|
|
|
|
|
def test_enter_nonexistent_room_raises(self):
|
|
|
|
|
p = Mempalace()
|
|
|
|
|
with pytest.raises(KeyError, match="No room"):
|
|
|
|
|
p.enter("ghost")
|
|
|
|
|
|
|
|
|
|
def test_store_without_enter_raises(self):
|
|
|
|
|
p = Mempalace()
|
|
|
|
|
p.add_room("r", "R")
|
|
|
|
|
with pytest.raises(RuntimeError, match="Enter a room"):
|
|
|
|
|
p.store("k", "v")
|
|
|
|
|
|
|
|
|
|
def test_store_and_retrieve_via_palace(self):
|
|
|
|
|
p = Mempalace()
|
|
|
|
|
p.add_room("r", "R")
|
|
|
|
|
p.enter("r")
|
|
|
|
|
p.store("count", 10)
|
|
|
|
|
assert p.retrieve("r", "count") == 10
|
|
|
|
|
|
|
|
|
|
def test_retrieve_missing_room_returns_default(self):
|
|
|
|
|
p = Mempalace()
|
|
|
|
|
assert p.retrieve("nope", "key") is None
|
|
|
|
|
assert p.retrieve("nope", "key", 99) == 99
|
|
|
|
|
|
|
|
|
|
def test_render_includes_domain(self):
|
|
|
|
|
p = Mempalace(domain="audit")
|
|
|
|
|
p.add_room("r", "Room")
|
|
|
|
|
p.enter("r")
|
|
|
|
|
p.store("item", "value")
|
|
|
|
|
output = p.render()
|
|
|
|
|
assert "audit" in output
|
|
|
|
|
assert "Room" in output
|
|
|
|
|
|
|
|
|
|
def test_to_dict_structure(self):
|
|
|
|
|
p = Mempalace(domain="test")
|
|
|
|
|
p.add_room("r", "R")
|
|
|
|
|
p.enter("r")
|
|
|
|
|
p.store("a", 1)
|
|
|
|
|
d = p.to_dict()
|
|
|
|
|
assert d["domain"] == "test"
|
|
|
|
|
assert "elapsed_seconds" in d
|
|
|
|
|
assert d["rooms"]["r"] == {"a": 1}
|
|
|
|
|
|
|
|
|
|
def test_to_json_is_valid(self):
|
|
|
|
|
p = Mempalace(domain="j")
|
|
|
|
|
p.add_room("x", "X")
|
|
|
|
|
p.enter("x")
|
|
|
|
|
p.store("v", [1, 2, 3])
|
|
|
|
|
parsed = json.loads(p.to_json())
|
|
|
|
|
assert parsed["rooms"]["x"]["v"] == [1, 2, 3]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Factory constructor tests ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestFactories:
|
|
|
|
|
def test_for_issue_analysis_rooms(self):
|
|
|
|
|
p = Mempalace.for_issue_analysis()
|
|
|
|
|
assert p.domain == "issue_analysis"
|
|
|
|
|
for key in ("repo_architecture", "assignment_status",
|
|
|
|
|
"triage_priority", "resolution_patterns"):
|
|
|
|
|
p.enter(key) # should not raise
|
|
|
|
|
|
|
|
|
|
def test_for_health_check_rooms(self):
|
|
|
|
|
p = Mempalace.for_health_check()
|
|
|
|
|
assert p.domain == "health_check"
|
|
|
|
|
for key in ("service_topology", "failure_signals", "recovery_history"):
|
|
|
|
|
p.enter(key)
|
|
|
|
|
|
|
|
|
|
def test_for_code_review_rooms(self):
|
|
|
|
|
p = Mempalace.for_code_review()
|
|
|
|
|
assert p.domain == "code_review"
|
|
|
|
|
for key in ("change_scope", "risk_surface",
|
|
|
|
|
"test_coverage", "reviewer_context"):
|
|
|
|
|
p.enter(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── analyse_issues entry-point tests ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestAnalyseIssues:
|
|
|
|
|
SAMPLE_DATA = [
|
|
|
|
|
{"repo": "the-nexus", "open_issues": 40, "assigned": 30, "unassigned": 10},
|
|
|
|
|
{"repo": "timmy-home", "open_issues": 30, "assigned": 25, "unassigned": 5},
|
|
|
|
|
{"repo": "hermes-agent", "open_issues": 20, "assigned": 15, "unassigned": 5},
|
|
|
|
|
{"repo": "empty-repo", "open_issues": 0, "assigned": 0, "unassigned": 0},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_returns_string(self):
|
|
|
|
|
result = analyse_issues(self.SAMPLE_DATA)
|
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
assert len(result) > 0
|
|
|
|
|
|
|
|
|
|
def test_contains_room_headers(self):
|
|
|
|
|
result = analyse_issues(self.SAMPLE_DATA)
|
|
|
|
|
assert "Repository Architecture" in result
|
|
|
|
|
assert "Assignment Status" in result
|
|
|
|
|
|
|
|
|
|
def test_coverage_below_target(self):
|
|
|
|
|
result = analyse_issues(self.SAMPLE_DATA, target_assignee_rate=0.90)
|
|
|
|
|
assert "BELOW TARGET" in result
|
|
|
|
|
|
|
|
|
|
def test_coverage_meets_target(self):
|
|
|
|
|
good_data = [
|
|
|
|
|
{"repo": "a", "open_issues": 10, "assigned": 10, "unassigned": 0},
|
|
|
|
|
]
|
|
|
|
|
result = analyse_issues(good_data, target_assignee_rate=0.80)
|
|
|
|
|
assert "OK" in result
|
|
|
|
|
|
|
|
|
|
def test_empty_repos_list(self):
|
|
|
|
|
result = analyse_issues([])
|
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
|
|
|
|
|
def test_single_repo(self):
|
|
|
|
|
data = [{"repo": "solo", "open_issues": 5, "assigned": 3, "unassigned": 2}]
|
|
|
|
|
result = analyse_issues(data)
|
|
|
|
|
assert "solo" in result or "issue_analysis" in result
|