forked from Rockachopa/Timmy-time-dashboard
## FastAPI Morrowind Harness (#821) - GET /api/v1/morrowind/perception — reads perception.json, validates against PerceptionOutput - POST /api/v1/morrowind/command — validates CommandInput, logs via CommandLogger, stubs bridge - GET /api/v1/morrowind/status — connection state, last perception, queue depth, vitals - Router registered in dashboard app.py ## SOUL.md Framework (#854) - Template, authoring guide, and role extensions docs in docs/soul-framework/ - SoulLoader: parse SOUL.md files into structured SoulDocument - SoulValidator: check required sections, detect contradictions, validate structure - SoulVersioner: hash-based change detection and JSONL history tracking - 39 tests covering all endpoints and framework components Depends on #864 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
8.2 KiB
Python
245 lines
8.2 KiB
Python
"""Tests for the Morrowind FastAPI harness endpoints.
|
|
|
|
Covers:
|
|
- GET /api/v1/morrowind/perception
|
|
- POST /api/v1/morrowind/command
|
|
- GET /api/v1/morrowind/status
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from infrastructure.morrowind.api import router, _get_command_logger
|
|
from infrastructure.morrowind import api as api_module
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SAMPLE_PERCEPTION = {
|
|
"protocol_version": "1.0.0",
|
|
"timestamp": "2024-06-15T10:30:00Z",
|
|
"agent_id": "timmy",
|
|
"location": {
|
|
"cell": "Balmora, Guild of Mages",
|
|
"x": 1234.5,
|
|
"y": 6789.0,
|
|
"z": 0.0,
|
|
"interior": True,
|
|
},
|
|
"health": {"current": 80, "max": 100},
|
|
"nearby_entities": [
|
|
{
|
|
"entity_id": "npc_001",
|
|
"name": "Ranis Athrys",
|
|
"entity_type": "npc",
|
|
"distance": 5.2,
|
|
"disposition": 65,
|
|
}
|
|
],
|
|
"inventory_summary": {
|
|
"gold": 250,
|
|
"item_count": 12,
|
|
"encumbrance_pct": 0.45,
|
|
},
|
|
"active_quests": [
|
|
{"quest_id": "mq_01", "name": "A Mysterious Note", "stage": 2}
|
|
],
|
|
"environment": {
|
|
"time_of_day": "morning",
|
|
"weather": "clear",
|
|
"is_combat": False,
|
|
"is_dialogue": False,
|
|
},
|
|
}
|
|
|
|
SAMPLE_COMMAND = {
|
|
"protocol_version": "1.0.0",
|
|
"timestamp": "2024-06-15T10:31:00Z",
|
|
"agent_id": "timmy",
|
|
"command": "move_to",
|
|
"params": {"x": 1300.0, "y": 6800.0, "z": 0.0},
|
|
"reasoning": "Moving to the guild entrance to speak with the quest giver.",
|
|
"episode_id": "ep_001",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""Create a fresh FastAPI app with the morrowind router."""
|
|
test_app = FastAPI()
|
|
test_app.include_router(router)
|
|
return test_app
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""FastAPI test client."""
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def perception_file(tmp_path):
|
|
"""Write sample perception JSON to a temp file and patch the module path."""
|
|
p = tmp_path / "perception.json"
|
|
p.write_text(json.dumps(SAMPLE_PERCEPTION), encoding="utf-8")
|
|
with patch.object(api_module, "PERCEPTION_PATH", p):
|
|
yield p
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_command_logger():
|
|
"""Patch the command logger with a mock."""
|
|
mock_logger = MagicMock()
|
|
mock_logger.log_command.return_value = 42
|
|
mock_logger.count.return_value = 7
|
|
with patch.object(api_module, "_command_logger", mock_logger):
|
|
yield mock_logger
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /perception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetPerception:
|
|
def test_success(self, client, perception_file):
|
|
"""Perception endpoint returns validated data."""
|
|
response = client.get("/api/v1/morrowind/perception")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["agent_id"] == "timmy"
|
|
assert data["location"]["cell"] == "Balmora, Guild of Mages"
|
|
assert data["health"]["current"] == 80
|
|
assert data["health"]["max"] == 100
|
|
|
|
def test_file_not_found(self, client, tmp_path):
|
|
"""Returns 404 when perception file doesn't exist."""
|
|
missing = tmp_path / "nonexistent.json"
|
|
with patch.object(api_module, "PERCEPTION_PATH", missing):
|
|
response = client.get("/api/v1/morrowind/perception")
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
def test_invalid_json(self, client, tmp_path):
|
|
"""Returns 422 when perception file contains invalid JSON."""
|
|
bad_file = tmp_path / "bad.json"
|
|
bad_file.write_text("not json", encoding="utf-8")
|
|
with patch.object(api_module, "PERCEPTION_PATH", bad_file):
|
|
response = client.get("/api/v1/morrowind/perception")
|
|
assert response.status_code == 422
|
|
|
|
def test_schema_validation_failure(self, client, tmp_path):
|
|
"""Returns 500 when JSON doesn't match PerceptionOutput schema."""
|
|
bad_data = tmp_path / "bad_schema.json"
|
|
bad_data.write_text(json.dumps({"not": "valid"}), encoding="utf-8")
|
|
with patch.object(api_module, "PERCEPTION_PATH", bad_data):
|
|
response = client.get("/api/v1/morrowind/perception")
|
|
assert response.status_code == 500
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPostCommand:
|
|
def test_success(self, client, mock_command_logger, perception_file):
|
|
"""Command is accepted, logged, and returns a command_id."""
|
|
response = client.post(
|
|
"/api/v1/morrowind/command",
|
|
json=SAMPLE_COMMAND,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["command_id"] == 42
|
|
assert data["status"] == "accepted"
|
|
assert data["bridge_forwarded"] is False
|
|
|
|
mock_command_logger.log_command.assert_called_once()
|
|
|
|
def test_invalid_command_type(self, client, mock_command_logger):
|
|
"""Rejects commands with unknown command types."""
|
|
bad_command = {**SAMPLE_COMMAND, "command": "fly_to_moon"}
|
|
response = client.post(
|
|
"/api/v1/morrowind/command",
|
|
json=bad_command,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_missing_reasoning(self, client, mock_command_logger):
|
|
"""Rejects commands without a reasoning field."""
|
|
no_reasoning = {**SAMPLE_COMMAND}
|
|
del no_reasoning["reasoning"]
|
|
response = client.post(
|
|
"/api/v1/morrowind/command",
|
|
json=no_reasoning,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_empty_reasoning(self, client, mock_command_logger):
|
|
"""Rejects commands with empty reasoning."""
|
|
empty_reasoning = {**SAMPLE_COMMAND, "reasoning": ""}
|
|
response = client.post(
|
|
"/api/v1/morrowind/command",
|
|
json=empty_reasoning,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_log_failure(self, client, tmp_path):
|
|
"""Returns 500 when command logging fails."""
|
|
mock_logger = MagicMock()
|
|
mock_logger.log_command.side_effect = RuntimeError("DB unavailable")
|
|
missing = tmp_path / "no_perception.json"
|
|
with (
|
|
patch.object(api_module, "_command_logger", mock_logger),
|
|
patch.object(api_module, "PERCEPTION_PATH", missing),
|
|
):
|
|
response = client.post(
|
|
"/api/v1/morrowind/command",
|
|
json=SAMPLE_COMMAND,
|
|
)
|
|
assert response.status_code == 500
|
|
assert "log command" in response.json()["detail"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /morrowind/status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetStatus:
|
|
def test_connected(self, client, perception_file, mock_command_logger):
|
|
"""Status reports connected when perception file exists."""
|
|
response = client.get("/api/v1/morrowind/status")
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["connected"] is True
|
|
assert data["current_cell"] == "Balmora, Guild of Mages"
|
|
assert data["command_queue_depth"] == 7
|
|
assert data["vitals"]["health"] == "80/100"
|
|
|
|
def test_disconnected(self, client, tmp_path, mock_command_logger):
|
|
"""Status reports disconnected when perception file is missing."""
|
|
missing = tmp_path / "nonexistent.json"
|
|
with patch.object(api_module, "PERCEPTION_PATH", missing):
|
|
response = client.get("/api/v1/morrowind/status")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["connected"] is False
|
|
assert data["current_cell"] is None
|
|
assert data["last_perception_timestamp"] is None
|