strat-gameplay-webapp/backend/tests/integration/test_game_engine.py
Cal Corum 0ebe72c09d CLAUDE: Phase 3F - Substitution System Testing Complete
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>
2025-11-06 15:25:53 -06:00

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"