golfgame/server/test_v3_features.py
adlee-was-taken 9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:03:45 -05:00

319 lines
11 KiB
Python

"""
Test suite for V3 features in 6-Card Golf.
Covers:
- V3_01: Dealer rotation
- V3_03/V3_05: Finisher tracking, knock penalty/bonus
- V3_09: Knock early
Run with: pytest test_v3_features.py -v
"""
import pytest
from game import (
Card, Deck, Player, Game, GamePhase, GameOptions,
Suit, Rank, RANK_VALUES
)
# =============================================================================
# Helper: create a game with N players in PLAYING phase
# =============================================================================
def make_game(num_players=2, options=None, rounds=1):
"""Create a game with N players, dealt and in PLAYING phase."""
opts = options or GameOptions()
game = Game(num_rounds=rounds, options=opts)
for i in range(num_players):
game.add_player(Player(id=f"p{i}", name=f"Player {i}"))
game.start_round()
# Force into PLAYING phase (skip initial flip)
if game.phase == GamePhase.INITIAL_FLIP:
for p in game.players:
game.flip_initial_cards(p.id, [0, 1])
return game
def flip_all_but(player, keep_down=0):
"""Flip all cards face-up except `keep_down` cards."""
for i, card in enumerate(player.cards):
if i < len(player.cards) - keep_down:
card.face_up = True
else:
card.face_up = False
def set_hand(player, ranks):
"""Set player hand to specific ranks (all hearts, all face-up)."""
player.cards = [
Card(Suit.HEARTS, rank, face_up=True) for rank in ranks
]
# =============================================================================
# V3_01: Dealer Rotation
# =============================================================================
class TestDealerRotation:
"""Verify dealer rotates each round and first player is after dealer."""
def test_initial_dealer_is_zero(self):
game = make_game(3)
assert game.dealer_idx == 0
def test_first_player_is_after_dealer(self):
game = make_game(3)
# Dealer is 0, first player should be 1
assert game.current_player_index == 1
def test_dealer_rotates_after_round(self):
game = make_game(3, rounds=3)
assert game.dealer_idx == 0
# End the round by having a player flip all cards
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
game.finisher_id = player.id
game.phase = GamePhase.FINAL_TURN
# Give remaining players their final turns
game.players_with_final_turn = {p.id for p in game.players}
game._end_round()
# Start next round
game.start_next_round()
assert game.dealer_idx == 1
# First player should be after new dealer
assert game.current_player_index == 2
def test_dealer_wraps_around(self):
game = make_game(3, rounds=4)
# Simulate 3 rounds to wrap dealer
for expected_dealer in [0, 1, 2]:
assert game.dealer_idx == expected_dealer
# Force round end
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
game.finisher_id = player.id
game.phase = GamePhase.FINAL_TURN
game.players_with_final_turn = {p.id for p in game.players}
game._end_round()
game.start_next_round()
# After 3 rotations with 3 players, wraps back to 0
assert game.dealer_idx == 0
def test_dealer_in_state_dict(self):
game = make_game(3)
state = game.get_state("p0")
assert "dealer_id" in state
assert "dealer_idx" in state
assert state["dealer_id"] == "p0"
assert state["dealer_idx"] == 0
# =============================================================================
# V3_03/V3_05: Finisher Tracking + Knock Penalty/Bonus
# =============================================================================
class TestFinisherTracking:
"""Verify finisher_id is set and penalties/bonuses apply."""
def test_finisher_id_initially_none(self):
game = make_game(2)
assert game.finisher_id is None
def test_finisher_set_when_all_flipped(self):
game = make_game(2)
# Get current player and flip all their cards
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
# Draw and discard to trigger _check_end_turn
card = game.deck.draw()
if card:
game.drawn_card = card
game.discard_drawn(player.id)
assert game.finisher_id == player.id
assert game.phase == GamePhase.FINAL_TURN
def test_finisher_in_state_dict(self):
game = make_game(2)
game.finisher_id = "p0"
state = game.get_state("p0")
assert state["finisher_id"] == "p0"
def test_knock_penalty_applied(self):
"""Finisher gets +10 if they don't have the lowest score."""
opts = GameOptions(knock_penalty=True, initial_flips=0)
game = make_game(2, options=opts)
# Set hands with different ranks per column to avoid column pairing
# Layout: [0][1][2] / [3][4][5], columns: (0,3),(1,4),(2,5)
set_hand(game.players[0], [Rank.TEN, Rank.NINE, Rank.EIGHT,
Rank.SEVEN, Rank.SIX, Rank.FIVE]) # 10+9+8+7+6+5 = 45
set_hand(game.players[1], [Rank.ACE, Rank.THREE, Rank.FOUR,
Rank.TWO, Rank.KING, Rank.ACE]) # 1+3+4+(-2)+0+1 = 7
game.finisher_id = "p0"
game.phase = GamePhase.FINAL_TURN
game.players_with_final_turn = {"p0", "p1"}
game._end_round()
# p0 had score 45, gets +10 penalty = 55
assert game.players[0].score == 55
# p1 unaffected
assert game.players[1].score == 7
def test_knock_bonus_applied(self):
"""Finisher gets -5 bonus."""
opts = GameOptions(knock_bonus=True, initial_flips=0)
game = make_game(2, options=opts)
# Different ranks per column to avoid pairing
set_hand(game.players[0], [Rank.ACE, Rank.THREE, Rank.FOUR,
Rank.TWO, Rank.KING, Rank.ACE]) # 1+3+4+(-2)+0+1 = 7
set_hand(game.players[1], [Rank.TEN, Rank.NINE, Rank.EIGHT,
Rank.SEVEN, Rank.SIX, Rank.FIVE]) # 10+9+8+7+6+5 = 45
game.finisher_id = "p0"
game.phase = GamePhase.FINAL_TURN
game.players_with_final_turn = {"p0", "p1"}
game._end_round()
# p0 gets -5 bonus: 7 - 5 = 2
assert game.players[0].score == 2
assert game.players[1].score == 45
# =============================================================================
# V3_09: Knock Early
# =============================================================================
class TestKnockEarly:
"""Verify knock_early house rule mechanics."""
def test_knock_early_disabled_by_default(self):
opts = GameOptions()
assert opts.knock_early is False
def test_knock_early_requires_option(self):
game = make_game(2)
player = game.players[game.current_player_index]
# Flip 4 cards, leave 2 face-down
for i in range(4):
player.cards[i].face_up = True
result = game.knock_early(player.id)
assert result is False
def test_knock_early_with_option_enabled(self):
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
# Flip 4 cards, leave 2 face-down
for i in range(4):
player.cards[i].face_up = True
for i in range(4, 6):
player.cards[i].face_up = False
result = game.knock_early(player.id)
assert result is True
def test_knock_early_requires_face_up_cards(self):
"""Must have at least 4 face-up (at most 2 face-down) to knock."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
# Only 3 face-up, 3 face-down — too many hidden
for i in range(3):
player.cards[i].face_up = True
for i in range(3, 6):
player.cards[i].face_up = False
result = game.knock_early(player.id)
assert result is False
def test_knock_early_triggers_final_turn(self):
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
flip_all_but(player, keep_down=2)
game.knock_early(player.id)
assert game.phase == GamePhase.FINAL_TURN
def test_knock_early_sets_finisher(self):
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
flip_all_but(player, keep_down=1)
game.knock_early(player.id)
assert game.finisher_id == player.id
def test_knock_early_not_during_initial_flip(self):
"""Knock early should fail during initial flip phase."""
opts = GameOptions(knock_early=True, initial_flips=2)
game = Game(num_rounds=1, options=opts)
game.add_player(Player(id="p0", name="Player 0"))
game.add_player(Player(id="p1", name="Player 1"))
game.start_round()
# Should be in INITIAL_FLIP
assert game.phase == GamePhase.INITIAL_FLIP
player = game.players[0]
flip_all_but(player, keep_down=2)
result = game.knock_early(player.id)
assert result is False
def test_knock_early_fails_with_drawn_card(self):
"""Can't knock if you've already drawn a card."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
flip_all_but(player, keep_down=2)
# Simulate having drawn a card
game.drawn_card = Card(Suit.HEARTS, Rank.ACE)
result = game.knock_early(player.id)
assert result is False
def test_knock_early_fails_all_face_up(self):
"""Can't knock early if all cards are already face-up (0 face-down)."""
opts = GameOptions(knock_early=True, initial_flips=0)
game = make_game(2, options=opts)
player = game.players[game.current_player_index]
for card in player.cards:
card.face_up = True
result = game.knock_early(player.id)
assert result is False
def test_knock_early_in_state_dict(self):
opts = GameOptions(knock_early=True)
game = make_game(2, options=opts)
state = game.get_state("p0")
assert state["knock_early"] is True
def test_knock_early_active_rules(self):
"""Knock early should appear in active_rules list."""
opts = GameOptions(knock_early=True)
game = make_game(2, options=opts)
state = game.get_state("p0")
assert "Early Knock" in state["active_rules"]