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

1"""Tests for beaverbunch.core.rules — pure validation functions.""" 

2 

3import pytest 

4 

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) 

23 

24 

25# --------------------------------------------------------------------------- 

26# Helpers 

27# --------------------------------------------------------------------------- 

28 

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) 

33 

34 

35def _hand(values: list[int]) -> Hand: 

36 return Hand(slots=[CardSlot(card=_card(v)) for v in values]) 

37 

38 

39def _player(name: str, values: list[int] | None = None) -> Player: 

40 return Player(name=name, hand=_hand(values or [3, 3, 3, 3])) 

41 

42 

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 

66 

67 

68# =================================================================== 

69# _get_player — unknown player (line 49) 

70# =================================================================== 

71 

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") 

77 

78 

79# =================================================================== 

80# validate_player_count (line 68) 

81# =================================================================== 

82 

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) 

88 

89 

90# =================================================================== 

91# validate_initial_peek (line 104) 

92# =================================================================== 

93 

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]) 

100 

101 

102# =================================================================== 

103# validate_draw (lines 126, 128) 

104# =================================================================== 

105 

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") 

111 

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") 

116 

117 

118# =================================================================== 

119# validate_draw_from_discard (lines 140, 144, 146) 

120# =================================================================== 

121 

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") 

127 

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") 

132 

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") 

137 

138 

139# =================================================================== 

140# validate_replace_card (lines 158, 160, 162) 

141# =================================================================== 

142 

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) 

149 

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) 

155 

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) 

161 

162 

163# =================================================================== 

164# validate_discard_drawn (lines 174, 176) 

165# =================================================================== 

166 

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) 

173 

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) 

179 

180 

181# =================================================================== 

182# validate_peek (lines 192, 194, 198) 

183# =================================================================== 

184 

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) 

193 

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) 

201 

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) 

209 

210 

211# =================================================================== 

212# validate_swap (lines 210, 212, 219, 221) 

213# =================================================================== 

214 

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) 

223 

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) 

231 

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) 

239 

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) 

247 

248 

249# =================================================================== 

250# validate_skip_bonus (lines 233, 235) 

251# =================================================================== 

252 

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") 

260 

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") 

267 

268 

269# =================================================================== 

270# validate_snap (lines 250, 252) 

271# =================================================================== 

272 

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) 

279 

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) 

285 

286 

287# =================================================================== 

288# determine_winner (line 270) 

289# =================================================================== 

290 

291class TestDetermineWinner: 

292 def test_empty_players_returns_empty(self): 

293 assert determine_winner([]) == []