strat-gameplay-webapp/backend/tests/unit/models/test_game_models.py
Cal Corum a287784328 CLAUDE: Complete Week 4 - State Management & Persistence
Implemented hybrid state management system with in-memory game states and async
PostgreSQL persistence. This provides the foundation for fast gameplay (<500ms
response) with complete state recovery capabilities.

## Components Implemented

### Production Code (3 files, 1,150 lines)
- app/models/game_models.py (492 lines)
  - Pydantic GameState with 20+ helper methods
  - RunnerState, LineupPlayerState, TeamLineupState
  - DefensiveDecision and OffensiveDecision models
  - Full Pydantic v2 validation with field validators

- app/core/state_manager.py (296 lines)
  - In-memory state management with O(1) lookups
  - State recovery from database
  - Idle game eviction mechanism
  - Statistics tracking

- app/database/operations.py (362 lines)
  - Async PostgreSQL operations
  - Game, lineup, and play persistence
  - Complete state loading for recovery
  - GameSession WebSocket state tracking

### Tests (4 files, 1,963 lines, 115 tests)
- tests/unit/models/test_game_models.py (60 tests, ALL PASSING)
- tests/unit/core/test_state_manager.py (26 tests, ALL PASSING)
- tests/integration/database/test_operations.py (21 tests)
- tests/integration/test_state_persistence.py (8 tests)
- pytest.ini (async test configuration)

### Documentation (6 files)
- backend/CLAUDE.md (updated with Week 4 patterns)
- .claude/implementation/02-week4-state-management.md (marked complete)
- .claude/status-2025-10-22-0113.md (planning session summary)
- .claude/status-2025-10-22-1147.md (implementation session summary)
- .claude/implementation/player-data-catalog.md (player data reference)
- Week 5 & 6 plans created

## Key Features

- Hybrid state: in-memory (fast) + PostgreSQL (persistent)
- O(1) state access via dictionary lookups
- Async database writes (non-blocking)
- Complete state recovery from database
- Pydantic validation on all models
- Helper methods for common game operations
- Idle game eviction with configurable timeout
- 86 unit tests passing (100%)

## Performance

- State access: O(1) via UUID lookup
- Memory per game: ~1KB (just state)
- Target response time: <500ms 
- Database writes: <100ms (async) 

## Testing

- Unit tests: 86/86 passing (100%)
- Integration tests: 29 written
- Test configuration: pytest.ini created
- Fixed Pydantic v2 config deprecation
- Fixed pytest-asyncio configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:01:03 -05:00

788 lines
26 KiB
Python

