Coverage for tests / test_core / test_rules.py: 100.0%
154 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-05 20:45 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-05 20:45 +0000
1"""Tests for beaverbunch.core.rules — pure validation functions."""
3import pytest
5from beaverbunch.core.actions import (
6 DiscardDrawnAction,
7 PeekOwnCardAction,
8 ReplaceCardAction,
9 SnapAction,
10 SwapCardAction,
11)
12from beaverbunch.core.card import Card, FaceCard, Suit
13from beaverbunch.core.deck import Deck
14from beaverbunch.core.game import GamePhase, GameState
15from beaverbunch.core.game_settings import GameSettings
16from beaverbunch.core.hand import CardSlot, Hand
17from beaverbunch.core.player import Player
18from beaverbunch.core.rules import (
19 RuleViolation, determine_winner, validate_discard_drawn, validate_draw, validate_draw_from_discard,
20 validate_initial_peek, validate_peek, validate_player_count, validate_replace_card, validate_skip_bonus,
21 validate_snap, validate_swap,
22)
25# ---------------------------------------------------------------------------
26# Helpers
27# ---------------------------------------------------------------------------
29def _card(value: int, suit: Suit = Suit.HEARTS) -> Card:
30 if value == 0:
31 return Card(suit=None, value=0) # pragma: no cover
32 return Card(suit=suit, value=value)
35def _hand(values: list[int]) -> Hand:
36 return Hand(slots=[CardSlot(card=_card(v)) for v in values])
39def _player(name: str, values: list[int] | None = None) -> Player:
40 return Player(name=name, hand=_hand(values or [3, 3, 3, 3]))
43def _state(
44 *,
45 phase: GamePhase = GamePhase.WAITING_FOR_DRAW,
46 players: list[Player] | None = None,
47 drawn_card: Card | None = None,
48 discard_pile: list[Card] | None = None,
49 deck_cards: list[Card] | None = None,
50 pending_bonus: FaceCard | None = None,
51 current_player_index: int = 0,
52) -> GameState:
53 s = GameState()
54 s.phase = phase
55 s.current_player_index = current_player_index
56 s.drawn_card = drawn_card
57 s.pending_bonus = pending_bonus
58 s.discard_pile = discard_pile if discard_pile is not None else [_card(5)]
59 if deck_cards is not None:
60 s.deck = Deck(cards=list(deck_cards))
61 else:
62 s.deck = Deck(cards=[_card(2)] * 5)
63 for p in (players or [_player("P0"), _player("P1")]):
64 s.add_player(p)
65 return s
68# ===================================================================
69# _get_player — unknown player (line 49)
70# ===================================================================
72class TestGetPlayer:
73 def test_unknown_player_raises(self):
74 state = _state()
75 with pytest.raises(RuleViolation, match="Unknown player"):
76 validate_draw(state, "NOBODY")
79# ===================================================================
80# validate_player_count (line 68)
81# ===================================================================
83class TestPlayerCount:
84 def test_too_many_players_raises(self):
85 settings = GameSettings(max_players=2)
86 with pytest.raises(RuleViolation, match="Too many players"):
87 validate_player_count(settings, 3)
90# ===================================================================
91# validate_initial_peek (line 104)
92# ===================================================================
94class TestInitialPeek:
95 def test_index_out_of_range_raises(self):
96 state = _state(phase=GamePhase.STARTING)
97 settings = GameSettings(initial_cards_known=2)
98 with pytest.raises(RuleViolation, match="out of range"):
99 validate_initial_peek(state, settings, "P0", [0, 99])
102# ===================================================================
103# validate_draw (lines 126, 128)
104# ===================================================================
106class TestValidateDraw:
107 def test_already_drawn_raises(self):
108 state = _state(drawn_card=_card(7))
109 with pytest.raises(RuleViolation, match="already been drawn"):
110 validate_draw(state, "P0")
112 def test_empty_deck_raises(self):
113 state = _state(deck_cards=[], drawn_card=None)
114 with pytest.raises(RuleViolation, match="deck is empty"):
115 validate_draw(state, "P0")
118# ===================================================================
119# validate_draw_from_discard (lines 140, 144, 146)
120# ===================================================================
122class TestValidateDrawFromDiscard:
123 def test_wrong_player_raises(self):
124 state = _state()
125 with pytest.raises(RuleViolation, match="turn"):
126 validate_draw_from_discard(state, "P1")
128 def test_already_drawn_raises(self):
129 state = _state(drawn_card=_card(7))
130 with pytest.raises(RuleViolation, match="already been drawn"):
131 validate_draw_from_discard(state, "P0")
133 def test_empty_discard_raises(self):
134 state = _state(discard_pile=[])
135 with pytest.raises(RuleViolation, match="discard pile is empty"):
136 validate_draw_from_discard(state, "P0")
139# ===================================================================
140# validate_replace_card (lines 158, 160, 162)
141# ===================================================================
143class TestValidateReplaceCard:
144 def test_wrong_player_raises(self):
145 state = _state(phase=GamePhase.WAITING_FOR_ACTION, drawn_card=_card(7))
146 action = ReplaceCardAction(player_id="P1", hand_index=0)
147 with pytest.raises(RuleViolation, match="turn"):
148 validate_replace_card(state, action)
150 def test_no_drawn_card_raises(self):
151 state = _state(phase=GamePhase.WAITING_FOR_ACTION, drawn_card=None)
152 action = ReplaceCardAction(player_id="P0", hand_index=0)
153 with pytest.raises(RuleViolation, match="No card has been drawn"):
154 validate_replace_card(state, action)
156 def test_index_out_of_range_raises(self):
157 state = _state(phase=GamePhase.WAITING_FOR_ACTION, drawn_card=_card(7))
158 action = ReplaceCardAction(player_id="P0", hand_index=99)
159 with pytest.raises(RuleViolation, match="out of range"):
160 validate_replace_card(state, action)
163# ===================================================================
164# validate_discard_drawn (lines 174, 176)
165# ===================================================================
167class TestValidateDiscardDrawn:
168 def test_wrong_player_raises(self):
169 state = _state(phase=GamePhase.WAITING_FOR_ACTION, drawn_card=_card(7))
170 action = DiscardDrawnAction(player_id="P1")
171 with pytest.raises(RuleViolation, match="turn"):
172 validate_discard_drawn(state, action)
174 def test_no_drawn_card_raises(self):
175 state = _state(phase=GamePhase.WAITING_FOR_ACTION, drawn_card=None)
176 action = DiscardDrawnAction(player_id="P0")
177 with pytest.raises(RuleViolation, match="No card has been drawn"):
178 validate_discard_drawn(state, action)
181# ===================================================================
182# validate_peek (lines 192, 194, 198)
183# ===================================================================
185class TestValidatePeek:
186 def test_wrong_player_raises(self):
187 state = _state(
188 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.JACK,
189 )
190 action = PeekOwnCardAction(player_id="P1", hand_index=0)
191 with pytest.raises(RuleViolation, match="turn"):
192 validate_peek(state, action)
194 def test_no_jack_bonus_raises(self):
195 state = _state(
196 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.QUEEN,
197 )
198 action = PeekOwnCardAction(player_id="P0", hand_index=0)
199 with pytest.raises(RuleViolation, match="No Jack bonus"):
200 validate_peek(state, action)
202 def test_index_out_of_range_raises(self):
203 state = _state(
204 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.JACK,
205 )
206 action = PeekOwnCardAction(player_id="P0", hand_index=99)
207 with pytest.raises(RuleViolation, match="out of range"):
208 validate_peek(state, action)
211# ===================================================================
212# validate_swap (lines 210, 212, 219, 221)
213# ===================================================================
215class TestValidateSwap:
216 def test_wrong_player_raises(self):
217 state = _state(
218 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.QUEEN,
219 )
220 action = SwapCardAction(player_id="P1", own_index=0, target_player_id="P0", target_index=0)
221 with pytest.raises(RuleViolation, match="turn"):
222 validate_swap(state, action)
224 def test_no_queen_bonus_raises(self):
225 state = _state(
226 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.JACK,
227 )
228 action = SwapCardAction(player_id="P0", own_index=0, target_player_id="P1", target_index=0)
229 with pytest.raises(RuleViolation, match="No Queen bonus"):
230 validate_swap(state, action)
232 def test_own_index_out_of_range_raises(self):
233 state = _state(
234 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.QUEEN,
235 )
236 action = SwapCardAction(player_id="P0", own_index=99, target_player_id="P1", target_index=0)
237 with pytest.raises(RuleViolation, match="Own card index"):
238 validate_swap(state, action)
240 def test_target_index_out_of_range_raises(self):
241 state = _state(
242 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.QUEEN,
243 )
244 action = SwapCardAction(player_id="P0", own_index=0, target_player_id="P1", target_index=99)
245 with pytest.raises(RuleViolation, match="Target card index"):
246 validate_swap(state, action)
249# ===================================================================
250# validate_skip_bonus (lines 233, 235)
251# ===================================================================
253class TestValidateSkipBonus:
254 def test_wrong_player_raises(self):
255 state = _state(
256 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=FaceCard.JACK,
257 )
258 with pytest.raises(RuleViolation, match="turn"):
259 validate_skip_bonus(state, "P1")
261 def test_no_bonus_pending_raises(self):
262 state = _state(
263 phase=GamePhase.WAITING_FOR_BONUS, pending_bonus=None,
264 )
265 with pytest.raises(RuleViolation, match="No bonus action to skip"):
266 validate_skip_bonus(state, "P0")
269# ===================================================================
270# validate_snap (lines 250, 252)
271# ===================================================================
273class TestValidateSnap:
274 def test_empty_discard_raises(self):
275 state = _state(discard_pile=[])
276 action = SnapAction(player_id="P0", hand_index=0)
277 with pytest.raises(RuleViolation, match="Discard pile is empty"):
278 validate_snap(state, action)
280 def test_index_out_of_range_raises(self):
281 state = _state()
282 action = SnapAction(player_id="P0", hand_index=99)
283 with pytest.raises(RuleViolation, match="out of range"):
284 validate_snap(state, action)
287# ===================================================================
288# determine_winner (line 270)
289# ===================================================================
291class TestDetermineWinner:
292 def test_empty_players_returns_empty(self):
293 assert determine_winner([]) == []