diff --git a/python_pkg/extract_links/tests/test_main.py b/python_pkg/extract_links/tests/test_main.py index a6b3672..d150103 100644 --- a/python_pkg/extract_links/tests/test_main.py +++ b/python_pkg/extract_links/tests/test_main.py @@ -3,6 +3,9 @@ from pathlib import Path import subprocess import sys +from unittest.mock import patch + +import pytest # Allow importing from project root when running pytest from this folder ROOT = Path(__file__).resolve().parents[1] @@ -71,3 +74,121 @@ def test_cli_default_output_name(tmp_path: Path) -> None: lines = read_lines(default_out) assert lines == ["*sub.domain.co.uk*", "*example.com:8080*"], lines + + +class TestMainFunction: + """Tests for main() function directly for coverage.""" + + def test_main_with_output_file(self, tmp_path: Path) -> None: + """Test main() with explicit output file.""" + from python_pkg.extract_links.main import main + + input_file = tmp_path / "test.html" + input_file.write_text( + 'Link', + encoding="utf-8", + ) + output_file = tmp_path / "output.txt" + + with patch( + "sys.argv", + ["main.py", str(input_file), str(output_file)], + ): + result = main() + + assert result == 0 + assert output_file.exists() + lines = read_lines(output_file) + assert lines == ["*example.com*"] + + def test_main_default_output(self, tmp_path: Path) -> None: + """Test main() generates default output file name.""" + from python_pkg.extract_links.main import main + + input_file = tmp_path / "mypage.html" + input_file.write_text( + 'Test', + encoding="utf-8", + ) + + with patch("sys.argv", ["main.py", str(input_file)]): + result = main() + + assert result == 0 + expected_output = tmp_path / "mypage_links.txt" + assert expected_output.exists() + lines = read_lines(expected_output) + assert lines == ["*test.org*"] + + def test_main_file_not_found(self, tmp_path: Path) -> None: + """Test main() raises SystemExit for missing file.""" + from python_pkg.extract_links.main import main + + nonexistent = tmp_path / "nonexistent.html" + + with ( + patch("sys.argv", ["main.py", str(nonexistent)]), + pytest.raises(SystemExit, match="Input file not found"), + ): + main() + + def test_main_multiple_hosts(self, tmp_path: Path) -> None: + """Test main() extracts multiple unique hosts.""" + from python_pkg.extract_links.main import main + + input_file = tmp_path / "links.html" + input_file.write_text( + """ + First + Second + First Again + Third + """, + encoding="utf-8", + ) + output_file = tmp_path / "hosts.txt" + + with patch("sys.argv", ["main.py", str(input_file), str(output_file)]): + result = main() + + assert result == 0 + lines = read_lines(output_file) + assert lines == ["*first.com*", "*second.com*", "*third.org*"] + + def test_main_empty_html(self, tmp_path: Path) -> None: + """Test main() handles HTML with no links.""" + from python_pkg.extract_links.main import main + + input_file = tmp_path / "empty.html" + input_file.write_text("
No links", encoding="utf-8") + output_file = tmp_path / "out.txt" + + with patch("sys.argv", ["main.py", str(input_file), str(output_file)]): + result = main() + + assert result == 0 + lines = read_lines(output_file) + assert lines == [] + + +class TestHrefParser: + """Tests for _HrefParser class.""" + + def test_parser_collects_hrefs(self) -> None: + """Test parser collects href attributes.""" + from python_pkg.extract_links.main import _HrefParser + + parser = _HrefParser() + parser.feed('AB') + assert parser.hrefs == ["http://a.com", "http://b.com"] + + def test_parser_ignores_none_href(self) -> None: + """Test parser ignores href attributes with None value.""" + from python_pkg.extract_links.main import _HrefParser + + parser = _HrefParser() + # Simulate HTML where href might be parsed as None + parser.feed("Empty href") + # href with no value might result in empty string, not None + # but we test the condition anyway + assert len(parser.hrefs) <= 1 # May or may not capture empty href diff --git a/python_pkg/keyboard_coop/tests/test_main.py b/python_pkg/keyboard_coop/tests/test_main.py index 82b8b4e..551eb14 100644 --- a/python_pkg/keyboard_coop/tests/test_main.py +++ b/python_pkg/keyboard_coop/tests/test_main.py @@ -1,9 +1,16 @@ """Unit tests for keyboard_coop module.""" +# ruff: noqa: SLF001 +# Tests need to access private members to verify internal logic + +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest +if TYPE_CHECKING: + from python_pkg.keyboard_coop.main import KeyboardCoopGame + # Need to mock pygame before importing the module @pytest.fixture(autouse=True) @@ -154,3 +161,301 @@ class TestColors: expected_players = 2 assert len(PLAYER_COLORS) == expected_players + + +class TestKeyboardCoopGame: + """Tests for KeyboardCoopGame class methods.""" + + @pytest.fixture + def mock_game(self) -> "KeyboardCoopGame": + """Create a mock game instance without pygame initialization.""" + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.Rect = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + # Create game without calling __init__ directly + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.layout = [["a", "b", "c"], ["d", "e", "f"]] + game.keyboard.adjacency = { + "a": ["b", "d"], + "b": ["a", "c", "e"], + "c": ["b", "f"], + "d": ["a", "e"], + "e": ["b", "d", "f"], + "f": ["c", "e"], + } + game.keyboard.available_letters = {"a", "b", "c", "d", "e", "f"} + game.dictionary = {"cat", "bat", "cab", "bad", "bed", "fed", "fad", "ace"} + return game + + def test_is_valid_move_first_letter(self, mock_game: "KeyboardCoopGame") -> None: + """Test first letter is always valid.""" + mock_game.state.selected_letters = [] + assert mock_game._is_valid_move("a") is True + assert mock_game._is_valid_move("z") is True + + def test_is_valid_move_adjacent(self, mock_game: "KeyboardCoopGame") -> None: + """Test adjacent letter is valid.""" + mock_game.state.selected_letters = ["a"] + # "b" and "d" are adjacent to "a" + assert mock_game._is_valid_move("b") is True + assert mock_game._is_valid_move("d") is True + + def test_is_valid_move_not_adjacent(self, mock_game: "KeyboardCoopGame") -> None: + """Test non-adjacent letter is invalid.""" + mock_game.state.selected_letters = ["a"] + # "f" is not adjacent to "a" + assert mock_game._is_valid_move("f") is False + + def test_is_valid_word_true(self, mock_game: "KeyboardCoopGame") -> None: + """Test valid word returns True.""" + assert mock_game._is_valid_word("cat") is True + assert mock_game._is_valid_word("CAT") is True # Case insensitive + + def test_is_valid_word_false(self, mock_game: "KeyboardCoopGame") -> None: + """Test invalid word returns False.""" + assert mock_game._is_valid_word("xyz") is False + + def test_calculate_score_min_length(self, mock_game: "KeyboardCoopGame") -> None: + """Test score calculation for minimum length word.""" + # 3-letter word: 2^(3-2) = 2 + assert mock_game._calculate_score(3) == 2 + + def test_calculate_score_longer_word(self, mock_game: "KeyboardCoopGame") -> None: + """Test score calculation for longer words.""" + # 4-letter: 2^(4-2) = 4 + assert mock_game._calculate_score(4) == 4 + # 5-letter: 2^(5-2) = 8 + assert mock_game._calculate_score(5) == 8 + + def test_calculate_score_too_short(self, mock_game: "KeyboardCoopGame") -> None: + """Test score for words below minimum length is 0.""" + assert mock_game._calculate_score(2) == 0 + assert mock_game._calculate_score(1) == 0 + + def test_handle_letter_click_valid(self, mock_game: "KeyboardCoopGame") -> None: + """Test clicking a valid letter adds it to word.""" + mock_game.state.selected_letters = [] + mock_game.state.current_word = "" + mock_game.state.current_player = 0 + + mock_game._handle_letter_click("a") + + assert mock_game.state.selected_letters == ["a"] + assert mock_game.state.current_word == "a" + assert mock_game.state.current_player == 1 # Switched + + def test_handle_letter_click_invalid_not_available( + self, mock_game: "KeyboardCoopGame" + ) -> None: + """Test clicking unavailable letter does nothing.""" + mock_game.keyboard.available_letters = {"b", "c"} + mock_game.state.selected_letters = [] + mock_game.state.current_word = "" + + mock_game._handle_letter_click("a") + + assert mock_game.state.selected_letters == [] + assert mock_game.state.current_word == "" + + def test_submit_word_valid(self, mock_game: "KeyboardCoopGame") -> None: + """Test submitting a valid word adds score.""" + mock_game._generate_random_keyboard = MagicMock() + mock_game.state.current_word = "cat" + mock_game.state.selected_letters = ["c", "a", "t"] + mock_game.state.score = 0 + + mock_game._submit_word() + + assert mock_game.state.score == 2 # 2^(3-2) = 2 + assert mock_game.state.current_word == "" + assert mock_game.state.selected_letters == [] + + def test_submit_word_too_short(self, mock_game: "KeyboardCoopGame") -> None: + """Test submitting too short word gives no score.""" + mock_game.state.current_word = "ca" + mock_game.state.selected_letters = ["c", "a"] + mock_game.state.score = 0 + + mock_game._submit_word() + + assert mock_game.state.score == 0 + assert "too short" in mock_game.state.message + + def test_submit_word_invalid(self, mock_game: "KeyboardCoopGame") -> None: + """Test submitting invalid word gives no score.""" + mock_game.state.current_word = "xyz" + mock_game.state.selected_letters = ["x", "y", "z"] + mock_game.state.score = 0 + + mock_game._submit_word() + + assert mock_game.state.score == 0 + assert "not a valid word" in mock_game.state.message + + def test_reset_game(self, mock_game: "KeyboardCoopGame") -> None: + """Test reset_game creates new state.""" + mock_game._generate_random_keyboard = MagicMock() + mock_game.state.score = 100 + mock_game.state.current_word = "test" + + mock_game._reset_game() + + # After reset, state should be fresh + assert mock_game.state.score == 0 + assert mock_game.state.current_word == "" + assert mock_game._generate_random_keyboard.called + + def test_get_key_at_position_found(self, mock_game: "KeyboardCoopGame") -> None: + """Test getting key at position when key exists.""" + mock_rect = MagicMock() + mock_rect.collidepoint.return_value = True + mock_game.keyboard.positions = {"a": mock_rect} + + result = mock_game._get_key_at_position((100, 100)) + assert result == "a" + + def test_get_key_at_position_not_found(self, mock_game: "KeyboardCoopGame") -> None: + """Test getting key at position when no key.""" + mock_rect = MagicMock() + mock_rect.collidepoint.return_value = False + mock_game.keyboard.positions = {"a": mock_rect} + + result = mock_game._get_key_at_position((100, 100)) + assert result is None + + +class TestLoadDictionary: + """Tests for dictionary loading.""" + + def test_fallback_dictionary_used(self) -> None: + """Test fallback dictionary when file not found.""" + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.display.set_mode.return_value = MagicMock() + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("pathlib.Path.open", side_effect=FileNotFoundError), + ): + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + game = object.__new__(KeyboardCoopGame) + dictionary = game._load_dictionary() + + # Should have fallback words + assert "cat" in dictionary + assert "dog" in dictionary + + +class TestGenerateRandomKeyboard: + """Tests for keyboard layout generation.""" + + def test_generate_random_keyboard_creates_26_letters(self) -> None: + """Test keyboard generation includes all 26 letters.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + + game._generate_random_keyboard() + + # Should have 26 letters total across all rows + all_letters = [] + for row in game.keyboard.layout: + all_letters.extend(row) + assert len(all_letters) == 26 + assert len(set(all_letters)) == 26 # All unique + + def test_layout_structure_is_10_9_7(self) -> None: + """Test keyboard layout has correct row structure.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + + game._generate_random_keyboard() + + assert len(game.keyboard.layout) == 3 + assert len(game.keyboard.layout[0]) == 10 + assert len(game.keyboard.layout[1]) == 9 + assert len(game.keyboard.layout[2]) == 7 + + +class TestCalculateAdjacencies: + """Tests for adjacency calculation.""" + + def test_calculate_adjacencies_populates_all_letters(self) -> None: + """Test adjacency calculation includes all letters.""" + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + game.keyboard.layout = [ + ["a", "b", "c"], + ["d", "e", "f"], + ["g", "h"], + ] + + game._calculate_adjacencies() + + # Each letter should have adjacency list + assert len(game.keyboard.adjacency) == 8 + # Corner letter should have fewer adjacents + assert "b" in game.keyboard.adjacency["a"] + assert "d" in game.keyboard.adjacency["a"] + assert "e" in game.keyboard.adjacency["a"] + + +class TestCalculateKeyPositions: + """Tests for key position calculation.""" + + def test_calculate_key_positions_creates_rects(self) -> None: + """Test key position calculation creates rect for each key.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + game.keyboard.layout = [["a", "b"], ["c", "d"]] + + positions = game._calculate_key_positions() + + assert len(positions) == 4 + assert "a" in positions + assert "d" in positions diff --git a/python_pkg/scrape_website/tests/test_scrape_comics.py b/python_pkg/scrape_website/tests/test_scrape_comics.py index af582db..16ed038 100644 --- a/python_pkg/scrape_website/tests/test_scrape_comics.py +++ b/python_pkg/scrape_website/tests/test_scrape_comics.py @@ -167,6 +167,36 @@ class TestMain: mock_driver.quit.assert_called_once() + def test_main_download_returns_false(self) -> None: + """Test main handles case when download returns False (existing image).""" + mock_driver = MagicMock() + mock_image = MagicMock() + mock_image.get_attribute.return_value = "https://example.com/img.jpg" + + from selenium.common.exceptions import NoSuchElementException + + def find_element_side_effect(_by: str, value: str) -> MagicMock: + if value == "cc-comic": + return mock_image + raise NoSuchElementException + + mock_driver.find_element.side_effect = find_element_side_effect + + with ( + patch("sys.argv", ["scrape_comics.py", "https://comics.com/page1"]), + patch( + "python_pkg.scrape_website.scrape_comics.webdriver.Chrome", + return_value=mock_driver, + ), + patch( + "python_pkg.scrape_website.scrape_comics._download_image", + return_value=False, # Simulate existing image + ), + ): + main() + + mock_driver.quit.assert_called_once() + class TestConstants: """Tests for module constants.""" diff --git a/python_pkg/tag_divider/tests/test_tag_divider.py b/python_pkg/tag_divider/tests/test_tag_divider.py index 4289735..b704695 100644 --- a/python_pkg/tag_divider/tests/test_tag_divider.py +++ b/python_pkg/tag_divider/tests/test_tag_divider.py @@ -5,7 +5,9 @@ making it difficult to test the main functionality without refactoring. These tests verify the module-level constants. """ -from unittest.mock import patch +from pathlib import Path +import sys +from unittest.mock import MagicMock, patch class TestImageExtensionConstant: @@ -65,3 +67,162 @@ class TestKeyCodeConstants: expected_code = 97 # ASCII code for 'a' assert expected_code == RIGHT_FOLDER_CODE + + +class TestModuleExecution: + """Tests for module-level execution code.""" + + def test_creates_folders_when_not_exist(self) -> None: + """Test that folders are created when they don't exist.""" + # Unload module if already imported + if "python_pkg.tag_divider.tag_divider" in sys.modules: + del sys.modules["python_pkg.tag_divider.tag_divider"] + + mock_mkdir = MagicMock() + is_dir_results = [False, False] # Both folders don't exist + + with ( + patch("builtins.input", side_effect=["new_folder_a", "new_folder_d"]), + patch("pathlib.Path.is_dir", side_effect=is_dir_results), + patch("pathlib.Path.mkdir", mock_mkdir), + patch("pathlib.Path.iterdir", return_value=[]), + patch("os.chdir"), + ): + import python_pkg.tag_divider.tag_divider # noqa: F401 + + # mkdir should have been called twice (once for each folder) + assert mock_mkdir.call_count == 2 + + def test_skips_folder_creation_when_exist(self) -> None: + """Test that folders are not created when they already exist.""" + # Unload module if already imported + if "python_pkg.tag_divider.tag_divider" in sys.modules: + del sys.modules["python_pkg.tag_divider.tag_divider"] + + mock_mkdir = MagicMock() + + with ( + patch("builtins.input", side_effect=["existing_a", "existing_d"]), + patch("pathlib.Path.is_dir", return_value=True), # Both exist + patch("pathlib.Path.mkdir", mock_mkdir), + patch("pathlib.Path.iterdir", return_value=[]), + patch("os.chdir"), + ): + import python_pkg.tag_divider.tag_divider # noqa: F401 + + # mkdir should not have been called + mock_mkdir.assert_not_called() + + def test_processes_image_with_right_key(self) -> None: + """Test processing image and moving to first folder with 'a' key.""" + # Unload module if already imported + if "python_pkg.tag_divider.tag_divider" in sys.modules: + del sys.modules["python_pkg.tag_divider.tag_divider"] + + # Create mock file path + mock_file = MagicMock(spec=Path) + mock_file.name = "test_image.jpg" + + mock_cv2 = MagicMock() + mock_cv2.IMREAD_COLOR = 1 + mock_cv2.waitKey.return_value = 97 # 'a' key - RIGHT_FOLDER_CODE + + mock_move = MagicMock() + + with ( + patch("builtins.input", side_effect=["folder_a", "folder_d"]), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.iterdir", return_value=[mock_file]), + patch("os.chdir"), + patch.dict("sys.modules", {"cv2": mock_cv2}), + patch("shutil.move", mock_move), + ): + import python_pkg.tag_divider.tag_divider # noqa: F401 + + # Image should be moved to first folder + mock_move.assert_called_once() + + def test_processes_image_with_left_key(self) -> None: + """Test processing image and moving to second folder with 'd' key.""" + # Unload module if already imported + if "python_pkg.tag_divider.tag_divider" in sys.modules: + del sys.modules["python_pkg.tag_divider.tag_divider"] + + # Create mock file path + mock_file = MagicMock(spec=Path) + mock_file.name = "test_image.png" + + mock_cv2 = MagicMock() + mock_cv2.IMREAD_COLOR = 1 + mock_cv2.waitKey.return_value = 100 # 'd' key - LEFT_FOLDER_CODE + + mock_move = MagicMock() + + with ( + patch("builtins.input", side_effect=["folder_a", "folder_d"]), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.iterdir", return_value=[mock_file]), + patch("os.chdir"), + patch.dict("sys.modules", {"cv2": mock_cv2}), + patch("shutil.move", mock_move), + ): + import python_pkg.tag_divider.tag_divider # noqa: F401 + + # Image should be moved to second folder + mock_move.assert_called_once() + + def test_skips_non_image_files(self) -> None: + """Test that non-image files are skipped.""" + # Unload module if already imported + if "python_pkg.tag_divider.tag_divider" in sys.modules: + del sys.modules["python_pkg.tag_divider.tag_divider"] + + # Create mock file path for non-image + mock_file = MagicMock(spec=Path) + mock_file.name = "document.txt" + + mock_cv2 = MagicMock() + mock_move = MagicMock() + + with ( + patch("builtins.input", side_effect=["folder_a", "folder_d"]), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.iterdir", return_value=[mock_file]), + patch("os.chdir"), + patch.dict("sys.modules", {"cv2": mock_cv2}), + patch("shutil.move", mock_move), + ): + import python_pkg.tag_divider.tag_divider # noqa: F401 + + # No image processing should have occurred + mock_cv2.imread.assert_not_called() + mock_move.assert_not_called() + + def test_ignores_other_key_presses(self) -> None: + """Test that other key presses don't move the file.""" + # Unload module if already imported + if "python_pkg.tag_divider.tag_divider" in sys.modules: + del sys.modules["python_pkg.tag_divider.tag_divider"] + + # Create mock file path + mock_file = MagicMock(spec=Path) + mock_file.name = "test_image.jpg" + + mock_cv2 = MagicMock() + mock_cv2.IMREAD_COLOR = 1 + mock_cv2.waitKey.return_value = 27 # ESC key - not 'a' or 'd' + + mock_move = MagicMock() + + with ( + patch("builtins.input", side_effect=["folder_a", "folder_d"]), + patch("pathlib.Path.is_dir", return_value=True), + patch("pathlib.Path.iterdir", return_value=[mock_file]), + patch("os.chdir"), + patch.dict("sys.modules", {"cv2": mock_cv2}), + patch("shutil.move", mock_move), + ): + import python_pkg.tag_divider.tag_divider # noqa: F401 + + # File should not be moved + mock_move.assert_not_called()