303 lines
11 KiB
Python
303 lines
11 KiB
Python
|
|
#!/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)
|