#!/usr/bin/env python3 """Tests for bugs identified in the codebase audit.""" import sys import os import unittest sys.path.insert(0, os.path.dirname(__file__)) from monop_parser import MonopParser, BOARD, SQUARE_BY_NAME TS = "2026-01-01 00:00:00" def feed(parser, lines): for line in lines: parser.parse_line(f"{TS}\t{line}") def setup_3player_game(): """Set up a 3-player game (merp, hiro, fbs) in playing phase. fbs goes first (turn order: fbs→merp→hiro).""" p = MonopParser() feed(p, [ "monop\tHow many players? ", "monop\tPlayer 1, say 'me' please.", "monop\tmerp (1) rolls 5", "monop\tPlayer 2, say 'me' please.", "monop\thiro (2) rolls 3", "monop\tPlayer 3, say 'me' please.", "monop\tfbs (3) rolls 8", "monop\tfbs (3) goes first", "monop\tfbs (3) (cash $1500) on === GO ===", "monop\t-- Command: ", ]) return p def advance_to_merp_turn(p): """Advance from fbs's turn to merp's turn.""" feed(p, [ "fbs\t.", "monop\troll is 3, 4", "monop\tThat puts you on Oriental ave. (L)", "monop\tThat would cost $100, do you want it? ", "fbs\t.n", "monop\tmerp (1) (cash $1500) on === GO ===", "monop\t-- Command: ", ]) def give_properties(game, player_num, sq_ids): for sq_id in sq_ids: game.property_owner[sq_id] = player_num # ===================================================================== # Bug #1: Trade doesn't transfer properties # ===================================================================== class TestBug1_TradeProperties(unittest.TestCase): """_execute_trade() only handles cash+GOJF, not property transfers.""" def test_trade_property_transfer(self): """hiro gives Tennessee ave to fbs for $290.""" p = setup_3player_game() g = p.game # hiro (2) owns Tennessee ave (sq 18) give_properties(g, 2, [18]) # fbs's turn — initiate trade feed(p, [ # Trade summary "monop\tPlayer fbs (3) gives:", "monop\t $290", "monop\tPlayer hiro (2) gives:", "monop\t Tennessee 2 Orange 180 * 14", "monop\thiro, is the trade ok? ", "hiro\t.y", "monop\tTrade is done!", "monop\tfbs (3) (cash $1210) on === GO ===", "monop\t-- Command: ", ]) # Tennessee (sq 18) should now belong to fbs (3) self.assertEqual(g.property_owner.get(18), 3, "Tennessee ave should be transferred to fbs") # fbs paid $290 fbs = g.get_player(name="fbs") self.assertEqual(fbs.money, 1500 - 290) # hiro received $290 hiro = g.get_player(name="hiro") self.assertEqual(hiro.money, 1500 + 290) def test_trade_multiple_properties(self): """xLink gives Indiana + Illinois to fbs for $550.""" p = setup_3player_game() g = p.game # Use merp as xLink stand-in (player 1 owns Indiana=23, Illinois=24) give_properties(g, 1, [23, 24]) feed(p, [ "monop\tPlayer fbs (3) gives:", "monop\t $550", "monop\tPlayer merp (1) gives:", "monop\t Indiana av 1 Red 220 * 18", "monop\t Illinois a 1 Red 240 * 20", "monop\tmerp, is the trade ok? ", "merp\t.y", "monop\tTrade is done!", "monop\tfbs (3) (cash $950) on === GO ===", "monop\t-- Command: ", ]) self.assertEqual(g.property_owner.get(23), 3, "Indiana should be fbs's") self.assertEqual(g.property_owner.get(24), 3, "Illinois should be fbs's") def test_real_log_trade_line_789(self): """Replay real log through trade at line 789 — Tennessee from hiro to fbs.""" p = MonopParser() with open("test_data/monop.log") as f: for i, line in enumerate(f, 1): p.parse_line(line.rstrip('\n')) if i >= 795: break g = p.game state = p.get_state() self.assertIsNotNone(state) # Tennessee ave (sq 18) should be owned by fbs after the trade # fbs is player 2 at this point in the log owner = g.property_owner.get(18) self.assertIsNotNone(owner, "Tennessee should be owned after trade") # ===================================================================== # Bug #2: sq.owner uses player number but UI indexes by array position # ===================================================================== class TestBug2_OwnerColorIndex(unittest.TestCase): """UI uses sq.owner as array index, but it's a 1-based player number. This is a UI-only bug — test the data contract instead.""" def test_owner_is_player_number_not_index(self): """Verify get_state() emits owner as player number (1-based).""" p = setup_3player_game() g = p.game # fbs is player 3, give them a property give_properties(g, 3, [1]) # Mediterranean state = p.get_state() sq1 = next(sq for sq in state["squares"] if sq["id"] == 1) # owner should be 3 (fbs's player number), not 2 (array index) self.assertEqual(sq1["owner"], 3) # ===================================================================== # Bug #3: _first_player_idx not set on mid-stream pickup # ===================================================================== class TestBug3_FirstPlayerIdx(unittest.TestCase): """When game is picked up from a checkpoint, _first_player_idx is never set.""" def test_midstream_pickup_has_first_player_idx(self): """Picking up game from checkpoint should set _first_player_idx.""" p = MonopParser() feed(p, [ "monop\tmerp (1) (cash $1200) on Connecticut ave. (L)", "monop\t-- Command: ", ]) g = p.game self.assertIsNotNone(g) self.assertTrue(hasattr(g, '_first_player_idx'), "_first_player_idx should be set on mid-stream pickup") # ===================================================================== # Bug #4: Resign-to-player with no target falls through to bank msg # ===================================================================== class TestBug4_ResignNoTarget(unittest.TestCase): """If _resign_target can't resolve, logs 'resigned to bank' but doesn't clear properties like the real bank path.""" def test_resign_unresolved_target_clears_properties(self): """If resign target can't be found, treat as bank resignation.""" p = setup_3player_game() g = p.game give_properties(g, 1, [1, 3]) # merp owns Mediterranean, Baltic advance_to_merp_turn(p) # Force _resign_target to a bad name p._resign_target = "nonexistent_player" p._resign_pending = True feed(p, [ "monop\tTrade is done!", "monop\thiro (1) (cash $1500) on === GO ===", "monop\t-- Command: ", ]) # Properties should be cleared (bank behavior) self.assertNotIn(1, g.property_owner, "Props should be cleared on failed resign target") self.assertNotIn(3, g.property_owner) # ===================================================================== # Bug #5: Houses/mortgages not transferred on resign-to-player # ===================================================================== class TestBug5_ResignTransferHouses(unittest.TestCase): """When resigning to a player, houses/mortgage state should transfer.""" def test_houses_preserved_on_resign(self): """Houses on transferred properties should be preserved.""" p = setup_3player_game() g = p.game give_properties(g, 1, [1, 3]) g.property_houses[1] = 3 # 3 houses on Mediterranean g.property_mortgaged[3] = True # Baltic mortgaged advance_to_merp_turn(p) feed(p, [ "monop\tYou would resign to fbs", "monop\tDo you really want to resign? ", "merp\t.y", "monop\tresigning to player", "monop\tTrade is done!", "monop\thiro (1) (cash $1500) on === GO ===", "monop\t-- Command: ", ]) fbs = g.get_player(name="fbs") # Properties transferred to fbs self.assertEqual(g.property_owner.get(1), fbs.number) # Houses should be preserved self.assertEqual(g.property_houses.get(1), 3, "Houses should be preserved on transfer") # Mortgage state should be preserved self.assertTrue(g.property_mortgaged.get(3), "Mortgage state should be preserved on transfer") # ===================================================================== # Bug #6: spec flag never consumed (Chance: nearest RR/utility) # ===================================================================== class TestBug6_SpecFlag(unittest.TestCase): """The spec flag is set for 'Advance to nearest Railroad/Utility' Chance cards but never cleared, potentially affecting future rent.""" def test_spec_flag_cleared_after_rent(self): """spec should be cleared after rent is calculated.""" p = setup_3player_game() g = p.game g.spec = True # simulate having drawn the card # Player pays rent give_properties(g, 2, [5]) # hiro owns Reading RR feed(p, [ "monop\tOwned by hiro", "monop\trent is 25", "monop\thiro (2) (cash $1500) on === GO ===", "monop\t-- Command: ", ]) self.assertFalse(g.spec, "spec flag should be cleared after rent payment") # ===================================================================== # Bug #13: get_state() doesn't emit phase: "playing" # ===================================================================== class TestBug13_PhasePlaying(unittest.TestCase): """get_state() should always include phase field.""" def test_phase_playing_emitted(self): p = setup_3player_game() state = p.get_state() self.assertIn("phase", state, "State should always include phase") self.assertEqual(state["phase"], "playing") # ===================================================================== # Bug #16: Resign log entries missing timestamps # ===================================================================== class TestBug16_ResignTimestamp(unittest.TestCase): """Resign-to-player log entries should have timestamps.""" def test_resign_to_player_has_timestamp(self): p = setup_3player_game() g = p.game advance_to_merp_turn(p) feed(p, [ "monop\tYou would resign to fbs", "monop\tDo you really want to resign? ", "merp\t.y", "monop\tresigning to player", "monop\tTrade is done!", "monop\thiro (1) (cash $1500) on === GO ===", "monop\t-- Command: ", ]) resign_entries = [e for e in g.log if "resigned" in e.get("text", "")] self.assertTrue(len(resign_entries) > 0, "Should have resign log entry") self.assertIn("timestamp", resign_entries[0], "Resign log entry should have a timestamp") self.assertEqual(resign_entries[0]["timestamp"], TS) if __name__ == "__main__": unittest.main(verbosity=2)