"""Unit tests for engine.py — the code-owned rules layer. No model needed. Run from app/: python -m pytest test_engine.py """ import json import engine # --- parse_output: the lenient §13 parser ----------------------------------- def test_parse_valid_say(): raw = json.dumps({"action": "say", "text": "The moss is cool."}) assert engine.parse_output(raw) == {"action": "say", "text": "The moss is cool."} def test_parse_valid_roll(): obj = { "action": "roll", "stat": "might", "difficulty": "hard", "on_success": "It moves.", "on_fail": "It does not.", } assert engine.parse_output(json.dumps(obj)) == obj def test_parse_json_with_surrounding_prose(): raw = 'Sure! {"action": "say", "text": "A beetle hums."} hope that helps' assert engine.parse_output(raw)["text"] == "A beetle hums." def test_parse_garbage_degrades_to_say(): out = engine.parse_output("the forest just vibes, no json here") assert out["action"] == "say" assert "vibes" in out["text"] def test_parse_bad_stat_degrades_to_say(): obj = { "action": "roll", "stat": "charisma", "difficulty": "hard", "on_success": "yes", "on_fail": "no", } assert engine.parse_output(json.dumps(obj))["action"] == "say" def test_parse_empty_degrades_to_say(): out = engine.parse_output("") assert out["action"] == "say" assert out["text"] def test_parse_truncated_roll_recovers_roll(): # Cut off mid-on_fail (max_tokens truncation): both narrations recoverable. raw = ( '{"action": "roll", "stat": "speed", "difficulty": "medium", ' '"on_success": "You skitter clear.", "on_fail": "The leaf snaps and you tum' ) out = engine.parse_output(raw) assert out["action"] == "roll" assert out["stat"] == "speed" assert out["difficulty"] == "medium" assert out["on_success"] == "You skitter clear." assert "leaf snaps" in out["on_fail"] def test_parse_truncated_roll_without_on_fail_degrades_to_say(): raw = ( '{"action": "roll", "stat": "might", "difficulty": "hard", ' '"on_success": "The log shifts' ) out = engine.parse_output(raw) assert out["action"] == "say" assert "log shifts" in out["text"] def test_parse_roll_with_unescaped_quotes_salvages(): raw = ( '{"action": "roll", "stat": "smarts", "difficulty": "easy", ' '"on_success": "The ant says "fine" and moves.", "on_fail": "The ant balks."}' ) out = engine.parse_output(raw) assert out["action"] == "roll" assert "fine" in out["on_success"] assert out["on_fail"] == "The ant balks." def test_parse_log_tagged_lines_keep_narration(): raw = "[dm · Might check, Easy, failed] The bark holds firm against you." out = engine.parse_output(raw) assert out["action"] == "say" assert "bark holds firm" in out["text"] def test_parse_headers_then_prose_keeps_narration(): raw = "PLAYER'S TURN\nsay\nA snail watches you with mild interest." out = engine.parse_output(raw) assert out["action"] == "say" assert out["text"] == "A snail watches you with mild interest." # --- dice --------------------------------------------------------------------- def test_resolve_roll_bounds(): # 2d6 is 2..12; stat 10 always beats easy DC 6, stat -10 never reaches hard DC 10. assert all(engine.resolve_roll(10, "easy") == "success" for _ in range(50)) assert all(engine.resolve_roll(-10, "hard") == "fail" for _ in range(50)) def test_resolve_roll_returns_both_outcomes(): results = {engine.resolve_roll(2, "medium") for _ in range(300)} assert results == {"success", "fail"} # --- damage / parity with training --------------------------------------------- def test_band_damage_matches_exploder(): import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import explode_transcript assert engine.BAND_DAMAGE == explode_transcript.BAND_DAMAGE def test_apply_damage_floors_at_zero(): sheet = {"hp": {"cur": 2, "max": 8}} assert engine.apply_damage(sheet, "hard")["hp"]["cur"] == 0 assert engine.apply_damage(sheet, "easy")["hp"]["cur"] == 2 # --- XP ladder / level-up --------------------------------------------------------- def test_level_for_xp_ladder(): assert engine.level_for_xp(0) == 1 assert engine.level_for_xp(3) == 1 assert engine.level_for_xp(4) == 2 assert engine.level_for_xp(10) == 3 assert engine.level_for_xp(999) == 7 def test_maybe_level_up_carries_damage(): import roster bug_id = sorted(roster.load().keys())[0] sheet = roster.load_at_level(bug_id, 1) sheet["hp"]["cur"] = max(1, sheet["hp"]["cur"] - 2) # wounded old_cur = sheet["hp"]["cur"] old_max = sheet["hp"]["max"] leveled, notes = engine.maybe_level_up(sheet, engine.XP_LADDER[2]) assert leveled["level"] == 2 assert leveled["hp"]["max"] == old_max + 1 assert leveled["hp"]["cur"] == old_cur + 1 # gained the +1, kept the wound assert notes and "level 2" in notes[0] def test_maybe_level_up_noop_below_threshold(): import roster bug_id = sorted(roster.load().keys())[0] sheet = roster.load_at_level(bug_id, 1) same, notes = engine.maybe_level_up(sheet, engine.XP_LADDER[2] - 1) assert same is sheet assert notes == []