#!/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")