This commit completes all Phase 3 work with comprehensive test coverage: Test Coverage: - 31 unit tests for SubstitutionRules (all validation paths) - 10 integration tests for SubstitutionManager (DB + state sync) - 679 total tests in test suite (609/609 unit tests passing - 100%) Testing Scope: - Pinch hitter validation and execution - Defensive replacement validation and execution - Pitching change validation and execution (min batters, force changes) - Double switch validation - Multiple substitutions in sequence - Batting order preservation - Database persistence verification - State sync verification - Lineup cache updates All substitution system components are now production-ready: ✅ Core validation logic (SubstitutionRules) ✅ Orchestration layer (SubstitutionManager) ✅ Database operations ✅ WebSocket event handlers ✅ Comprehensive test coverage ✅ Complete documentation Phase 3 Overall: 100% Complete - Phase 3A-D (X-Check Core): 100% - Phase 3E (Position Ratings + Redis): 100% - Phase 3F (Substitutions): 100% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
598 lines
19 KiB
Python
598 lines
19 KiB
Python
"""
|
|
Integration Tests for GameEngine
|
|
|
|
Tests complete game flow including:
|
|
- Single at-bat execution
|
|
- Full half-inning (3 outs)
|
|
- Lineup validation
|
|
- Snapshot tracking
|
|
- Batting order cycling
|
|
|
|
These tests require database access (marked with @pytest.mark.integration).
|
|
"""
|
|
import pytest
|
|
from uuid import uuid4
|
|
|
|
from app.core.state_manager import state_manager
|
|
from app.core.game_engine import game_engine
|
|
from app.core.validators import ValidationError
|
|
from app.database.operations import DatabaseOperations
|
|
from app.models.game_models import DefensiveDecision, OffensiveDecision
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
class TestSingleAtBat:
|
|
"""Test single at-bat execution"""
|
|
|
|
async def test_complete_at_bat_flow(self):
|
|
"""Test complete at-bat flow: create → start → decisions → resolve"""
|
|
# Create game
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
# Create dummy lineups
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
|
|
# Away team lineup
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
# Home team lineup
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
# Create in state manager
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
assert state.status == "pending"
|
|
|
|
# Start game
|
|
state = await game_engine.start_game(game_id)
|
|
assert state.status == "active"
|
|
assert state.inning == 1
|
|
assert state.half == "top"
|
|
assert state.outs == 0
|
|
|
|
# Submit defensive decision
|
|
def_decision = DefensiveDecision(
|
|
alignment="normal",
|
|
infield_depth="normal",
|
|
outfield_depth="normal"
|
|
)
|
|
state = await game_engine.submit_defensive_decision(game_id, def_decision)
|
|
assert state.pending_decision == "offensive"
|
|
|
|
# Submit offensive decision
|
|
off_decision = OffensiveDecision(approach="normal")
|
|
state = await game_engine.submit_offensive_decision(game_id, off_decision)
|
|
assert state.pending_decision == "resolution"
|
|
|
|
# Resolve play
|
|
result = await game_engine.resolve_play(game_id)
|
|
assert result is not None
|
|
assert result.outcome is not None
|
|
assert result.ab_roll is not None
|
|
|
|
# Verify state updated
|
|
final_state = await game_engine.get_game_state(game_id)
|
|
assert final_state.play_count == 1
|
|
assert final_state.last_play_result is not None
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
class TestFullInning:
|
|
"""Test complete half-inning execution"""
|
|
|
|
async def test_full_half_inning_three_outs(self):
|
|
"""Test playing until 3 outs completes half-inning"""
|
|
# Create and start game
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
# Create dummy lineups
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
await game_engine.start_game(game_id)
|
|
|
|
at_bat_count = 0
|
|
initial_inning = 1
|
|
initial_half = "top"
|
|
|
|
# Play until 3 outs
|
|
while True:
|
|
state = await game_engine.get_game_state(game_id)
|
|
|
|
# Check if inning changed
|
|
if state.inning != initial_inning or state.half != initial_half:
|
|
break
|
|
|
|
# Safety check
|
|
if at_bat_count > 50:
|
|
pytest.fail("Safety limit reached - something wrong with inning advancement")
|
|
|
|
# Submit decisions
|
|
await game_engine.submit_defensive_decision(game_id, DefensiveDecision(alignment="normal"))
|
|
await game_engine.submit_offensive_decision(game_id, OffensiveDecision(approach="normal"))
|
|
|
|
# Resolve
|
|
result = await game_engine.resolve_play(game_id)
|
|
at_bat_count += 1
|
|
|
|
# Verify inning advanced
|
|
final_state = await game_engine.get_game_state(game_id)
|
|
assert final_state.inning == 1
|
|
assert final_state.half == "bottom"
|
|
assert final_state.outs == 0 # Reset after inning change
|
|
assert at_bat_count >= 3 # At least 3 at-bats for 3 outs
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
class TestLineupValidation:
|
|
"""Test lineup validation at game start"""
|
|
|
|
async def test_start_game_fails_with_no_lineups(self):
|
|
"""Test starting game with no lineups fails"""
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
# Should raise ValidationError
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await game_engine.start_game(game_id)
|
|
|
|
assert "lineup incomplete" in str(exc_info.value).lower()
|
|
|
|
async def test_start_game_fails_with_incomplete_lineup(self):
|
|
"""Test starting game with incomplete lineup (< 9 players) fails"""
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
# Add only 5 players per team
|
|
for i in range(1, 6):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=["P", "C", "1B", "2B", "3B"][i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=["P", "C", "1B", "2B", "3B"][i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
# Should raise ValidationError
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await game_engine.start_game(game_id)
|
|
|
|
assert "lineup incomplete" in str(exc_info.value).lower()
|
|
assert "5 players" in str(exc_info.value) # Shows actual count
|
|
|
|
async def test_start_game_fails_with_missing_positions(self):
|
|
"""Test starting game with missing defensive positions fails"""
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
# Create 9 players but missing SS
|
|
positions = ["P", "C", "1B", "2B", "3B", "LF", "CF", "RF", "DH"] # Missing SS, has DH
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
# Should raise ValidationError
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await game_engine.start_game(game_id)
|
|
|
|
assert "missing active player at ss" in str(exc_info.value).lower()
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
class TestSnapshotTracking:
|
|
"""Test snapshot tracking in GameState"""
|
|
|
|
async def test_snapshot_fields_populated(self):
|
|
"""Test that snapshot fields are populated in GameState"""
|
|
# Create game with lineups
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
await game_engine.start_game(game_id)
|
|
|
|
# Check snapshot fields after game start
|
|
state = await game_engine.get_game_state(game_id)
|
|
assert state.current_batter is not None
|
|
assert state.current_pitcher is not None
|
|
assert state.current_catcher is not None
|
|
assert state.current_on_base_code == 0 # Empty bases
|
|
|
|
async def test_on_base_code_calculation(self):
|
|
"""Test on_base_code matches runners"""
|
|
# Create game and play until we have runners
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
await game_engine.start_game(game_id)
|
|
|
|
# Play until we get runners on base
|
|
for _ in range(20):
|
|
state = await game_engine.get_game_state(game_id)
|
|
|
|
await game_engine.submit_defensive_decision(game_id, DefensiveDecision())
|
|
await game_engine.submit_offensive_decision(game_id, OffensiveDecision())
|
|
await game_engine.resolve_play(game_id)
|
|
|
|
state = await game_engine.get_game_state(game_id)
|
|
all_runners = state.get_all_runners()
|
|
if all_runners:
|
|
# Verify on_base_code matches runners
|
|
expected_code = 0
|
|
for base, runner in all_runners:
|
|
if base == 1:
|
|
expected_code |= 1
|
|
elif base == 2:
|
|
expected_code |= 2
|
|
elif base == 3:
|
|
expected_code |= 4
|
|
|
|
assert state.current_on_base_code == expected_code
|
|
break
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
class TestBattingOrderCycling:
|
|
"""Test batting order cycles independently per team"""
|
|
|
|
async def test_independent_batting_order_indices(self):
|
|
"""Test that each team tracks batting order independently"""
|
|
# Create game
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
state = await game_engine.start_game(game_id)
|
|
|
|
# Check initial indices
|
|
assert state.away_team_batter_idx == 1 # Advanced from 0 during start_game
|
|
assert state.home_team_batter_idx == 0 # Not advanced yet (top of 1st)
|
|
|
|
# After first play, away_team should advance to 2
|
|
await game_engine.submit_defensive_decision(game_id, DefensiveDecision())
|
|
await game_engine.submit_offensive_decision(game_id, OffensiveDecision())
|
|
await game_engine.resolve_play(game_id)
|
|
|
|
state = await game_engine.get_game_state(game_id)
|
|
# After first play, away idx advances again (now at 2)
|
|
# Home idx still at 0 (bottom hasn't started)
|
|
assert state.away_team_batter_idx == 2
|
|
assert state.home_team_batter_idx == 0
|
|
|
|
async def test_batting_order_wraps_at_nine(self):
|
|
"""Test batting order wraps from 8 back to 0"""
|
|
# Create game
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
# Manually set away_team_batter_idx to 8
|
|
state.away_team_batter_idx = 8
|
|
state_manager.update_state(game_id, state)
|
|
|
|
await game_engine.start_game(game_id)
|
|
|
|
# After start_game, idx should wrap from 8 to 0 (8+1 % 9 = 0)
|
|
state = await game_engine.get_game_state(game_id)
|
|
assert state.away_team_batter_idx == 0 # Wrapped around
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.asyncio
|
|
class TestGameCompletion:
|
|
"""Test game completion conditions"""
|
|
|
|
async def test_game_completes_after_9_innings(self):
|
|
"""Test game status changes to completed when game ends"""
|
|
# This is a longer test - we'll fast-forward to end
|
|
game_id = uuid4()
|
|
db_ops = DatabaseOperations()
|
|
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode="friendly",
|
|
visibility="public"
|
|
)
|
|
|
|
positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
|
for i in range(1, 10):
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=2,
|
|
player_id=100 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
await db_ops.add_sba_lineup_player(
|
|
game_id=game_id,
|
|
team_id=1,
|
|
player_id=200 + i,
|
|
position=positions[i-1],
|
|
batting_order=i
|
|
)
|
|
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
await game_engine.start_game(game_id)
|
|
|
|
# Fast-forward: Manually set to bottom 9th with home team ahead
|
|
state = await game_engine.get_game_state(game_id)
|
|
state.inning = 9
|
|
state.half = "bottom"
|
|
state.home_score = 5
|
|
state.away_score = 2
|
|
state.outs = 2 # 2 outs
|
|
state_manager.update_state(game_id, state)
|
|
|
|
# Play one more out (should end game)
|
|
await game_engine.submit_defensive_decision(game_id, DefensiveDecision())
|
|
await game_engine.submit_offensive_decision(game_id, OffensiveDecision())
|
|
await game_engine.resolve_play(game_id)
|
|
|
|
# Game should be completed
|
|
final_state = await game_engine.get_game_state(game_id)
|
|
assert final_state.status == "completed"
|