Spaces:
Running on Zero
Running on Zero
| """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 == [] | |