forked from Rockachopa/Timmy-time-dashboard
## FastAPI Morrowind Harness (#821) - GET /api/v1/morrowind/perception — reads perception.json, validates against PerceptionOutput schema - POST /api/v1/morrowind/command — validates CommandInput, logs via CommandLogger, stubs bridge forwarding - GET /api/v1/morrowind/status — connection state, last perception, queue depth, agent vitals - Router registered in dashboard app alongside existing routers - 12 tests with FastAPI TestClient ## SOUL.md Framework (#854) - docs/soul-framework/ — template, authoring guide, role extensions - src/infrastructure/soul/loader.py — parse SOUL.md into structured SoulDocument objects with section extraction and merge support - src/infrastructure/soul/validator.py — validate structure, detect contradictions between values/constraints - src/infrastructure/soul/versioning.py — hash-based version tracking with JSON persistence - 27 tests covering loader, validator, versioning, and Timmy's soul Builds on PR #864 (protocol spec + command log). Closes #821 Closes #854
This commit is contained in:
227
tests/test_morrowind_api.py
Normal file
227
tests/test_morrowind_api.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Tests for the Morrowind FastAPI harness endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.infrastructure.morrowind.api import (
|
||||
MorrowindStatus,
|
||||
router,
|
||||
)
|
||||
from src.infrastructure.morrowind.schemas import (
|
||||
CommandType,
|
||||
PerceptionOutput,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOW = datetime(2026, 3, 21, 14, 30, 0, tzinfo=UTC)
|
||||
|
||||
SAMPLE_PERCEPTION = {
|
||||
"timestamp": NOW.isoformat(),
|
||||
"agent_id": "timmy",
|
||||
"location": {"cell": "Balmora", "x": 1024.5, "y": -512.3, "z": 64.0, "interior": False},
|
||||
"health": {"current": 85, "max": 100},
|
||||
"nearby_entities": [],
|
||||
"inventory_summary": {"gold": 42, "item_count": 5, "encumbrance_pct": 0.3},
|
||||
"active_quests": [{"quest_id": "mq_01", "name": "Report to Caius", "stage": 10}],
|
||||
"environment": {
|
||||
"time_of_day": "afternoon",
|
||||
"weather": "clear",
|
||||
"is_combat": False,
|
||||
"is_dialogue": False,
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_COMMAND = {
|
||||
"timestamp": NOW.isoformat(),
|
||||
"agent_id": "timmy",
|
||||
"command": "move_to",
|
||||
"params": {"target_cell": "Balmora", "target_x": 1050.0},
|
||||
"reasoning": "Moving closer to the quest target.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def morrowind_app():
|
||||
"""Create a minimal FastAPI app with only the morrowind router."""
|
||||
import src.infrastructure.morrowind.api as api_mod
|
||||
|
||||
# Reset module-level state between tests.
|
||||
api_mod._command_logger = None
|
||||
api_mod._command_queue.clear()
|
||||
api_mod._last_perception = None
|
||||
api_mod._last_perception_ts = None
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(morrowind_app):
|
||||
with TestClient(morrowind_app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /perception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetPerception:
|
||||
def test_returns_perception_when_file_exists(self, client, tmp_path):
|
||||
pfile = tmp_path / "perception.json"
|
||||
pfile.write_text(json.dumps(SAMPLE_PERCEPTION), encoding="utf-8")
|
||||
|
||||
with patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", pfile):
|
||||
resp = client.get("/api/v1/morrowind/perception")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["agent_id"] == "timmy"
|
||||
assert data["location"]["cell"] == "Balmora"
|
||||
assert data["health"]["current"] == 85
|
||||
|
||||
def test_404_when_file_missing(self, client, tmp_path):
|
||||
missing = tmp_path / "does_not_exist.json"
|
||||
|
||||
with patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", missing):
|
||||
resp = client.get("/api/v1/morrowind/perception")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_500_when_file_is_invalid_json(self, client, tmp_path):
|
||||
pfile = tmp_path / "perception.json"
|
||||
pfile.write_text("not json", encoding="utf-8")
|
||||
|
||||
with patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", pfile):
|
||||
resp = client.get("/api/v1/morrowind/perception")
|
||||
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_422_when_schema_invalid(self, client, tmp_path):
|
||||
pfile = tmp_path / "perception.json"
|
||||
# Missing required fields.
|
||||
pfile.write_text(json.dumps({"agent_id": "timmy"}), encoding="utf-8")
|
||||
|
||||
with patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", pfile):
|
||||
resp = client.get("/api/v1/morrowind/perception")
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostCommand:
|
||||
def test_accepts_valid_command(self, client, tmp_path):
|
||||
# Provide a CommandLogger backed by a temp DB.
|
||||
from src.infrastructure.morrowind.command_log import CommandLogger
|
||||
|
||||
db_url = f"sqlite:///{tmp_path / 'cmd.db'}"
|
||||
mock_logger = CommandLogger(db_url=db_url)
|
||||
|
||||
with patch("src.infrastructure.morrowind.api._get_command_logger", return_value=mock_logger):
|
||||
resp = client.post("/api/v1/morrowind/command", json=SAMPLE_COMMAND)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "accepted"
|
||||
assert "command_id" in data
|
||||
assert data["command_id"] >= 1
|
||||
|
||||
def test_rejects_invalid_command_type(self, client):
|
||||
bad_cmd = {**SAMPLE_COMMAND, "command": "fly_to_moon"}
|
||||
resp = client.post("/api/v1/morrowind/command", json=bad_cmd)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_rejects_empty_reasoning(self, client):
|
||||
bad_cmd = {**SAMPLE_COMMAND, "reasoning": ""}
|
||||
resp = client.post("/api/v1/morrowind/command", json=bad_cmd)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_rejects_missing_fields(self, client):
|
||||
resp = client.post("/api/v1/morrowind/command", json={"command": "noop"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_command_queued_for_forwarding(self, client, tmp_path):
|
||||
import src.infrastructure.morrowind.api as api_mod
|
||||
from src.infrastructure.morrowind.command_log import CommandLogger
|
||||
|
||||
db_url = f"sqlite:///{tmp_path / 'cmd.db'}"
|
||||
mock_logger = CommandLogger(db_url=db_url)
|
||||
|
||||
with patch("src.infrastructure.morrowind.api._get_command_logger", return_value=mock_logger):
|
||||
client.post("/api/v1/morrowind/command", json=SAMPLE_COMMAND)
|
||||
|
||||
assert len(api_mod._command_queue) == 1
|
||||
assert api_mod._command_queue[0]["command"]["command"] == "move_to"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /morrowind/status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetStatus:
|
||||
def test_status_when_no_perception(self, client, tmp_path):
|
||||
missing = tmp_path / "no_such_file.json"
|
||||
with patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", missing):
|
||||
resp = client.get("/api/v1/morrowind/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["connected"] is False
|
||||
assert data["last_perception_at"] is None
|
||||
assert data["command_queue_depth"] == 0
|
||||
assert data["current_cell"] is None
|
||||
|
||||
def test_status_after_perception_read(self, client, tmp_path):
|
||||
pfile = tmp_path / "perception.json"
|
||||
pfile.write_text(json.dumps(SAMPLE_PERCEPTION), encoding="utf-8")
|
||||
|
||||
with patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", pfile):
|
||||
# First read perception to populate cache.
|
||||
client.get("/api/v1/morrowind/perception")
|
||||
# Then check status.
|
||||
resp = client.get("/api/v1/morrowind/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["connected"] is True
|
||||
assert data["last_perception_at"] is not None
|
||||
assert data["current_cell"] == "Balmora"
|
||||
assert data["vitals"]["health_current"] == 85
|
||||
assert data["vitals"]["health_max"] == 100
|
||||
|
||||
def test_status_includes_queue_depth(self, client, tmp_path):
|
||||
import src.infrastructure.morrowind.api as api_mod
|
||||
from src.infrastructure.morrowind.command_log import CommandLogger
|
||||
|
||||
db_url = f"sqlite:///{tmp_path / 'cmd.db'}"
|
||||
mock_logger = CommandLogger(db_url=db_url)
|
||||
|
||||
with (
|
||||
patch("src.infrastructure.morrowind.api._get_command_logger", return_value=mock_logger),
|
||||
patch("src.infrastructure.morrowind.api.PERCEPTION_FILE", tmp_path / "nope.json"),
|
||||
):
|
||||
# Submit two commands.
|
||||
client.post("/api/v1/morrowind/command", json=SAMPLE_COMMAND)
|
||||
client.post("/api/v1/morrowind/command", json=SAMPLE_COMMAND)
|
||||
|
||||
resp = client.get("/api/v1/morrowind/status")
|
||||
|
||||
assert resp.json()["command_queue_depth"] == 2
|
||||
Reference in New Issue
Block a user