diff --git a/backend/app/core/validators.py b/backend/app/core/validators.py index 2c1e423..56671ea 100644 --- a/backend/app/core/validators.py +++ b/backend/app/core/validators.py @@ -44,42 +44,87 @@ class GameValidator: @staticmethod def validate_defensive_decision(decision: DefensiveDecision, state: GameState) -> None: - """Validate defensive team decision""" - valid_alignments = ["normal", "shifted_left", "shifted_right"] + """ + Validate defensive team decision against current game state. + + Args: + decision: Defensive decision to validate + state: Current game state + + Raises: + ValidationError: If decision is invalid for current situation + """ + # Validate alignment (already validated by Pydantic, but double-check) + valid_alignments = ["normal", "shifted_left", "shifted_right", "extreme_shift"] if decision.alignment not in valid_alignments: raise ValidationError(f"Invalid alignment: {decision.alignment}") - valid_depths = ["in", "normal", "back", "double_play"] # TODO: update these to strat-specific values - if decision.infield_depth not in valid_depths: + # Validate depths (already validated by Pydantic, but double-check) + valid_infield_depths = ["in", "normal", "back", "double_play"] + if decision.infield_depth not in valid_infield_depths: raise ValidationError(f"Invalid infield depth: {decision.infield_depth}") + valid_outfield_depths = ["in", "normal", "back"] + if decision.outfield_depth not in valid_outfield_depths: + raise ValidationError(f"Invalid outfield depth: {decision.outfield_depth}") + # Validate hold runners - can't hold empty bases occupied_bases = state.bases_occupied() for base in decision.hold_runners: + if base not in [1, 2, 3]: + raise ValidationError(f"Invalid hold runner base: {base} (must be 1, 2, or 3)") if base not in occupied_bases: - raise ValidationError(f"Can't hold base {base} - no runner present") + raise ValidationError(f"Cannot hold runner on base {base} - no runner present") + + # Validate double play depth requirements + if decision.infield_depth == "double_play": + if state.outs >= 2: + raise ValidationError("Cannot play for double play with 2 outs") + if not state.is_runner_on_first(): + raise ValidationError("Cannot play for double play without runner on first base") logger.debug("Defensive decision validated") @staticmethod def validate_offensive_decision(decision: OffensiveDecision, state: GameState) -> None: - """Validate offensive team decision""" - valid_approaches = ["normal", "contact", "power", "patient"] # TODO: update these to strat-specific values + """ + Validate offensive team decision against current game state. + + Args: + decision: Offensive decision to validate + state: Current game state + + Raises: + ValidationError: If decision is invalid for current situation + """ + # Validate approach (already validated by Pydantic, but double-check) + valid_approaches = ["normal", "contact", "power", "patient"] if decision.approach not in valid_approaches: raise ValidationError(f"Invalid approach: {decision.approach}") # Validate steal attempts occupied_bases = state.bases_occupied() for base in decision.steal_attempts: + # Validate steal base is valid (2, 3, or 4 for home) + if base not in [2, 3, 4]: + raise ValidationError(f"Invalid steal attempt to base {base} (must be 2, 3, or 4)") + # Must have runner on base-1 to steal base - if (base - 1) not in occupied_bases: - raise ValidationError(f"Can't steal {base} - no runner on {base-1}") + stealing_from = base - 1 + if stealing_from not in occupied_bases: + raise ValidationError(f"Cannot steal base {base} - no runner on base {stealing_from}") - # TODO: add check that base in front of stealing runner is unoccupied + # Validate bunt attempt + if decision.bunt_attempt: + if state.outs >= 2: + raise ValidationError("Cannot bunt with 2 outs") + if decision.hit_and_run: + raise ValidationError("Cannot bunt and hit-and-run simultaneously") - # Can't bunt with 2 outs (simplified rule) - if decision.bunt_attempt and state.outs == 2: - raise ValidationError("Cannot bunt with 2 outs") + # Validate hit and run - requires at least one runner on base + if decision.hit_and_run: + if not any(state.get_runner_at_base(b) is not None for b in [1, 2, 3]): + raise ValidationError("Hit and run requires at least one runner on base") logger.debug("Offensive decision validated") diff --git a/backend/tests/unit/core/test_validators.py b/backend/tests/unit/core/test_validators.py index 45239c6..90194a6 100644 --- a/backend/tests/unit/core/test_validators.py +++ b/backend/tests/unit/core/test_validators.py @@ -224,8 +224,7 @@ class TestDefensiveDecisionValidation: game_id=uuid4(), league_id="sba", home_team_id=1, - away_team_id=2, - runners=[] + away_team_id=2 ) decision = DefensiveDecision( alignment="normal", @@ -235,9 +234,159 @@ class TestDefensiveDecisionValidation: with pytest.raises(ValidationError) as exc_info: validator.validate_defensive_decision(decision, state) - assert "can't hold base" in str(exc_info.value).lower() + assert "cannot hold runner on base" in str(exc_info.value).lower() assert "no runner" in str(exc_info.value).lower() + def test_validate_defensive_decision_hold_invalid_base_number(self): + """Test holding invalid base number fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + 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) + ) + decision = DefensiveDecision( + alignment="normal", + hold_runners=[1, 4] # 4 is invalid + ) + + with pytest.raises(ValidationError) as exc_info: + validator.validate_defensive_decision(decision, state) + + assert "invalid hold runner base" in str(exc_info.value).lower() + assert "4" in str(exc_info.value) + + def test_validate_defensive_decision_hold_multiple_runners(self): + """Test holding multiple runners with bases loaded""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + 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="SS", batting_order=2), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="LF", batting_order=3) + ) + decision = DefensiveDecision( + alignment="normal", + hold_runners=[1, 2, 3] + ) + + # Should not raise + validator.validate_defensive_decision(decision, state) + + def test_validate_defensive_decision_double_play_depth_without_runner_fails(self): + """Test double play depth without runner on first fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + outs=0 + ) + decision = DefensiveDecision( + infield_depth="double_play" + ) + + with pytest.raises(ValidationError) as exc_info: + validator.validate_defensive_decision(decision, state) + + assert "cannot play for double play" in str(exc_info.value).lower() + assert "without runner on first" in str(exc_info.value).lower() + + def test_validate_defensive_decision_double_play_depth_with_runner_succeeds(self): + """Test double play depth with runner on first succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + outs=0, + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = DefensiveDecision( + infield_depth="double_play" + ) + + # Should not raise + validator.validate_defensive_decision(decision, state) + + def test_validate_defensive_decision_double_play_depth_with_2_outs_fails(self): + """Test double play depth with 2 outs fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + outs=2, + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = DefensiveDecision( + infield_depth="double_play" + ) + + with pytest.raises(ValidationError) as exc_info: + validator.validate_defensive_decision(decision, state) + + assert "cannot play for double play" in str(exc_info.value).lower() + assert "2 outs" in str(exc_info.value).lower() + + def test_validate_defensive_decision_double_play_depth_with_1_out_succeeds(self): + """Test double play depth with 1 out succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + outs=1, + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = DefensiveDecision( + infield_depth="double_play" + ) + + # Should not raise + validator.validate_defensive_decision(decision, state) + + def test_validate_defensive_decision_all_valid_alignments(self): + """Test all valid alignment options""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2 + ) + + valid_alignments = ["normal", "shifted_left", "shifted_right", "extreme_shift"] + for alignment in valid_alignments: + decision = DefensiveDecision(alignment=alignment) + # Should not raise + validator.validate_defensive_decision(decision, state) + + def test_validate_defensive_decision_all_valid_outfield_depths(self): + """Test all valid outfield depth options""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2 + ) + + valid_depths = ["in", "normal", "back"] + for depth in valid_depths: + decision = DefensiveDecision(outfield_depth=depth) + # Should not raise + validator.validate_defensive_decision(decision, state) + class TestOffensiveDecisionValidation: """Test offensive decision validation""" @@ -292,8 +441,7 @@ class TestOffensiveDecisionValidation: game_id=uuid4(), league_id="sba", home_team_id=1, - away_team_id=2, - runners=[] + away_team_id=2 ) decision = OffensiveDecision( approach="normal", @@ -303,7 +451,7 @@ class TestOffensiveDecisionValidation: with pytest.raises(ValidationError) as exc_info: validator.validate_offensive_decision(decision, state) - assert "can't steal" in str(exc_info.value).lower() + assert "cannot steal" in str(exc_info.value).lower() assert "no runner" in str(exc_info.value).lower() def test_validate_offensive_decision_bunt_with_two_outs_fails(self): @@ -347,6 +495,175 @@ class TestOffensiveDecisionValidation: # Should not raise validator.validate_offensive_decision(decision, state) + def test_validate_offensive_decision_steal_third_with_runner_on_second(self): + """Test stealing third with runner on second succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + on_second=LineupPlayerState(lineup_id=2, card_id=102, position="SS", batting_order=2) + ) + decision = OffensiveDecision( + steal_attempts=[3] + ) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_steal_home_with_runner_on_third(self): + """Test stealing home with runner on third succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="LF", batting_order=3) + ) + decision = OffensiveDecision( + steal_attempts=[4] + ) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_double_steal(self): + """Test double steal with runners on first and second""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + 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="SS", batting_order=2) + ) + decision = OffensiveDecision( + steal_attempts=[2, 3] + ) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_steal_invalid_base_fails(self): + """Test stealing to invalid base number fails at Pydantic validation""" + from pydantic_core import ValidationError as PydanticValidationError + + # Pydantic catches invalid base at model creation + with pytest.raises(PydanticValidationError) as exc_info: + decision = OffensiveDecision( + steal_attempts=[5] # 5 is invalid + ) + + assert "steal_attempts" in str(exc_info.value).lower() + + def test_validate_offensive_decision_bunt_and_hit_and_run_fails(self): + """Test bunting and hit-and-run simultaneously fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + 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) + ) + decision = OffensiveDecision( + bunt_attempt=True, + hit_and_run=True + ) + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "cannot bunt and hit-and-run simultaneously" in str(exc_info.value).lower() + + def test_validate_offensive_decision_hit_and_run_without_runners_fails(self): + """Test hit-and-run without runners on base fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2 + ) + decision = OffensiveDecision( + hit_and_run=True + ) + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "hit and run requires at least one runner" in str(exc_info.value).lower() + + def test_validate_offensive_decision_hit_and_run_with_runner_on_first_succeeds(self): + """Test hit-and-run with runner on first succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + 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) + ) + decision = OffensiveDecision( + hit_and_run=True + ) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_hit_and_run_with_runner_on_second_only(self): + """Test hit-and-run with runner on second only succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + on_second=LineupPlayerState(lineup_id=2, card_id=102, position="SS", batting_order=2) + ) + decision = OffensiveDecision( + hit_and_run=True + ) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_hit_and_run_with_runner_on_third_only(self): + """Test hit-and-run with runner on third only succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="LF", batting_order=3) + ) + decision = OffensiveDecision( + hit_and_run=True + ) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_all_valid_approaches(self): + """Test all valid batting approach options""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2 + ) + + valid_approaches = ["normal", "contact", "power", "patient"] + for approach in valid_approaches: + decision = OffensiveDecision(approach=approach) + # Should not raise + validator.validate_offensive_decision(decision, state) + class TestLineupValidation: """Test lineup position validation"""