strat-gameplay-webapp/backend/tests/integration/test_game_engine.py
Cal Corum 1c32787195 CLAUDE: Refactor game models and modularize terminal client
This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.

## Game Models Refactor

**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
  - on_first: Optional[LineupPlayerState]
  - on_second: Optional[LineupPlayerState]
  - on_third: Optional[LineupPlayerState]
- Updated helper methods:
  - get_runner_at_base() now returns LineupPlayerState directly
  - get_all_runners() returns List[Tuple[int, LineupPlayerState]]
  - is_runner_on_X() simplified to direct None checks

**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic

**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data

## Terminal Client Refactor

**Modularization (DRY principle):**
Created separate modules for better code organization:

1. **terminal_client/commands.py** (10,243 bytes)
   - Shared command functions for game operations
   - Used by both CLI (main.py) and REPL (repl.py)
   - Functions: submit_defensive_decision, submit_offensive_decision,
     resolve_play, quick_play_sequence
   - Single source of truth for command logic

2. **terminal_client/arg_parser.py** (7,280 bytes)
   - Centralized argument parsing and validation
   - Handles defensive/offensive decision arguments
   - Validates formats (alignment, depths, hold runners, steal attempts)

3. **terminal_client/completions.py** (10,357 bytes)
   - TAB completion support for REPL mode
   - Command completions, option completions, dynamic completions
   - Game ID completions, defensive/offensive option suggestions

4. **terminal_client/help_text.py** (10,839 bytes)
   - Centralized help text and command documentation
   - Detailed command descriptions
   - Usage examples for all commands

**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions

**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns

## Documentation

**New files:**
- app/models/visual_model_relationships.md
  - Visual documentation of model relationships
  - Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
  - Phased documentation for terminal client evolution
  - Historical context for implementation decisions

## Tests

**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py

**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure

## Summary

-  Simplified game state model (removed RunnerState)
-  Better alignment with database structure
-  Modularized terminal client (DRY principle)
-  Shared command logic between CLI and REPL
-  Comprehensive test coverage
-  Improved documentation

Total changes: 26 files modified/created

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 14:16:38 -05: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_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)
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"