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>
243 lines
7.7 KiB
Python
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
|