1996 lines
74 KiB
Python
1996 lines
74 KiB
Python
"""Tests for GET /api/world/state endpoint and /api/world/ws relay."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from dashboard.routes.world import (
|
|
_GROUND_TTL,
|
|
_REMIND_AFTER,
|
|
_STALE_THRESHOLD,
|
|
_bark_and_broadcast,
|
|
_broadcast,
|
|
_build_commitment_context,
|
|
_build_matrix_agents_response,
|
|
_build_world_state,
|
|
_commitments,
|
|
_compute_circular_positions,
|
|
_conversation,
|
|
_extract_commitments,
|
|
_generate_bark,
|
|
_get_agent_color,
|
|
_get_agent_shape,
|
|
_handle_client_message,
|
|
_heartbeat,
|
|
_log_bark_failure,
|
|
_read_presence_file,
|
|
_record_commitments,
|
|
_refresh_ground,
|
|
_tick_commitments,
|
|
broadcast_world_state,
|
|
close_commitment,
|
|
get_commitments,
|
|
reset_commitments,
|
|
reset_conversation_ground,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_world_state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_world_state_maps_fields():
|
|
presence = {
|
|
"version": 1,
|
|
"liveness": "2026-03-19T02:00:00Z",
|
|
"mood": "exploring",
|
|
"current_focus": "reviewing PR",
|
|
"energy": 0.8,
|
|
"confidence": 0.9,
|
|
"active_threads": [{"type": "thinking", "ref": "test", "status": "active"}],
|
|
"recent_events": [],
|
|
"concerns": [],
|
|
}
|
|
result = _build_world_state(presence)
|
|
|
|
assert result["timmyState"]["mood"] == "exploring"
|
|
assert result["timmyState"]["activity"] == "reviewing PR"
|
|
assert result["timmyState"]["energy"] == 0.8
|
|
assert result["timmyState"]["confidence"] == 0.9
|
|
assert result["updatedAt"] == "2026-03-19T02:00:00Z"
|
|
assert result["version"] == 1
|
|
assert result["visitorPresent"] is False
|
|
assert len(result["activeThreads"]) == 1
|
|
|
|
|
|
def test_build_world_state_defaults():
|
|
"""Missing fields get safe defaults."""
|
|
result = _build_world_state({})
|
|
assert result["timmyState"]["mood"] == "calm"
|
|
assert result["timmyState"]["energy"] == 0.5
|
|
assert result["version"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _read_presence_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_read_presence_file_missing(tmp_path):
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"):
|
|
assert _read_presence_file() is None
|
|
|
|
|
|
def test_read_presence_file_stale(tmp_path):
|
|
f = tmp_path / "presence.json"
|
|
f.write_text(json.dumps({"version": 1}))
|
|
# Backdate the file
|
|
stale_time = time.time() - _STALE_THRESHOLD - 10
|
|
import os
|
|
|
|
os.utime(f, (stale_time, stale_time))
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", f):
|
|
assert _read_presence_file() is None
|
|
|
|
|
|
def test_read_presence_file_fresh(tmp_path):
|
|
f = tmp_path / "presence.json"
|
|
f.write_text(json.dumps({"version": 1, "mood": "focused"}))
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", f):
|
|
result = _read_presence_file()
|
|
assert result is not None
|
|
assert result["version"] == 1
|
|
|
|
|
|
def test_read_presence_file_bad_json(tmp_path):
|
|
f = tmp_path / "presence.json"
|
|
f.write_text("not json {{{")
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", f):
|
|
assert _read_presence_file() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full endpoint via TestClient
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
app = FastAPI()
|
|
from dashboard.routes.world import router
|
|
|
|
app.include_router(router)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_world_state_endpoint_with_file(client, tmp_path):
|
|
"""Endpoint returns data from presence file when fresh."""
|
|
f = tmp_path / "presence.json"
|
|
f.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"liveness": "2026-03-19T02:00:00Z",
|
|
"mood": "exploring",
|
|
"current_focus": "testing",
|
|
"active_threads": [],
|
|
"recent_events": [],
|
|
"concerns": [],
|
|
}
|
|
)
|
|
)
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", f):
|
|
resp = client.get("/api/world/state")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["timmyState"]["mood"] == "exploring"
|
|
assert data["timmyState"]["activity"] == "testing"
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
|
|
def test_world_state_endpoint_fallback(client, tmp_path):
|
|
"""Endpoint falls back to live state when file missing."""
|
|
with (
|
|
patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"),
|
|
patch("timmy.workshop_state.get_state_dict") as mock_get,
|
|
):
|
|
mock_get.return_value = {
|
|
"version": 1,
|
|
"liveness": "2026-03-19T02:00:00Z",
|
|
"mood": "calm",
|
|
"current_focus": "",
|
|
"active_threads": [],
|
|
"recent_events": [],
|
|
"concerns": [],
|
|
}
|
|
resp = client.get("/api/world/state")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["timmyState"]["mood"] == "calm"
|
|
|
|
|
|
def test_world_state_endpoint_full_fallback(client, tmp_path):
|
|
"""Endpoint returns safe defaults when everything fails."""
|
|
with (
|
|
patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"),
|
|
patch(
|
|
"timmy.workshop_state.get_state_dict",
|
|
side_effect=RuntimeError("boom"),
|
|
),
|
|
):
|
|
resp = client.get("/api/world/state")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["timmyState"]["mood"] == "calm"
|
|
assert data["version"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# broadcast_world_state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_world_state_sends_timmy_state():
|
|
"""broadcast_world_state sends timmy_state JSON to connected clients."""
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
ws = AsyncMock()
|
|
_ws_clients.append(ws)
|
|
try:
|
|
presence = {
|
|
"version": 1,
|
|
"mood": "exploring",
|
|
"current_focus": "testing",
|
|
"energy": 0.8,
|
|
"confidence": 0.9,
|
|
}
|
|
await broadcast_world_state(presence)
|
|
|
|
ws.send_text.assert_called_once()
|
|
msg = json.loads(ws.send_text.call_args[0][0])
|
|
assert msg["type"] == "timmy_state"
|
|
assert msg["mood"] == "exploring"
|
|
assert msg["activity"] == "testing"
|
|
finally:
|
|
_ws_clients.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_world_state_removes_dead_clients():
|
|
"""Dead WebSocket connections are cleaned up on broadcast."""
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
dead_ws = AsyncMock()
|
|
dead_ws.send_text.side_effect = ConnectionError("gone")
|
|
_ws_clients.append(dead_ws)
|
|
try:
|
|
await broadcast_world_state({"mood": "idle"})
|
|
assert dead_ws not in _ws_clients
|
|
finally:
|
|
_ws_clients.clear()
|
|
|
|
|
|
def test_world_ws_endpoint_accepts_connection(client):
|
|
"""WebSocket endpoint at /api/world/ws accepts connections."""
|
|
with client.websocket_connect("/api/world/ws"):
|
|
pass # Connection accepted — just close it
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket Authentication Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWebSocketAuth:
|
|
"""Tests for WebSocket token-based authentication."""
|
|
|
|
def test_ws_auth_disabled_when_token_unset(self, client):
|
|
"""When matrix_ws_token is empty, auth is disabled (dev mode)."""
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = ""
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
# Should receive world_state without auth
|
|
msg = json.loads(ws.receive_text())
|
|
assert msg["type"] == "world_state"
|
|
|
|
def test_ws_valid_token_via_query_param(self, client):
|
|
"""Valid token via ?token= query param allows connection."""
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "secret123"
|
|
with client.websocket_connect("/api/world/ws?token=secret123") as ws:
|
|
# Should receive connection_ack first
|
|
ack = json.loads(ws.receive_text())
|
|
assert ack["type"] == "connection_ack"
|
|
# Then world_state
|
|
msg = json.loads(ws.receive_text())
|
|
assert msg["type"] == "world_state"
|
|
|
|
def test_ws_valid_token_via_first_message(self, client):
|
|
"""Valid token via first auth message allows connection."""
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "secret123"
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
# Send auth message
|
|
ws.send_text(json.dumps({"type": "auth", "token": "secret123"}))
|
|
# Should receive connection_ack
|
|
ack = json.loads(ws.receive_text())
|
|
assert ack["type"] == "connection_ack"
|
|
# Then world_state
|
|
msg = json.loads(ws.receive_text())
|
|
assert msg["type"] == "world_state"
|
|
|
|
def test_ws_invalid_token_via_query_param(self, client):
|
|
"""Invalid token via ?token= closes connection with code 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "secret123"
|
|
# When auth fails with query param, accept() is called then close()
|
|
# The test client raises WebSocketDisconnect on close
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
with client.websocket_connect("/api/world/ws?token=wrongtoken") as ws:
|
|
# Try to receive - should trigger the close
|
|
ws.receive_text()
|
|
assert exc_info.value.code == 4001
|
|
|
|
def test_ws_invalid_token_via_first_message(self, client):
|
|
"""Invalid token via first message closes connection with code 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "secret123"
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
# Send invalid auth message
|
|
ws.send_text(json.dumps({"type": "auth", "token": "wrongtoken"}))
|
|
# Connection should close with 4001
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
ws.receive_text()
|
|
assert exc_info.value.code == 4001
|
|
|
|
def test_ws_no_token_when_auth_required(self, client):
|
|
"""No token when auth is required closes connection with code 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "secret123"
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
# Send non-auth message without token
|
|
ws.send_text(json.dumps({"type": "visitor_message", "text": "hello"}))
|
|
# Connection should close with 4001
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
ws.receive_text()
|
|
assert exc_info.value.code == 4001
|
|
|
|
def test_ws_non_json_first_message_when_auth_required(self, client):
|
|
"""Non-JSON first message when auth required closes with 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "secret123"
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
# Send non-JSON message
|
|
ws.send_text("not json")
|
|
# Connection should close with 4001
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
ws.receive_text()
|
|
assert exc_info.value.code == 4001
|
|
|
|
def test_ws_existing_behavior_unchanged_when_token_not_configured(self, client, tmp_path):
|
|
"""Existing /api/world/ws behavior unchanged when token not configured."""
|
|
f = tmp_path / "presence.json"
|
|
f.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"liveness": "2026-03-19T02:00:00Z",
|
|
"mood": "exploring",
|
|
"current_focus": "testing",
|
|
"active_threads": [],
|
|
"recent_events": [],
|
|
"concerns": [],
|
|
}
|
|
)
|
|
)
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.matrix_ws_token = "" # Not configured
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", f):
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
# Should receive world_state directly (no connection_ack)
|
|
msg = json.loads(ws.receive_text())
|
|
assert msg["type"] == "world_state"
|
|
assert msg["timmyState"]["mood"] == "exploring"
|
|
|
|
|
|
def test_world_ws_sends_snapshot_on_connect(client, tmp_path):
|
|
"""WebSocket sends a world_state snapshot immediately on connect."""
|
|
f = tmp_path / "presence.json"
|
|
f.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"liveness": "2026-03-19T02:00:00Z",
|
|
"mood": "exploring",
|
|
"current_focus": "testing",
|
|
"active_threads": [],
|
|
"recent_events": [],
|
|
"concerns": [],
|
|
}
|
|
)
|
|
)
|
|
with patch("dashboard.routes.world.PRESENCE_FILE", f):
|
|
with client.websocket_connect("/api/world/ws") as ws:
|
|
msg = json.loads(ws.receive_text())
|
|
|
|
assert msg["type"] == "world_state"
|
|
assert msg["timmyState"]["mood"] == "exploring"
|
|
assert msg["timmyState"]["activity"] == "testing"
|
|
assert "updatedAt" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Visitor chat — bark engine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_client_message_ignores_non_json():
|
|
"""Non-JSON messages are silently ignored."""
|
|
await _handle_client_message("not json") # should not raise
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_client_message_ignores_unknown_type():
|
|
"""Unknown message types are ignored."""
|
|
await _handle_client_message(json.dumps({"type": "unknown"}))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_client_message_ignores_empty_text():
|
|
"""Empty visitor_message text is ignored."""
|
|
await _handle_client_message(json.dumps({"type": "visitor_message", "text": " "}))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_bark_returns_response():
|
|
"""_generate_bark returns the chat response."""
|
|
reset_conversation_ground()
|
|
with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
|
|
mock_chat.return_value = "Woof! Good to see you."
|
|
result = await _generate_bark("Hey Timmy!")
|
|
|
|
assert result == "Woof! Good to see you."
|
|
mock_chat.assert_called_once_with("Hey Timmy!", session_id="workshop")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_bark_fallback_on_error():
|
|
"""_generate_bark returns canned response when chat fails."""
|
|
reset_conversation_ground()
|
|
with patch(
|
|
"timmy.session.chat",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("no model"),
|
|
):
|
|
result = await _generate_bark("Hello?")
|
|
|
|
assert "tangled" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bark_and_broadcast_sends_thinking_then_speech():
|
|
"""_bark_and_broadcast sends thinking indicator then speech."""
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
ws = AsyncMock()
|
|
_ws_clients.append(ws)
|
|
_conversation.clear()
|
|
reset_conversation_ground()
|
|
try:
|
|
with patch(
|
|
"timmy.session.chat",
|
|
new_callable=AsyncMock,
|
|
return_value="All good here!",
|
|
):
|
|
await _bark_and_broadcast("How are you?")
|
|
|
|
# Should have sent two messages: thinking + speech
|
|
assert ws.send_text.call_count == 2
|
|
thinking = json.loads(ws.send_text.call_args_list[0][0][0])
|
|
speech = json.loads(ws.send_text.call_args_list[1][0][0])
|
|
|
|
assert thinking["type"] == "timmy_thinking"
|
|
assert speech["type"] == "timmy_speech"
|
|
assert speech["text"] == "All good here!"
|
|
assert len(speech["recentExchanges"]) == 1
|
|
assert speech["recentExchanges"][0]["visitor"] == "How are you?"
|
|
finally:
|
|
_ws_clients.clear()
|
|
_conversation.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_removes_dead_clients():
|
|
"""Dead clients are cleaned up during broadcast."""
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
dead = AsyncMock()
|
|
dead.send_text.side_effect = ConnectionError("gone")
|
|
_ws_clients.append(dead)
|
|
try:
|
|
await _broadcast(json.dumps({"type": "timmy_speech", "text": "test"}))
|
|
assert dead not in _ws_clients
|
|
finally:
|
|
_ws_clients.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conversation_buffer_caps_at_max():
|
|
"""Conversation buffer only keeps the last _MAX_EXCHANGES entries."""
|
|
from dashboard.routes.world import _MAX_EXCHANGES, _ws_clients
|
|
|
|
ws = AsyncMock()
|
|
_ws_clients.append(ws)
|
|
_conversation.clear()
|
|
reset_conversation_ground()
|
|
try:
|
|
with patch(
|
|
"timmy.session.chat",
|
|
new_callable=AsyncMock,
|
|
return_value="reply",
|
|
):
|
|
for i in range(_MAX_EXCHANGES + 2):
|
|
await _bark_and_broadcast(f"msg {i}")
|
|
|
|
assert len(_conversation) == _MAX_EXCHANGES
|
|
# Oldest messages should have been evicted
|
|
assert _conversation[0]["visitor"] == f"msg {_MAX_EXCHANGES + 2 - _MAX_EXCHANGES}"
|
|
finally:
|
|
_ws_clients.clear()
|
|
_conversation.clear()
|
|
|
|
|
|
def test_log_bark_failure_logs_exception(caplog):
|
|
"""_log_bark_failure logs errors from failed bark tasks."""
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
async def _fail():
|
|
raise RuntimeError("bark boom")
|
|
|
|
task = loop.create_task(_fail())
|
|
loop.run_until_complete(asyncio.sleep(0.01))
|
|
loop.close()
|
|
with caplog.at_level(logging.ERROR):
|
|
_log_bark_failure(task)
|
|
assert "bark boom" in caplog.text
|
|
|
|
|
|
def test_log_bark_failure_ignores_cancelled():
|
|
"""_log_bark_failure silently ignores cancelled tasks."""
|
|
|
|
task = MagicMock(spec=asyncio.Task)
|
|
task.cancelled.return_value = True
|
|
_log_bark_failure(task) # should not raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conversation grounding (#322)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConversationGrounding:
|
|
"""Tests for conversation grounding — prevent topic drift."""
|
|
|
|
def setup_method(self):
|
|
reset_conversation_ground()
|
|
|
|
def teardown_method(self):
|
|
reset_conversation_ground()
|
|
|
|
def test_refresh_ground_sets_topic_on_first_message(self):
|
|
"""First visitor message becomes the grounding anchor."""
|
|
import dashboard.routes.world as w
|
|
|
|
_refresh_ground("Tell me about the Bible")
|
|
assert w._ground_topic == "Tell me about the Bible"
|
|
assert w._ground_set_at > 0
|
|
|
|
def test_refresh_ground_keeps_topic_on_subsequent_messages(self):
|
|
"""Subsequent messages don't overwrite the anchor."""
|
|
import dashboard.routes.world as w
|
|
|
|
_refresh_ground("Tell me about the Bible")
|
|
_refresh_ground("What about Genesis?")
|
|
assert w._ground_topic == "Tell me about the Bible"
|
|
|
|
def test_refresh_ground_resets_after_ttl(self):
|
|
"""Anchor expires after _GROUND_TTL seconds of inactivity."""
|
|
import dashboard.routes.world as w
|
|
|
|
_refresh_ground("Tell me about the Bible")
|
|
# Simulate TTL expiry
|
|
w._ground_set_at = time.time() - _GROUND_TTL - 1
|
|
_refresh_ground("Now tell me about cooking")
|
|
assert w._ground_topic == "Now tell me about cooking"
|
|
|
|
def test_refresh_ground_truncates_long_messages(self):
|
|
"""Anchor text is capped at 120 characters."""
|
|
import dashboard.routes.world as w
|
|
|
|
long_msg = "x" * 200
|
|
_refresh_ground(long_msg)
|
|
assert len(w._ground_topic) == 120
|
|
|
|
def test_reset_conversation_ground_clears_state(self):
|
|
"""reset_conversation_ground clears the anchor."""
|
|
import dashboard.routes.world as w
|
|
|
|
_refresh_ground("Some topic")
|
|
reset_conversation_ground()
|
|
assert w._ground_topic is None
|
|
assert w._ground_set_at == 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_bark_prepends_ground_topic(self):
|
|
"""When grounded, the topic is prepended to the visitor message."""
|
|
_refresh_ground("Tell me about prayer")
|
|
with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
|
|
mock_chat.return_value = "Great question!"
|
|
await _generate_bark("What else can you share?")
|
|
|
|
call_text = mock_chat.call_args[0][0]
|
|
assert "[Workshop conversation topic: Tell me about prayer]" in call_text
|
|
assert "What else can you share?" in call_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_bark_no_prefix_for_first_message(self):
|
|
"""First message (which IS the anchor) is not prefixed."""
|
|
_refresh_ground("Tell me about prayer")
|
|
with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat:
|
|
mock_chat.return_value = "Sure!"
|
|
await _generate_bark("Tell me about prayer")
|
|
|
|
call_text = mock_chat.call_args[0][0]
|
|
assert "[Workshop conversation topic:" not in call_text
|
|
assert call_text == "Tell me about prayer"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bark_and_broadcast_sets_ground(self):
|
|
"""_bark_and_broadcast sets the ground topic automatically."""
|
|
import dashboard.routes.world as w
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
ws = AsyncMock()
|
|
_ws_clients.append(ws)
|
|
_conversation.clear()
|
|
try:
|
|
with patch(
|
|
"timmy.session.chat",
|
|
new_callable=AsyncMock,
|
|
return_value="Interesting!",
|
|
):
|
|
await _bark_and_broadcast("What is grace?")
|
|
assert w._ground_topic == "What is grace?"
|
|
finally:
|
|
_ws_clients.clear()
|
|
_conversation.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conversation grounding — commitment tracking (rescued from PR #408)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=False)
|
|
def _clean_commitments():
|
|
"""Reset commitments before and after each commitment test."""
|
|
reset_commitments()
|
|
yield
|
|
reset_commitments()
|
|
|
|
|
|
class TestExtractCommitments:
|
|
def test_extracts_ill_pattern(self):
|
|
text = "I'll draft the skeleton ticket in 30 minutes."
|
|
result = _extract_commitments(text)
|
|
assert len(result) == 1
|
|
assert "draft the skeleton ticket" in result[0]
|
|
|
|
def test_extracts_i_will_pattern(self):
|
|
result = _extract_commitments("I will review that PR tomorrow.")
|
|
assert len(result) == 1
|
|
assert "review that PR tomorrow" in result[0]
|
|
|
|
def test_extracts_let_me_pattern(self):
|
|
result = _extract_commitments("Let me write up a summary for you.")
|
|
assert len(result) == 1
|
|
assert "write up a summary" in result[0]
|
|
|
|
def test_skips_short_matches(self):
|
|
result = _extract_commitments("I'll do it.")
|
|
# "do it" is 5 chars — should be skipped (needs > 5)
|
|
assert result == []
|
|
|
|
def test_no_commitments_in_normal_text(self):
|
|
result = _extract_commitments("The weather is nice today.")
|
|
assert result == []
|
|
|
|
def test_truncates_long_commitments(self):
|
|
long_phrase = "a" * 200
|
|
result = _extract_commitments(f"I'll {long_phrase}.")
|
|
assert len(result) == 1
|
|
assert len(result[0]) == 120
|
|
|
|
|
|
class TestRecordCommitments:
|
|
def test_records_new_commitment(self, _clean_commitments):
|
|
_record_commitments("I'll draft the ticket now.")
|
|
assert len(get_commitments()) == 1
|
|
assert get_commitments()[0]["messages_since"] == 0
|
|
|
|
def test_avoids_duplicate_commitments(self, _clean_commitments):
|
|
_record_commitments("I'll draft the ticket now.")
|
|
_record_commitments("I'll draft the ticket now.")
|
|
assert len(get_commitments()) == 1
|
|
|
|
def test_caps_at_max(self, _clean_commitments):
|
|
from dashboard.routes.world import _MAX_COMMITMENTS
|
|
|
|
for i in range(_MAX_COMMITMENTS + 3):
|
|
_record_commitments(f"I'll handle commitment number {i} right away.")
|
|
assert len(get_commitments()) <= _MAX_COMMITMENTS
|
|
|
|
|
|
class TestTickAndContext:
|
|
def test_tick_increments_messages_since(self, _clean_commitments):
|
|
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
|
|
_tick_commitments()
|
|
_tick_commitments()
|
|
assert _commitments[0]["messages_since"] == 2
|
|
|
|
def test_context_empty_when_no_overdue(self, _clean_commitments):
|
|
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
|
|
assert _build_commitment_context() == ""
|
|
|
|
def test_context_surfaces_overdue_commitments(self, _clean_commitments):
|
|
_commitments.append(
|
|
{
|
|
"text": "draft the skeleton ticket",
|
|
"created_at": 0,
|
|
"messages_since": _REMIND_AFTER,
|
|
}
|
|
)
|
|
ctx = _build_commitment_context()
|
|
assert "draft the skeleton ticket" in ctx
|
|
assert "Open commitments" in ctx
|
|
|
|
def test_context_only_includes_overdue(self, _clean_commitments):
|
|
_commitments.append({"text": "recent thing", "created_at": 0, "messages_since": 1})
|
|
_commitments.append(
|
|
{
|
|
"text": "old thing",
|
|
"created_at": 0,
|
|
"messages_since": _REMIND_AFTER,
|
|
}
|
|
)
|
|
ctx = _build_commitment_context()
|
|
assert "old thing" in ctx
|
|
assert "recent thing" not in ctx
|
|
|
|
|
|
class TestCloseCommitment:
|
|
def test_close_valid_index(self, _clean_commitments):
|
|
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
|
|
assert close_commitment(0) is True
|
|
assert len(get_commitments()) == 0
|
|
|
|
def test_close_invalid_index(self, _clean_commitments):
|
|
assert close_commitment(99) is False
|
|
|
|
|
|
class TestGroundingIntegration:
|
|
@pytest.mark.asyncio
|
|
async def test_bark_records_commitments_from_reply(self, _clean_commitments):
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
ws = AsyncMock()
|
|
_ws_clients.append(ws)
|
|
_conversation.clear()
|
|
try:
|
|
with patch(
|
|
"timmy.session.chat",
|
|
new_callable=AsyncMock,
|
|
return_value="I'll draft the ticket for you!",
|
|
):
|
|
await _bark_and_broadcast("Can you help?")
|
|
|
|
assert len(get_commitments()) == 1
|
|
assert "draft the ticket" in get_commitments()[0]["text"]
|
|
finally:
|
|
_ws_clients.clear()
|
|
_conversation.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bark_prepends_context_after_n_messages(self, _clean_commitments):
|
|
"""After _REMIND_AFTER messages, commitment context is prepended."""
|
|
_commitments.append(
|
|
{
|
|
"text": "draft the skeleton ticket",
|
|
"created_at": 0,
|
|
"messages_since": _REMIND_AFTER - 1,
|
|
}
|
|
)
|
|
|
|
with patch(
|
|
"timmy.session.chat",
|
|
new_callable=AsyncMock,
|
|
return_value="Sure thing!",
|
|
) as mock_chat:
|
|
# This tick will push messages_since to _REMIND_AFTER
|
|
await _generate_bark("Any updates?")
|
|
# _generate_bark doesn't tick — _bark_and_broadcast does.
|
|
# But we pre-set messages_since to _REMIND_AFTER - 1,
|
|
# so we need to tick once to make it overdue.
|
|
_tick_commitments()
|
|
await _generate_bark("Any updates?")
|
|
|
|
# Second call should have context prepended
|
|
last_call = mock_chat.call_args_list[-1]
|
|
sent_text = last_call[0][0]
|
|
assert "draft the skeleton ticket" in sent_text
|
|
assert "Open commitments" in sent_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket heartbeat ping (rescued from PR #399)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_sends_ping():
|
|
"""Heartbeat sends a ping JSON frame after the interval elapses."""
|
|
ws = AsyncMock()
|
|
|
|
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
|
# Let the first sleep complete, then raise to exit the loop
|
|
call_count = 0
|
|
|
|
async def sleep_side_effect(_interval):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count > 1:
|
|
raise ConnectionError("stop")
|
|
|
|
mock_sleep.side_effect = sleep_side_effect
|
|
await _heartbeat(ws)
|
|
|
|
ws.send_text.assert_called_once()
|
|
msg = json.loads(ws.send_text.call_args[0][0])
|
|
assert msg["type"] == "ping"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_exits_on_dead_connection():
|
|
"""Heartbeat exits cleanly when the WebSocket is dead."""
|
|
ws = AsyncMock()
|
|
ws.send_text.side_effect = ConnectionError("gone")
|
|
|
|
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock):
|
|
await _heartbeat(ws) # should not raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix Agent Registry (/api/matrix/agents)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMatrixAgentRegistry:
|
|
"""Tests for the Matrix agent registry endpoint."""
|
|
|
|
def test_get_agent_color_known_agents(self):
|
|
"""Known agents return their assigned colors."""
|
|
assert _get_agent_color("timmy") == "#FFD700" # Gold
|
|
assert _get_agent_color("orchestrator") == "#FFD700" # Gold
|
|
assert _get_agent_color("kimi") == "#06B6D4" # Cyan
|
|
assert _get_agent_color("claude") == "#A855F7" # Purple
|
|
assert _get_agent_color("researcher") == "#10B981" # Emerald
|
|
assert _get_agent_color("coder") == "#EF4444" # Red
|
|
|
|
def test_get_agent_color_unknown_agent(self):
|
|
"""Unknown agents return the default gray color."""
|
|
assert _get_agent_color("unknown") == "#9CA3AF"
|
|
assert _get_agent_color("xyz") == "#9CA3AF"
|
|
|
|
def test_get_agent_color_case_insensitive(self):
|
|
"""Agent ID lookup is case insensitive."""
|
|
assert _get_agent_color("Timmy") == "#FFD700"
|
|
assert _get_agent_color("KIMI") == "#06B6D4"
|
|
|
|
def test_get_agent_shape_known_agents(self):
|
|
"""Known agents return their assigned shapes."""
|
|
assert _get_agent_shape("timmy") == "sphere"
|
|
assert _get_agent_shape("coder") == "cube"
|
|
assert _get_agent_shape("writer") == "cone"
|
|
|
|
def test_get_agent_shape_unknown_agent(self):
|
|
"""Unknown agents return the default sphere shape."""
|
|
assert _get_agent_shape("unknown") == "sphere"
|
|
|
|
def test_compute_circular_positions(self):
|
|
"""Agents are arranged in a circle on the XZ plane."""
|
|
positions = _compute_circular_positions(4, radius=3.0)
|
|
assert len(positions) == 4
|
|
# All positions should have y=0
|
|
for pos in positions:
|
|
assert pos["y"] == 0.0
|
|
assert "x" in pos
|
|
assert "z" in pos
|
|
# First position should be at angle 0 (x=radius, z=0)
|
|
assert positions[0]["x"] == 3.0
|
|
assert positions[0]["z"] == 0.0
|
|
|
|
def test_compute_circular_positions_empty(self):
|
|
"""Zero agents returns empty positions list."""
|
|
positions = _compute_circular_positions(0)
|
|
assert positions == []
|
|
|
|
def test_build_matrix_agents_response_structure(self):
|
|
"""Response contains all required fields for each agent."""
|
|
with patch("timmy.agents.loader.list_agents") as mock_list:
|
|
mock_list.return_value = [
|
|
{"id": "timmy", "name": "Timmy", "role": "orchestrator", "status": "available"},
|
|
{"id": "researcher", "name": "Seer", "role": "research", "status": "busy"},
|
|
]
|
|
result = _build_matrix_agents_response()
|
|
|
|
assert len(result) == 2
|
|
# Check first agent
|
|
assert result[0]["id"] == "timmy"
|
|
assert result[0]["display_name"] == "Timmy"
|
|
assert result[0]["role"] == "orchestrator"
|
|
assert result[0]["color"] == "#FFD700"
|
|
assert result[0]["shape"] == "sphere"
|
|
assert result[0]["status"] == "available"
|
|
assert "position" in result[0]
|
|
assert "x" in result[0]["position"]
|
|
assert "y" in result[0]["position"]
|
|
assert "z" in result[0]["position"]
|
|
|
|
def test_build_matrix_agents_response_empty(self):
|
|
"""Returns empty list when no agents configured."""
|
|
with patch("timmy.agents.loader.list_agents") as mock_list:
|
|
mock_list.return_value = []
|
|
result = _build_matrix_agents_response()
|
|
assert result == []
|
|
|
|
def test_build_matrix_agents_response_handles_errors(self):
|
|
"""Returns empty list when loader fails."""
|
|
with patch("timmy.agents.loader.list_agents") as mock_list:
|
|
mock_list.side_effect = RuntimeError("Loader failed")
|
|
result = _build_matrix_agents_response()
|
|
assert result == []
|
|
|
|
|
|
@pytest.fixture
|
|
def matrix_client():
|
|
"""TestClient with matrix router."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
app = FastAPI()
|
|
from dashboard.routes.world import matrix_router
|
|
|
|
app.include_router(matrix_router)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_matrix_agents_endpoint_returns_json(matrix_client):
|
|
"""GET /api/matrix/agents returns JSON list."""
|
|
with patch("timmy.agents.loader.list_agents") as mock_list:
|
|
mock_list.return_value = [
|
|
{"id": "timmy", "name": "Timmy", "role": "orchestrator", "status": "available"},
|
|
]
|
|
resp = matrix_client.get("/api/matrix/agents")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 1
|
|
assert data[0]["id"] == "timmy"
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
|
|
def test_matrix_agents_endpoint_empty_list(matrix_client):
|
|
"""Endpoint returns 200 with empty list when no agents."""
|
|
with patch("timmy.agents.loader.list_agents") as mock_list:
|
|
mock_list.return_value = []
|
|
resp = matrix_client.get("/api/matrix/agents")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
|
|
def test_matrix_agents_endpoint_graceful_degradation(matrix_client):
|
|
"""Endpoint returns empty list when loader fails."""
|
|
with patch("timmy.agents.loader.list_agents") as mock_list:
|
|
mock_list.side_effect = FileNotFoundError("agents.yaml not found")
|
|
resp = matrix_client.get("/api/matrix/agents")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix Configuration Endpoint (/api/matrix/config)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMatrixConfigEndpoint:
|
|
"""Tests for the Matrix configuration endpoint."""
|
|
|
|
def test_matrix_config_endpoint_returns_json(self, matrix_client):
|
|
"""GET /api/matrix/config returns JSON config."""
|
|
resp = matrix_client.get("/api/matrix/config")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, dict)
|
|
assert "lighting" in data
|
|
assert "environment" in data
|
|
assert "features" in data
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
def test_matrix_config_lighting_structure(self, matrix_client):
|
|
"""Config has correct lighting section structure."""
|
|
resp = matrix_client.get("/api/matrix/config")
|
|
data = resp.json()
|
|
|
|
lighting = data["lighting"]
|
|
assert "ambient_color" in lighting
|
|
assert "ambient_intensity" in lighting
|
|
assert "point_lights" in lighting
|
|
assert isinstance(lighting["point_lights"], list)
|
|
|
|
# Check first point light structure
|
|
if lighting["point_lights"]:
|
|
pl = lighting["point_lights"][0]
|
|
assert "color" in pl
|
|
assert "intensity" in pl
|
|
assert "position" in pl
|
|
assert "x" in pl["position"]
|
|
assert "y" in pl["position"]
|
|
assert "z" in pl["position"]
|
|
|
|
def test_matrix_config_environment_structure(self, matrix_client):
|
|
"""Config has correct environment section structure."""
|
|
resp = matrix_client.get("/api/matrix/config")
|
|
data = resp.json()
|
|
|
|
env = data["environment"]
|
|
assert "rain_enabled" in env
|
|
assert "starfield_enabled" in env
|
|
assert "fog_color" in env
|
|
assert "fog_density" in env
|
|
assert isinstance(env["rain_enabled"], bool)
|
|
assert isinstance(env["starfield_enabled"], bool)
|
|
|
|
def test_matrix_config_features_structure(self, matrix_client):
|
|
"""Config has correct features section structure."""
|
|
resp = matrix_client.get("/api/matrix/config")
|
|
data = resp.json()
|
|
|
|
features = data["features"]
|
|
assert "chat_enabled" in features
|
|
assert "visitor_avatars" in features
|
|
assert "pip_familiar" in features
|
|
assert "workshop_portal" in features
|
|
assert isinstance(features["chat_enabled"], bool)
|
|
|
|
|
|
class TestMatrixConfigLoading:
|
|
"""Tests for _load_matrix_config function."""
|
|
|
|
def test_load_matrix_config_returns_dict(self):
|
|
"""_load_matrix_config returns a dictionary."""
|
|
from dashboard.routes.world import _load_matrix_config
|
|
|
|
config = _load_matrix_config()
|
|
assert isinstance(config, dict)
|
|
assert "lighting" in config
|
|
assert "environment" in config
|
|
assert "features" in config
|
|
|
|
def test_load_matrix_config_has_all_required_sections(self):
|
|
"""Config contains all required sections."""
|
|
from dashboard.routes.world import _load_matrix_config
|
|
|
|
config = _load_matrix_config()
|
|
lighting = config["lighting"]
|
|
env = config["environment"]
|
|
features = config["features"]
|
|
|
|
# Lighting fields
|
|
assert "ambient_color" in lighting
|
|
assert "ambient_intensity" in lighting
|
|
assert "point_lights" in lighting
|
|
|
|
# Environment fields
|
|
assert "rain_enabled" in env
|
|
assert "starfield_enabled" in env
|
|
assert "fog_color" in env
|
|
assert "fog_density" in env
|
|
|
|
# Features fields
|
|
assert "chat_enabled" in features
|
|
assert "visitor_avatars" in features
|
|
assert "pip_familiar" in features
|
|
assert "workshop_portal" in features
|
|
|
|
def test_load_matrix_config_fallback_on_missing_file(self, tmp_path):
|
|
"""Returns defaults when matrix.yaml is missing."""
|
|
from dashboard.routes.world import _load_matrix_config
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.repo_root = str(tmp_path)
|
|
config = _load_matrix_config()
|
|
|
|
# Should return defaults
|
|
assert config["lighting"]["ambient_color"] == "#1a1a2e"
|
|
assert config["environment"]["rain_enabled"] is False
|
|
assert config["features"]["chat_enabled"] is True
|
|
|
|
def test_load_matrix_config_merges_with_defaults(self, tmp_path):
|
|
"""Partial config file is merged with defaults."""
|
|
from dashboard.routes.world import _load_matrix_config
|
|
|
|
# Create a partial config file
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
config_file = config_dir / "matrix.yaml"
|
|
config_file.write_text("""
|
|
lighting:
|
|
ambient_color: "#ff0000"
|
|
ambient_intensity: 0.8
|
|
environment:
|
|
rain_enabled: true
|
|
""")
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.repo_root = str(tmp_path)
|
|
config = _load_matrix_config()
|
|
|
|
# Custom values
|
|
assert config["lighting"]["ambient_color"] == "#ff0000"
|
|
assert config["lighting"]["ambient_intensity"] == 0.8
|
|
assert config["environment"]["rain_enabled"] is True
|
|
|
|
# Defaults preserved
|
|
assert config["features"]["chat_enabled"] is True
|
|
assert config["environment"]["starfield_enabled"] is True
|
|
assert len(config["lighting"]["point_lights"]) == 3
|
|
|
|
def test_load_matrix_config_handles_invalid_yaml(self, tmp_path):
|
|
"""Returns defaults when YAML is invalid."""
|
|
from dashboard.routes.world import _load_matrix_config
|
|
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
config_file = config_dir / "matrix.yaml"
|
|
config_file.write_text("not: valid: yaml: [{")
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.repo_root = str(tmp_path)
|
|
config = _load_matrix_config()
|
|
|
|
# Should return defaults despite invalid YAML
|
|
assert "lighting" in config
|
|
assert "environment" in config
|
|
assert "features" in config
|
|
|
|
def test_load_matrix_config_custom_point_lights(self, tmp_path):
|
|
"""Custom point lights override defaults completely."""
|
|
from dashboard.routes.world import _load_matrix_config
|
|
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
config_file = config_dir / "matrix.yaml"
|
|
config_file.write_text("""
|
|
lighting:
|
|
point_lights:
|
|
- color: "#FFFFFF"
|
|
intensity: 2.0
|
|
position: { x: 1, y: 2, z: 3 }
|
|
""")
|
|
|
|
with patch("dashboard.routes.world.settings") as mock_settings:
|
|
mock_settings.repo_root = str(tmp_path)
|
|
config = _load_matrix_config()
|
|
|
|
# Should have custom point lights, not defaults
|
|
lights = config["lighting"]["point_lights"]
|
|
assert len(lights) == 1
|
|
assert lights[0]["color"] == "#FFFFFF"
|
|
assert lights[0]["intensity"] == 2.0
|
|
assert lights[0]["position"] == {"x": 1, "y": 2, "z": 3}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix Thoughts Endpoint (/api/matrix/thoughts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMatrixThoughtsEndpoint:
|
|
"""Tests for the Matrix thoughts endpoint."""
|
|
|
|
def test_thoughts_endpoint_returns_json(self, matrix_client):
|
|
"""GET /api/matrix/thoughts returns JSON list."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = [
|
|
MagicMock(
|
|
id="test-1",
|
|
content="First thought",
|
|
created_at="2026-03-21T10:00:00Z",
|
|
parent_id=None,
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 1
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
def test_thoughts_endpoint_default_limit(self, matrix_client):
|
|
"""Default limit is 10 thoughts."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = []
|
|
matrix_client.get("/api/matrix/thoughts")
|
|
|
|
# Should call with default limit of 10
|
|
mock_engine.get_recent_thoughts.assert_called_once_with(limit=10)
|
|
|
|
def test_thoughts_endpoint_custom_limit(self, matrix_client):
|
|
"""Custom limit can be specified via query param."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = []
|
|
matrix_client.get("/api/matrix/thoughts?limit=25")
|
|
|
|
mock_engine.get_recent_thoughts.assert_called_once_with(limit=25)
|
|
|
|
def test_thoughts_endpoint_max_limit_capped(self, matrix_client):
|
|
"""Limit is capped at 50 maximum."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = []
|
|
matrix_client.get("/api/matrix/thoughts?limit=100")
|
|
|
|
mock_engine.get_recent_thoughts.assert_called_once_with(limit=50)
|
|
|
|
def test_thoughts_endpoint_min_limit(self, matrix_client):
|
|
"""Limit less than 1 is clamped to 1."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = []
|
|
matrix_client.get("/api/matrix/thoughts?limit=0")
|
|
|
|
mock_engine.get_recent_thoughts.assert_called_once_with(limit=1)
|
|
|
|
def test_thoughts_endpoint_response_structure(self, matrix_client):
|
|
"""Response has all required fields with correct types."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = [
|
|
MagicMock(
|
|
id="thought-uuid-1",
|
|
content="This is a test thought",
|
|
created_at="2026-03-21T10:00:00Z",
|
|
parent_id="parent-uuid",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
thought = data[0]
|
|
assert thought["id"] == "thought-uuid-1"
|
|
assert thought["text"] == "This is a test thought"
|
|
assert thought["created_at"] == "2026-03-21T10:00:00Z"
|
|
assert thought["chain_id"] == "parent-uuid"
|
|
|
|
def test_thoughts_endpoint_null_parent_id(self, matrix_client):
|
|
"""Root thoughts have chain_id as null."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = [
|
|
MagicMock(
|
|
id="root-thought",
|
|
content="Root thought",
|
|
created_at="2026-03-21T10:00:00Z",
|
|
parent_id=None,
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
data = resp.json()
|
|
assert data[0]["chain_id"] is None
|
|
|
|
def test_thoughts_endpoint_text_truncation(self, matrix_client):
|
|
"""Text is truncated to 500 characters."""
|
|
long_content = "A" * 1000
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = [
|
|
MagicMock(
|
|
id="long-thought",
|
|
content=long_content,
|
|
created_at="2026-03-21T10:00:00Z",
|
|
parent_id=None,
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
data = resp.json()
|
|
assert len(data[0]["text"]) == 500
|
|
assert data[0]["text"] == "A" * 500
|
|
|
|
def test_thoughts_endpoint_empty_list(self, matrix_client):
|
|
"""Endpoint returns 200 with empty list when no thoughts."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = []
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
def test_thoughts_endpoint_graceful_degradation(self, matrix_client):
|
|
"""Returns empty list when thinking engine fails."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.side_effect = RuntimeError("Engine down")
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
def test_thoughts_endpoint_multiple_thoughts(self, matrix_client):
|
|
"""Multiple thoughts are returned in order from thinking engine."""
|
|
with patch("timmy.thinking.thinking_engine") as mock_engine:
|
|
mock_engine.get_recent_thoughts.return_value = [
|
|
MagicMock(
|
|
id="t1",
|
|
content="First",
|
|
created_at="2026-03-21T10:00:00Z",
|
|
parent_id=None,
|
|
),
|
|
MagicMock(
|
|
id="t2",
|
|
content="Second",
|
|
created_at="2026-03-21T10:01:00Z",
|
|
parent_id="t1",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/thoughts")
|
|
|
|
data = resp.json()
|
|
assert len(data) == 2
|
|
assert data[0]["id"] == "t1"
|
|
assert data[1]["id"] == "t2"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix Bark Endpoint (/api/matrix/bark)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMatrixBarkEndpoint:
|
|
"""Tests for the Matrix bark HTTP fallback endpoint."""
|
|
|
|
def setup_method(self):
|
|
"""Reset rate limiting state before each test."""
|
|
from dashboard.routes.world import _bark_last_request
|
|
|
|
_bark_last_request.clear()
|
|
|
|
def teardown_method(self):
|
|
"""Clean up rate limiting state after each test."""
|
|
from dashboard.routes.world import _bark_last_request
|
|
|
|
_bark_last_request.clear()
|
|
|
|
def test_bark_endpoint_requires_text(self, matrix_client):
|
|
"""POST /api/matrix/bark returns 422 if text is missing."""
|
|
resp = matrix_client.post("/api/matrix/bark", json={"text": "", "visitor_id": "test123"})
|
|
|
|
assert resp.status_code == 422
|
|
data = resp.json()
|
|
assert "error" in data
|
|
|
|
def test_bark_endpoint_requires_visitor_id(self, matrix_client):
|
|
"""POST /api/matrix/bark returns 422 if visitor_id is missing."""
|
|
resp = matrix_client.post("/api/matrix/bark", json={"text": "Hello", "visitor_id": ""})
|
|
|
|
assert resp.status_code == 422
|
|
data = resp.json()
|
|
assert "error" in data
|
|
|
|
def test_bark_endpoint_returns_bark_format(self, matrix_client):
|
|
"""POST /api/matrix/bark returns bark in produce_bark format."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value="Woof! Hello there!",
|
|
):
|
|
resp = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Hey Timmy!", "visitor_id": "visitor_123"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# Check produce_bark format
|
|
assert data["type"] == "bark"
|
|
assert data["agent_id"] == "timmy"
|
|
assert "data" in data
|
|
assert data["data"]["text"] == "Woof! Hello there!"
|
|
assert data["data"]["style"] == "speech"
|
|
assert "ts" in data
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
def test_bark_endpoint_uses_generate_bark(self, matrix_client):
|
|
"""POST /api/matrix/bark uses _generate_bark for response generation."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value="Generated response",
|
|
) as mock_generate:
|
|
resp = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Test message", "visitor_id": "v1"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
mock_generate.assert_called_once_with("Test message")
|
|
|
|
def test_bark_endpoint_rate_limit_blocks_second_request(self, matrix_client):
|
|
"""Second request within 3 seconds returns 429."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value="Response",
|
|
):
|
|
# First request should succeed
|
|
resp1 = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "First", "visitor_id": "rate_test_visitor"},
|
|
)
|
|
assert resp1.status_code == 200
|
|
|
|
# Second request within 3 seconds should be rate limited
|
|
resp2 = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Second", "visitor_id": "rate_test_visitor"},
|
|
)
|
|
assert resp2.status_code == 429
|
|
data = resp2.json()
|
|
assert "error" in data
|
|
assert "Rate limit" in data["error"]
|
|
|
|
def test_bark_endpoint_rate_limit_per_visitor(self, matrix_client):
|
|
"""Rate limiting is per-visitor_id, not global."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value="Response",
|
|
):
|
|
# First visitor makes a request
|
|
resp1 = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Hello", "visitor_id": "visitor_a"},
|
|
)
|
|
assert resp1.status_code == 200
|
|
|
|
# Different visitor can still make a request
|
|
resp2 = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Hello", "visitor_id": "visitor_b"},
|
|
)
|
|
assert resp2.status_code == 200
|
|
|
|
def test_bark_endpoint_rate_limit_retry_after_header(self, matrix_client):
|
|
"""429 response includes Retry-After header."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value="Response",
|
|
):
|
|
# First request
|
|
matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "First", "visitor_id": "retry_test_visitor"},
|
|
)
|
|
|
|
# Second request (rate limited)
|
|
resp = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Second", "visitor_id": "retry_test_visitor"},
|
|
)
|
|
|
|
assert resp.status_code == 429
|
|
assert "Retry-After" in resp.headers
|
|
# Should be approximately 3 seconds
|
|
retry_after = int(resp.headers["Retry-After"])
|
|
assert 1 <= retry_after <= 4
|
|
|
|
def test_bark_endpoint_graceful_fallback_on_error(self, matrix_client):
|
|
"""When _generate_bark fails, returns graceful fallback."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("LLM unavailable"),
|
|
):
|
|
resp = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Hello", "visitor_id": "error_test_visitor"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["type"] == "bark"
|
|
assert "tangled" in data["data"]["text"]
|
|
|
|
def test_bark_endpoint_strips_whitespace(self, matrix_client):
|
|
"""Whitespace is stripped from text and visitor_id."""
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value="Response",
|
|
) as mock_generate:
|
|
resp = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": " Hello Timmy! ", "visitor_id": " visitor_123 "},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
# Should be called with stripped text
|
|
mock_generate.assert_called_once_with("Hello Timmy!")
|
|
|
|
def test_bark_endpoint_response_truncation(self, matrix_client):
|
|
"""Long responses are truncated to 280 characters."""
|
|
long_response = "A" * 500
|
|
with patch(
|
|
"dashboard.routes.world._generate_bark",
|
|
new_callable=AsyncMock,
|
|
return_value=long_response,
|
|
):
|
|
resp = matrix_client.post(
|
|
"/api/matrix/bark",
|
|
json={"text": "Tell me a long story", "visitor_id": "long_test"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["data"]["text"]) == 280
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix Memory Search Endpoint (/api/matrix/memory/search)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMatrixMemorySearchEndpoint:
|
|
"""Tests for the Matrix memory search endpoint."""
|
|
|
|
def setup_method(self):
|
|
"""Reset rate limiting state before each test."""
|
|
from dashboard.routes.world import _memory_search_last_request
|
|
|
|
_memory_search_last_request.clear()
|
|
|
|
def teardown_method(self):
|
|
"""Clean up rate limiting state after each test."""
|
|
from dashboard.routes.world import _memory_search_last_request
|
|
|
|
_memory_search_last_request.clear()
|
|
|
|
def test_memory_search_requires_query(self, matrix_client):
|
|
"""GET /api/matrix/memory/search returns 400 if q is missing."""
|
|
resp = matrix_client.get("/api/matrix/memory/search")
|
|
|
|
assert resp.status_code == 400
|
|
data = resp.json()
|
|
assert "error" in data
|
|
assert "'q' is required" in data["error"]
|
|
|
|
def test_memory_search_rejects_empty_query(self, matrix_client):
|
|
"""GET /api/matrix/memory/search returns 400 if q is empty."""
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=")
|
|
|
|
assert resp.status_code == 400
|
|
data = resp.json()
|
|
assert "error" in data
|
|
|
|
def test_memory_search_rejects_whitespace_query(self, matrix_client):
|
|
"""GET /api/matrix/memory/search returns 400 if q is whitespace."""
|
|
resp = matrix_client.get("/api/matrix/memory/search?q= ")
|
|
|
|
assert resp.status_code == 400
|
|
data = resp.json()
|
|
assert "error" in data
|
|
|
|
def test_memory_search_returns_json_array(self, matrix_client):
|
|
"""GET /api/matrix/memory/search returns JSON array of results."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = [
|
|
MagicMock(
|
|
content="Bitcoin is a decentralized digital currency",
|
|
relevance_score=0.92,
|
|
timestamp="2026-03-21T10:00:00Z",
|
|
context_type="conversation",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=bitcoin")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 1
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
def test_memory_search_result_structure(self, matrix_client):
|
|
"""Each result has required fields with correct types."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = [
|
|
MagicMock(
|
|
content="Bitcoin sovereignty content here",
|
|
relevance_score=0.85,
|
|
timestamp="2026-03-21T10:00:00Z",
|
|
context_type="fact",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=sovereignty")
|
|
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
result = data[0]
|
|
assert "text" in result
|
|
assert "relevance" in result
|
|
assert "created_at" in result
|
|
assert "context_type" in result
|
|
assert isinstance(result["text"], str)
|
|
assert isinstance(result["relevance"], (int, float))
|
|
assert isinstance(result["created_at"], str)
|
|
assert isinstance(result["context_type"], str)
|
|
|
|
def test_memory_search_text_truncation(self, matrix_client):
|
|
"""Text is truncated to 200 characters with ellipsis."""
|
|
long_content = "A" * 300
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = [
|
|
MagicMock(
|
|
content=long_content,
|
|
relevance_score=0.75,
|
|
timestamp="2026-03-21T10:00:00Z",
|
|
context_type="conversation",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=test")
|
|
|
|
data = resp.json()
|
|
assert len(data[0]["text"]) == 203 # 200 chars + "..."
|
|
assert data[0]["text"].endswith("...")
|
|
|
|
def test_memory_search_relevance_rounding(self, matrix_client):
|
|
"""Relevance score is rounded to 4 decimal places."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = [
|
|
MagicMock(
|
|
content="Test content",
|
|
relevance_score=0.123456789,
|
|
timestamp="2026-03-21T10:00:00Z",
|
|
context_type="conversation",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=test")
|
|
|
|
data = resp.json()
|
|
# Should be rounded to 4 decimal places
|
|
assert data[0]["relevance"] == 0.1235
|
|
|
|
def test_memory_search_max_results(self, matrix_client):
|
|
"""Endpoint returns max 5 results."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
# Return more than 5 results
|
|
mock_search.return_value = [
|
|
MagicMock(
|
|
content=f"Memory {i}",
|
|
relevance_score=0.9 - (i * 0.05),
|
|
timestamp="2026-03-21T10:00:00Z",
|
|
context_type="conversation",
|
|
)
|
|
for i in range(10)
|
|
]
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=test")
|
|
|
|
data = resp.json()
|
|
# Should be limited to 5 results
|
|
assert len(data) <= 5
|
|
|
|
def test_memory_search_passes_limit_to_search(self, matrix_client):
|
|
"""Endpoint passes correct limit to search_memories."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
matrix_client.get("/api/matrix/memory/search?q=bitcoin")
|
|
|
|
mock_search.assert_called_once_with("bitcoin", limit=5)
|
|
|
|
def test_memory_search_empty_results(self, matrix_client):
|
|
"""Endpoint returns empty array when no memories found."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=nonexistent")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
def test_memory_search_graceful_degradation(self, matrix_client):
|
|
"""Returns empty array when search fails."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.side_effect = RuntimeError("Database error")
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=test")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
def test_memory_search_rate_limit_blocks_second_request(self, matrix_client):
|
|
"""Second request within 5 seconds returns 429."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
# First request should succeed
|
|
resp1 = matrix_client.get("/api/matrix/memory/search?q=first")
|
|
assert resp1.status_code == 200
|
|
|
|
# Second request within 5 seconds should be rate limited
|
|
resp2 = matrix_client.get("/api/matrix/memory/search?q=second")
|
|
assert resp2.status_code == 429
|
|
data = resp2.json()
|
|
assert "error" in data
|
|
assert "Rate limit" in data["error"]
|
|
|
|
def test_memory_search_rate_limit_per_ip(self, matrix_client):
|
|
"""Rate limiting is per-IP address."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
# First request from one IP
|
|
resp1 = matrix_client.get(
|
|
"/api/matrix/memory/search?q=test",
|
|
headers={"X-Forwarded-For": "1.2.3.4"},
|
|
)
|
|
assert resp1.status_code == 200
|
|
|
|
# Same request from different IP should succeed
|
|
resp2 = matrix_client.get(
|
|
"/api/matrix/memory/search?q=test",
|
|
headers={"X-Forwarded-For": "5.6.7.8"},
|
|
)
|
|
assert resp2.status_code == 200
|
|
|
|
def test_memory_search_rate_limit_uses_client_host(self, matrix_client):
|
|
"""Rate limiting falls back to client.host when no X-Forwarded-For."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
# First request
|
|
resp1 = matrix_client.get("/api/matrix/memory/search?q=first")
|
|
assert resp1.status_code == 200
|
|
|
|
# Second request should be rate limited (same client)
|
|
resp2 = matrix_client.get("/api/matrix/memory/search?q=second")
|
|
assert resp2.status_code == 429
|
|
|
|
def test_memory_search_rate_limit_retry_after_header(self, matrix_client):
|
|
"""429 response includes Retry-After header."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
# First request
|
|
matrix_client.get("/api/matrix/memory/search?q=first")
|
|
|
|
# Second request (rate limited)
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=second")
|
|
|
|
assert resp.status_code == 429
|
|
assert "Retry-After" in resp.headers
|
|
retry_after = int(resp.headers["Retry-After"])
|
|
assert 1 <= retry_after <= 6 # Should be around 5 seconds
|
|
|
|
def test_memory_search_multiple_results_ordering(self, matrix_client):
|
|
"""Results maintain order from search_memories."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = [
|
|
MagicMock(
|
|
content="First memory",
|
|
relevance_score=0.95,
|
|
timestamp="2026-03-21T10:00:00Z",
|
|
context_type="fact",
|
|
),
|
|
MagicMock(
|
|
content="Second memory",
|
|
relevance_score=0.85,
|
|
timestamp="2026-03-21T10:01:00Z",
|
|
context_type="conversation",
|
|
),
|
|
]
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=test")
|
|
|
|
data = resp.json()
|
|
assert len(data) == 2
|
|
assert data[0]["text"] == "First memory"
|
|
assert data[1]["text"] == "Second memory"
|
|
|
|
def test_memory_search_url_encoding(self, matrix_client):
|
|
"""Query parameter can be URL encoded."""
|
|
with patch("dashboard.routes.world.search_memories") as mock_search:
|
|
mock_search.return_value = []
|
|
resp = matrix_client.get("/api/matrix/memory/search?q=bitcoin%20sovereignty")
|
|
|
|
assert resp.status_code == 200
|
|
mock_search.assert_called_once_with("bitcoin sovereignty", limit=5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix Health Endpoint (/api/matrix/health)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMatrixHealthEndpoint:
|
|
"""Tests for the Matrix health endpoint."""
|
|
|
|
def test_health_endpoint_returns_json(self, matrix_client):
|
|
"""GET /api/matrix/health returns JSON response."""
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, dict)
|
|
assert "status" in data
|
|
assert "version" in data
|
|
assert "capabilities" in data
|
|
assert resp.headers["cache-control"] == "no-cache, no-store"
|
|
|
|
def test_health_endpoint_status_field(self, matrix_client):
|
|
"""Response contains status field with valid values."""
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
data = resp.json()
|
|
|
|
assert data["status"] in ["ok", "degraded"]
|
|
|
|
def test_health_endpoint_version_field(self, matrix_client):
|
|
"""Response contains version string."""
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
data = resp.json()
|
|
|
|
assert isinstance(data["version"], str)
|
|
assert len(data["version"]) > 0
|
|
|
|
def test_health_endpoint_capabilities_structure(self, matrix_client):
|
|
"""Capabilities dict has all required features."""
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
data = resp.json()
|
|
|
|
caps = data["capabilities"]
|
|
assert isinstance(caps, dict)
|
|
|
|
# All required capabilities
|
|
required_caps = ["thinking", "memory", "bark", "familiar", "lightning"]
|
|
for cap in required_caps:
|
|
assert cap in caps, f"Missing capability: {cap}"
|
|
assert isinstance(caps[cap], bool), f"Capability {cap} should be bool"
|
|
|
|
def test_health_endpoint_returns_200_when_degraded(self, matrix_client):
|
|
"""Returns 200 even when some capabilities are unavailable."""
|
|
# Mock all capability checks to return False
|
|
with patch.multiple(
|
|
"dashboard.routes.world",
|
|
_check_capability_thinking=lambda: False,
|
|
_check_capability_memory=lambda: False,
|
|
_check_capability_bark=lambda: False,
|
|
_check_capability_familiar=lambda: False,
|
|
_check_capability_lightning=lambda: False,
|
|
):
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "degraded"
|
|
|
|
def test_health_endpoint_ok_when_core_caps_available(self, matrix_client):
|
|
"""Status is 'ok' when core capabilities are available."""
|
|
with patch.multiple(
|
|
"dashboard.routes.world",
|
|
_check_capability_thinking=lambda: True,
|
|
_check_capability_memory=lambda: True,
|
|
_check_capability_bark=lambda: True,
|
|
_check_capability_familiar=lambda: False,
|
|
_check_capability_lightning=lambda: False,
|
|
):
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "ok"
|
|
assert data["capabilities"]["thinking"] is True
|
|
assert data["capabilities"]["memory"] is True
|
|
assert data["capabilities"]["bark"] is True
|
|
assert data["capabilities"]["familiar"] is False
|
|
assert data["capabilities"]["lightning"] is False
|
|
|
|
|
|
class TestMatrixHealthCapabilityChecks:
|
|
"""Tests for individual capability check functions."""
|
|
|
|
def test_check_thinking_returns_true_when_available(self):
|
|
"""_check_capability_thinking returns True when thinking engine is available."""
|
|
from dashboard.routes.world import _check_capability_thinking
|
|
|
|
# Mock thinking engine with _db attribute
|
|
mock_engine = MagicMock()
|
|
mock_engine._db = "/path/to/thoughts.db"
|
|
|
|
with patch("timmy.thinking.thinking_engine", mock_engine):
|
|
result = _check_capability_thinking()
|
|
|
|
assert result is True
|
|
|
|
def test_check_thinking_returns_false_when_no_db(self):
|
|
"""_check_capability_thinking returns False when _db is None."""
|
|
from dashboard.routes.world import _check_capability_thinking
|
|
|
|
mock_engine = MagicMock()
|
|
mock_engine._db = None
|
|
|
|
with patch("timmy.thinking.thinking_engine", mock_engine):
|
|
result = _check_capability_thinking()
|
|
|
|
assert result is False
|
|
|
|
def test_check_memory_returns_true_when_hot_memory_exists(self, tmp_path):
|
|
"""_check_capability_memory returns True when HOT_MEMORY_PATH exists."""
|
|
from dashboard.routes.world import _check_capability_memory
|
|
|
|
# Create a temporary memory path
|
|
mock_path = MagicMock()
|
|
mock_path.exists.return_value = True
|
|
|
|
with patch("timmy.memory_system.HOT_MEMORY_PATH", mock_path):
|
|
result = _check_capability_memory()
|
|
|
|
assert result is True
|
|
|
|
def test_check_memory_returns_false_when_not_exists(self):
|
|
"""_check_capability_memory returns False when HOT_MEMORY_PATH doesn't exist."""
|
|
from dashboard.routes.world import _check_capability_memory
|
|
|
|
mock_path = MagicMock()
|
|
mock_path.exists.return_value = False
|
|
|
|
with patch("timmy.memory_system.HOT_MEMORY_PATH", mock_path):
|
|
result = _check_capability_memory()
|
|
|
|
assert result is False
|
|
|
|
def test_check_bark_returns_true_when_produce_bark_available(self):
|
|
"""_check_capability_bark returns True when produce_bark is callable."""
|
|
from dashboard.routes.world import _check_capability_bark
|
|
|
|
with patch(
|
|
"infrastructure.presence.produce_bark",
|
|
return_value={"type": "bark"},
|
|
):
|
|
result = _check_capability_bark()
|
|
|
|
assert result is True
|
|
|
|
def test_check_familiar_returns_true_when_pip_available(self):
|
|
"""_check_capability_familiar returns True when pip_familiar is available."""
|
|
from dashboard.routes.world import _check_capability_familiar
|
|
|
|
mock_pip = MagicMock()
|
|
|
|
with patch("timmy.familiar.pip_familiar", mock_pip):
|
|
result = _check_capability_familiar()
|
|
|
|
assert result is True
|
|
|
|
def test_check_familiar_returns_false_when_pip_none(self):
|
|
"""_check_capability_familiar returns False when pip_familiar is None."""
|
|
from dashboard.routes.world import _check_capability_familiar
|
|
|
|
with patch("timmy.familiar.pip_familiar", None):
|
|
result = _check_capability_familiar()
|
|
|
|
assert result is False
|
|
|
|
def test_check_lightning_returns_false(self):
|
|
"""_check_capability_lightning returns False (disabled per health.py)."""
|
|
from dashboard.routes.world import _check_capability_lightning
|
|
|
|
result = _check_capability_lightning()
|
|
|
|
assert result is False
|
|
|
|
def test_health_response_time_is_fast(self, matrix_client):
|
|
"""Health endpoint responds quickly (<100ms for lightweight checks)."""
|
|
import time
|
|
|
|
start = time.time()
|
|
resp = matrix_client.get("/api/matrix/health")
|
|
elapsed = time.time() - start
|
|
|
|
assert resp.status_code == 200
|
|
# Should be very fast since checks are lightweight
|
|
assert elapsed < 1.0 # Generous timeout for test environments
|