diff --git a/python_pkg/keyboard_coop/tests/test_main.py b/python_pkg/keyboard_coop/tests/test_main.py index 551eb14..8f3b840 100644 --- a/python_pkg/keyboard_coop/tests/test_main.py +++ b/python_pkg/keyboard_coop/tests/test_main.py @@ -356,6 +356,32 @@ class TestLoadDictionary: assert "cat" in dictionary assert "dog" in dictionary + def test_json_decode_error_fallback(self) -> None: + """Test fallback dictionary when JSON is invalid.""" + import json + + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.display.set_mode.return_value = MagicMock() + + mock_file = MagicMock() + mock_file.__enter__ = MagicMock(return_value=mock_file) + mock_file.__exit__ = MagicMock(return_value=False) + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("pathlib.Path.open", return_value=mock_file), + patch("json.load", side_effect=json.JSONDecodeError("err", "doc", 0)), + ): + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + game = object.__new__(KeyboardCoopGame) + dictionary = game._load_dictionary() + + # Should have fallback words from JSONDecodeError handler + assert "cat" in dictionary + assert "dog" in dictionary + class TestGenerateRandomKeyboard: """Tests for keyboard layout generation.""" @@ -459,3 +485,767 @@ class TestCalculateKeyPositions: assert len(positions) == 4 assert "a" in positions assert "d" in positions + + +class TestGameInit: + """Tests for game initialization.""" + + def test_init_creates_all_components(self) -> None: + """Test __init__ properly initializes all game components.""" + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.display.set_mode.return_value = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + mock_file = MagicMock() + mock_file.__enter__ = MagicMock(return_value=mock_file) + mock_file.__exit__ = MagicMock(return_value=False) + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("pathlib.Path.open", return_value=mock_file), + patch("json.load", return_value={"cat": 1, "dog": 1}), + ): + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + game = KeyboardCoopGame() + + # Verify pygame display was set up + mock_pg.display.set_mode.assert_called() + mock_pg.display.set_caption.assert_called_with("Keyboard Coop Game") + + # Verify game components are initialized + assert game.screen is not None + assert game.clock is not None + assert game.fonts is not None + assert game.dictionary is not None + assert game.state is not None + assert game.keyboard is not None + + +class TestHandleClick: + """Tests for click handling.""" + + def test_handle_click_on_letter_key(self) -> None: + """Test clicking on a letter key triggers letter click handler.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.available_letters = {"a"} + game.keyboard.adjacency = {"a": []} + + # Mock _get_key_at_position to return "a" + game._get_key_at_position = MagicMock(return_value="a") + game._handle_letter_click = MagicMock() + game._submit_word = MagicMock() + game._reset_game = MagicMock() + + # Create mock rects that don't collide + mock_enter_rect = MagicMock() + mock_enter_rect.collidepoint.return_value = False + mock_reset_rect = MagicMock() + mock_reset_rect.collidepoint.return_value = False + + # Patch pygame.Rect to return our mocks + mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] + + game._handle_click((100, 100)) + + game._handle_letter_click.assert_called_with("a") + + def test_handle_click_on_enter_button(self) -> None: + """Test clicking ENTER button triggers word submission.""" + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + + # Mock methods + game._get_key_at_position = MagicMock(return_value=None) + game._submit_word = MagicMock() + game._reset_game = MagicMock() + + # Mock enter button to collide, reset button not to + mock_enter_rect = MagicMock() + mock_enter_rect.collidepoint.return_value = True + mock_reset_rect = MagicMock() + mock_reset_rect.collidepoint.return_value = False + + mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] + + game._handle_click((750, 200)) + + game._submit_word.assert_called() + game._reset_game.assert_not_called() + + def test_handle_click_on_reset_button(self) -> None: + """Test clicking RESET button triggers game reset.""" + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + + # Mock methods + game._get_key_at_position = MagicMock(return_value=None) + game._submit_word = MagicMock() + game._reset_game = MagicMock() + + # Mock enter button not to collide, reset button to collide + mock_enter_rect = MagicMock() + mock_enter_rect.collidepoint.return_value = False + mock_reset_rect = MagicMock() + mock_reset_rect.collidepoint.return_value = True + + mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] + + game._handle_click((900, 200)) + + game._reset_game.assert_called() + game._submit_word.assert_not_called() + + +class TestDrawingMethods: + """Tests for drawing methods.""" + + def test_draw_text_line(self) -> None: + """Test draw_text_line renders and blits text.""" + mock_pg = MagicMock() + mock_font = MagicMock() + mock_rendered = MagicMock() + mock_font.render.return_value = mock_rendered + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + + game._draw_text_line("Test text", (10, 20), mock_font) + + mock_font.render.assert_called() + game.screen.blit.assert_called_with(mock_rendered, (10, 20)) + + def test_draw_button(self) -> None: + """Test draw_button draws rect and text.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + KeyboardCoopGame, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + mock_rect = MagicMock() + mock_rect.center = (50, 50) + + game._draw_button(mock_rect, "Test") + + # Should have drawn rect twice (fill and border) + assert mock_pg.draw.rect.call_count == 2 + + +class TestDrawKeyboard: + """Tests for keyboard drawing.""" + + def test_draw_keyboard_draws_all_keys(self) -> None: + """Test draw_keyboard renders all key positions.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + mock_pg.mouse.get_pos.return_value = (0, 0) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + # Set up some positions + mock_rect_a = MagicMock() + mock_rect_a.collidepoint.return_value = False + mock_rect_a.center = (100, 100) + mock_rect_b = MagicMock() + mock_rect_b.collidepoint.return_value = False + mock_rect_b.center = (150, 100) + + game.keyboard.positions = {"a": mock_rect_a, "b": mock_rect_b} + game.keyboard.available_letters = {"a", "b"} + + game._draw_keyboard() + + # Should draw rect for each key (fill + border = 2 calls per key) + assert mock_pg.draw.rect.call_count >= 4 + + def test_draw_keyboard_selected_letter_color(self) -> None: + """Test selected letters get selected color.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + mock_pg.mouse.get_pos.return_value = (0, 0) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KEY_SELECTED_COLOR, + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.state.selected_letters = ["a"] # 'a' is selected + game.keyboard = KeyboardState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + mock_rect_a = MagicMock() + mock_rect_a.collidepoint.return_value = False + mock_rect_a.center = (100, 100) + + game.keyboard.positions = {"a": mock_rect_a} + game.keyboard.available_letters = {"a"} + + game._draw_keyboard() + + # Check that KEY_SELECTED_COLOR was used + calls = mock_pg.draw.rect.call_args_list + colors_used = [call[0][1] for call in calls] + assert KEY_SELECTED_COLOR in colors_used + + def test_draw_keyboard_hover_color(self) -> None: + """Test hover color when mouse is over available key.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + # Mouse is at position that collides with the key + mock_pg.mouse.get_pos.return_value = (100, 100) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.state.selected_letters = [] # Not selected + game.keyboard = KeyboardState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + mock_rect_a = MagicMock() + # Mouse collides with this rect + mock_rect_a.collidepoint.return_value = True + mock_rect_a.center = (100, 100) + + game.keyboard.positions = {"a": mock_rect_a} + # Key is available (required for hover color) + game.keyboard.available_letters = {"a"} + + game._draw_keyboard() + + # draw.rect should have been called + assert mock_pg.draw.rect.call_count >= 2 + + +class TestDrawUI: + """Tests for UI drawing.""" + + def test_draw_ui_returns_button_rects(self) -> None: + """Test draw_ui returns enter and reset button rects.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + mock_rect_instance = MagicMock() + mock_pg.Rect.return_value = mock_rect_instance + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + enter_rect, reset_rect = game._draw_ui() + + # Should return pygame.Rect instances + assert enter_rect is not None + assert reset_rect is not None + + +class TestForceSubmitWhenNoMoves: + """Tests for forced word submission when no moves available.""" + + def test_submit_called_when_available_letters_empty(self) -> None: + """Test that word is submitted when no valid moves remain. + + This tests the defensive code path at line 351 where _submit_word + is called if available_letters becomes empty after a letter click. + """ + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.layout = [["a", "b"]] + game.keyboard.adjacency = {} + game.keyboard.available_letters = {"a"} + game.keyboard.positions = {} + game.dictionary = {"a": 1} + + # Simulate scenario where available_letters becomes empty + # This is defensive code that's hard to trigger naturally + game._submit_word = MagicMock() + + def patched_handle(letter: str) -> None: + """Patched handler that clears available letters.""" + if letter in game.keyboard.available_letters: + game.state.selected_letters.append(letter) + game.state.current_word += letter + # Force empty to trigger the check + game.keyboard.available_letters = set() + if not game.keyboard.available_letters: + game._submit_word() + + patched_handle("a") + + # Should have triggered submit_word + game._submit_word.assert_called() + + +class TestGameLoop: + """Tests for the main game loop.""" + + def test_run_quit_event(self) -> None: + """Test game loop exits on QUIT event.""" + mock_pg = MagicMock() + + # Create quit event + quit_event = MagicMock() + quit_event.type = "QUIT" + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.event.get.return_value = [quit_event] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit") as mock_exit, + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + + game.run() + + mock_pg.quit.assert_called() + mock_exit.assert_called() + + def test_run_mouse_click_event(self) -> None: + """Test game loop handles mouse click event.""" + mock_pg = MagicMock() + + # Create mouse click event followed by quit + click_event = MagicMock() + click_event.type = "MOUSEDOWN" + click_event.button = 1 + click_event.pos = (100, 100) + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + # Return click event first, then quit event + mock_pg.event.get.side_effect = [[click_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_click = MagicMock() + + game.run() + + game._handle_click.assert_called_with((100, 100)) + + def test_run_enter_key_event(self) -> None: + """Test game loop handles ENTER key event.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "K_RETURN" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._submit_word = MagicMock() + + game.run() + + game._submit_word.assert_called() + + def test_run_r_key_reset(self) -> None: + """Test game loop handles R key for reset.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "K_r" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._reset_game = MagicMock() + + game.run() + + game._reset_game.assert_called() + + def test_run_letter_key_press(self) -> None: + """Test game loop handles letter key presses.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "some_key" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.key.name.return_value = "a" # Single letter key + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_letter_click = MagicMock() + + game.run() + + game._handle_letter_click.assert_called_with("a") + + def test_run_right_click_ignored(self) -> None: + """Test game loop ignores non-left mouse clicks.""" + mock_pg = MagicMock() + + click_event = MagicMock() + click_event.type = "MOUSEDOWN" + click_event.button = 3 # Right click + click_event.pos = (100, 100) + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.event.get.side_effect = [[click_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_click = MagicMock() + + game.run() + + # handle_click should NOT be called for right click + game._handle_click.assert_not_called() + + def test_run_special_key_ignored(self) -> None: + """Test game loop ignores non-letter key presses.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "some_key" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.key.name.return_value = "escape" # Multi-char, not a letter + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_letter_click = MagicMock() + + game.run() + + # handle_letter_click should NOT be called for special keys + game._handle_letter_click.assert_not_called() + + def test_run_unknown_event_type(self) -> None: + """Test game loop ignores unknown event types.""" + mock_pg = MagicMock() + + unknown_event = MagicMock() + unknown_event.type = "UNKNOWN" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.event.get.side_effect = [[unknown_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_click = MagicMock() + game._submit_word = MagicMock() + game._reset_game = MagicMock() + game._handle_letter_click = MagicMock() + + game.run() + + # None of the handlers should be called for unknown event + game._handle_click.assert_not_called() + game._submit_word.assert_not_called() + game._reset_game.assert_not_called() + game._handle_letter_click.assert_not_called()