From e165b449f58b7ed25f16110884eb0bd4f479d560 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 14 Nov 2025 15:07:54 -0600 Subject: [PATCH] CLAUDE: Refactor offensive decisions - replace approach with action field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend refactor complete - removed all deprecated parameters and replaced with clean action-based system. Changes: - OffensiveDecision model: Added 'action' field (6 choices), removed deprecated 'hit_and_run' and 'bunt_attempt' boolean fields - Validators: Added action-specific validation (squeeze_bunt, check_jump, sac_bunt, hit_and_run situational constraints) - WebSocket handler: Updated submit_offensive_decision to use action field - Terminal client: Updated CLI, REPL, arg parser, and display for actions - Tests: Updated all 739 unit tests (100% passing) Action field values: - swing_away (default) - steal (requires steal_attempts parameter) - check_jump (requires runner on base) - hit_and_run (requires runner on base) - sac_bunt (cannot use with 2 outs) - squeeze_bunt (requires R3, not with bases loaded, not with 2 outs) Breaking changes: - Removed: hit_and_run boolean → use action="hit_and_run" - Removed: bunt_attempt boolean → use action="sac_bunt" or "squeeze_bunt" - Removed: approach field → use action field Files modified: - app/models/game_models.py - app/core/validators.py - app/websocket/handlers.py - terminal_client/main.py - terminal_client/arg_parser.py - terminal_client/commands.py - terminal_client/repl.py - terminal_client/display.py - tests/unit/models/test_game_models.py - tests/unit/core/test_validators.py - tests/unit/terminal_client/test_arg_parser.py - tests/unit/terminal_client/test_commands.py Test results: 739/739 passing (100%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/app/core/validators.py | 50 +++- backend/app/models/game_models.py | 20 +- backend/app/websocket/handlers.py | 22 +- backend/terminal_client/arg_parser.py | 6 +- backend/terminal_client/commands.py | 18 +- backend/terminal_client/display.py | 3 +- backend/terminal_client/main.py | 17 +- backend/terminal_client/repl.py | 8 +- backend/tests/unit/core/test_validators.py | 256 ++++++++++++++++-- backend/tests/unit/models/test_game_models.py | 27 +- .../unit/terminal_client/test_arg_parser.py | 20 +- .../unit/terminal_client/test_commands.py | 7 +- 12 files changed, 350 insertions(+), 104 deletions(-) diff --git a/backend/app/core/validators.py b/backend/app/core/validators.py index 60497bf..fe671f7 100644 --- a/backend/app/core/validators.py +++ b/backend/app/core/validators.py @@ -117,9 +117,45 @@ class GameValidator: Raises: ValidationError: If decision is invalid for current situation + + Session 2 Update (2025-01-14): Added validation for action field. """ - # Validate steal attempts + # Validate action field (already validated by Pydantic, but enforce situational rules) occupied_bases = state.bases_occupied() + + # Validate steal action - requires steal_attempts to be specified + if decision.action == 'steal': + if not decision.steal_attempts: + raise ValidationError("Steal action requires steal_attempts to specify which bases to steal") + + # Validate squeeze_bunt - requires R3 and bases NOT loaded + if decision.action == 'squeeze_bunt': + if not state.is_runner_on_third(): + raise ValidationError("Squeeze bunt requires a runner on third base") + if len(occupied_bases) == 3: # Bases loaded + raise ValidationError("Squeeze bunt cannot be used with bases loaded") + if state.outs >= 2: + raise ValidationError("Squeeze bunt cannot be used with 2 outs") + + # Validate check_jump - requires runner on base (lead runner only OR both if 1st+3rd) + if decision.action == 'check_jump': + if len(occupied_bases) == 0: + raise ValidationError("Check jump requires at least one runner on base") + # Lead runner validation: can't check jump at 2nd if R3 exists + if state.is_runner_on_second() and state.is_runner_on_third(): + raise ValidationError("Check jump not allowed for trail runner (R2) when R3 is on base") + + # Validate sac_bunt - cannot be used with 2 outs + if decision.action == 'sac_bunt': + if state.outs >= 2: + raise ValidationError("Sacrifice bunt cannot be used with 2 outs") + + # Validate hit_and_run action - requires runner on base + if decision.action == 'hit_and_run': + if len(occupied_bases) == 0: + raise ValidationError("Hit and run action requires at least one runner on base") + + # Validate steal attempts (when provided) for base in decision.steal_attempts: # Validate steal base is valid (2, 3, or 4 for home) if base not in [2, 3, 4]: @@ -130,18 +166,6 @@ class GameValidator: if stealing_from not in occupied_bases: raise ValidationError(f"Cannot steal base {base} - no runner on base {stealing_from}") - # 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") - - # 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") @staticmethod diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 9933679..6ce616c 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -179,10 +179,26 @@ class OffensiveDecision(BaseModel): Offensive team strategic decisions for a play. These decisions affect baserunner actions. + + Session 2 Update (2025-01-14): Replaced approach field with action field. + Valid actions: swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt + + When action="steal", steal_attempts must specify which bases to steal. """ + # Specific action choice + action: str = "swing_away" + + # Base stealing - only used when action="steal" steal_attempts: List[int] = Field(default_factory=list) # [2] = steal second, [2, 3] = double steal - hit_and_run: bool = False - bunt_attempt: bool = False + + @field_validator('action') + @classmethod + def validate_action(cls, v: str) -> str: + """Validate action is one of the allowed values""" + valid_actions = ['swing_away', 'steal', 'check_jump', 'hit_and_run', 'sac_bunt', 'squeeze_bunt'] + if v not in valid_actions: + raise ValueError(f"action must be one of {valid_actions}") + return v @field_validator('steal_attempts') @classmethod diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 6da0805..a3cb58e 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -1131,13 +1131,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: Event data: game_id: UUID of the game - steal_attempts: List of bases for steal attempts (e.g., [2, 3]) - hit_and_run: Boolean - enable hit-and-run play - bunt_attempt: Boolean - attempt bunt + action: String - offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt) + steal_attempts: List of bases for steal attempts - REQUIRED when action="steal" (e.g., [2, 3]) Emits: offensive_decision_submitted: To requester and broadcast to game room error: To requester if validation fails + + Session 2 Update (2025-01-14): Replaced approach with action field. Stealing is now an action choice. """ try: # Extract and validate game_id @@ -1174,17 +1175,15 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # user_id = manager.user_sessions.get(sid) # Extract decision data + action = data.get("action", "swing_away") # Default: swing_away steal_attempts = data.get("steal_attempts", []) - hit_and_run = data.get("hit_and_run", False) - bunt_attempt = data.get("bunt_attempt", False) # Create offensive decision from app.models.game_models import OffensiveDecision decision = OffensiveDecision( - steal_attempts=steal_attempts, - hit_and_run=hit_and_run, - bunt_attempt=bunt_attempt + action=action, + steal_attempts=steal_attempts ) # Submit decision through game engine @@ -1192,7 +1191,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: logger.info( f"Offensive decision submitted for game {game_id}: " - f"steal={steal_attempts}, hit_and_run={hit_and_run}, bunt={bunt_attempt}" + f"action={action}, steal={steal_attempts}" ) # Broadcast to game room @@ -1202,9 +1201,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "game_id": str(game_id), "decision": { - "steal_attempts": steal_attempts, - "hit_and_run": hit_and_run, - "bunt_attempt": bunt_attempt + "action": action, + "steal_attempts": steal_attempts }, "pending_decision": updated_state.pending_decision } diff --git a/backend/terminal_client/arg_parser.py b/backend/terminal_client/arg_parser.py index 51ff902..0e2c867 100644 --- a/backend/terminal_client/arg_parser.py +++ b/backend/terminal_client/arg_parser.py @@ -177,10 +177,8 @@ DEFENSIVE_SCHEMA = { } OFFENSIVE_SCHEMA = { - 'approach': {'type': str, 'default': 'normal'}, - 'steal': {'type': 'int_list', 'default': []}, - 'hit_run': {'type': bool, 'flag': True, 'default': False}, - 'bunt': {'type': bool, 'flag': True, 'default': False} + 'action': {'type': str, 'default': 'swing_away'}, # Session 2: changed from approach + 'steal': {'type': 'int_list', 'default': []} } QUICK_PLAY_SCHEMA = { diff --git a/backend/terminal_client/commands.py b/backend/terminal_client/commands.py index 85953fb..6384b39 100644 --- a/backend/terminal_client/commands.py +++ b/backend/terminal_client/commands.py @@ -169,23 +169,25 @@ class GameCommands: async def submit_offensive_decision( self, game_id: UUID, - approach: str = 'normal', - steal_attempts: Optional[List[int]] = None, - hit_and_run: bool = False, - bunt_attempt: bool = False + action: str = 'swing_away', + steal_attempts: Optional[List[int]] = None ) -> bool: """ Submit offensive decision. + Args: + action: Offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt) + steal_attempts: List of bases to steal (required when action="steal") + Returns: True if successful, False otherwise + + Session 2 Update (2025-01-14): Replaced approach with action field. Removed deprecated fields. """ try: decision = OffensiveDecision( - approach=approach, - steal_attempts=steal_attempts or [], - hit_and_run=hit_and_run, - bunt_attempt=bunt_attempt + action=action, + steal_attempts=steal_attempts or [] ) state = await game_engine.submit_offensive_decision(game_id, decision) diff --git a/backend/terminal_client/display.py b/backend/terminal_client/display.py index f0fff29..31f61fa 100644 --- a/backend/terminal_client/display.py +++ b/backend/terminal_client/display.py @@ -193,10 +193,9 @@ def display_decision(decision_type: str, decision: Optional[DefensiveDecision | if decision.hold_runners: decision_text.append(f"Hold Runners: {decision.hold_runners}\n") elif isinstance(decision, OffensiveDecision): + decision_text.append(f"Action: {decision.action}\n") if decision.steal_attempts: decision_text.append(f"Steal Attempts: {decision.steal_attempts}\n") - decision_text.append(f"Hit-and-Run: {decision.hit_and_run}\n") - decision_text.append(f"Bunt Attempt: {decision.bunt_attempt}\n") panel = Panel( decision_text, diff --git a/backend/terminal_client/main.py b/backend/terminal_client/main.py index c5e4280..a0c7d52 100644 --- a/backend/terminal_client/main.py +++ b/backend/terminal_client/main.py @@ -136,16 +136,15 @@ def defensive(game_id, alignment, infield, outfield, hold): @cli.command() @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') -@click.option('--approach', default='normal', help='Batting approach') +@click.option('--action', default='swing_away', help='Offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt)') @click.option('--steal', default=None, help='Comma-separated bases to steal (e.g., 2,3)') -@click.option('--hit-run', is_flag=True, help='Hit-and-run play') -@click.option('--bunt', is_flag=True, help='Bunt attempt') -def offensive(game_id, approach, steal, hit_run, bunt): +def offensive(game_id, action, steal): """ Submit offensive decision. - Valid approach: normal, contact, power, patient - Valid steal: comma-separated bases (2, 3, 4) + Session 2 Update (2025-01-14): Changed --approach to --action, removed deprecated flags. + Valid actions: swing_away (default), steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt + Valid steal: comma-separated bases (2, 3, 4) - used only when action="steal" """ async def _offensive(): gid = UUID(game_id) if game_id else get_current_game() @@ -162,10 +161,8 @@ def offensive(game_id, approach, steal, hit_run, bunt): # Use shared command success = await game_commands.submit_offensive_decision( game_id=gid, - approach=approach, - steal_attempts=steal_list, - hit_and_run=hit_run, - bunt_attempt=bunt + action=action, + steal_attempts=steal_list ) if not success: diff --git a/backend/terminal_client/repl.py b/backend/terminal_client/repl.py index 8f835ea..39e22cb 100644 --- a/backend/terminal_client/repl.py +++ b/backend/terminal_client/repl.py @@ -229,13 +229,11 @@ Press Ctrl+D or type 'quit' to exit. # Parse arguments with robust parser args = parse_offensive_args(arg) - # Submit decision + # Submit decision (Session 2: replaced approach with action, removed deprecated fields) await game_commands.submit_offensive_decision( game_id=gid, - approach=args['approach'], - steal_attempts=args['steal'], - hit_and_run=args['hit_run'], - bunt_attempt=args['bunt'] + action=args.get('action', 'swing_away'), + steal_attempts=args['steal'] ) except ArgumentParseError as e: diff --git a/backend/tests/unit/core/test_validators.py b/backend/tests/unit/core/test_validators.py index 8863c1d..793dbcd 100644 --- a/backend/tests/unit/core/test_validators.py +++ b/backend/tests/unit/core/test_validators.py @@ -496,15 +496,13 @@ class TestOffensiveDecisionValidation: outs=2 ) decision = OffensiveDecision( - approach="normal", - bunt_attempt=True + action="sac_bunt" ) with pytest.raises(ValidationError) as exc_info: validator.validate_offensive_decision(decision, state) - assert "cannot bunt" in str(exc_info.value).lower() - assert "2 outs" in str(exc_info.value).lower() + assert "sacrifice bunt cannot be used with 2 outs" in str(exc_info.value).lower() def test_validate_offensive_decision_bunt_with_less_than_two_outs_valid(self): """Test bunting with 0-1 outs is valid""" @@ -520,8 +518,7 @@ class TestOffensiveDecisionValidation: outs=outs ) decision = OffensiveDecision( - approach="normal", - bunt_attempt=True + action="sac_bunt" ) # Should not raise @@ -605,15 +602,9 @@ class TestOffensiveDecisionValidation: current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), 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() + # This test is no longer relevant - actions are mutually exclusive via the field + # Can't have action="sac_bunt" and action="hit_and_run" at the same time + pass def test_validate_offensive_decision_hit_and_run_without_runners_fails(self): """Test hit-and-run without runners on base fails""" @@ -626,13 +617,14 @@ class TestOffensiveDecisionValidation: current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1) ) decision = OffensiveDecision( - hit_and_run=True + action="hit_and_run" ) 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() + assert "hit and run" in str(exc_info.value).lower() + assert "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""" @@ -646,7 +638,7 @@ class TestOffensiveDecisionValidation: on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) decision = OffensiveDecision( - hit_and_run=True + action="hit_and_run" ) # Should not raise @@ -704,9 +696,235 @@ class TestOffensiveDecisionValidation: decision = OffensiveDecision(hit_and_run=True, steal_attempts=[2]) validator.validate_offensive_decision(decision, state) - decision = OffensiveDecision(bunt_attempt=True) + # Session 2: Tests for new action field + def test_validate_offensive_decision_action_squeeze_bunt_valid(self): + """Test squeeze bunt with runner on third succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="LF", batting_order=3), + outs=1 + ) + decision = OffensiveDecision(action="squeeze_bunt") + + # Should not raise validator.validate_offensive_decision(decision, state) + def test_validate_offensive_decision_action_squeeze_bunt_no_runner_on_third_fails(self): + """Test squeeze bunt without runner on third fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="squeeze_bunt") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "squeeze bunt requires a runner on third" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_squeeze_bunt_bases_loaded_fails(self): + """Test squeeze bunt with bases loaded fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + 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), + outs=1 + ) + decision = OffensiveDecision(action="squeeze_bunt") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "squeeze bunt cannot be used with bases loaded" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_squeeze_bunt_two_outs_fails(self): + """Test squeeze bunt with 2 outs fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="LF", batting_order=3), + outs=2 + ) + decision = OffensiveDecision(action="squeeze_bunt") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "squeeze bunt cannot be used with 2 outs" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_check_jump_valid(self): + """Test check jump with runner on base succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="check_jump") + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_action_check_jump_no_runners_fails(self): + """Test check jump without runners fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="check_jump") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "check jump requires at least one runner" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_check_jump_trail_runner_fails(self): + """Test check jump with trail runner (R2 when R3 is on base) fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, 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 = OffensiveDecision(action="check_jump") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "check jump not allowed for trail runner" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_sac_bunt_two_outs_fails(self): + """Test sac bunt with 2 outs fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + outs=2 + ) + decision = OffensiveDecision(action="sac_bunt") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "sacrifice bunt cannot be used with 2 outs" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_hit_and_run_valid(self): + """Test hit_and_run action with runner on base succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="hit_and_run") + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_action_hit_and_run_no_runners_fails(self): + """Test hit_and_run action without runners fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="hit_and_run") + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "hit and run action requires at least one runner" in str(exc_info.value).lower() + + def test_validate_offensive_decision_action_swing_away_always_valid(self): + """Test swing_away action is always valid""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + outs=2 # Even with 2 outs and no runners + ) + decision = OffensiveDecision(action="swing_away") + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_action_steal_valid(self): + """Test steal action with steal_attempts succeeds""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="steal", steal_attempts=[2]) + + # Should not raise + validator.validate_offensive_decision(decision, state) + + def test_validate_offensive_decision_action_steal_no_attempts_fails(self): + """Test steal action without steal_attempts fails""" + validator = GameValidator() + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + ) + decision = OffensiveDecision(action="steal") # No steal_attempts + + with pytest.raises(ValidationError) as exc_info: + validator.validate_offensive_decision(decision, state) + + assert "steal action requires steal_attempts" in str(exc_info.value).lower() + class TestLineupValidation: """Test lineup position validation""" diff --git a/backend/tests/unit/models/test_game_models.py b/backend/tests/unit/models/test_game_models.py index 4188b7c..a229584 100644 --- a/backend/tests/unit/models/test_game_models.py +++ b/backend/tests/unit/models/test_game_models.py @@ -255,16 +255,16 @@ class TestOffensiveDecision: def test_create_offensive_decision_defaults(self): """Test creating offensive decision with defaults""" decision = OffensiveDecision() + assert decision.action == "swing_away" assert decision.steal_attempts == [] - assert decision.hit_and_run is False - assert decision.bunt_attempt is False - def test_offensive_decision_steal_attempts(self): - """Test steal attempts""" - decision = OffensiveDecision(steal_attempts=[2]) + def test_offensive_decision_steal_action(self): + """Test steal action with steal attempts""" + decision = OffensiveDecision(action="steal", steal_attempts=[2]) + assert decision.action == "steal" assert decision.steal_attempts == [2] - decision = OffensiveDecision(steal_attempts=[2, 3]) # Double steal + decision = OffensiveDecision(action="steal", steal_attempts=[2, 3]) # Double steal assert decision.steal_attempts == [2, 3] def test_offensive_decision_invalid_steal_base(self): @@ -273,14 +273,17 @@ class TestOffensiveDecision: 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 + """Test hit and run action""" + decision = OffensiveDecision(action="hit_and_run") + assert decision.action == "hit_and_run" def test_offensive_decision_bunt(self): - """Test bunt attempt""" - decision = OffensiveDecision(bunt_attempt=True) - assert decision.bunt_attempt is True + """Test bunt actions""" + decision = OffensiveDecision(action="sac_bunt") + assert decision.action == "sac_bunt" + + decision = OffensiveDecision(action="squeeze_bunt") + assert decision.action == "squeeze_bunt" # ============================================================================ diff --git a/backend/tests/unit/terminal_client/test_arg_parser.py b/backend/tests/unit/terminal_client/test_arg_parser.py index 191cd69..312f9b9 100644 --- a/backend/tests/unit/terminal_client/test_arg_parser.py +++ b/backend/tests/unit/terminal_client/test_arg_parser.py @@ -208,17 +208,13 @@ class TestPrebuiltParsers: def test_parse_offensive_args_defaults(self): """Test offensive parser with defaults.""" result = parse_offensive_args('') - assert result['approach'] == 'normal' + assert result['action'] == 'swing_away' assert result['steal'] == [] - assert result['hit_run'] is False - assert result['bunt'] is False - def test_parse_offensive_args_flags(self): - """Test offensive parser with flags.""" - result = parse_offensive_args('--approach power --hit-run --bunt') - assert result['approach'] == 'power' - assert result['hit_run'] is True - assert result['bunt'] is True + def test_parse_offensive_args_action(self): + """Test offensive parser with action.""" + result = parse_offensive_args('--action hit_and_run') + assert result['action'] == 'hit_and_run' def test_parse_offensive_args_steal(self): """Test offensive parser with steal attempts.""" @@ -227,11 +223,9 @@ class TestPrebuiltParsers: def test_parse_offensive_args_all_options(self): """Test offensive parser with all options.""" - result = parse_offensive_args('--approach patient --steal 2 --hit-run') - assert result['approach'] == 'patient' + result = parse_offensive_args('--action steal --steal 2') + assert result['action'] == 'steal' assert result['steal'] == [2] - assert result['hit_run'] is True - assert result['bunt'] is False def test_parse_quick_play_args_default(self): """Test quick_play parser with default.""" diff --git a/backend/tests/unit/terminal_client/test_commands.py b/backend/tests/unit/terminal_client/test_commands.py index f10b888..0b02fe9 100644 --- a/backend/tests/unit/terminal_client/test_commands.py +++ b/backend/tests/unit/terminal_client/test_commands.py @@ -161,9 +161,8 @@ async def test_submit_offensive_decision_success(game_commands): success = await game_commands.submit_offensive_decision( game_id=game_id, - approach='power', - steal_attempts=[2], - hit_and_run=True + action='hit_and_run', + steal_attempts=[2] ) assert success is True @@ -180,7 +179,7 @@ async def test_submit_offensive_decision_failure(game_commands): success = await game_commands.submit_offensive_decision( game_id=game_id, - approach='invalid_approach' + action='invalid_action' ) assert success is False