"""
Unit tests for Pydantic game state models.
Tests validation, helper methods, and business logic for all game_models.py classes.
Author: Claude
Date: 2025-10-22
"""
import pytest
from uuid import uuid4
from pydantic import ValidationError
from app.models.game_models import (
RunnerState,
LineupPlayerState,
TeamLineupState,
DefensiveDecision,
OffensiveDecision,
GameState,
)
# ============================================================================
# RUNNERSTATE TESTS
# ============================================================================
class TestRunnerState:
"""Tests for RunnerState model"""
def test_create_runner_state_valid(self):
"""Test creating a valid RunnerState"""
runner = RunnerState(lineup_id=1, card_id=100, on_base=1)
assert runner.lineup_id == 1
assert runner.card_id == 100
assert runner.on_base == 1
def test_runner_state_all_bases(self):
"""Test runner can be on all valid bases"""
for base in [1, 2, 3]:
runner = RunnerState(lineup_id=1, card_id=100, on_base=base)
assert runner.on_base == base
def test_runner_state_invalid_base_zero(self):
"""Test that base 0 is invalid"""
with pytest.raises(ValidationError) as exc_info:
RunnerState(lineup_id=1, card_id=100, on_base=0)
assert "on_base" in str(exc_info.value)
def test_runner_state_invalid_base_four(self):
"""Test that base 4 is invalid"""
with pytest.raises(ValidationError) as exc_info:
RunnerState(lineup_id=1, card_id=100, on_base=4)
assert "on_base" in str(exc_info.value)
def test_runner_state_invalid_base_negative(self):
"""Test that negative bases are invalid"""
with pytest.raises(ValidationError):
RunnerState(lineup_id=1, card_id=100, on_base=-1)
# ============================================================================
# LINEUP TESTS
# ============================================================================
class TestLineupPlayerState:
"""Tests for LineupPlayerState model"""
def test_create_lineup_player_valid(self):
"""Test creating a valid lineup player"""
player = LineupPlayerState(
lineup_id=1,
card_id=100,
position="CF",
batting_order=1,
is_active=True
)
assert player.lineup_id == 1
assert player.card_id == 100
assert player.position == "CF"
assert player.batting_order == 1
assert player.is_active is True
def test_lineup_player_valid_positions(self):
"""Test all valid positions"""
valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH']
for pos in valid_positions:
player = LineupPlayerState(lineup_id=1, card_id=100, position=pos)
assert player.position == pos
def test_lineup_player_invalid_position(self):
"""Test that invalid positions raise error"""
with pytest.raises(ValidationError) as exc_info:
LineupPlayerState(lineup_id=1, card_id=100, position="XX")
assert "Position must be one of" in str(exc_info.value)
def test_lineup_player_batting_order_range(self):
"""Test valid batting order range (1-9)"""
for order in range(1, 10):
player = LineupPlayerState(lineup_id=1, card_id=100, position="CF", batting_order=order)
assert player.batting_order == order
def test_lineup_player_batting_order_invalid_zero(self):
"""Test that batting order 0 is invalid"""
with pytest.raises(ValidationError):
LineupPlayerState(lineup_id=1, card_id=100, position="CF", batting_order=0)
def test_lineup_player_batting_order_invalid_ten(self):
"""Test that batting order 10 is invalid"""
with pytest.raises(ValidationError):
LineupPlayerState(lineup_id=1, card_id=100, position="CF", batting_order=10)
def test_lineup_player_no_batting_order(self):
"""Test that batting order can be None (for pitchers in AL)"""
player = LineupPlayerState(lineup_id=1, card_id=100, position="P", batting_order=None)
assert player.batting_order is None
class TestTeamLineupState:
"""Tests for TeamLineupState model"""
def test_create_empty_lineup(self):
"""Test creating an empty lineup"""
lineup = TeamLineupState(team_id=1, players=[])
assert lineup.team_id == 1
assert len(lineup.players) == 0
def test_get_batting_order(self):
"""Test getting players in batting order"""
players = [
LineupPlayerState(lineup_id=3, card_id=103, position="CF", batting_order=3),
LineupPlayerState(lineup_id=1, card_id=101, position="LF", batting_order=1),
LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2),
LineupPlayerState(lineup_id=10, card_id=110, position="P", batting_order=None),
]
lineup = TeamLineupState(team_id=1, players=players)
ordered = lineup.get_batting_order()
assert len(ordered) == 3 # Pitcher excluded
assert ordered[0].batting_order == 1
assert ordered[1].batting_order == 2
assert ordered[2].batting_order == 3
def test_get_pitcher(self):
"""Test getting the active pitcher"""
players = [
LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
LineupPlayerState(lineup_id=10, card_id=110, position="P", is_active=True),
]
lineup = TeamLineupState(team_id=1, players=players)
pitcher = lineup.get_pitcher()
assert pitcher is not None
assert pitcher.position == "P"
assert pitcher.card_id == 110
def test_get_pitcher_none_when_inactive(self):
"""Test that inactive pitcher is not returned"""
players = [
LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
LineupPlayerState(lineup_id=10, card_id=110, position="P", is_active=False),
]
lineup = TeamLineupState(team_id=1, players=players)
pitcher = lineup.get_pitcher()
assert pitcher is None
def test_get_player_by_lineup_id(self):
"""Test getting player by lineup ID"""
players = [
LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
LineupPlayerState(lineup_id=5, card_id=105, position="SS", batting_order=5),
]
lineup = TeamLineupState(team_id=1, players=players)
player = lineup.get_player_by_lineup_id(5)
assert player is not None
assert player.card_id == 105
assert player.position == "SS"
def test_get_player_by_lineup_id_not_found(self):
"""Test that None is returned for non-existent lineup ID"""
players = [
LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
]
lineup = TeamLineupState(team_id=1, players=players)
player = lineup.get_player_by_lineup_id(99)
assert player is None
def test_get_batter(self):
"""Test getting batter by batting order index"""
players = [
LineupPlayerState(lineup_id=1, card_id=101, position="LF", batting_order=1),
LineupPlayerState(lineup_id=2, card_id=102, position="CF", batting_order=2),
LineupPlayerState(lineup_id=3, card_id=103, position="RF", batting_order=3),
]
lineup = TeamLineupState(team_id=1, players=players)
batter = lineup.get_batter(0) # First batter (index 0)
assert batter is not None
assert batter.batting_order == 1
batter = lineup.get_batter(2) # Third batter (index 2)
assert batter is not None
assert batter.batting_order == 3
def test_get_batter_out_of_range(self):
"""Test that None is returned for out-of-range index"""
players = [
LineupPlayerState(lineup_id=1, card_id=101, position="LF", batting_order=1),
]
lineup = TeamLineupState(team_id=1, players=players)
batter = lineup.get_batter(5)
assert batter is None
# ============================================================================
# DECISION TESTS
# ============================================================================
class TestDefensiveDecision:
"""Tests for DefensiveDecision model"""
def test_create_defensive_decision_defaults(self):
"""Test creating defensive decision with defaults"""
decision = DefensiveDecision()
assert decision.alignment == "normal"
assert decision.infield_depth == "normal"
assert decision.outfield_depth == "normal"
assert decision.hold_runners == []
def test_defensive_decision_valid_alignments(self):
"""Test all valid alignments"""
valid = ['normal', 'shifted_left', 'shifted_right', 'extreme_shift']
for alignment in valid:
decision = DefensiveDecision(alignment=alignment)
assert decision.alignment == alignment
def test_defensive_decision_invalid_alignment(self):
"""Test that invalid alignment raises error"""
with pytest.raises(ValidationError):
DefensiveDecision(alignment="invalid")
def test_defensive_decision_valid_infield_depths(self):
"""Test all valid infield depths"""
valid = ['in', 'normal', 'back', 'double_play']
for depth in valid:
decision = DefensiveDecision(infield_depth=depth)
assert decision.infield_depth == depth
def test_defensive_decision_invalid_infield_depth(self):
"""Test that invalid infield depth raises error"""
with pytest.raises(ValidationError):
DefensiveDecision(infield_depth="invalid")
def test_defensive_decision_hold_runners(self):
"""Test holding runners"""
decision = DefensiveDecision(hold_runners=[1, 3])
assert decision.hold_runners == [1, 3]
class TestOffensiveDecision:
"""Tests for OffensiveDecision model"""
def test_create_offensive_decision_defaults(self):
"""Test creating offensive decision with defaults"""
decision = OffensiveDecision()
assert decision.approach == "normal"
assert decision.steal_attempts == []
assert decision.hit_and_run is False
assert decision.bunt_attempt is False
def test_offensive_decision_valid_approaches(self):
"""Test all valid batting approaches"""
valid = ['normal', 'contact', 'power', 'patient']
for approach in valid:
decision = OffensiveDecision(approach=approach)
assert decision.approach == approach
def test_offensive_decision_invalid_approach(self):
"""Test that invalid approach raises error"""
with pytest.raises(ValidationError):
OffensiveDecision(approach="invalid")
def test_offensive_decision_steal_attempts(self):
"""Test steal attempts"""
decision = OffensiveDecision(steal_attempts=[2])
assert decision.steal_attempts == [2]
decision = OffensiveDecision(steal_attempts=[2, 3]) # Double steal
assert decision.steal_attempts == [2, 3]
def test_offensive_decision_invalid_steal_base(self):
"""Test that invalid steal base raises error"""
with pytest.raises(ValidationError):
OffensiveDecision(steal_attempts=[1]) # Can't steal first
def test_offensive_decision_hit_and_run(self):
"""Test hit and run"""
decision = OffensiveDecision(hit_and_run=True)
assert decision.hit_and_run is True
def test_offensive_decision_bunt(self):
"""Test bunt attempt"""
decision = OffensiveDecision(bunt_attempt=True)
assert decision.bunt_attempt is True
# ============================================================================
# GAMESTATE TESTS
# ============================================================================
class TestGameState:
"""Tests for GameState model"""
def test_create_game_state_minimal(self):
"""Test creating game state with minimal fields"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2
)
assert state.game_id == game_id
assert state.league_id == "sba"
assert state.home_team_id == 1
assert state.away_team_id == 2
assert state.status == "pending"
assert state.inning == 1
assert state.half == "top"
assert state.outs == 0
assert state.home_score == 0
assert state.away_score == 0
def test_game_state_valid_league_ids(self):
"""Test valid league IDs"""
game_id = uuid4()
for league in ['sba', 'pd']:
state = GameState(
game_id=game_id,
league_id=league,
home_team_id=1,
away_team_id=2
)
assert state.league_id == league
def test_game_state_invalid_league_id(self):
"""Test that invalid league ID raises error"""
game_id = uuid4()
with pytest.raises(ValidationError):
GameState(
game_id=game_id,
league_id="invalid",
home_team_id=1,
away_team_id=2
)
def test_game_state_valid_statuses(self):
"""Test valid game statuses"""
game_id = uuid4()
for status in ['pending', 'active', 'paused', 'completed']:
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
status=status
)
assert state.status == status
def test_game_state_invalid_status(self):
"""Test that invalid status raises error"""
game_id = uuid4()
with pytest.raises(ValidationError):
GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
status="invalid"
)
def test_game_state_valid_halves(self):
"""Test valid inning halves"""
game_id = uuid4()
for half in ['top', 'bottom']:
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half=half
)
assert state.half == half
def test_game_state_invalid_half(self):
"""Test that invalid half raises error"""
game_id = uuid4()
with pytest.raises(ValidationError):
GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half="middle"
)
def test_game_state_outs_validation(self):
"""Test outs validation (0-2)"""
game_id = uuid4()
# Valid outs
for outs in [0, 1, 2]:
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
outs=outs
)
assert state.outs == outs
# Invalid outs
with pytest.raises(ValidationError):
GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
outs=3
)
def test_get_batting_team_id(self):
"""Test getting batting team ID"""
game_id = uuid4()
# Top of inning - away team bats
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half="top"
)
assert state.get_batting_team_id() == 2
# Bottom of inning - home team bats
state.half = "bottom"
assert state.get_batting_team_id() == 1
def test_get_fielding_team_id(self):
"""Test getting fielding team ID"""
game_id = uuid4()
# Top of inning - home team fields
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half="top"
)
assert state.get_fielding_team_id() == 1
# Bottom of inning - away team fields
state.half = "bottom"
assert state.get_fielding_team_id() == 2
def test_is_runner_on_base(self):
"""Test checking for runners on specific bases"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=1),
RunnerState(lineup_id=3, card_id=103, on_base=3),
]
)
assert state.is_runner_on_first() is True
assert state.is_runner_on_second() is False
assert state.is_runner_on_third() is True
def test_get_runner_at_base(self):
"""Test getting runner at specific base"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=1),
RunnerState(lineup_id=2, card_id=102, on_base=2),
]
)
runner = state.get_runner_at_base(1)
assert runner is not None
assert runner.lineup_id == 1
runner = state.get_runner_at_base(3)
assert runner is None
def test_bases_occupied(self):
"""Test getting list of occupied bases"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=1),
RunnerState(lineup_id=3, card_id=103, on_base=3),
]
)
bases = state.bases_occupied()
assert bases == [1, 3]
def test_clear_bases(self):
"""Test clearing all runners"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=1),
RunnerState(lineup_id=2, card_id=102, on_base=2),
]
)
assert len(state.runners) == 2
state.clear_bases()
assert len(state.runners) == 0
def test_add_runner(self):
"""Test adding a runner to a base"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2
)
state.add_runner(lineup_id=1, card_id=101, base=1)
assert len(state.runners) == 1
assert state.is_runner_on_first() is True
def test_add_runner_replaces_existing(self):
"""Test that adding runner to occupied base replaces existing"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=1),
]
)
state.add_runner(lineup_id=2, card_id=102, base=1)
assert len(state.runners) == 1 # Still only 1 runner
runner = state.get_runner_at_base(1)
assert runner.lineup_id == 2 # New runner replaced old
def test_advance_runner_to_base(self):
"""Test advancing runner to another base"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=1),
]
)
state.advance_runner(from_base=1, to_base=2)
assert state.is_runner_on_first() is False
assert state.is_runner_on_second() is True
def test_advance_runner_to_home(self):
"""Test advancing runner to home (scoring)"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half="top",
runners=[
RunnerState(lineup_id=1, card_id=101, on_base=3),
]
)
initial_score = state.away_score
state.advance_runner(from_base=3, to_base=4)
assert len(state.runners) == 0 # Runner removed from bases
assert state.away_score == initial_score + 1 # Score increased
def test_advance_runner_scores_correct_team(self):
"""Test that scoring increments correct team's score"""
game_id = uuid4()
# Top of inning - away team batting
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half="top",
runners=[RunnerState(lineup_id=1, card_id=101, on_base=3)]
)
state.advance_runner(from_base=3, to_base=4)
assert state.away_score == 1
assert state.home_score == 0
# Bottom of inning - home team batting
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
half="bottom",
runners=[RunnerState(lineup_id=5, card_id=105, on_base=3)]
)
state.advance_runner(from_base=3, to_base=4)
assert state.home_score == 1
assert state.away_score == 0
def test_increment_outs(self):
"""Test incrementing outs"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
outs=0
)
assert state.increment_outs() is False # 1 out - not end of inning
assert state.outs == 1
assert state.increment_outs() is False # 2 outs - not end of inning
assert state.outs == 2
assert state.increment_outs() is True # 3 outs - end of inning
assert state.outs == 3
def test_end_half_inning_top_to_bottom(self):
"""Test ending top of inning goes to bottom"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=3,
half="top",
outs=2,
runners=[RunnerState(lineup_id=1, card_id=101, on_base=1)]
)
state.end_half_inning()
assert state.inning == 3 # Same inning
assert state.half == "bottom" # Now bottom
assert state.outs == 0 # Outs reset
assert len(state.runners) == 0 # Bases cleared
def test_end_half_inning_bottom_to_next_top(self):
"""Test ending bottom of inning goes to next inning top"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=3,
half="bottom",
outs=2,
runners=[RunnerState(lineup_id=1, card_id=101, on_base=2)]
)
state.end_half_inning()
assert state.inning == 4 # Next inning
assert state.half == "top" # Top of next inning
assert state.outs == 0 # Outs reset
assert len(state.runners) == 0 # Bases cleared
def test_is_game_over_early_innings(self):
"""Test game is not over in early innings"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=5,
half="bottom",
home_score=5,
away_score=2
)
assert state.is_game_over() is False
def test_is_game_over_bottom_ninth_home_ahead(self):
"""Test game over when home team ahead in bottom 9th"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=9,
half="bottom",
home_score=5,
away_score=2
)
assert state.is_game_over() is True
def test_is_game_over_bottom_ninth_tied(self):
"""Test game continues when tied in bottom 9th"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=9,
half="bottom",
home_score=2,
away_score=2
)
assert state.is_game_over() is False
def test_is_game_over_extra_innings_walkoff(self):
"""Test game over on walk-off in extra innings"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=10,
half="bottom",
home_score=5,
away_score=4
)
assert state.is_game_over() is True
def test_is_game_over_after_top_ninth_home_ahead(self):
"""Test game over at start of bottom 9th when away team ahead"""
game_id = uuid4()
# Bottom of 9th, away team ahead - home team can't catch up
# Note: This would only happen at START of bottom 9th
# In reality, game wouldn't start bottom 9th if home is losing
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
inning=9,
half="bottom",
outs=0,
home_score=2,
away_score=5
)
# Game continues - home team gets chance to catch up
assert state.is_game_over() is False