CLAUDE: Refactor offensive decisions - replace approach with action field

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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-14 15:07:54 -06:00
parent 63bffbc23d
commit e165b449f5
12 changed files with 350 additions and 104 deletions

View File

@ -117,9 +117,45 @@ class GameValidator:
Raises: Raises:
ValidationError: If decision is invalid for current situation 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() 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: for base in decision.steal_attempts:
# Validate steal base is valid (2, 3, or 4 for home) # Validate steal base is valid (2, 3, or 4 for home)
if base not in [2, 3, 4]: if base not in [2, 3, 4]:
@ -130,18 +166,6 @@ class GameValidator:
if stealing_from not in occupied_bases: if stealing_from not in occupied_bases:
raise ValidationError(f"Cannot steal base {base} - no runner on base {stealing_from}") 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") logger.debug("Offensive decision validated")
@staticmethod @staticmethod

View File

@ -179,10 +179,26 @@ class OffensiveDecision(BaseModel):
Offensive team strategic decisions for a play. Offensive team strategic decisions for a play.
These decisions affect baserunner actions. 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 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') @field_validator('steal_attempts')
@classmethod @classmethod

View File

@ -1131,13 +1131,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
Event data: Event data:
game_id: UUID of the game game_id: UUID of the game
steal_attempts: List of bases for steal attempts (e.g., [2, 3]) action: String - offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt)
hit_and_run: Boolean - enable hit-and-run play steal_attempts: List of bases for steal attempts - REQUIRED when action="steal" (e.g., [2, 3])
bunt_attempt: Boolean - attempt bunt
Emits: Emits:
offensive_decision_submitted: To requester and broadcast to game room offensive_decision_submitted: To requester and broadcast to game room
error: To requester if validation fails error: To requester if validation fails
Session 2 Update (2025-01-14): Replaced approach with action field. Stealing is now an action choice.
""" """
try: try:
# Extract and validate game_id # Extract and validate game_id
@ -1174,17 +1175,15 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
# user_id = manager.user_sessions.get(sid) # user_id = manager.user_sessions.get(sid)
# Extract decision data # Extract decision data
action = data.get("action", "swing_away") # Default: swing_away
steal_attempts = data.get("steal_attempts", []) 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 # Create offensive decision
from app.models.game_models import OffensiveDecision from app.models.game_models import OffensiveDecision
decision = OffensiveDecision( decision = OffensiveDecision(
steal_attempts=steal_attempts, action=action,
hit_and_run=hit_and_run, steal_attempts=steal_attempts
bunt_attempt=bunt_attempt
) )
# Submit decision through game engine # Submit decision through game engine
@ -1192,7 +1191,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
logger.info( logger.info(
f"Offensive decision submitted for game {game_id}: " 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 # Broadcast to game room
@ -1202,9 +1201,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
{ {
"game_id": str(game_id), "game_id": str(game_id),
"decision": { "decision": {
"steal_attempts": steal_attempts, "action": action,
"hit_and_run": hit_and_run, "steal_attempts": steal_attempts
"bunt_attempt": bunt_attempt
}, },
"pending_decision": updated_state.pending_decision "pending_decision": updated_state.pending_decision
} }

View File

@ -177,10 +177,8 @@ DEFENSIVE_SCHEMA = {
} }
OFFENSIVE_SCHEMA = { OFFENSIVE_SCHEMA = {
'approach': {'type': str, 'default': 'normal'}, 'action': {'type': str, 'default': 'swing_away'}, # Session 2: changed from approach
'steal': {'type': 'int_list', 'default': []}, 'steal': {'type': 'int_list', 'default': []}
'hit_run': {'type': bool, 'flag': True, 'default': False},
'bunt': {'type': bool, 'flag': True, 'default': False}
} }
QUICK_PLAY_SCHEMA = { QUICK_PLAY_SCHEMA = {

View File

@ -169,23 +169,25 @@ class GameCommands:
async def submit_offensive_decision( async def submit_offensive_decision(
self, self,
game_id: UUID, game_id: UUID,
approach: str = 'normal', action: str = 'swing_away',
steal_attempts: Optional[List[int]] = None, steal_attempts: Optional[List[int]] = None
hit_and_run: bool = False,
bunt_attempt: bool = False
) -> bool: ) -> bool:
""" """
Submit offensive decision. 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: Returns:
True if successful, False otherwise True if successful, False otherwise
Session 2 Update (2025-01-14): Replaced approach with action field. Removed deprecated fields.
""" """
try: try:
decision = OffensiveDecision( decision = OffensiveDecision(
approach=approach, action=action,
steal_attempts=steal_attempts or [], steal_attempts=steal_attempts or []
hit_and_run=hit_and_run,
bunt_attempt=bunt_attempt
) )
state = await game_engine.submit_offensive_decision(game_id, decision) state = await game_engine.submit_offensive_decision(game_id, decision)

View File

@ -193,10 +193,9 @@ def display_decision(decision_type: str, decision: Optional[DefensiveDecision |
if decision.hold_runners: if decision.hold_runners:
decision_text.append(f"Hold Runners: {decision.hold_runners}\n") decision_text.append(f"Hold Runners: {decision.hold_runners}\n")
elif isinstance(decision, OffensiveDecision): elif isinstance(decision, OffensiveDecision):
decision_text.append(f"Action: {decision.action}\n")
if decision.steal_attempts: if decision.steal_attempts:
decision_text.append(f"Steal Attempts: {decision.steal_attempts}\n") 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( panel = Panel(
decision_text, decision_text,

View File

@ -136,16 +136,15 @@ def defensive(game_id, alignment, infield, outfield, hold):
@cli.command() @cli.command()
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') @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('--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') def offensive(game_id, action, steal):
@click.option('--bunt', is_flag=True, help='Bunt attempt')
def offensive(game_id, approach, steal, hit_run, bunt):
""" """
Submit offensive decision. Submit offensive decision.
Valid approach: normal, contact, power, patient Session 2 Update (2025-01-14): Changed --approach to --action, removed deprecated flags.
Valid steal: comma-separated bases (2, 3, 4) 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(): async def _offensive():
gid = UUID(game_id) if game_id else get_current_game() 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 # Use shared command
success = await game_commands.submit_offensive_decision( success = await game_commands.submit_offensive_decision(
game_id=gid, game_id=gid,
approach=approach, action=action,
steal_attempts=steal_list, steal_attempts=steal_list
hit_and_run=hit_run,
bunt_attempt=bunt
) )
if not success: if not success:

View File

@ -229,13 +229,11 @@ Press Ctrl+D or type 'quit' to exit.
# Parse arguments with robust parser # Parse arguments with robust parser
args = parse_offensive_args(arg) args = parse_offensive_args(arg)
# Submit decision # Submit decision (Session 2: replaced approach with action, removed deprecated fields)
await game_commands.submit_offensive_decision( await game_commands.submit_offensive_decision(
game_id=gid, game_id=gid,
approach=args['approach'], action=args.get('action', 'swing_away'),
steal_attempts=args['steal'], steal_attempts=args['steal']
hit_and_run=args['hit_run'],
bunt_attempt=args['bunt']
) )
except ArgumentParseError as e: except ArgumentParseError as e:

View File

@ -496,15 +496,13 @@ class TestOffensiveDecisionValidation:
outs=2 outs=2
) )
decision = OffensiveDecision( decision = OffensiveDecision(
approach="normal", action="sac_bunt"
bunt_attempt=True
) )
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
validator.validate_offensive_decision(decision, state) validator.validate_offensive_decision(decision, state)
assert "cannot bunt" in str(exc_info.value).lower() assert "sacrifice bunt cannot be used with 2 outs" in str(exc_info.value).lower()
assert "2 outs" in str(exc_info.value).lower()
def test_validate_offensive_decision_bunt_with_less_than_two_outs_valid(self): def test_validate_offensive_decision_bunt_with_less_than_two_outs_valid(self):
"""Test bunting with 0-1 outs is valid""" """Test bunting with 0-1 outs is valid"""
@ -520,8 +518,7 @@ class TestOffensiveDecisionValidation:
outs=outs outs=outs
) )
decision = OffensiveDecision( decision = OffensiveDecision(
approach="normal", action="sac_bunt"
bunt_attempt=True
) )
# Should not raise # Should not raise
@ -605,15 +602,9 @@ class TestOffensiveDecisionValidation:
current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1), 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_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
) )
decision = OffensiveDecision( # This test is no longer relevant - actions are mutually exclusive via the field
bunt_attempt=True, # Can't have action="sac_bunt" and action="hit_and_run" at the same time
hit_and_run=True pass
)
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): def test_validate_offensive_decision_hit_and_run_without_runners_fails(self):
"""Test hit-and-run without runners on base fails""" """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) current_batter=LineupPlayerState(lineup_id=1, card_id=1*100, position="CF", batting_order=1)
) )
decision = OffensiveDecision( decision = OffensiveDecision(
hit_and_run=True action="hit_and_run"
) )
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
validator.validate_offensive_decision(decision, state) 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): def test_validate_offensive_decision_hit_and_run_with_runner_on_first_succeeds(self):
"""Test hit-and-run with runner on first succeeds""" """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) on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
) )
decision = OffensiveDecision( decision = OffensiveDecision(
hit_and_run=True action="hit_and_run"
) )
# Should not raise # Should not raise
@ -704,9 +696,235 @@ class TestOffensiveDecisionValidation:
decision = OffensiveDecision(hit_and_run=True, steal_attempts=[2]) decision = OffensiveDecision(hit_and_run=True, steal_attempts=[2])
validator.validate_offensive_decision(decision, state) 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) 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: class TestLineupValidation:
"""Test lineup position validation""" """Test lineup position validation"""

