Files
Timmy-time-dashboard/tests/test_morrowind_schemas.py
Perplexity Computer 215329146a
Some checks failed
Tests / lint (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
feat(morrowind): add Perception/Command protocol + SQLite command log (#859, #855)
Implement two foundational infrastructure pieces for the Morrowind integration:

1. Perception/Command Protocol (Issue #859):
   - Formal spec document with JSON schemas, API contracts, versioning strategy
   - Engine-agnostic design following the Falsework Rule
   - Pydantic v2 models (PerceptionOutput, CommandInput) for runtime validation

2. SQLite Command Log + Training Pipeline (Issue #855):
   - SQLAlchemy model for command_log table with full indexing
   - CommandLogger class with log_command(), query(), export_training_data()
   - TrainingExporter with chat-completion, episode, and instruction formats
   - Storage management (rotation/archival) utilities
   - Alembic migration for the new table

Includes 39 passing tests covering schema validation, logging, querying, and export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 22:33:13 +00:00

243 lines
7.7 KiB
Python

"""Tests for Morrowind Perception/Command protocol Pydantic schemas."""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from src.infrastructure.morrowind.schemas import (
PROTOCOL_VERSION,
CommandContext,
CommandInput,
CommandType,
EntityType,
Environment,
HealthStatus,
InventorySummary,
Location,
NearbyEntity,
PerceptionOutput,
QuestInfo,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
NOW = datetime(2026, 3, 21, 14, 30, 0, tzinfo=UTC)
def _make_perception(**overrides) -> PerceptionOutput:
defaults = {
"timestamp": NOW,
"agent_id": "timmy",
"location": {"cell": "Balmora", "x": 1024.5, "y": -512.3, "z": 64.0, "interior": False},
"health": {"current": 85, "max": 100},
}
defaults.update(overrides)
return PerceptionOutput(**defaults)
def _make_command(**overrides) -> CommandInput:
defaults = {
"timestamp": NOW,
"agent_id": "timmy",
"command": "move_to",
"params": {"target_cell": "Balmora", "target_x": 1050.0},
"reasoning": "Moving closer to the quest target.",
}
defaults.update(overrides)
return CommandInput(**defaults)
# ---------------------------------------------------------------------------
# PerceptionOutput tests
# ---------------------------------------------------------------------------
class TestPerceptionOutput:
def test_minimal_valid(self):
p = _make_perception()
assert p.protocol_version == PROTOCOL_VERSION
assert p.agent_id == "timmy"
assert p.location.cell == "Balmora"
assert p.health.current == 85
assert p.nearby_entities == []
assert p.active_quests == []
def test_full_payload(self):
p = _make_perception(
nearby_entities=[
{
"entity_id": "npc_001",
"name": "Caius Cosades",
"entity_type": "npc",
"distance": 12.5,
"disposition": 65,
}
],
inventory_summary={"gold": 150, "item_count": 23, "encumbrance_pct": 0.45},
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,
},
raw_engine_data={"tes3mp_version": "0.8.1"},
)
assert len(p.nearby_entities) == 1
assert p.nearby_entities[0].entity_type == EntityType.NPC
assert p.inventory_summary.gold == 150
assert p.active_quests[0].quest_id == "mq_01"
assert p.raw_engine_data["tes3mp_version"] == "0.8.1"
def test_serialization_roundtrip(self):
p = _make_perception()
json_str = p.model_dump_json()
p2 = PerceptionOutput.model_validate_json(json_str)
assert p2.location.cell == p.location.cell
assert p2.health.current == p.health.current
def test_missing_required_fields(self):
with pytest.raises(ValidationError):
PerceptionOutput(timestamp=NOW, agent_id="timmy") # no location/health
def test_default_protocol_version(self):
p = _make_perception()
assert p.protocol_version == "1.0.0"
# ---------------------------------------------------------------------------
# Health validation
# ---------------------------------------------------------------------------
class TestHealthStatus:
def test_current_cannot_exceed_max(self):
with pytest.raises(ValidationError, match="cannot exceed max"):
HealthStatus(current=150, max=100)
def test_max_must_be_positive(self):
with pytest.raises(ValidationError):
HealthStatus(current=0, max=0)
def test_current_can_be_zero(self):
h = HealthStatus(current=0, max=100)
assert h.current == 0
# ---------------------------------------------------------------------------
# Location
# ---------------------------------------------------------------------------
class TestLocation:
def test_defaults(self):
loc = Location(cell="Seyda Neen", x=0.0, y=0.0)
assert loc.z == 0.0
assert loc.interior is False
# ---------------------------------------------------------------------------
# NearbyEntity
# ---------------------------------------------------------------------------
class TestNearbyEntity:
def test_all_entity_types(self):
for et in EntityType:
e = NearbyEntity(entity_id="e1", name="Test", entity_type=et, distance=1.0)
assert e.entity_type == et
def test_invalid_entity_type(self):
with pytest.raises(ValidationError):
NearbyEntity(entity_id="e1", name="Test", entity_type="dragon", distance=1.0)
def test_negative_distance_rejected(self):
with pytest.raises(ValidationError):
NearbyEntity(entity_id="e1", name="Test", entity_type="npc", distance=-5.0)
# ---------------------------------------------------------------------------
# InventorySummary
# ---------------------------------------------------------------------------
class TestInventorySummary:
def test_encumbrance_bounds(self):
with pytest.raises(ValidationError):
InventorySummary(encumbrance_pct=1.5)
with pytest.raises(ValidationError):
InventorySummary(encumbrance_pct=-0.1)
def test_defaults(self):
inv = InventorySummary()
assert inv.gold == 0
assert inv.item_count == 0
assert inv.encumbrance_pct == 0.0
# ---------------------------------------------------------------------------
# CommandInput tests
# ---------------------------------------------------------------------------
class TestCommandInput:
def test_minimal_valid(self):
c = _make_command()
assert c.command == CommandType.MOVE_TO
assert c.reasoning == "Moving closer to the quest target."
assert c.episode_id is None
def test_all_command_types(self):
for ct in CommandType:
c = _make_command(command=ct.value)
assert c.command == ct
def test_invalid_command_type(self):
with pytest.raises(ValidationError):
_make_command(command="fly_to_moon")
def test_reasoning_required(self):
with pytest.raises(ValidationError):
CommandInput(
timestamp=NOW,
agent_id="timmy",
command="noop",
reasoning="", # min_length=1
)
def test_with_episode_and_context(self):
c = _make_command(
episode_id="ep_001",
context={"perception_timestamp": NOW, "heartbeat_cycle": 42},
)
assert c.episode_id == "ep_001"
assert c.context.heartbeat_cycle == 42
def test_serialization_roundtrip(self):
c = _make_command(episode_id="ep_002")
json_str = c.model_dump_json()
c2 = CommandInput.model_validate_json(json_str)
assert c2.command == c.command
assert c2.episode_id == c.episode_id
# ---------------------------------------------------------------------------
# Enum coverage
# ---------------------------------------------------------------------------
class TestEnums:
def test_entity_type_values(self):
assert set(EntityType) == {"npc", "creature", "item", "door", "container"}
def test_command_type_values(self):
expected = {
"move_to", "interact", "use_item", "wait",
"combat_action", "dialogue", "journal_note", "noop",
}
assert set(CommandType) == expected