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>
376 lines
13 KiB
Python
376 lines
13 KiB
Python
"""
|
|
Unit tests for terminal client shared commands.
|
|
"""
|
|
import pytest
|
|
from uuid import uuid4
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from terminal_client.commands import GameCommands
|
|
from app.models.game_models import GameState
|
|
|
|
|
|
@pytest.fixture
|
|
def game_commands():
|
|
"""Create GameCommands instance with mocked dependencies."""
|
|
commands = GameCommands()
|
|
commands.db_ops = AsyncMock()
|
|
return commands
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_new_game_success(game_commands):
|
|
"""Test successful game creation."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.state_manager') as mock_sm:
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
with patch('terminal_client.commands.uuid4', return_value=game_id):
|
|
with patch('terminal_client.commands.Config') as mock_config:
|
|
# Setup mocks
|
|
mock_state = GameState(
|
|
game_id=game_id,
|
|
league_id='sba',
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
inning=1,
|
|
half='top'
|
|
)
|
|
mock_sm.create_game = AsyncMock(return_value=mock_state)
|
|
mock_ge.start_game = AsyncMock(return_value=mock_state)
|
|
|
|
# Execute
|
|
gid, success = await game_commands.create_new_game()
|
|
|
|
# Verify
|
|
assert success is True
|
|
assert gid == game_id
|
|
mock_sm.create_game.assert_called_once()
|
|
mock_ge.start_game.assert_called_once()
|
|
mock_config.set_current_game.assert_called_once_with(game_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_new_game_with_pd_league(game_commands):
|
|
"""Test game creation with PD league."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.state_manager') as mock_sm:
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
with patch('terminal_client.commands.uuid4', return_value=game_id):
|
|
with patch('terminal_client.commands.Config') as mock_config:
|
|
# Setup mocks
|
|
mock_state = GameState(
|
|
game_id=game_id,
|
|
league_id='pd',
|
|
home_team_id=3,
|
|
away_team_id=5,
|
|
inning=1,
|
|
half='top'
|
|
)
|
|
mock_sm.create_game = AsyncMock(return_value=mock_state)
|
|
mock_ge.start_game = AsyncMock(return_value=mock_state)
|
|
|
|
# Execute
|
|
gid, success = await game_commands.create_new_game(
|
|
league='pd',
|
|
home_team=3,
|
|
away_team=5
|
|
)
|
|
|
|
# Verify
|
|
assert success is True
|
|
assert gid == game_id
|
|
# Check that PD-specific lineup creation was called
|
|
assert game_commands.db_ops.add_pd_lineup_card.call_count == 18 # 9 per team
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_new_game_failure(game_commands):
|
|
"""Test game creation failure."""
|
|
with patch('terminal_client.commands.state_manager') as mock_sm:
|
|
with patch('terminal_client.commands.uuid4', return_value=uuid4()):
|
|
# Setup mocks to fail
|
|
mock_sm.create_game = AsyncMock(side_effect=Exception("Database error"))
|
|
|
|
# Execute
|
|
gid, success = await game_commands.create_new_game()
|
|
|
|
# Verify
|
|
assert success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_defensive_decision_success(game_commands):
|
|
"""Test successful defensive decision submission."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_state = MagicMock()
|
|
mock_ge.submit_defensive_decision = AsyncMock(return_value=mock_state)
|
|
|
|
success = await game_commands.submit_defensive_decision(
|
|
game_id=game_id,
|
|
alignment='shifted_left',
|
|
hold_runners=[1, 2]
|
|
)
|
|
|
|
assert success is True
|
|
mock_ge.submit_defensive_decision.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_defensive_decision_failure(game_commands):
|
|
"""Test defensive decision submission failure."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_ge.submit_defensive_decision = AsyncMock(side_effect=Exception("Invalid decision"))
|
|
|
|
success = await game_commands.submit_defensive_decision(
|
|
game_id=game_id,
|
|
alignment='invalid_alignment'
|
|
)
|
|
|
|
assert success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_offensive_decision_success(game_commands):
|
|
"""Test successful offensive decision submission."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_state = MagicMock()
|
|
mock_ge.submit_offensive_decision = AsyncMock(return_value=mock_state)
|
|
|
|
success = await game_commands.submit_offensive_decision(
|
|
game_id=game_id,
|
|
approach='power',
|
|
steal_attempts=[2],
|
|
hit_and_run=True
|
|
)
|
|
|
|
assert success is True
|
|
mock_ge.submit_offensive_decision.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_offensive_decision_failure(game_commands):
|
|
"""Test offensive decision submission failure."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_ge.submit_offensive_decision = AsyncMock(side_effect=Exception("Invalid decision"))
|
|
|
|
success = await game_commands.submit_offensive_decision(
|
|
game_id=game_id,
|
|
approach='invalid_approach'
|
|
)
|
|
|
|
assert success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_play_success(game_commands):
|
|
"""Test successful play resolution."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
with patch('terminal_client.commands.display'):
|
|
# Setup mock result with proper attributes
|
|
mock_result = MagicMock()
|
|
mock_result.description = "Single to center field"
|
|
mock_result.outs_recorded = 0
|
|
mock_result.runs_scored = 1
|
|
|
|
# Setup mock state
|
|
mock_state = MagicMock()
|
|
mock_state.away_score = 1
|
|
mock_state.home_score = 0
|
|
|
|
mock_ge.resolve_play = AsyncMock(return_value=mock_result)
|
|
mock_ge.get_game_state = AsyncMock(return_value=mock_state)
|
|
|
|
success = await game_commands.resolve_play(game_id)
|
|
|
|
assert success is True
|
|
mock_ge.resolve_play.assert_called_once_with(game_id)
|
|
mock_ge.get_game_state.assert_called_once_with(game_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_play_game_not_found(game_commands):
|
|
"""Test play resolution when game is not found."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_result = MagicMock()
|
|
mock_ge.resolve_play = AsyncMock(return_value=mock_result)
|
|
mock_ge.get_game_state = AsyncMock(return_value=None) # Game not found
|
|
|
|
success = await game_commands.resolve_play(game_id)
|
|
|
|
assert success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_play_failure(game_commands):
|
|
"""Test play resolution failure."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_ge.resolve_play = AsyncMock(side_effect=Exception("Resolution error"))
|
|
|
|
success = await game_commands.resolve_play(game_id)
|
|
|
|
assert success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_play_rounds_success(game_commands):
|
|
"""Test successful quick play execution."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
with patch('terminal_client.commands.asyncio.sleep', new_callable=AsyncMock):
|
|
with patch('terminal_client.commands.display'):
|
|
# Setup mocks
|
|
mock_state = MagicMock()
|
|
mock_state.status = "active"
|
|
mock_state.away_score = 0
|
|
mock_state.home_score = 0
|
|
mock_state.inning = 1
|
|
mock_state.half = "top"
|
|
mock_state.outs = 0
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.description = "Groundout"
|
|
|
|
mock_ge.get_game_state = AsyncMock(return_value=mock_state)
|
|
mock_ge.submit_defensive_decision = AsyncMock()
|
|
mock_ge.submit_offensive_decision = AsyncMock()
|
|
mock_ge.resolve_play = AsyncMock(return_value=mock_result)
|
|
|
|
# Execute 3 plays
|
|
plays_completed = await game_commands.quick_play_rounds(game_id, count=3)
|
|
|
|
# Verify
|
|
assert plays_completed == 3
|
|
assert mock_ge.resolve_play.call_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_play_rounds_game_ends(game_commands):
|
|
"""Test quick play when game ends mid-execution."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
with patch('terminal_client.commands.asyncio.sleep', new_callable=AsyncMock):
|
|
with patch('terminal_client.commands.display'):
|
|
# Setup mocks - game becomes completed after 2 plays
|
|
mock_state_active_1 = MagicMock()
|
|
mock_state_active_1.status = "active"
|
|
mock_state_active_1.away_score = 0
|
|
mock_state_active_1.home_score = 0
|
|
mock_state_active_1.inning = 1
|
|
mock_state_active_1.half = "top"
|
|
mock_state_active_1.outs = 0
|
|
|
|
mock_state_active_2 = MagicMock()
|
|
mock_state_active_2.status = "active"
|
|
mock_state_active_2.away_score = 1
|
|
mock_state_active_2.home_score = 0
|
|
mock_state_active_2.inning = 1
|
|
mock_state_active_2.half = "top"
|
|
mock_state_active_2.outs = 0
|
|
|
|
mock_state_completed = MagicMock()
|
|
mock_state_completed.status = "completed"
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.description = "Game winning hit"
|
|
|
|
# Setup get_game_state to return:
|
|
# 1. active (before play 1)
|
|
# 2. active (after play 1)
|
|
# 3. active (before play 2)
|
|
# 4. active (after play 2)
|
|
# 5. completed (before play 3 - should stop)
|
|
# 6. completed (final state query)
|
|
mock_ge.get_game_state = AsyncMock(
|
|
side_effect=[
|
|
mock_state_active_1, # Before play 1
|
|
mock_state_active_2, # After play 1
|
|
mock_state_active_2, # Before play 2
|
|
mock_state_active_2, # After play 2
|
|
mock_state_completed, # Before play 3 - stops here
|
|
mock_state_completed # Final state query
|
|
]
|
|
)
|
|
mock_ge.submit_defensive_decision = AsyncMock()
|
|
mock_ge.submit_offensive_decision = AsyncMock()
|
|
mock_ge.resolve_play = AsyncMock(return_value=mock_result)
|
|
|
|
# Execute 5 plays but should stop at 2
|
|
plays_completed = await game_commands.quick_play_rounds(game_id, count=5)
|
|
|
|
# Verify - should only complete 2 plays
|
|
assert plays_completed == 2
|
|
assert mock_ge.resolve_play.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_show_game_status_success(game_commands):
|
|
"""Test showing game status successfully."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_state = MagicMock()
|
|
mock_ge.get_game_state = AsyncMock(return_value=mock_state)
|
|
|
|
success = await game_commands.show_game_status(game_id)
|
|
|
|
assert success is True
|
|
mock_ge.get_game_state.assert_called_once_with(game_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_show_game_status_not_found(game_commands):
|
|
"""Test showing game status when game not found."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_ge.get_game_state = AsyncMock(return_value=None)
|
|
|
|
success = await game_commands.show_game_status(game_id)
|
|
|
|
assert success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_show_box_score_success(game_commands):
|
|
"""Test showing box score successfully."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_state = MagicMock()
|
|
mock_ge.get_game_state = AsyncMock(return_value=mock_state)
|
|
|
|
success = await game_commands.show_box_score(game_id)
|
|
|
|
assert success is True
|
|
mock_ge.get_game_state.assert_called_once_with(game_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_show_box_score_not_found(game_commands):
|
|
"""Test showing box score when game not found."""
|
|
game_id = uuid4()
|
|
|
|
with patch('terminal_client.commands.game_engine') as mock_ge:
|
|
mock_ge.get_game_state = AsyncMock(return_value=None)
|
|
|
|
success = await game_commands.show_box_score(game_id)
|
|
|
|
assert success is False
|