strat-gameplay-webapp/backend/tests/integration/test_game_engine.py
Cal Corum f3238c4e6d CLAUDE: Complete Week 5 testing and update documentation
Add comprehensive unit and integration tests for Week 5 deliverables:
- test_play_resolver.py: 18 tests covering outcome resolution and runner advancement
- test_validators.py: 36 tests covering game state, decisions, lineups, and flow
- test_game_engine.py: 7 test classes for complete game flow integration

Update implementation documentation to reflect completed status:
- 00-index.md: Mark Phase 2 Weeks 4-5 complete with test coverage
- 02-week5-game-logic.md: Comprehensive test details and completion status
- 02-game-engine.md: Forward-looking snapshot pattern documentation

Week 5 now fully complete with 54 unit tests + 7 integration test classes passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 22:57:23 -05:00

597 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_lineup_id is not None
assert state.current_pitcher_lineup_id is not None
assert state.current_catcher_lineup_id 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)
if state.runners:
# Verify on_base_code matches runners
expected_code = 0
for runner in state.runners:
if runner.on_base == 1:
expected_code |= 1
elif runner.on_base == 2:
expected_code |= 2
elif runner.on_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"