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
228 lines
8.1 KiB
Python
228 lines
8.1 KiB
Python
"""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
|