View File

@ -255,16 +255,16 @@ class TestOffensiveDecision:
def test_create_offensive_decision_defaults(self): def test_create_offensive_decision_defaults(self):
"""Test creating offensive decision with defaults""" """Test creating offensive decision with defaults"""
decision = OffensiveDecision() decision = OffensiveDecision()
assert decision.action == "swing_away"
assert decision.steal_attempts == [] assert decision.steal_attempts == []
assert decision.hit_and_run is False
assert decision.bunt_attempt is False
def test_offensive_decision_steal_attempts(self): def test_offensive_decision_steal_action(self):
"""Test steal attempts""" """Test steal action with steal attempts"""
decision = OffensiveDecision(steal_attempts=[2]) decision = OffensiveDecision(action="steal", steal_attempts=[2])
assert decision.action == "steal"
assert decision.steal_attempts == [2] 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] assert decision.steal_attempts == [2, 3]
def test_offensive_decision_invalid_steal_base(self): def test_offensive_decision_invalid_steal_base(self):
@ -273,14 +273,17 @@ class TestOffensiveDecision:
OffensiveDecision(steal_attempts=[1]) # Can't steal first OffensiveDecision(steal_attempts=[1]) # Can't steal first
def test_offensive_decision_hit_and_run(self): def test_offensive_decision_hit_and_run(self):
"""Test hit and run""" """Test hit and run action"""
decision = OffensiveDecision(hit_and_run=True) decision = OffensiveDecision(action="hit_and_run")
assert decision.hit_and_run is True assert decision.action == "hit_and_run"
def test_offensive_decision_bunt(self): def test_offensive_decision_bunt(self):
"""Test bunt attempt""" """Test bunt actions"""
decision = OffensiveDecision(bunt_attempt=True) decision = OffensiveDecision(action="sac_bunt")
assert decision.bunt_attempt is True assert decision.action == "sac_bunt"
decision = OffensiveDecision(action="squeeze_bunt")
assert decision.action == "squeeze_bunt"
# ============================================================================ # ============================================================================

