splinter-keep/tools/test_validation.py
2026-07-04 15:52:22 +02:00

266 lines
9.8 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")
# ── 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')")
def _mock_read(p: str) -> str:
"""Helper for mock_read_file side_effect handling char/world/journal."""
low = str(p).lower()
if "character" in low:
return "HP: 10\nGold: 5\nInventory:\n- Healing Salve"
if "journal" in low:
return "# Journal\n\n## TODO\n- Find the relic\n\n## DONE\n"
return "## Location\nTavern"
def _mock_read_no_gold(p: str) -> str:
low = str(p).lower()
if "character" in low:
return "HP: 10\nGold: 0"
if "journal" in low:
return "# Journal\n\n## TODO\n\n## DONE\n"
return "## Location\nTavern"
def _mock_read_basic(p: str) -> str:
low = str(p).lower()
if "character" in low:
return "HP: 10"
if "journal" in low:
return "# Journal\n\n## TODO\n\n## DONE\n"
return "## Location\nTavern"
def _tool_response(valid: bool, reason: str, action: str) -> str:
return f'```tool\n{{"tool": "validate", "args": {{"valid": {str(valid).lower()}, "reason": "{reason}", "action": "{action}"}}}}\n```'
@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 = _mock_read
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = _tool_response(True, "ok", "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 = _mock_read_no_gold
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = _tool_response(False, "Player has no gold", "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 = _mock_read
mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no remove_from_inventory", "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 = _mock_read_basic
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": "modify_vitals", "args": {"current_hp": 10}}],
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')")
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_turn_empty_inputs()
test_turn_valid()
test_turn_reject()
test_turn_regenerate()
test_turn_bad_json()
print("\n✓ All validation tests passed")