From 4540f9103683a8787e05516fae2b40a22ae9d367 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:12:09 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add generated unit tests --- tests/classic/test_color.py | 81 ++++++++ tests/classic/test_team.py | 142 +++++++++++++ tests/generic/test_board.py | 332 +++++++++++++++++++++++++++++++ tests/generic/test_card.py | 220 ++++++++++++++++++++ tests/generic/test_exceptions.py | 161 +++++++++++++++ tests/generic/test_move.py | 231 +++++++++++++++++++++ tests/utils/test_builder.py | 173 ++++++++++++++++ 7 files changed, 1340 insertions(+) create mode 100644 tests/classic/test_color.py create mode 100644 tests/classic/test_team.py create mode 100644 tests/generic/test_board.py create mode 100644 tests/generic/test_card.py create mode 100644 tests/generic/test_exceptions.py create mode 100644 tests/generic/test_move.py create mode 100644 tests/utils/test_builder.py diff --git a/tests/classic/test_color.py b/tests/classic/test_color.py new file mode 100644 index 0000000..d189b51 --- /dev/null +++ b/tests/classic/test_color.py @@ -0,0 +1,81 @@ +import pytest + +from codenames.classic.color import CARD_COLOR_TO_EMOJI, ClassicColor + + +def test_classic_color_values(): + """Test that all expected colors are defined.""" + assert ClassicColor.BLUE.value == "BLUE" + assert ClassicColor.RED.value == "RED" + assert ClassicColor.NEUTRAL.value == "NEUTRAL" + assert ClassicColor.ASSASSIN.value == "ASSASSIN" + + +def test_classic_color_emoji_blue(): + """Test emoji property for blue color.""" + assert ClassicColor.BLUE.emoji == "🟦" + + +def test_classic_color_emoji_red(): + """Test emoji property for red color.""" + assert ClassicColor.RED.emoji == "🟥" + + +def test_classic_color_emoji_neutral(): + """Test emoji property for neutral color.""" + assert ClassicColor.NEUTRAL.emoji == "⬜" + + +def test_classic_color_emoji_assassin(): + """Test emoji property for assassin color.""" + assert ClassicColor.ASSASSIN.emoji == "💀" + + +def test_all_colors_have_emoji_mapping(): + """Test that all colors have corresponding emoji in the mapping.""" + for color in ClassicColor: + assert color in CARD_COLOR_TO_EMOJI + assert isinstance(CARD_COLOR_TO_EMOJI[color], str) + assert len(CARD_COLOR_TO_EMOJI[color]) > 0 + + +def test_emoji_mapping_contains_only_valid_colors(): + """Test that emoji mapping doesn't contain extra keys.""" + assert len(CARD_COLOR_TO_EMOJI) == 4 + assert set(CARD_COLOR_TO_EMOJI.keys()) == { + ClassicColor.RED, + ClassicColor.BLUE, + ClassicColor.NEUTRAL, + ClassicColor.ASSASSIN, + } + + +def test_color_string_representation(): + """Test string representation of colors.""" + assert str(ClassicColor.BLUE) == "ClassicColor.BLUE" + assert str(ClassicColor.RED) == "ClassicColor.RED" + + +def test_color_equality(): + """Test that color instances are equal to themselves.""" + assert ClassicColor.BLUE == ClassicColor.BLUE + assert ClassicColor.RED != ClassicColor.BLUE + + +def test_color_iteration(): + """Test that we can iterate over all colors.""" + colors = list(ClassicColor) + assert len(colors) == 4 + assert ClassicColor.BLUE in colors + assert ClassicColor.RED in colors + assert ClassicColor.NEUTRAL in colors + assert ClassicColor.ASSASSIN in colors + + +def test_color_membership(): + """Test membership checks for color enum.""" + assert "BLUE" in ClassicColor.__members__ + assert "RED" in ClassicColor.__members__ + assert "NEUTRAL" in ClassicColor.__members__ + assert "ASSASSIN" in ClassicColor.__members__ + assert "INVALID" not in ClassicColor.__members__ \ No newline at end of file diff --git a/tests/classic/test_team.py b/tests/classic/test_team.py new file mode 100644 index 0000000..1cd6b9b --- /dev/null +++ b/tests/classic/test_team.py @@ -0,0 +1,142 @@ +import pytest + +from codenames.classic.color import ClassicColor +from codenames.classic.team import ClassicTeam + + +def test_classic_team_values(): + """Test that all expected teams are defined.""" + assert ClassicTeam.BLUE.value == "BLUE" + assert ClassicTeam.RED.value == "RED" + + +def test_classic_team_as_card_color_blue(): + """Test as_card_color for blue team.""" + assert ClassicTeam.BLUE.as_card_color == ClassicColor.BLUE + + +def test_classic_team_as_card_color_red(): + """Test as_card_color for red team.""" + assert ClassicTeam.RED.as_card_color == ClassicColor.RED + + +def test_classic_team_opponent_blue(): + """Test opponent property for blue team.""" + assert ClassicTeam.BLUE.opponent == ClassicTeam.RED + + +def test_classic_team_opponent_red(): + """Test opponent property for red team.""" + assert ClassicTeam.RED.opponent == ClassicTeam.BLUE + + +def test_classic_team_double_opponent(): + """Test that opponent of opponent returns original team.""" + assert ClassicTeam.BLUE.opponent.opponent == ClassicTeam.BLUE + assert ClassicTeam.RED.opponent.opponent == ClassicTeam.RED + + +def test_classic_team_string_representation(): + """Test string representation of teams.""" + assert str(ClassicTeam.BLUE) == "ClassicTeam.BLUE" + assert str(ClassicTeam.RED) == "ClassicTeam.RED" + + +def test_classic_team_equality(): + """Test that team instances are equal to themselves.""" + assert ClassicTeam.BLUE == ClassicTeam.BLUE + assert ClassicTeam.RED == ClassicTeam.RED + assert ClassicTeam.BLUE != ClassicTeam.RED + + +def test_classic_team_iteration(): + """Test that we can iterate over all teams.""" + teams = list(ClassicTeam) + assert len(teams) == 2 + assert ClassicTeam.BLUE in teams + assert ClassicTeam.RED in teams + + +def test_classic_team_membership(): + """Test membership checks for team enum.""" + assert "BLUE" in ClassicTeam.__members__ + assert "RED" in ClassicTeam.__members__ + assert "GREEN" not in ClassicTeam.__members__ + + +def test_classic_team_card_color_types(): + """Test that as_card_color returns ClassicColor instances.""" + assert isinstance(ClassicTeam.BLUE.as_card_color, ClassicColor) + assert isinstance(ClassicTeam.RED.as_card_color, ClassicColor) + + +def test_classic_team_opponent_types(): + """Test that opponent returns ClassicTeam instances.""" + assert isinstance(ClassicTeam.BLUE.opponent, ClassicTeam) + assert isinstance(ClassicTeam.RED.opponent, ClassicTeam) + + +def test_classic_team_all_teams_have_card_color(): + """Test that all teams have a corresponding card color.""" + for team in ClassicTeam: + card_color = team.as_card_color + assert card_color is not None + assert isinstance(card_color, ClassicColor) + + +def test_classic_team_all_teams_have_opponent(): + """Test that all teams have an opponent.""" + for team in ClassicTeam: + opponent = team.opponent + assert opponent is not None + assert isinstance(opponent, ClassicTeam) + assert opponent != team + + +def test_classic_team_card_color_matches_team_name(): + """Test that card color matches team name.""" + assert ClassicTeam.BLUE.as_card_color == ClassicColor.BLUE + assert ClassicTeam.RED.as_card_color == ClassicColor.RED + + +def test_classic_team_opponents_are_symmetric(): + """Test that if A's opponent is B, then B's opponent is A.""" + blue_opponent = ClassicTeam.BLUE.opponent + red_opponent = ClassicTeam.RED.opponent + + assert blue_opponent == ClassicTeam.RED + assert red_opponent == ClassicTeam.BLUE + + +def test_classic_team_can_be_used_in_set(): + """Test that teams can be used in sets.""" + team_set = {ClassicTeam.BLUE, ClassicTeam.RED, ClassicTeam.BLUE} + assert len(team_set) == 2 + + +def test_classic_team_can_be_used_as_dict_key(): + """Test that teams can be used as dictionary keys.""" + team_dict = { + ClassicTeam.BLUE: "Blue Team", + ClassicTeam.RED: "Red Team", + } + assert team_dict[ClassicTeam.BLUE] == "Blue Team" + assert team_dict[ClassicTeam.RED] == "Red Team" + + +def test_classic_team_hashable(): + """Test that teams are hashable.""" + blue_hash = hash(ClassicTeam.BLUE) + red_hash = hash(ClassicTeam.RED) + + assert isinstance(blue_hash, int) + assert isinstance(red_hash, int) + assert blue_hash != red_hash + + +def test_classic_team_comparison(): + """Test team comparison operations.""" + assert ClassicTeam.BLUE == ClassicTeam.BLUE + assert not (ClassicTeam.BLUE != ClassicTeam.BLUE) + assert ClassicTeam.BLUE != ClassicTeam.RED + assert not (ClassicTeam.BLUE == ClassicTeam.RED) \ No newline at end of file diff --git a/tests/generic/test_board.py b/tests/generic/test_board.py new file mode 100644 index 0000000..c654ef1 --- /dev/null +++ b/tests/generic/test_board.py @@ -0,0 +1,332 @@ +import pytest + +from codenames.classic.board import ClassicBoard +from codenames.classic.color import ClassicColor +from codenames.classic.team import ClassicTeam +from codenames.classic.types import ClassicCard +from codenames.generic.board import two_integer_factors +from codenames.generic.exceptions import CardNotFoundError + + +def test_board_size_property(): + """Test that board size property returns correct length.""" + cards = [ + ClassicCard(word=f"Card {i}", color=ClassicColor.BLUE, revealed=False) + for i in range(10) + ] + board = ClassicBoard(language="english", cards=cards) + assert board.size == 10 + + +def test_board_len(): + """Test that len() works on board.""" + cards = [ + ClassicCard(word=f"Card {i}", color=ClassicColor.BLUE, revealed=False) + for i in range(5) + ] + board = ClassicBoard(language="english", cards=cards) + assert len(board) == 5 + + +def test_board_is_clean_when_no_reveals(): + """Test is_clean returns True when no cards are revealed.""" + cards = [ + ClassicCard(word=f"Card {i}", color=ClassicColor.BLUE, revealed=False) + for i in range(5) + ] + board = ClassicBoard(language="english", cards=cards) + assert board.is_clean is True + + +def test_board_is_not_clean_when_card_revealed(): + """Test is_clean returns False when a card is revealed.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=True), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.is_clean is False + + +def test_board_all_words(): + """Test all_words property returns formatted words.""" + cards = [ + ClassicCard(word="Ocean", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Beach_Sand", color=ClassicColor.RED, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.all_words == ("ocean", "beach sand") + + +def test_board_all_reveals(): + """Test all_reveals property returns reveal states.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=True), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=False), + ClassicCard(word="Card 2", color=ClassicColor.BLUE, revealed=True), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.all_reveals == (True, False, True) + + +def test_board_revealed_card_indexes(): + """Test revealed_card_indexes returns correct indexes.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=True), + ClassicCard(word="Card 2", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 3", color=ClassicColor.NEUTRAL, revealed=True), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.revealed_card_indexes == (1, 3) + + +def test_board_unrevealed_cards(): + """Test unrevealed_cards property returns only unrevealed cards.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=True), + ClassicCard(word="Card 2", color=ClassicColor.BLUE, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + unrevealed = board.unrevealed_cards + assert len(unrevealed) == 2 + assert unrevealed[0].word == "Card 0" + assert unrevealed[1].word == "Card 2" + + +def test_board_revealed_cards(): + """Test revealed_cards property returns only revealed cards.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=True), + ClassicCard(word="Card 2", color=ClassicColor.BLUE, revealed=True), + ] + board = ClassicBoard(language="english", cards=cards) + revealed = board.revealed_cards + assert len(revealed) == 2 + assert revealed[0].word == "Card 1" + assert revealed[1].word == "Card 2" + + +def test_board_censored(): + """Test censored property hides colors of unrevealed cards.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=True), + ] + board = ClassicBoard(language="english", cards=cards) + censored = board.censored + + assert censored.cards[0].color is None # Unrevealed, color hidden + assert censored.cards[1].color == ClassicColor.RED # Revealed, color shown + + +def test_board_cards_for_color(): + """Test cards_for_color returns cards of specified color.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=False), + ClassicCard(word="Card 2", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 3", color=ClassicColor.NEUTRAL, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + blue_cards = board.cards_for_color(ClassicColor.BLUE) + assert len(blue_cards) == 2 + assert all(card.color == ClassicColor.BLUE for card in blue_cards) + + +def test_board_revealed_cards_for_color(): + """Test revealed_cards_for_color returns revealed cards of specified color.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=True), + ClassicCard(word="Card 1", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 2", color=ClassicColor.BLUE, revealed=True), + ClassicCard(word="Card 3", color=ClassicColor.RED, revealed=True), + ] + board = ClassicBoard(language="english", cards=cards) + revealed_blue = board.revealed_cards_for_color(ClassicColor.BLUE) + assert len(revealed_blue) == 2 + assert all(card.color == ClassicColor.BLUE and card.revealed for card in revealed_blue) + + +def test_board_unrevealed_cards_for_color(): + """Test unrevealed_cards_for_color returns unrevealed cards of specified color.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.RED, revealed=True), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=False), + ClassicCard(word="Card 2", color=ClassicColor.RED, revealed=False), + ClassicCard(word="Card 3", color=ClassicColor.BLUE, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + unrevealed_red = board.unrevealed_cards_for_color(ClassicColor.RED) + assert len(unrevealed_red) == 2 + assert all(card.color == ClassicColor.RED and not card.revealed for card in unrevealed_red) + + +def test_board_find_card_index(): + """Test find_card_index returns correct index.""" + cards = [ + ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="beach", color=ClassicColor.RED, revealed=False), + ClassicCard(word="sand", color=ClassicColor.NEUTRAL, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.find_card_index("beach") == 1 + + +def test_board_find_card_index_case_insensitive(): + """Test find_card_index is case insensitive.""" + cards = [ + ClassicCard(word="Ocean", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Beach", color=ClassicColor.RED, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.find_card_index("OCEAN") == 0 + assert board.find_card_index("beach") == 1 + + +def test_board_find_card_index_raises_error_for_missing_word(): + """Test find_card_index raises CardNotFoundError for missing word.""" + cards = [ + ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + with pytest.raises(CardNotFoundError): + board.find_card_index("missing") + + +def test_board_reset_state(): + """Test reset_state sets all cards to unrevealed.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=True), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=True), + ClassicCard(word="Card 2", color=ClassicColor.NEUTRAL, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + board.reset_state() + + assert all(not card.revealed for card in board.cards) + + +def test_board_iteration(): + """Test that board can be iterated over.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=False), + ] + board = ClassicBoard(language="english", cards=cards) + + words = [card.word for card in board] + assert words == ["Card 0", "Card 1"] + + +def test_two_integer_factors_perfect_square(): + """Test two_integer_factors with perfect square.""" + x, y = two_integer_factors(16) + assert x * y == 16 + assert x == 4 + assert y == 4 + + +def test_two_integer_factors_prime(): + """Test two_integer_factors with prime number.""" + x, y = two_integer_factors(7) + assert x * y == 7 + assert x == 7 + assert y == 1 + + +def test_two_integer_factors_rectangle(): + """Test two_integer_factors with non-square number.""" + x, y = two_integer_factors(12) + assert x * y == 12 + # Should return factors close to square root + assert (x, y) in [(4, 3), (3, 4), (6, 2), (2, 6)] + + +def test_two_integer_factors_zero(): + """Test two_integer_factors with zero.""" + x, y = two_integer_factors(0) + assert x == 0 + assert y == 0 + + +def test_two_integer_factors_one(): + """Test two_integer_factors with one.""" + x, y = two_integer_factors(1) + assert x * y == 1 + + +def test_board_from_vocabulary_creates_correct_size(): + """Test that from_vocabulary creates board of correct size.""" + board = ClassicBoard.from_vocabulary( + vocabulary=type("Vocabulary", (), {"language": "english", "words": [f"word{i}" for i in range(100)]})(), + board_size=20, + seed=42, + ) + assert len(board.cards) == 20 + + +def test_board_from_vocabulary_respects_assassin_amount(): + """Test that from_vocabulary respects assassin_amount parameter.""" + board = ClassicBoard.from_vocabulary( + vocabulary=type("Vocabulary", (), {"language": "english", "words": [f"word{i}" for i in range(100)]})(), + board_size=20, + assassin_amount=3, + seed=42, + ) + assert len(board.assassin_cards) == 3 + + +def test_board_from_vocabulary_respects_first_team(): + """Test that from_vocabulary respects first_team parameter.""" + board = ClassicBoard.from_vocabulary( + vocabulary=type("Vocabulary", (), {"language": "english", "words": [f"word{i}" for i in range(100)]})(), + board_size=15, + first_team=ClassicTeam.BLUE, + seed=42, + ) + # Blue team should have one more card (15 // 3 = 5, blue gets 6) + assert len(board.blue_cards) == len(board.red_cards) + 1 + + +def test_board_from_vocabulary_with_seed_is_reproducible(): + """Test that same seed produces same board.""" + vocab = type("Vocabulary", (), {"language": "english", "words": [f"word{i}" for i in range(100)]})() + + board1 = ClassicBoard.from_vocabulary(vocabulary=vocab, board_size=10, seed=42) + board2 = ClassicBoard.from_vocabulary(vocabulary=vocab, board_size=10, seed=42) + + assert board1.all_words == board2.all_words + assert [card.color for card in board1.cards] == [card.color for card in board2.cards] + + +def test_board_getitem_with_negative_index_raises(): + """Test that accessing board with negative index raises IndexError.""" + cards = [ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=False)] + board = ClassicBoard(language="english", cards=cards) + with pytest.raises(IndexError): + _ = board[-1] + + +def test_board_empty_board(): + """Test board with no cards.""" + board = ClassicBoard(language="english", cards=[]) + assert board.size == 0 + assert len(board.cards) == 0 + assert board.is_clean is True + assert board.revealed_cards == () + assert board.unrevealed_cards == () + + +def test_board_all_cards_revealed(): + """Test board where all cards are revealed.""" + cards = [ + ClassicCard(word="Card 0", color=ClassicColor.BLUE, revealed=True), + ClassicCard(word="Card 1", color=ClassicColor.RED, revealed=True), + ] + board = ClassicBoard(language="english", cards=cards) + assert board.is_clean is False + assert len(board.revealed_cards) == 2 + assert len(board.unrevealed_cards) == 0 \ No newline at end of file diff --git a/tests/generic/test_card.py b/tests/generic/test_card.py new file mode 100644 index 0000000..4e696d9 --- /dev/null +++ b/tests/generic/test_card.py @@ -0,0 +1,220 @@ +import pytest + +from codenames.classic.color import ClassicColor +from codenames.classic.types import ClassicCard +from codenames.generic.card import canonical_format + + +def test_canonical_format_lowercase(): + """Test that canonical_format converts to lowercase.""" + assert canonical_format("HELLO") == "hello" + assert canonical_format("Hello") == "hello" + assert canonical_format("HeLLo WoRLD") == "hello world" + + +def test_canonical_format_replaces_underscores(): + """Test that canonical_format replaces underscores with spaces.""" + assert canonical_format("hello_world") == "hello world" + assert canonical_format("one_two_three") == "one two three" + + +def test_canonical_format_strips_whitespace(): + """Test that canonical_format strips leading/trailing whitespace.""" + assert canonical_format(" hello ") == "hello" + assert canonical_format("\thello\n") == "hello" + assert canonical_format(" hello world ") == "hello world" + + +def test_canonical_format_combined_transformations(): + """Test canonical_format with multiple transformations.""" + assert canonical_format(" Hello_World ") == "hello world" + assert canonical_format("HELLO_WORLD") == "hello world" + assert canonical_format(" ONE_TWO_THREE ") == "one two three" + + +def test_canonical_format_empty_string(): + """Test canonical_format with empty string.""" + assert canonical_format("") == "" + + +def test_canonical_format_only_whitespace(): + """Test canonical_format with only whitespace.""" + assert canonical_format(" ") == "" + assert canonical_format("\t\n") == "" + + +def test_canonical_format_already_formatted(): + """Test canonical_format with already formatted string.""" + assert canonical_format("hello world") == "hello world" + assert canonical_format("test") == "test" + + +def test_canonical_format_multiple_spaces(): + """Test canonical_format preserves internal spaces.""" + assert canonical_format("hello world") == "hello world" + assert canonical_format("one two") == "one two" + + +def test_canonical_format_special_characters(): + """Test canonical_format with special characters.""" + assert canonical_format("Hello-World") == "hello-world" + assert canonical_format("Test@123") == "test@123" + assert canonical_format("Word!") == "word!" + + +def test_canonical_format_numbers(): + """Test canonical_format with numbers.""" + assert canonical_format("Test123") == "test123" + assert canonical_format("123_456") == "123 456" + + +def test_card_str_with_color(): + """Test Card string representation with color.""" + card = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + card_str = str(card) + assert "ocean" in card_str + assert "🟦" in card_str # Blue emoji + + +def test_card_str_without_color(): + """Test Card string representation without color.""" + card = ClassicCard(word="ocean", color=None, revealed=False) + assert str(card) == "ocean" + + +def test_card_str_red_color(): + """Test Card string representation with red color.""" + card = ClassicCard(word="fire", color=ClassicColor.RED, revealed=False) + card_str = str(card) + assert "fire" in card_str + assert "🟥" in card_str # Red emoji + + +def test_card_str_neutral_color(): + """Test Card string representation with neutral color.""" + card = ClassicCard(word="table", color=ClassicColor.NEUTRAL, revealed=False) + card_str = str(card) + assert "table" in card_str + assert "⬜" in card_str # Neutral emoji + + +def test_card_str_assassin_color(): + """Test Card string representation with assassin color.""" + card = ClassicCard(word="death", color=ClassicColor.ASSASSIN, revealed=False) + card_str = str(card) + assert "death" in card_str + assert "💀" in card_str # Assassin emoji + + +def test_card_hash_different_for_different_attributes(): + """Test that cards with different attributes have different hashes.""" + card1 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + card2 = ClassicCard(word="ocean", color=ClassicColor.RED, revealed=False) + card3 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=True) + card4 = ClassicCard(word="beach", color=ClassicColor.BLUE, revealed=False) + + # All should have different hashes + hashes = {hash(card1), hash(card2), hash(card3), hash(card4)} + assert len(hashes) == 4 + + +def test_card_hash_same_for_identical_cards(): + """Test that identical cards have the same hash.""" + card1 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + card2 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + + assert hash(card1) == hash(card2) + + +def test_card_hash_allows_set_membership(): + """Test that cards can be used in sets.""" + card1 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + card2 = ClassicCard(word="beach", color=ClassicColor.RED, revealed=False) + card3 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + + card_set = {card1, card2, card3} + # card1 and card3 are identical, so set should have 2 elements + assert len(card_set) == 2 + + +def test_card_censored_revealed_returns_self(): + """Test that censored property returns self when revealed.""" + card = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=True) + assert card.censored is card + + +def test_card_censored_not_revealed_hides_color(): + """Test that censored property hides color when not revealed.""" + card = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + censored = card.censored + + assert censored.word == "ocean" + assert censored.color is None + assert censored.revealed is False + + +def test_card_censored_preserves_word(): + """Test that censored property preserves word.""" + card = ClassicCard(word="test", color=ClassicColor.RED, revealed=False) + assert card.censored.word == "test" + + +def test_card_censored_preserves_revealed_false(): + """Test that censored property preserves revealed=False.""" + card = ClassicCard(word="test", color=ClassicColor.BLUE, revealed=False) + assert card.censored.revealed is False + + +def test_card_formatted_word_uses_canonical_format(): + """Test that formatted_word uses canonical_format.""" + card = ClassicCard(word="Hello_World", color=ClassicColor.BLUE, revealed=False) + assert card.formatted_word == "hello world" + + +def test_card_formatted_word_lowercase(): + """Test formatted_word converts to lowercase.""" + card = ClassicCard(word="OCEAN", color=ClassicColor.BLUE, revealed=False) + assert card.formatted_word == "ocean" + + +def test_card_formatted_word_with_underscores(): + """Test formatted_word replaces underscores.""" + card = ClassicCard(word="sea_creature", color=ClassicColor.BLUE, revealed=False) + assert card.formatted_word == "sea creature" + + +def test_card_equality(): + """Test card equality comparison.""" + card1 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + card2 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + card3 = ClassicCard(word="beach", color=ClassicColor.BLUE, revealed=False) + + assert card1 == card2 + assert card1 != card3 + + +def test_card_with_none_color_hash(): + """Test that cards with None color can be hashed.""" + card = ClassicCard(word="ocean", color=None, revealed=False) + # Should not raise an error + hash_value = hash(card) + assert isinstance(hash_value, int) + + +def test_card_revealed_affects_hash(): + """Test that revealed status affects hash.""" + card1 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=True) + card2 = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=False) + + assert hash(card1) != hash(card2) + + +def test_canonical_format_unicode(): + """Test canonical_format with unicode characters.""" + assert canonical_format("Café") == "café" + assert canonical_format("Über_Cool") == "über cool" + + +def test_canonical_format_mixed_case_underscore(): + """Test canonical_format with mixed transformations.""" + assert canonical_format(" HELLO_World_TEST ") == "hello world test" \ No newline at end of file diff --git a/tests/generic/test_exceptions.py b/tests/generic/test_exceptions.py new file mode 100644 index 0000000..04ac32d --- /dev/null +++ b/tests/generic/test_exceptions.py @@ -0,0 +1,161 @@ +import pytest + +from codenames.generic.exceptions import ( + CardNotFoundError, + GameIsOver, + GameRuleError, + InvalidClue, + InvalidGuess, + InvalidTurn, + QuitGame, +) + + +def test_card_not_found_error_message(): + """Test CardNotFoundError creates proper error message.""" + error = CardNotFoundError("test_word") + assert str(error) == "Card not found: test_word" + assert error.word == "test_word" + + +def test_card_not_found_error_is_value_error(): + """Test CardNotFoundError inherits from ValueError.""" + error = CardNotFoundError("test") + assert isinstance(error, ValueError) + + +def test_card_not_found_error_stores_word(): + """Test CardNotFoundError stores the word that wasn't found.""" + word = "banana" + error = CardNotFoundError(word) + assert error.word == word + + +def test_card_not_found_error_can_be_raised(): + """Test that CardNotFoundError can be raised and caught.""" + with pytest.raises(CardNotFoundError) as exc_info: + raise CardNotFoundError("missing") + assert exc_info.value.word == "missing" + + +def test_quit_game_exception(): + """Test QuitGame exception can be instantiated and raised.""" + error = QuitGame() + assert isinstance(error, Exception) + + with pytest.raises(QuitGame): + raise QuitGame() + + +def test_game_rule_error(): + """Test GameRuleError exception.""" + error = GameRuleError("Invalid move") + assert isinstance(error, Exception) + assert str(error) == "Invalid move" + + +def test_game_rule_error_can_be_raised(): + """Test that GameRuleError can be raised.""" + with pytest.raises(GameRuleError): + raise GameRuleError("Test error") + + +def test_invalid_turn_inherits_from_game_rule_error(): + """Test InvalidTurn is a subclass of GameRuleError.""" + error = InvalidTurn("Wrong turn") + assert isinstance(error, GameRuleError) + assert isinstance(error, Exception) + + +def test_invalid_turn_can_be_raised(): + """Test that InvalidTurn can be raised.""" + with pytest.raises(InvalidTurn): + raise InvalidTurn("Not your turn") + + +def test_game_is_over_inherits_from_invalid_turn(): + """Test GameIsOver is a subclass of InvalidTurn.""" + error = GameIsOver() + assert isinstance(error, InvalidTurn) + assert isinstance(error, GameRuleError) + assert isinstance(error, Exception) + + +def test_game_is_over_has_default_message(): + """Test GameIsOver has default error message.""" + error = GameIsOver() + assert str(error) == "Game is over!" + + +def test_game_is_over_can_be_raised(): + """Test that GameIsOver can be raised and caught.""" + with pytest.raises(GameIsOver) as exc_info: + raise GameIsOver() + assert str(exc_info.value) == "Game is over!" + + +def test_game_is_over_can_be_caught_as_invalid_turn(): + """Test GameIsOver can be caught as InvalidTurn.""" + with pytest.raises(InvalidTurn): + raise GameIsOver() + + +def test_game_is_over_can_be_caught_as_game_rule_error(): + """Test GameIsOver can be caught as GameRuleError.""" + with pytest.raises(GameRuleError): + raise GameIsOver() + + +def test_invalid_clue_inherits_from_game_rule_error(): + """Test InvalidClue is a subclass of GameRuleError.""" + error = InvalidClue("Bad clue") + assert isinstance(error, GameRuleError) + + +def test_invalid_clue_can_be_raised(): + """Test that InvalidClue can be raised.""" + with pytest.raises(InvalidClue): + raise InvalidClue("Clue contains card word") + + +def test_invalid_guess_inherits_from_game_rule_error(): + """Test InvalidGuess is a subclass of GameRuleError.""" + error = InvalidGuess("Invalid card") + assert isinstance(error, GameRuleError) + + +def test_invalid_guess_can_be_raised(): + """Test that InvalidGuess can be raised.""" + with pytest.raises(InvalidGuess): + raise InvalidGuess("Card already revealed") + + +def test_exception_hierarchy(): + """Test the exception class hierarchy is correct.""" + # GameRuleError is base for game rules + assert issubclass(InvalidTurn, GameRuleError) + assert issubclass(InvalidClue, GameRuleError) + assert issubclass(InvalidGuess, GameRuleError) + + # GameIsOver extends InvalidTurn + assert issubclass(GameIsOver, InvalidTurn) + assert issubclass(GameIsOver, GameRuleError) + + # Other exceptions are standalone + assert issubclass(CardNotFoundError, ValueError) + assert issubclass(QuitGame, Exception) + + +def test_card_not_found_error_with_empty_word(): + """Test CardNotFoundError with empty string.""" + error = CardNotFoundError("") + assert str(error) == "Card not found: " + assert error.word == "" + + +def test_card_not_found_error_with_special_characters(): + """Test CardNotFoundError with special characters in word.""" + word = "test-word_123!@#" + error = CardNotFoundError(word) + assert error.word == word + assert word in str(error) \ No newline at end of file diff --git a/tests/generic/test_move.py b/tests/generic/test_move.py new file mode 100644 index 0000000..4211b27 --- /dev/null +++ b/tests/generic/test_move.py @@ -0,0 +1,231 @@ +import pytest + +from codenames.classic.color import ClassicColor +from codenames.classic.team import ClassicTeam +from codenames.classic.types import ClassicCard, ClassicGivenClue, ClassicGivenGuess +from codenames.generic.move import PASS_GUESS, QUIT_GAME, Clue, Guess + + +def test_clue_creation(): + """Test creating a Clue object.""" + clue = Clue(word="ocean", card_amount=3) + assert clue.word == "ocean" + assert clue.card_amount == 3 + assert clue.for_words is None + + +def test_clue_with_for_words(): + """Test creating a Clue with for_words specified.""" + for_words = ("beach", "wave", "sand") + clue = Clue(word="ocean", card_amount=3, for_words=for_words) + assert clue.word == "ocean" + assert clue.card_amount == 3 + assert clue.for_words == for_words + + +def test_clue_str_without_for_words(): + """Test string representation of Clue without for_words.""" + clue = Clue(word="ocean", card_amount=3) + clue_str = str(clue) + assert "ocean" in clue_str + assert "3" in clue_str + + +def test_clue_str_with_for_words(): + """Test string representation of Clue with for_words.""" + for_words = ("beach", "wave") + clue = Clue(word="ocean", card_amount=2, for_words=for_words) + clue_str = str(clue) + assert "ocean" in clue_str + assert "2" in clue_str + assert "for:" in clue_str + + +def test_clue_with_zero_card_amount(): + """Test creating a Clue with zero card amount.""" + clue = Clue(word="zero", card_amount=0) + assert clue.card_amount == 0 + + +def test_clue_with_unlimited_card_amount(): + """Test creating a Clue with unlimited (9) card amount.""" + clue = Clue(word="all", card_amount=9) + assert clue.card_amount == 9 + + +def test_given_clue_creation(): + """Test creating a GivenClue object.""" + given_clue = ClassicGivenClue(word="ocean", card_amount=3, team=ClassicTeam.BLUE) + assert given_clue.word == "ocean" + assert given_clue.card_amount == 3 + assert given_clue.team == ClassicTeam.BLUE + + +def test_given_clue_str(): + """Test string representation of GivenClue.""" + given_clue = ClassicGivenClue(word="ocean", card_amount=3, team=ClassicTeam.RED) + clue_str = str(given_clue) + assert "ocean" in clue_str + assert "3" in clue_str + + +def test_given_clue_formatted_word(): + """Test formatted_word property of GivenClue.""" + given_clue = ClassicGivenClue(word="Ocean Wave", card_amount=2, team=ClassicTeam.BLUE) + # formatted_word should use canonical_format which lowercases and removes spaces + assert given_clue.formatted_word == "ocean wave" + + +def test_given_clue_hash_same_word_and_amount(): + """Test that GivenClues with same word and amount have same hash.""" + clue1 = ClassicGivenClue(word="ocean", card_amount=3, team=ClassicTeam.BLUE) + clue2 = ClassicGivenClue(word="ocean", card_amount=3, team=ClassicTeam.RED) + # Hash should be based on word and card_amount, not team + assert clue1.hash == clue2.hash + assert hash(clue1) == hash(clue2) + + +def test_given_clue_hash_different_word(): + """Test that GivenClues with different words have different hash.""" + clue1 = ClassicGivenClue(word="ocean", card_amount=3, team=ClassicTeam.BLUE) + clue2 = ClassicGivenClue(word="beach", card_amount=3, team=ClassicTeam.BLUE) + assert clue1.hash != clue2.hash + + +def test_given_clue_hash_different_amount(): + """Test that GivenClues with different amounts have different hash.""" + clue1 = ClassicGivenClue(word="ocean", card_amount=3, team=ClassicTeam.BLUE) + clue2 = ClassicGivenClue(word="ocean", card_amount=2, team=ClassicTeam.BLUE) + assert clue1.hash != clue2.hash + + +def test_guess_creation(): + """Test creating a Guess object.""" + guess = Guess(card_index=5) + assert guess.card_index == 5 + + +def test_guess_with_zero_index(): + """Test creating a Guess with zero index.""" + guess = Guess(card_index=0) + assert guess.card_index == 0 + + +def test_guess_pass_constant(): + """Test PASS_GUESS constant value.""" + assert PASS_GUESS == -1 + + +def test_guess_quit_constant(): + """Test QUIT_GAME constant value.""" + assert QUIT_GAME == -2 + + +def test_guess_with_pass_value(): + """Test creating a Guess with pass value.""" + guess = Guess(card_index=PASS_GUESS) + assert guess.card_index == -1 + + +def test_guess_with_quit_value(): + """Test creating a Guess with quit value.""" + guess = Guess(card_index=QUIT_GAME) + assert guess.card_index == -2 + + +def test_given_guess_correct_when_matching_team(): + """Test GivenGuess.correct returns True when card color matches team.""" + card = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=True) + clue = ClassicGivenClue(word="water", card_amount=2, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + assert given_guess.correct is True + + +def test_given_guess_incorrect_when_different_team(): + """Test GivenGuess.correct returns False when card color doesn't match team.""" + card = ClassicCard(word="ocean", color=ClassicColor.RED, revealed=True) + clue = ClassicGivenClue(word="water", card_amount=2, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + assert given_guess.correct is False + + +def test_given_guess_neutral_is_incorrect(): + """Test GivenGuess.correct returns False for neutral cards.""" + card = ClassicCard(word="ocean", color=ClassicColor.NEUTRAL, revealed=True) + clue = ClassicGivenClue(word="water", card_amount=2, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + assert given_guess.correct is False + + +def test_given_guess_assassin_is_incorrect(): + """Test GivenGuess.correct returns False for assassin cards.""" + card = ClassicCard(word="killer", color=ClassicColor.ASSASSIN, revealed=True) + clue = ClassicGivenClue(word="danger", card_amount=1, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + assert given_guess.correct is False + + +def test_given_guess_raises_error_when_card_has_no_color(): + """Test GivenGuess.correct raises ValueError when card has no color.""" + card = ClassicCard(word="unknown", color=None, revealed=False) + clue = ClassicGivenClue(word="test", card_amount=1, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + with pytest.raises(ValueError, match="has no color set"): + _ = given_guess.correct + + +def test_given_guess_str_correct(): + """Test string representation of correct GivenGuess.""" + card = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=True) + clue = ClassicGivenClue(word="water", card_amount=2, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + guess_str = str(given_guess) + assert "correct!" in guess_str + assert "ocean" in guess_str + + +def test_given_guess_str_incorrect(): + """Test string representation of incorrect GivenGuess.""" + card = ClassicCard(word="ocean", color=ClassicColor.RED, revealed=True) + clue = ClassicGivenClue(word="water", card_amount=2, team=ClassicTeam.BLUE) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + guess_str = str(given_guess) + assert "wrong!" in guess_str + assert "ocean" in guess_str + + +def test_given_guess_team_property(): + """Test GivenGuess.team property returns the clue's team.""" + card = ClassicCard(word="ocean", color=ClassicColor.BLUE, revealed=True) + clue = ClassicGivenClue(word="water", card_amount=2, team=ClassicTeam.RED) + given_guess = ClassicGivenGuess(guessed_card=card, for_clue=clue) + + assert given_guess.team == ClassicTeam.RED + + +def test_clue_with_negative_card_amount(): + """Test edge case of Clue with negative card amount.""" + clue = Clue(word="test", card_amount=-1) + assert clue.card_amount == -1 + + +def test_clue_with_large_card_amount(): + """Test edge case of Clue with very large card amount.""" + clue = Clue(word="test", card_amount=100) + assert clue.card_amount == 100 + + +def test_given_clue_case_insensitive_hash(): + """Test that GivenClue hash is case-insensitive.""" + clue1 = ClassicGivenClue(word="Ocean", card_amount=2, team=ClassicTeam.BLUE) + clue2 = ClassicGivenClue(word="ocean", card_amount=2, team=ClassicTeam.BLUE) + # Both should produce same formatted_word and thus same hash + assert clue1.formatted_word == clue2.formatted_word + assert clue1.hash == clue2.hash \ No newline at end of file diff --git a/tests/utils/test_builder.py b/tests/utils/test_builder.py new file mode 100644 index 0000000..2e7f435 --- /dev/null +++ b/tests/utils/test_builder.py @@ -0,0 +1,173 @@ +import random + +import pytest + +from codenames.utils.builder import ExtractResult, extract_random_subset + + +def test_extract_random_subset_returns_correct_types(): + """Test that extract_random_subset returns ExtractResult with tuples.""" + elements = ["a", "b", "c", "d", "e"] + result = extract_random_subset(elements, 2) + + assert isinstance(result, ExtractResult) + assert isinstance(result.remaining, tuple) + assert isinstance(result.sample, tuple) + + +def test_extract_random_subset_correct_sizes(): + """Test that extract_random_subset returns correct sizes.""" + elements = ["a", "b", "c", "d", "e"] + result = extract_random_subset(elements, 2) + + assert len(result.sample) == 2 + assert len(result.remaining) == 3 + assert len(result.sample) + len(result.remaining) == len(elements) + + +def test_extract_random_subset_no_overlap(): + """Test that sample and remaining don't overlap.""" + elements = ["a", "b", "c", "d", "e"] + result = extract_random_subset(elements, 2) + + assert set(result.sample).isdisjoint(set(result.remaining)) + + +def test_extract_random_subset_all_elements_present(): + """Test that all original elements are in sample or remaining.""" + elements = ["a", "b", "c", "d", "e"] + result = extract_random_subset(elements, 2) + + all_result_elements = set(result.sample) | set(result.remaining) + assert all_result_elements == set(elements) + + +def test_extract_random_subset_zero_size(): + """Test extracting zero elements.""" + elements = ["a", "b", "c"] + result = extract_random_subset(elements, 0) + + assert len(result.sample) == 0 + assert len(result.remaining) == 3 + assert set(result.remaining) == set(elements) + + +def test_extract_random_subset_all_elements(): + """Test extracting all elements.""" + elements = ["a", "b", "c"] + result = extract_random_subset(elements, 3) + + assert len(result.sample) == 3 + assert len(result.remaining) == 0 + assert set(result.sample) == set(elements) + + +def test_extract_random_subset_single_element(): + """Test extracting from single element collection.""" + elements = ["a"] + result = extract_random_subset(elements, 1) + + assert result.sample == ("a",) + assert result.remaining == () + + +def test_extract_random_subset_is_random(): + """Test that extract_random_subset produces different results.""" + elements = list(range(20)) + results = [extract_random_subset(elements, 10) for _ in range(5)] + + # At least some results should be different (very high probability) + unique_samples = {result.sample for result in results} + assert len(unique_samples) > 1 + + +def test_extract_random_subset_with_seed_is_reproducible(): + """Test that using same seed produces same results.""" + elements = list(range(20)) + + random.seed(42) + result1 = extract_random_subset(elements, 10) + + random.seed(42) + result2 = extract_random_subset(elements, 10) + + assert result1.sample == result2.sample + assert result1.remaining == result2.remaining + + +def test_extract_random_subset_with_tuple_input(): + """Test extract_random_subset works with tuple input.""" + elements = ("a", "b", "c", "d") + result = extract_random_subset(elements, 2) + + assert len(result.sample) == 2 + assert len(result.remaining) == 2 + + +def test_extract_random_subset_with_set_input(): + """Test extract_random_subset works with set input.""" + elements = {"a", "b", "c", "d"} + result = extract_random_subset(elements, 2) + + assert len(result.sample) == 2 + assert len(result.remaining) == 2 + assert set(result.sample) | set(result.remaining) == elements + + +def test_extract_random_subset_raises_on_too_large_sample(): + """Test that requesting more elements than available raises error.""" + elements = ["a", "b", "c"] + with pytest.raises(ValueError): + extract_random_subset(elements, 5) + + +def test_extract_random_subset_preserves_element_type(): + """Test that element types are preserved.""" + elements = [1, 2, 3, 4, 5] + result = extract_random_subset(elements, 2) + + assert all(isinstance(x, int) for x in result.sample) + assert all(isinstance(x, int) for x in result.remaining) + + +def test_extract_random_subset_with_duplicates_in_input(): + """Test behavior with duplicate elements in input.""" + elements = ["a", "a", "b", "b", "c"] + result = extract_random_subset(elements, 2) + + # Should select 2 items from the collection + assert len(result.sample) == 2 + # Remaining should have the others + assert len(result.remaining) == 3 + + +def test_extract_result_named_tuple_access(): + """Test that ExtractResult can be accessed by index and by name.""" + result = ExtractResult(remaining=("a", "b"), sample=("c", "d")) + + # Access by name + assert result.remaining == ("a", "b") + assert result.sample == ("c", "d") + + # Access by index + assert result[0] == ("a", "b") + assert result[1] == ("c", "d") + + +def test_extract_random_subset_empty_input(): + """Test extract_random_subset with empty collection.""" + elements = [] + result = extract_random_subset(elements, 0) + + assert result.sample == () + assert result.remaining == () + + +def test_extract_random_subset_with_complex_objects(): + """Test extract_random_subset with complex objects.""" + elements = [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}] + result = extract_random_subset(elements, 2) + + assert len(result.sample) == 2 + assert len(result.remaining) == 2 + assert all(isinstance(obj, dict) for obj in result.sample) \ No newline at end of file