289 lines
11 KiB
Python
289 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for engine_lib/validation.py."""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
def test_empty_action():
|
|
"""Empty action should return (True, '')."""
|
|
from engine_lib.validation import validate_action
|
|
valid, reason = validate_action("")
|
|
assert valid is True
|
|
assert reason == ""
|
|
print("✓ empty action returns (True, '')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_valid_action(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_action
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 5" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = json.dumps({"valid": True, "reason": "ok"})
|
|
|
|
valid, reason = validate_action("I buy a drink", story="At the tavern", log="- Entered the tavern")
|
|
|
|
assert valid is True
|
|
assert reason == "ok"
|
|
mock_call_llm.assert_called_once()
|
|
print("✓ valid action returns (True, reason)")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_invalid_action(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_action
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 0" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = json.dumps({"valid": False, "reason": "Not enough gold"})
|
|
|
|
valid, reason = validate_action("I buy a drink", story="At the tavern", log="- Entered the tavern")
|
|
|
|
assert valid is False
|
|
assert reason == "Not enough gold"
|
|
print("✓ invalid action returns (False, reason)")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_llm_returns_none(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_action
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = None
|
|
|
|
valid, reason = validate_action("I attack the dragon", story="A dragon appears!", log="- Dragon spotted")
|
|
|
|
assert valid is False
|
|
assert reason == "Not sure"
|
|
print("✓ LLM returning None gives (False, 'Not sure')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_llm_returns_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_action
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = "not valid json at all"
|
|
|
|
valid, reason = validate_action("I cast a spell", story="In a dungeon", log="- Found a weird altar")
|
|
|
|
assert valid is False
|
|
assert reason == "Unrecognized"
|
|
print("✓ bad JSON from LLM gives (False, 'Unrecognized')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
def test_missing_character_sheet(mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_action
|
|
|
|
mock_read_file.return_value = ""
|
|
mock_truncate_world.return_value = "*No world state.*"
|
|
|
|
with patch("engine_lib.validation.call_llm") as mock_call_llm:
|
|
mock_call_llm.return_value = json.dumps({"valid": True, "reason": "ok"})
|
|
valid, reason = validate_action("I look around", story="In a dark room", log="- Entered the room")
|
|
|
|
assert valid is True
|
|
print("✓ handles missing character sheet gracefully")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_on_debug_called(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_action
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = json.dumps({"valid": True, "reason": "ok"})
|
|
|
|
events = []
|
|
def debug_cb(key, data):
|
|
events.append((key, data))
|
|
|
|
valid, reason = validate_action("I open the door", story="In a hallway", log="- Heard noises", on_debug=debug_cb)
|
|
|
|
assert valid is True
|
|
assert len(events) == 1
|
|
assert events[0][0] == "action_validation"
|
|
assert events[0][1]["valid"] is True
|
|
print("✓ on_debug callback receives action_validation event")
|
|
|
|
|
|
# ── validate_turn tests ────────────────────────────────────
|
|
|
|
|
|
def test_turn_empty_inputs():
|
|
"""No action and no narrative should return (True, '', 'ok')."""
|
|
from engine_lib.validation import validate_turn
|
|
valid, reason, action = validate_turn("")
|
|
assert valid is True
|
|
assert reason == ""
|
|
assert action == "ok"
|
|
print("✓ empty inputs returns (True, '', 'ok')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_turn
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 5\nInventory:\n- Healing Salve" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = '{"valid": true, "reason": "ok", "action": "ok"}'
|
|
|
|
valid, reason, action = validate_turn(
|
|
"I use my healing salve",
|
|
narrative="Dillion applies the salve to his wound.",
|
|
log_entry="Dillion used his healing salve to restore 2 HP.",
|
|
changes=[{"tool": "remove_from_inventory", "args": {"item": "Healing Salve"}},
|
|
{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
|
story="At the tavern",
|
|
log="- Entered the tavern",
|
|
)
|
|
|
|
assert valid is True
|
|
assert reason == "ok"
|
|
assert action == "ok"
|
|
print("✓ turn validation returns (True, 'ok', 'ok')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_turn
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 0" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = '{"valid": false, "reason": "Player has no gold", "action": "reject"}'
|
|
|
|
valid, reason, action = validate_turn(
|
|
"I buy a round for the house",
|
|
narrative="Dillion orders drinks for everyone.",
|
|
log_entry="Dillion bought a round at the tavern.",
|
|
changes=[{"tool": "modify_vitals", "args": {"cash": 0}}],
|
|
story="At the tavern",
|
|
log="- Entered the tavern",
|
|
)
|
|
|
|
assert valid is False
|
|
assert reason == "Player has no gold"
|
|
assert action == "reject"
|
|
print("✓ turn validation returns (False, reason, 'reject')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_turn
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10\nInventory:\n- Healing Salve" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = '{"valid": false, "reason": "Narrative says salve used but no remove_from_inventory", "action": "regenerate"}'
|
|
|
|
valid, reason, action = validate_turn(
|
|
"I use my healing salve",
|
|
narrative="Dillion applies the salve to his wound.",
|
|
log_entry="Dillion used his healing salve.",
|
|
changes=[{"tool": "modify_vitals", "args": {"current_hp": 8}}],
|
|
story="At the tavern",
|
|
log="- Entered the tavern",
|
|
)
|
|
|
|
assert valid is False
|
|
assert reason == "Narrative says salve used but no remove_from_inventory"
|
|
assert action == "regenerate"
|
|
print("✓ turn validation returns (False, reason, 'regenerate')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_turn
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = "not valid json"
|
|
|
|
valid, reason, action = validate_turn(
|
|
"I attack the dragon",
|
|
narrative="Dillion swings his sword.",
|
|
log_entry="Dillion attacked the dragon.",
|
|
changes=[{"tool": "roll", "args": {"dice": "1d6"}}],
|
|
story="A dragon appears!",
|
|
log="- Dragon spotted",
|
|
)
|
|
|
|
assert valid is False
|
|
assert reason == "Unrecognized"
|
|
assert action == "reject"
|
|
print("✓ turn validation bad JSON gives (False, 'Unrecognized', 'reject')")
|
|
|
|
|
|
@patch("engine_lib.validation.state.read_file")
|
|
@patch("engine_lib.validation.state.truncate_world")
|
|
@patch("engine_lib.validation.call_llm")
|
|
def test_turn_on_debug(mock_call_llm, mock_truncate_world, mock_read_file):
|
|
from engine_lib.validation import validate_turn
|
|
|
|
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern"
|
|
mock_truncate_world.return_value = "## Location\nTavern"
|
|
mock_call_llm.return_value = '{"valid": true, "reason": "ok", "action": "ok"}'
|
|
|
|
events = []
|
|
def debug_cb(key, data):
|
|
events.append((key, data))
|
|
|
|
valid, reason, action = validate_turn(
|
|
"I open the door",
|
|
narrative="Dillion opens the door.",
|
|
log_entry="Dillion opened the door and entered the hall.",
|
|
story="In a hallway",
|
|
log="- Heard noises",
|
|
on_debug=debug_cb,
|
|
)
|
|
|
|
assert valid is True
|
|
assert len(events) == 1
|
|
assert events[0][0] == "turn_validation"
|
|
assert events[0][1]["valid"] is True
|
|
print("✓ on_debug callback receives turn_validation event")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_empty_action()
|
|
test_valid_action()
|
|
test_invalid_action()
|
|
test_llm_returns_none()
|
|
test_llm_returns_bad_json()
|
|
test_missing_character_sheet()
|
|
test_on_debug_called()
|
|
test_turn_empty_inputs()
|
|
test_turn_valid()
|
|
test_turn_reject()
|
|
test_turn_regenerate()
|
|
test_turn_bad_json()
|
|
test_turn_on_debug()
|
|
print("\n✓ All validation tests passed")
|