diff --git a/monop_parser.py b/monop_parser.py index 17f535a..58f6971 100644 --- a/monop_parser.py +++ b/monop_parser.py @@ -225,6 +225,9 @@ class MonopParser: # Trade property line: " NAME OWNER GROUP COST ..." # C's printsq uses %-10.10s for name, then owner+1, group, cost TRADE_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+\S*\s+(\d+)') + # Holdings property line (same format but with mortgage/houses/rent) + HOLDINGS_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+(\S*)\s+(\d+)\s*(\*)?\s*(\d+)?\s+(\d+)?') + HOLDINGS_HEADER_RE = re.compile(r'^\s+Name\s+Own\s+Price') SOLVENT_RE = re.compile(r'^-- You are now Solvent ---$') DEBT_RE = re.compile(r'^That leaves you \$(\d+) in debt$') @@ -260,6 +263,9 @@ class MonopParser: self._resign_target = None self._waiting_resign_target = False self._last_user_input = "" + self._command_context = None # tracks what command was issued for disambiguation + self._holdings_player = None # player number during holdings display parsing + self._in_holdings = False self._last_debt_amount = None # Card context self._in_card_block = False @@ -273,6 +279,51 @@ class MonopParser: self.games.append(self.game) self._awaiting_player_count = True + def _process_player_command(self, sender, cmd, timestamp): + """Track player commands to infer silent state changes.""" + g = self.game + if not g: + return + + cmd_lower = cmd.lower().strip() + + # `.card` — use GOJF card to exit jail (no output on success) + if cmd_lower in ("card", "c"): + player = g.get_player(name=sender) + if player and player.in_jail and player.get_out_of_jail_free_cards > 0: + player.get_out_of_jail_free_cards -= 1 + player.in_jail = False + player.jail_turns = 0 + player.location = 10 # Just Visiting + g.add_log("Used Get Out of Jail Free card", player=sender, timestamp=timestamp) + return + + # `.unmortgage` / `.unm` — next "That cost you $X" is an unmortgage + if cmd_lower.startswith("unm"): + self._command_context = "unmortgage" + return + + # `.mortgage` / `.mor` — next "That got you $X" is a mortgage + if cmd_lower.startswith("mor"): + self._command_context = "mortgage" + return + + # `.buy` — entering house buying mode + if cmd_lower in ("buy", "b"): + self._command_context = "buy_houses" + self._house_buy_props = {} # prop_name -> current houses (from prompts) + return + + # `.sell` — entering house selling mode + if cmd_lower.startswith("sell"): + self._command_context = "sell_houses" + return + + # `.holdings` / `.hold` — triggers holdings display (parsed from output) + if cmd_lower.startswith("hold"): + self._command_context = "holdings" + return + def _handle_setup_input(self, sender, msg, timestamp): """Handle user input during setup phase.""" g = self.game @@ -320,6 +371,7 @@ class MonopParser: user_msg = message.lstrip('.') if user_msg: self._last_user_input = user_msg + self._process_player_command(sender, user_msg, timestamp) # During setup, capture player count and registrations if self.game and self.game.phase == "setup": self._handle_setup_input(sender, user_msg, timestamp) @@ -833,16 +885,23 @@ class MonopParser: amount = int(m.group(1)) if cp: cp.money += amount + # Try to identify which property was mortgaged + # Mortgage value = cost/2, so cost = amount * 2 + self._resolve_mortgage(cp, amount) return - # ===== UNMORTGAGE ===== - # "That cost you $X" - but also used for jail pay + # ===== UNMORTGAGE / JAIL PAY ===== + # "That cost you $X" — ambiguous: could be unmortgage or jail pay + # Use _command_context to disambiguate m = self.UNMORTGAGE_COST_RE.match(msg) if m: amount = int(m.group(1)) - if cp and not cp.in_jail: + if self._command_context == "unmortgage" and cp: cp.money -= amount - elif cp and cp.in_jail and amount == 50: + # Try to identify which property was unmortgaged + self._resolve_unmortgage(cp, amount) + self._command_context = None + elif cp and cp.in_jail and amount == 50 and self._command_context != "unmortgage": # Jail pay cp.money -= 50 cp.location = 10 @@ -1011,10 +1070,45 @@ class MonopParser: return # ===== Holdings display ===== + # ===== HOLDINGS DISPLAY ===== m = self.HOLDINGS_RE.match(msg) if m: + name = m.group(1) + num = int(m.group(2)) + self._holdings_player = num + self._in_holdings = True return + if self._in_holdings: + # Header line + if self.HOLDINGS_HEADER_RE.match(msg_raw): + return + # Property line + m_h = self.HOLDINGS_PROP_RE.match(msg_raw) + if m_h: + trunc_name = m_h.group(1).rstrip() + cost = int(m_h.group(4)) + mortgaged = m_h.group(5) == '*' + houses = int(m_h.group(6)) if m_h.group(6) else 0 + + sq_id = resolve_trade_property(trunc_name, cost) + if sq_id is not None: + # Resync property state from holdings + g.property_owner[sq_id] = self._holdings_player + if mortgaged: + g.property_mortgaged[sq_id] = True + else: + g.property_mortgaged.pop(sq_id, None) + if houses > 0: + g.property_houses[sq_id] = houses + else: + g.property_houses.pop(sq_id, None) + return + # Any non-matching line ends the holdings display + self._in_holdings = False + self._holdings_player = None + # Fall through to process this line normally + # ===== Various prompts and info ===== if msg.startswith("-- Command:"): return @@ -1141,6 +1235,63 @@ class MonopParser: g.game_active = False return + def _resolve_mortgage(self, player, amount): + """Try to identify which property was mortgaged and set its mortgage flag.""" + g = self.game + if not g: + return + # Mortgage value = cost/2, so find properties where cost/2 == amount + candidates = [] + for sq_id, owner_num in g.property_owner.items(): + if owner_num != player.number: + continue + if g.property_mortgaged.get(sq_id): + continue # already mortgaged + sq = BOARD[sq_id] if sq_id < len(BOARD) else None + if sq and sq["cost"] // 2 == amount: + candidates.append(sq_id) + + if len(candidates) == 1: + g.property_mortgaged[candidates[0]] = True + elif len(candidates) > 1 and self._last_user_input: + inp = self._last_user_input.lower() + for sq_id in candidates: + sq_name = BOARD[sq_id]["name"].lower() + if sq_name.startswith(inp) or inp in sq_name: + g.property_mortgaged[sq_id] = True + break + + def _resolve_unmortgage(self, player, cost): + """Try to identify which property was unmortgaged and clear its mortgage flag.""" + g = self.game + if not g: + return + # Candidates: mortgaged properties owned by this player where unmortgage cost matches + candidates = [] + for sq_id, mortgaged in list(g.property_mortgaged.items()): + if not mortgaged: + continue + if g.property_owner.get(sq_id) != player.number: + continue + sq = BOARD[sq_id] if sq_id < len(BOARD) else None + if sq: + half = sq["cost"] // 2 + expected_cost = half + half // 10 + if expected_cost == cost: + candidates.append(sq_id) + + if len(candidates) == 1: + # Unambiguous + g.property_mortgaged.pop(candidates[0], None) + elif len(candidates) > 1 and self._last_user_input: + # Use user's input to disambiguate (prefix match like C's getinp) + inp = self._last_user_input.lower() + for sq_id in candidates: + sq_name = BOARD[sq_id]["name"].lower() + if sq_name.startswith(inp) or inp in sq_name: + g.property_mortgaged.pop(sq_id, None) + break + def _pay_rent(self, amount): g = self.game if not g: diff --git a/plugins/monop/monop_parser.py b/plugins/monop/monop_parser.py index 17f535a..58f6971 100644 --- a/plugins/monop/monop_parser.py +++ b/plugins/monop/monop_parser.py @@ -225,6 +225,9 @@ class MonopParser: # Trade property line: " NAME OWNER GROUP COST ..." # C's printsq uses %-10.10s for name, then owner+1, group, cost TRADE_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+\S*\s+(\d+)') + # Holdings property line (same format but with mortgage/houses/rent) + HOLDINGS_PROP_RE = re.compile(r'^\s(.{10})\s+(\d+)\s+(\S*)\s+(\d+)\s*(\*)?\s*(\d+)?\s+(\d+)?') + HOLDINGS_HEADER_RE = re.compile(r'^\s+Name\s+Own\s+Price') SOLVENT_RE = re.compile(r'^-- You are now Solvent ---$') DEBT_RE = re.compile(r'^That leaves you \$(\d+) in debt$') @@ -260,6 +263,9 @@ class MonopParser: self._resign_target = None self._waiting_resign_target = False self._last_user_input = "" + self._command_context = None # tracks what command was issued for disambiguation + self._holdings_player = None # player number during holdings display parsing + self._in_holdings = False self._last_debt_amount = None # Card context self._in_card_block = False @@ -273,6 +279,51 @@ class MonopParser: self.games.append(self.game) self._awaiting_player_count = True + def _process_player_command(self, sender, cmd, timestamp): + """Track player commands to infer silent state changes.""" + g = self.game + if not g: + return + + cmd_lower = cmd.lower().strip() + + # `.card` — use GOJF card to exit jail (no output on success) + if cmd_lower in ("card", "c"): + player = g.get_player(name=sender) + if player and player.in_jail and player.get_out_of_jail_free_cards > 0: + player.get_out_of_jail_free_cards -= 1 + player.in_jail = False + player.jail_turns = 0 + player.location = 10 # Just Visiting + g.add_log("Used Get Out of Jail Free card", player=sender, timestamp=timestamp) + return + + # `.unmortgage` / `.unm` — next "That cost you $X" is an unmortgage + if cmd_lower.startswith("unm"): + self._command_context = "unmortgage" + return + + # `.mortgage` / `.mor` — next "That got you $X" is a mortgage + if cmd_lower.startswith("mor"): + self._command_context = "mortgage" + return + + # `.buy` — entering house buying mode + if cmd_lower in ("buy", "b"): + self._command_context = "buy_houses" + self._house_buy_props = {} # prop_name -> current houses (from prompts) + return + + # `.sell` — entering house selling mode + if cmd_lower.startswith("sell"): + self._command_context = "sell_houses" + return + + # `.holdings` / `.hold` — triggers holdings display (parsed from output) + if cmd_lower.startswith("hold"): + self._command_context = "holdings" + return + def _handle_setup_input(self, sender, msg, timestamp): """Handle user input during setup phase.""" g = self.game @@ -320,6 +371,7 @@ class MonopParser: user_msg = message.lstrip('.') if user_msg: self._last_user_input = user_msg + self._process_player_command(sender, user_msg, timestamp) # During setup, capture player count and registrations if self.game and self.game.phase == "setup": self._handle_setup_input(sender, user_msg, timestamp) @@ -833,16 +885,23 @@ class MonopParser: amount = int(m.group(1)) if cp: cp.money += amount + # Try to identify which property was mortgaged + # Mortgage value = cost/2, so cost = amount * 2 + self._resolve_mortgage(cp, amount) return - # ===== UNMORTGAGE ===== - # "That cost you $X" - but also used for jail pay + # ===== UNMORTGAGE / JAIL PAY ===== + # "That cost you $X" — ambiguous: could be unmortgage or jail pay + # Use _command_context to disambiguate m = self.UNMORTGAGE_COST_RE.match(msg) if m: amount = int(m.group(1)) - if cp and not cp.in_jail: + if self._command_context == "unmortgage" and cp: cp.money -= amount - elif cp and cp.in_jail and amount == 50: + # Try to identify which property was unmortgaged + self._resolve_unmortgage(cp, amount) + self._command_context = None + elif cp and cp.in_jail and amount == 50 and self._command_context != "unmortgage": # Jail pay cp.money -= 50 cp.location = 10 @@ -1011,10 +1070,45 @@ class MonopParser: return # ===== Holdings display ===== + # ===== HOLDINGS DISPLAY ===== m = self.HOLDINGS_RE.match(msg) if m: + name = m.group(1) + num = int(m.group(2)) + self._holdings_player = num + self._in_holdings = True return + if self._in_holdings: + # Header line + if self.HOLDINGS_HEADER_RE.match(msg_raw): + return + # Property line + m_h = self.HOLDINGS_PROP_RE.match(msg_raw) + if m_h: + trunc_name = m_h.group(1).rstrip() + cost = int(m_h.group(4)) + mortgaged = m_h.group(5) == '*' + houses = int(m_h.group(6)) if m_h.group(6) else 0 + + sq_id = resolve_trade_property(trunc_name, cost) + if sq_id is not None: + # Resync property state from holdings + g.property_owner[sq_id] = self._holdings_player + if mortgaged: + g.property_mortgaged[sq_id] = True + else: + g.property_mortgaged.pop(sq_id, None) + if houses > 0: + g.property_houses[sq_id] = houses + else: + g.property_houses.pop(sq_id, None) + return + # Any non-matching line ends the holdings display + self._in_holdings = False + self._holdings_player = None + # Fall through to process this line normally + # ===== Various prompts and info ===== if msg.startswith("-- Command:"): return @@ -1141,6 +1235,63 @@ class MonopParser: g.game_active = False return + def _resolve_mortgage(self, player, amount): + """Try to identify which property was mortgaged and set its mortgage flag.""" + g = self.game + if not g: + return + # Mortgage value = cost/2, so find properties where cost/2 == amount + candidates = [] + for sq_id, owner_num in g.property_owner.items(): + if owner_num != player.number: + continue + if g.property_mortgaged.get(sq_id): + continue # already mortgaged + sq = BOARD[sq_id] if sq_id < len(BOARD) else None + if sq and sq["cost"] // 2 == amount: + candidates.append(sq_id) + + if len(candidates) == 1: + g.property_mortgaged[candidates[0]] = True + elif len(candidates) > 1 and self._last_user_input: + inp = self._last_user_input.lower() + for sq_id in candidates: + sq_name = BOARD[sq_id]["name"].lower() + if sq_name.startswith(inp) or inp in sq_name: + g.property_mortgaged[sq_id] = True + break + + def _resolve_unmortgage(self, player, cost): + """Try to identify which property was unmortgaged and clear its mortgage flag.""" + g = self.game + if not g: + return + # Candidates: mortgaged properties owned by this player where unmortgage cost matches + candidates = [] + for sq_id, mortgaged in list(g.property_mortgaged.items()): + if not mortgaged: + continue + if g.property_owner.get(sq_id) != player.number: + continue + sq = BOARD[sq_id] if sq_id < len(BOARD) else None + if sq: + half = sq["cost"] // 2 + expected_cost = half + half // 10 + if expected_cost == cost: + candidates.append(sq_id) + + if len(candidates) == 1: + # Unambiguous + g.property_mortgaged.pop(candidates[0], None) + elif len(candidates) > 1 and self._last_user_input: + # Use user's input to disambiguate (prefix match like C's getinp) + inp = self._last_user_input.lower() + for sq_id in candidates: + sq_name = BOARD[sq_id]["name"].lower() + if sq_name.startswith(inp) or inp in sq_name: + g.property_mortgaged.pop(sq_id, None) + break + def _pay_rent(self, amount): g = self.game if not g: diff --git a/test_parser_commands.py b/test_parser_commands.py new file mode 100644 index 0000000..8a013bf --- /dev/null +++ b/test_parser_commands.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Tests for player command tracking and holdings display parsing.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from monop_parser import MonopParser + +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(): + """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 give_properties(game, player_num, sq_ids): + for sq_id in sq_ids: + game.property_owner[sq_id] = player_num + + +# ===================================================================== +# GOJF card usage from player command +# ===================================================================== +class TestGOJFCardCommand(unittest.TestCase): + + def test_card_command_exits_jail(self): + """Player types .card while in jail with GOJF card.""" + p = setup_3player_game() + g = p.game + # Put fbs in jail with a GOJF card + fbs = g.get_player(name="fbs") + fbs.in_jail = True + fbs.jail_turns = 1 + fbs.location = 40 + fbs.get_out_of_jail_free_cards = 1 + + feed(p, ["fbs\t.card"]) + + self.assertFalse(fbs.in_jail) + self.assertEqual(fbs.location, 10) # Just Visiting + self.assertEqual(fbs.get_out_of_jail_free_cards, 0) + self.assertEqual(fbs.jail_turns, 0) + + def test_card_command_no_gojf(self): + """Player types .card but has no GOJF cards — nothing happens.""" + p = setup_3player_game() + g = p.game + fbs = g.get_player(name="fbs") + fbs.in_jail = True + fbs.location = 40 + fbs.get_out_of_jail_free_cards = 0 + + feed(p, ["fbs\t.card"]) + + # Should still be in jail + self.assertTrue(fbs.in_jail) + self.assertEqual(fbs.location, 40) + + def test_card_command_not_in_jail(self): + """Player types .card but isn't in jail — nothing happens.""" + p = setup_3player_game() + g = p.game + fbs = g.get_player(name="fbs") + fbs.get_out_of_jail_free_cards = 1 + + feed(p, ["fbs\t.card"]) + + # GOJF card should not be consumed + self.assertEqual(fbs.get_out_of_jail_free_cards, 1) + + def test_card_log_entry(self): + p = setup_3player_game() + g = p.game + fbs = g.get_player(name="fbs") + fbs.in_jail = True + fbs.location = 40 + fbs.get_out_of_jail_free_cards = 2 + + feed(p, ["fbs\t.card"]) + + log_texts = [e["text"] for e in g.log] + self.assertTrue(any("Get Out of Jail" in t for t in log_texts)) + self.assertEqual(fbs.get_out_of_jail_free_cards, 1) + + +# ===================================================================== +# Unmortgage tracking via command context +# ===================================================================== +class TestUnmortgageTracking(unittest.TestCase): + + def test_unmortgage_clears_flag(self): + """Player unmortgages a uniquely-identifiable property.""" + p = setup_3player_game() + g = p.game + # fbs owns Boardwalk (39), mortgaged + give_properties(g, 3, [39]) + g.property_mortgaged[39] = True + + feed(p, [ + "fbs\t.unm", + "monop\tYour only mortaged property is Boardwalk (D)", + "monop\tDo you want to unmortgage it? ", + "fbs\t.y", + "monop\tThat cost you $220", # 400/2 + 400/2/10 = 220 + "monop\tfbs (3) (cash $1280) on === GO ===", + "monop\t-- Command: ", + ]) + + self.assertNotIn(39, g.property_mortgaged) + + def test_unmortgage_with_disambiguation(self): + """Player unmortgages one of two same-cost properties.""" + p = setup_3player_game() + g = p.game + # fbs owns both Kentucky (21) and Indiana (23), both mortgaged + # Both cost $220 → unmortgage cost $121 each + give_properties(g, 3, [21, 23]) + g.property_mortgaged[21] = True + g.property_mortgaged[23] = True + + feed(p, [ + "fbs\t.unm", + "monop\tWhich property do you want to unmortgage? ", + "fbs\t.indiana", # user specifies Indiana + "monop\tThat cost you $121", + "monop\tfbs (3) (cash $1379) on === GO ===", + "monop\t-- Command: ", + ]) + + # Indiana unmortgaged, Kentucky still mortgaged + self.assertNotIn(23, g.property_mortgaged) + self.assertTrue(g.property_mortgaged.get(21)) + + +# ===================================================================== +# Mortgage tracking +# ===================================================================== +class TestMortgageTracking(unittest.TestCase): + + def test_mortgage_sets_flag(self): + """Player mortgages a property — flag should be set.""" + p = setup_3player_game() + g = p.game + give_properties(g, 3, [39]) # Boardwalk + + feed(p, [ + "fbs\t.mor", + "monop\tWhich property do you want to mortgage? ", + "fbs\t.boardwalk", + "monop\tThat got you $200", # 400/2 + "monop\tfbs (3) (cash $1700) on === GO ===", + "monop\t-- Command: ", + ]) + + self.assertTrue(g.property_mortgaged.get(39)) + + +# ===================================================================== +# Holdings display parsing +# ===================================================================== +class TestHoldingsDisplay(unittest.TestCase): + + def test_holdings_syncs_properties(self): + """Holdings display should resync property ownership.""" + p = setup_3player_game() + g = p.game + + feed(p, [ + "monop\tmerp's (1) holdings (Total worth: $1806):", + "monop\t Name Own Price Mg # Rent", + "monop\t Electric C 1 150 1", + "monop\t New York a 1 Orange 200 16", + "monop\t Kentucky a 1 RED 220 0 36", + "monop\tmerp (1) (cash $496) on Community Chest ii", + "monop\t-- Command: ", + ]) + + # Electric Co (12), New York ave (19), Kentucky ave (21) should be owned by merp (1) + self.assertEqual(g.property_owner.get(12), 1) + self.assertEqual(g.property_owner.get(19), 1) + self.assertEqual(g.property_owner.get(21), 1) + + def test_holdings_syncs_mortgage_status(self): + """Holdings display should set/clear mortgage flags.""" + p = setup_3player_game() + g = p.game + + feed(p, [ + "monop\tmerp's (1) holdings (Total worth: $1806):", + "monop\t Name Own Price Mg # Rent", + "monop\t Indiana av 1 RED 220 * 0 36", + "monop\t Illinois a 1 RED 240 0 40", + "monop\tmerp (1) (cash $496) on Community Chest ii", + "monop\t-- Command: ", + ]) + + self.assertTrue(g.property_mortgaged.get(23)) # Indiana mortgaged + self.assertNotIn(24, g.property_mortgaged) # Illinois not mortgaged + + def test_holdings_syncs_house_counts(self): + """Holdings display should update house counts.""" + p = setup_3player_game() + g = p.game + + feed(p, [ + "monop\tfbs's (3) holdings (Total worth: $5000):", + "monop\t Name Own Price Mg # Rent", + "monop\t Kentucky a 3 RED 220 3 180", + "monop\t Indiana av 3 RED 220 3 180", + "monop\t Illinois a 3 RED 240 4 220", + "monop\tfbs (3) (cash $2000) on === GO ===", + "monop\t-- Command: ", + ]) + + self.assertEqual(g.property_houses.get(21), 3) # Kentucky 3 houses + self.assertEqual(g.property_houses.get(23), 3) # Indiana 3 houses + self.assertEqual(g.property_houses.get(24), 4) # Illinois 4 houses + + def test_holdings_clears_stale_houses(self): + """If holdings shows 0 houses, clear any stale house count.""" + p = setup_3player_game() + g = p.game + g.property_houses[21] = 3 # stale: Kentucky had 3 houses + + feed(p, [ + "monop\tmerp's (1) holdings (Total worth: $1000):", + "monop\t Name Own Price Mg # Rent", + "monop\t Kentucky a 1 RED 220 0 36", + "monop\tmerp (1) (cash $500) on === GO ===", + "monop\t-- Command: ", + ]) + + self.assertNotIn(21, g.property_houses) # houses cleared + + def test_real_log_holdings_line_26(self): + """Replay real holdings display from line 26 of test log.""" + 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 >= 35: + break + + g = p.game + self.assertIsNotNone(g) + # merp (1) should own Electric Co (12) and others from the holdings dump + self.assertEqual(g.property_owner.get(12), 1) # Electric Co + + +# ===================================================================== +# Real log: unmortgage at line 36-41 +# ===================================================================== +class TestRealLogUnmortgage(unittest.TestCase): + + def test_real_unmortgage(self): + """Real log: merp unmortgages Indiana ave around line 36-41.""" + 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 >= 42: + break + + g = p.game + # Indiana ave (23) should NOT be mortgaged after unmortgage + self.assertFalse(g.property_mortgaged.get(23, False), + "Indiana ave should be unmortgaged after .unm at line 36") + + +if __name__ == "__main__": + unittest.main(verbosity=2)