diff --git a/zulip_bots/zulip_bots/bots/merels/test_merels.py b/zulip_bots/zulip_bots/bots/merels/test_merels.py index 249a569a7..75223a1de 100644 --- a/zulip_bots/zulip_bots/bots/merels/test_merels.py +++ b/zulip_bots/zulip_bots/bots/merels/test_merels.py @@ -1,6 +1,7 @@ -from typing import Any, List, Tuple +from typing import Dict + +from typing_extensions import override -from zulip_bots.game_handler import GameInstance from zulip_bots.test_lib import BotTestCase, DefaultTests from .libraries.constants import EMPTY_BOARD @@ -9,7 +10,8 @@ class TestMerelsBot(BotTestCase, DefaultTests): bot_name = "merels" - def test_no_command(self): + def test_no_command(self) -> None: + # Out-of-game message for arbitrary input. message = dict( content="magic", type="stream", sender_email="boo@email.com", sender_full_name="boo" ) @@ -18,76 +20,184 @@ def test_no_command(self): res["content"], "You are not in a game at the moment. Type `help` for help." ) - # FIXME: Add tests for computer moves - # FIXME: Add test lib for game_handler + def test_parse_board_identity_empty_board(self) -> None: + # Merels parse_board is identity; verify with the canonical empty board. + bot, _ = self._get_handlers() + self.assertEqual(bot.game_message_handler.parse_board(EMPTY_BOARD), EMPTY_BOARD) - # Test for unchanging aspects within the game - # Player Color, Start Message, Moving Message - def test_static_responses(self) -> None: - model, message_handler = self._get_game_handlers() - self.assertNotEqual(message_handler.get_player_color(0), None) - self.assertNotEqual(message_handler.game_start_message(), None) - self.assertEqual( - message_handler.alert_move_message("foo", "moved right"), "foo :moved right" + +class GameAdapterTestLib: + """Small helpers for driving GameAdapter-based bots in tests.""" + + def send( + self, + bot, + bot_handler, + content: str, + *, + user: str = "foo@example.com", + user_name: str = "foo", + ) -> None: + bot.handle_message( + self.make_request_message(content, user=user, user_name=user_name), + bot_handler, ) - # Test to see if the attributes exist - def test_has_attributes(self) -> None: - model, message_handler = self._get_game_handlers() - # Attributes from the Merels Handler - self.assertTrue(hasattr(message_handler, "parse_board") is not None) - self.assertTrue(hasattr(message_handler, "get_player_color") is not None) - self.assertTrue(hasattr(message_handler, "alert_move_message") is not None) - self.assertTrue(hasattr(message_handler, "game_start_message") is not None) - self.assertTrue(hasattr(message_handler, "alert_move_message") is not None) - # Attributes from the Merels Model - self.assertTrue(hasattr(model, "determine_game_over") is not None) - self.assertTrue(hasattr(model, "contains_winning_move") is not None) - self.assertTrue(hasattr(model, "make_move") is not None) - - def test_parse_board(self) -> None: - board = EMPTY_BOARD - expect_response = EMPTY_BOARD - self._test_parse_board(board, expect_response) - - def test_add_user_to_cache(self): - self.add_user_to_cache("Name") - - def test_setup_game(self): - self.setup_game() - - def add_user_to_cache(self, name: str, bot: Any = None) -> Any: - if bot is None: - bot, bot_handler = self._get_handlers() - message = { - "sender_email": f"{name}@example.com", - "sender_full_name": f"{name}", + def replies(self, bot_handler): + # Return the bot message 'content' fields from the transcript. + return [m["content"] for (_method, m) in bot_handler.transcript] + + def send_and_collect( + self, + bot, + bot_handler, + content: str, + *, + user: str = "foo@example.com", + user_name: str = "foo", + ): + bot_handler.reset_transcript() + self.send(bot, bot_handler, content, user=user, user_name=user_name) + return self.replies(bot_handler) + + +# Note: Merels has no vs-computer mode (in merels.py, supports_computer=False). +# If computer mode is added in the future, add adapter-level tests here. + + +class TestMerelsAdapter(BotTestCase, DefaultTests, GameAdapterTestLib): + """Adapter-focused tests (mirrors connect_four); use stable fragment assertions.""" + + bot_name = "merels" + + @override + def make_request_message( + self, content: str, user: str = "foo@example.com", user_name: str = "foo" + ) -> Dict[str, str]: + # Provide stream metadata consumed by GameAdapter. + return { + "sender_email": user, + "sender_full_name": user_name, + "content": content, + "type": "stream", + "display_recipient": "general", + "subject": "merels-test-topic", } - bot.add_user_to_cache(message) - return bot - - def setup_game(self) -> None: - bot = self.add_user_to_cache("foo") - self.add_user_to_cache("baz", bot) - instance = GameInstance( - bot, False, "test game", "abc123", ["foo@example.com", "baz@example.com"], "test" + + def test_help_is_merels_help(self) -> None: + bot, bot_handler = self._get_handlers() + + bot_handler.reset_transcript() + bot.handle_message(self.make_request_message("help"), bot_handler) + + responses = [m for (_method, m) in bot_handler.transcript] + self.assertTrue(responses, "No bot response to 'help'") + help_text = responses[0]["content"] + + # Assert on stable fragments to avoid brittle exact matches. + self.assertIn("Merels Bot Help", help_text) + self.assertIn("start game", help_text) + self.assertIn("play game", help_text) + self.assertIn("quit", help_text) + self.assertIn("rules", help_text) + self.assertIn("leaderboard", help_text) + self.assertIn("cancel game", help_text) + + def test_start_game_emits_invite(self) -> None: + bot, bot_handler = self._get_handlers() + bot_handler.reset_transcript() + + bot.handle_message( + self.make_request_message("start game", user="foo@example.com", user_name="foo"), + bot_handler, ) - bot.instances.update({"abc123": instance}) - instance.start() - return bot - def _get_game_handlers(self) -> Tuple[Any, Any]: + contents = [m["content"] for (_method, m) in bot_handler.transcript] + self.assertTrue(contents, "No bot reply recorded for 'start game'") + first = contents[0] + self.assertIn("wants to play", first) + self.assertIn("Merels", first) + self.assertIn("join", first) + + def test_join_starts_game_emits_start_message(self) -> None: bot, bot_handler = self._get_handlers() - return bot.model, bot.game_message_handler + expected_fragment = bot.game_message_handler.game_start_message() - def _test_parse_board(self, board: str, expected_response: str) -> None: - model, message_handler = self._get_game_handlers() - response = message_handler.parse_board(board) - self.assertEqual(response, expected_response) + bot_handler.reset_transcript() + bot.handle_message( + self.make_request_message("start game", "foo@example.com", "foo"), bot_handler + ) + bot.handle_message(self.make_request_message("join", "bar@example.com", "bar"), bot_handler) - def _test_determine_game_over( - self, board: List[List[int]], players: List[str], expected_response: str - ) -> None: - model, message_handler = self._get_game_handlers() - response = model.determine_game_over(players) - self.assertEqual(response, expected_response) + contents = [m["content"] for (_method, m) in bot_handler.transcript] + self.assertTrue( + any(expected_fragment in c for c in contents), + "Merels start message not found after 'join'", + ) + + def test_message_handler_helpers(self) -> None: + bot, _ = self._get_handlers() + + # Identity parse_board. + self.assertEqual( + bot.game_message_handler.parse_board("sample_board_repr"), "sample_board_repr" + ) + + # Token color in allowed set. + self.assertIn( + bot.game_message_handler.get_player_color(0), + (":o_button:", ":cross_mark_button:"), + ) + self.assertIn( + bot.game_message_handler.get_player_color(1), + (":o_button:", ":cross_mark_button:"), + ) + + # Basic move alert format. + self.assertEqual( + bot.game_message_handler.alert_move_message("foo", "move 1,1"), + "foo :move 1,1", + ) + + def test_move_after_join_invokes_make_move_and_replies(self) -> None: + """ + Start a two-player game, send a move via the adapter, count make_move calls, + and assert we get a reply. Try both users to avoid turn assumptions. + """ + import types + + bot, bot_handler = self._get_handlers() + + # Start 2P game. + _ = self.send_and_collect( + bot, bot_handler, "start game", user="foo@example.com", user_name="foo" + ) + _ = self.send_and_collect(bot, bot_handler, "join", user="bar@example.com", user_name="bar") + + # Count model.make_move invocations. + self.assertTrue(hasattr(bot.model, "make_move"), "Merels model has no make_move method") + original = bot.model.make_move + calls = {"n": 0} + + def _wrapped_make_move(*args, **kwargs): + calls["n"] += 1 + return original(*args, **kwargs) + + bot.model.make_move = types.MethodType(_wrapped_make_move, bot.model) # type: ignore[attr-defined] + try: + contents_foo = self.send_and_collect( + bot, bot_handler, "move 1,1", user="foo@example.com", user_name="foo" + ) + if calls["n"] == 0: + contents_bar = self.send_and_collect( + bot, bot_handler, "move 1,1", user="bar@example.com", user_name="bar" + ) + else: + contents_bar = [] + + self.assertGreaterEqual(calls["n"], 1, "make_move was not called for a move command") + self.assertTrue( + contents_foo or contents_bar, "No bot reply after sending a move command" + ) + finally: + bot.model.make_move = original # type: ignore[assignment]