165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
|
|
"""Tests for the observation and context-building layer (context.py).
|
||
|
|
|
||
|
|
All tests are offline — httpx is mocked so no live state-hub required.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from context import (
|
||
|
|
build_context,
|
||
|
|
fetch_state,
|
||
|
|
load_constitution,
|
||
|
|
CONSTITUTION_PATH,
|
||
|
|
API_BASE,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# fetch_state
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestFetchState:
|
||
|
|
def _mock_response(self, data: dict, status: int = 200):
|
||
|
|
resp = MagicMock()
|
||
|
|
resp.status_code = status
|
||
|
|
resp.json.return_value = data
|
||
|
|
resp.raise_for_status = MagicMock()
|
||
|
|
return resp
|
||
|
|
|
||
|
|
def test_fetch_state_calls_summary_endpoint(self):
|
||
|
|
state_data = {"totals": {"tasks": {"todo": 5}}, "topics": []}
|
||
|
|
with patch("httpx.get") as mock_get:
|
||
|
|
mock_get.return_value = self._mock_response(state_data)
|
||
|
|
result = fetch_state(domain=None)
|
||
|
|
mock_get.assert_called_once()
|
||
|
|
call_url = mock_get.call_args[0][0]
|
||
|
|
assert "/state/summary" in call_url
|
||
|
|
|
||
|
|
def test_fetch_state_with_domain_calls_domain_endpoint(self):
|
||
|
|
domain_data = {"domain": "custodian", "workstreams": []}
|
||
|
|
with patch("httpx.get") as mock_get:
|
||
|
|
mock_get.return_value = self._mock_response(domain_data)
|
||
|
|
result = fetch_state(domain="custodian")
|
||
|
|
call_url = mock_get.call_args[0][0]
|
||
|
|
assert "custodian" in call_url
|
||
|
|
|
||
|
|
def test_fetch_state_returns_dict(self):
|
||
|
|
state_data = {"totals": {}, "topics": []}
|
||
|
|
with patch("httpx.get") as mock_get:
|
||
|
|
mock_get.return_value = self._mock_response(state_data)
|
||
|
|
result = fetch_state()
|
||
|
|
assert isinstance(result, dict)
|
||
|
|
|
||
|
|
def test_fetch_state_handles_connection_error(self):
|
||
|
|
with patch("httpx.get") as mock_get:
|
||
|
|
mock_get.side_effect = Exception("Connection refused")
|
||
|
|
result = fetch_state()
|
||
|
|
# Graceful degradation: returns empty dict, does not raise
|
||
|
|
assert result == {}
|
||
|
|
|
||
|
|
def test_fetch_state_handles_non_200(self):
|
||
|
|
resp = MagicMock()
|
||
|
|
resp.raise_for_status.side_effect = Exception("503 Service Unavailable")
|
||
|
|
with patch("httpx.get") as mock_get:
|
||
|
|
mock_get.return_value = resp
|
||
|
|
result = fetch_state()
|
||
|
|
assert result == {}
|
||
|
|
|
||
|
|
def test_fetch_state_custom_api_base(self):
|
||
|
|
with patch("httpx.get") as mock_get:
|
||
|
|
mock_get.return_value = self._mock_response({})
|
||
|
|
fetch_state(api_base="http://localhost:9999")
|
||
|
|
call_url = mock_get.call_args[0][0]
|
||
|
|
assert "localhost:9999" in call_url
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# load_constitution
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestLoadConstitution:
|
||
|
|
def test_load_constitution_returns_non_empty_string(self):
|
||
|
|
text = load_constitution()
|
||
|
|
assert isinstance(text, str)
|
||
|
|
assert len(text) > 100
|
||
|
|
|
||
|
|
def test_load_constitution_contains_key_clauses(self):
|
||
|
|
text = load_constitution()
|
||
|
|
assert "Custodian" in text
|
||
|
|
assert "Forbidden" in text or "forbidden" in text.lower()
|
||
|
|
|
||
|
|
def test_constitution_path_exists(self):
|
||
|
|
assert CONSTITUTION_PATH.exists(), (
|
||
|
|
f"Constitution not found at {CONSTITUTION_PATH}. "
|
||
|
|
"The path is hardcoded relative to this file — check context.py."
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_load_constitution_with_missing_file(self, tmp_path, monkeypatch):
|
||
|
|
"""If constitution is missing, return a minimal fallback, not an exception."""
|
||
|
|
import context as ctx_module
|
||
|
|
monkeypatch.setattr(ctx_module, "CONSTITUTION_PATH",
|
||
|
|
tmp_path / "nonexistent.md")
|
||
|
|
text = load_constitution()
|
||
|
|
assert isinstance(text, str)
|
||
|
|
# Should return fallback, not crash
|
||
|
|
assert len(text) > 0
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# build_context
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestBuildContext:
|
||
|
|
def _minimal_state(self) -> dict:
|
||
|
|
return {
|
||
|
|
"totals": {
|
||
|
|
"tasks": {"todo": 3, "done": 10},
|
||
|
|
"workstreams": {"active": 2, "completed": 5},
|
||
|
|
"decisions": {"open": 0},
|
||
|
|
},
|
||
|
|
"blocking_decisions": [],
|
||
|
|
"blocked_tasks": [],
|
||
|
|
"open_workstreams": [],
|
||
|
|
}
|
||
|
|
|
||
|
|
def test_build_context_returns_string(self):
|
||
|
|
ctx = build_context(self._minimal_state(), "## Constitution\nBe safe.")
|
||
|
|
assert isinstance(ctx, str)
|
||
|
|
|
||
|
|
def test_build_context_includes_constitution(self):
|
||
|
|
ctx = build_context(self._minimal_state(), "## Constitution\nBe safe.")
|
||
|
|
assert "Be safe" in ctx
|
||
|
|
|
||
|
|
def test_build_context_includes_task_counts(self):
|
||
|
|
ctx = build_context(self._minimal_state(), "")
|
||
|
|
assert "3" in ctx # todo count
|
||
|
|
|
||
|
|
def test_build_context_mentions_blocked_tasks_when_present(self):
|
||
|
|
state = self._minimal_state()
|
||
|
|
state["blocked_tasks"] = [
|
||
|
|
{"id": "t1", "title": "Deploy postgres", "blocking_reason": "no cluster"}
|
||
|
|
]
|
||
|
|
ctx = build_context(state, "")
|
||
|
|
assert "Deploy postgres" in ctx or "blocked" in ctx.lower()
|
||
|
|
|
||
|
|
def test_build_context_mentions_blocking_decisions_when_present(self):
|
||
|
|
state = self._minimal_state()
|
||
|
|
state["blocking_decisions"] = [
|
||
|
|
{"id": "d1", "title": "Which DB?", "type": "pending"}
|
||
|
|
]
|
||
|
|
ctx = build_context(state, "")
|
||
|
|
assert "Which DB?" in ctx or "decision" in ctx.lower()
|
||
|
|
|
||
|
|
def test_build_context_with_empty_state_does_not_crash(self):
|
||
|
|
ctx = build_context({}, "some constitution")
|
||
|
|
assert isinstance(ctx, str)
|
||
|
|
|
||
|
|
def test_build_context_includes_json_response_instruction(self):
|
||
|
|
"""The prompt must instruct the LLM to return a JSON block."""
|
||
|
|
ctx = build_context(self._minimal_state(), "")
|
||
|
|
assert "```json" in ctx or "JSON" in ctx
|