View File

@ -208,17 +208,13 @@ class TestPrebuiltParsers:
def test_parse_offensive_args_defaults(self): def test_parse_offensive_args_defaults(self):
"""Test offensive parser with defaults.""" """Test offensive parser with defaults."""
result = parse_offensive_args('') result = parse_offensive_args('')
assert result['approach'] == 'normal' assert result['action'] == 'swing_away'
assert result['steal'] == [] assert result['steal'] == []
assert result['hit_run'] is False
assert result['bunt'] is False
def test_parse_offensive_args_flags(self): def test_parse_offensive_args_action(self):
"""Test offensive parser with flags.""" """Test offensive parser with action."""
result = parse_offensive_args('--approach power --hit-run --bunt') result = parse_offensive_args('--action hit_and_run')
assert result['approach'] == 'power' assert result['action'] == 'hit_and_run'
assert result['hit_run'] is True
assert result['bunt'] is True
def test_parse_offensive_args_steal(self): def test_parse_offensive_args_steal(self):
"""Test offensive parser with steal attempts.""" """Test offensive parser with steal attempts."""
@ -227,11 +223,9 @@ class TestPrebuiltParsers:
def test_parse_offensive_args_all_options(self): def test_parse_offensive_args_all_options(self):
"""Test offensive parser with all options.""" """Test offensive parser with all options."""
result = parse_offensive_args('--approach patient --steal 2 --hit-run') result = parse_offensive_args('--action steal --steal 2')
assert result['approach'] == 'patient' assert result['action'] == 'steal'
assert result['steal'] == [2] assert result['steal'] == [2]
assert result['hit_run'] is True
assert result['bunt'] is False
def test_parse_quick_play_args_default(self): def test_parse_quick_play_args_default(self):
"""Test quick_play parser with default.""" """Test quick_play parser with default."""

View File

@ -161,9 +161,8 @@ async def test_submit_offensive_decision_success(game_commands):
success = await game_commands.submit_offensive_decision( success = await game_commands.submit_offensive_decision(
game_id=game_id, game_id=game_id,
approach='power', action='hit_and_run',
steal_attempts=[2], steal_attempts=[2]
hit_and_run=True
) )
assert success is True assert success is True
@ -180,7 +179,7 @@ async def test_submit_offensive_decision_failure(game_commands):
success = await game_commands.submit_offensive_decision( success = await game_commands.submit_offensive_decision(
game_id=game_id, game_id=game_id,
approach='invalid_approach' action='invalid_action'
) )
assert success is False assert success is False