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>
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_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"
|