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>
This commit is contained in:
244
tests/test_morrowind_api.py
Normal file
244
tests/test_morrowind_api.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user