""" 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, ) # ============================================================================ # 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, 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): """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, 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): """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, 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): """Test clearing all runners""" game_id = uuid4() state = GameState( game_id=game_id, league_id="sba", home_team_id=1, away_team_id=2, 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): """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 ) 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): """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, 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): """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, 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): """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", 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): """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", 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, 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): """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, 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): """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, 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): """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