strat-gameplay-webapp/backend/tests/unit/models/test_game_models.py
Cal Corum d560844704 CLAUDE: Phase 3E-Prep - Refactor GameState to use full LineupPlayerState objects
**Architectural Improvement**: Unified player references in GameState

**Changed**: Make all player references consistent
- BEFORE: current_batter/pitcher/catcher were IDs (int)
- AFTER: current_batter/pitcher/catcher are full LineupPlayerState objects
- Matches pattern of on_first/on_second/on_third (already objects)

**Benefits**:
1. Consistent API - all player references use same type
2. Self-contained GameState - everything needed for resolution
3. No lookups needed - direct access to player data
4. Sets foundation for Phase 3E-Main (adding position ratings)

**Files Modified**:
- app/models/game_models.py: Changed current_batter/pitcher/catcher to objects
- app/core/game_engine.py: Updated _prepare_next_play() to populate full objects
- app/core/state_manager.py: Create placeholder batter on game creation
- tests/unit/models/test_game_models.py: Updated all 27 GameState tests

**Database Operations**:
- No schema changes needed
- Play table still stores IDs (for referential integrity)
- IDs extracted from objects when saving: state.current_batter.lineup_id

**Testing**:
- All 27 GameState tests passing
- No regressions in existing functionality
- Type checking passes

**Next**: Phase 3E-Main - Add PositionRating dataclass and load ratings at game start

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:11:40 -06:00

809 lines
28 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 (
LineupPlayerState,
TeamLineupState,
DefensiveDecision,
OffensiveDecision,
GameState,
)
# ============================================================================
# TEST FIXTURES
# ============================================================================
@pytest.fixture
def sample_batter():
"""Create a sample batter for testing GameState"""
return LineupPlayerState(
lineup_id=1,
card_id=100,
position="RF",
batting_order=3,
is_active=True
)
@pytest.fixture
def sample_pitcher():
"""Create a sample pitcher for testing GameState"""
return LineupPlayerState(
lineup_id=10,
card_id=200,
position="P",
batting_order=9,
is_active=True
)
@pytest.fixture
def sample_catcher():
"""Create a sample catcher for testing GameState"""
return LineupPlayerState(
lineup_id=2,
card_id=101,
position="C",
batting_order=2,
is_active=True
)
# ============================================================================
# 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 = ['infield_in', 'normal', 'corners_in']
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, sample_batter):
"""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,
current_batter=sample_batter
)
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, sample_batter):
"""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,
current_batter=sample_batter
)
assert state.league_id == league
def test_game_state_invalid_league_id(self, sample_batter):
"""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,
current_batter=sample_batter
)
def test_game_state_valid_statuses(self, sample_batter):
"""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,
current_batter=sample_batter,
status=status
)
assert state.status == status
def test_game_state_invalid_status(self, sample_batter):
"""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,
current_batter=sample_batter,
status="invalid"
)
def test_game_state_valid_halves(self, sample_batter):
"""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,
current_batter=sample_batter,
half=half
)
assert state.half == half
def test_game_state_invalid_half(self, sample_batter):
"""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,
current_batter=sample_batter,
half="middle"
)
def test_game_state_outs_validation(self, sample_batter):
"""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,
current_batter=sample_batter,
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,
current_batter=sample_batter,
outs=3
)
def test_get_batting_team_id(self, sample_batter):
"""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,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=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, sample_batter):
"""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,
current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=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, sample_batter):
"""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,
current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3)
)
bases = state.bases_occupied()
assert bases == [1, 3]
def test_clear_bases(self, sample_batter):
"""Test clearing all runners"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2)
)
assert len(state.get_all_runners()) == 2
state.clear_bases()
assert len(state.get_all_runners()) == 0
assert state.on_first is None
assert state.on_second is None
assert state.on_third is None
def test_add_runner(self, sample_batter):
"""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,
current_batter=sample_batter
)
player = LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
state.add_runner(player=player, base=1)
assert len(state.get_all_runners()) == 1
assert state.is_runner_on_first() is True
def test_add_runner_replaces_existing(self, sample_batter):
"""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,
current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
)
new_player = LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2)
state.add_runner(player=new_player, base=1)
assert len(state.get_all_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, sample_batter):
"""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,
current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=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, sample_batter):
"""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,
current_batter=sample_batter,
half="top",
on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
)
initial_score = state.away_score
state.advance_runner(from_base=3, to_base=4)
assert len(state.get_all_runners()) == 0 # Runner removed from bases
assert state.away_score == initial_score + 1 # Score increased
def test_advance_runner_scores_correct_team(self, sample_batter):
"""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,
current_batter=sample_batter,
half="top",
on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
)
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,
current_batter=sample_batter,
half="bottom",
on_third=LineupPlayerState(lineup_id=5, card_id=105, position="RF", batting_order=5)
)
state.advance_runner(from_base=3, to_base=4)
assert state.home_score == 1
assert state.away_score == 0
def test_increment_outs(self, sample_batter):
"""Test incrementing outs"""
game_id = uuid4()
state = GameState(
game_id=game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
inning=3,
half="top",
outs=2,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=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.get_all_runners()) == 0 # Bases cleared
def test_end_half_inning_bottom_to_next_top(self, sample_batter):
"""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,
current_batter=sample_batter,
inning=3,
half="bottom",
outs=2,
on_second=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
)
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.get_all_runners()) == 0 # Bases cleared
def test_is_game_over_early_innings(self, sample_batter):
"""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,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
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, sample_batter):
"""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,
current_batter=sample_batter,
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