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>
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""
|
|
Unit tests for tab completion system.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from terminal_client.completions import CompletionHelper, GameREPLCompletions
|
|
|
|
|
|
class TestCompletionHelper:
|
|
"""Tests for CompletionHelper utility class."""
|
|
|
|
def test_filter_completions_exact_match(self):
|
|
"""Test filtering with exact match."""
|
|
options = ['apple', 'apricot', 'banana']
|
|
result = CompletionHelper.filter_completions('appl', options)
|
|
assert result == ['apple']
|
|
|
|
def test_filter_completions_no_match(self):
|
|
"""Test filtering with no matches."""
|
|
options = ['apple', 'apricot', 'banana']
|
|
result = CompletionHelper.filter_completions('cherry', options)
|
|
assert result == []
|
|
|
|
def test_filter_completions_empty_text(self):
|
|
"""Test filtering with empty text returns all."""
|
|
options = ['apple', 'apricot', 'banana']
|
|
result = CompletionHelper.filter_completions('', options)
|
|
assert result == options
|
|
|
|
def test_complete_option_with_prefix(self):
|
|
"""Test completing option with -- prefix."""
|
|
available = ['league', 'home-team', 'away-team']
|
|
result = CompletionHelper.complete_option('--le', 'cmd --le', available)
|
|
assert result == ['--league']
|
|
|
|
def test_complete_option_partial_match(self):
|
|
"""Test completing option with partial match."""
|
|
available = ['league', 'home-team', 'away-team']
|
|
result = CompletionHelper.complete_option('--ho', 'cmd --ho', available)
|
|
assert result == ['--home-team']
|
|
|
|
def test_complete_option_show_all(self):
|
|
"""Test showing all options when text is empty."""
|
|
available = ['league', 'home-team']
|
|
result = CompletionHelper.complete_option('', 'cmd ', available)
|
|
assert set(result) == {'--league', '--home-team'}
|
|
|
|
def test_complete_option_no_match(self):
|
|
"""Test completing option with no match."""
|
|
available = ['league', 'home-team']
|
|
result = CompletionHelper.complete_option('--invalid', 'cmd --invalid', available)
|
|
assert result == []
|
|
|
|
def test_get_current_option_simple(self):
|
|
"""Test getting current option from simple line."""
|
|
line = 'defensive --alignment '
|
|
result = CompletionHelper.get_current_option(line, len(line))
|
|
assert result == 'alignment'
|
|
|
|
def test_get_current_option_multiple(self):
|
|
"""Test getting current option with multiple options."""
|
|
line = 'defensive --infield normal --alignment '
|
|
result = CompletionHelper.get_current_option(line, len(line))
|
|
assert result == 'alignment'
|
|
|
|
def test_get_current_option_none(self):
|
|
"""Test getting current option when none present."""
|
|
line = 'defensive '
|
|
result = CompletionHelper.get_current_option(line, len(line))
|
|
assert result is None
|
|
|
|
def test_get_current_option_hyphen_to_underscore(self):
|
|
"""Test option name converts hyphens to underscores."""
|
|
line = 'new_game --home-team '
|
|
result = CompletionHelper.get_current_option(line, len(line))
|
|
assert result == 'home_team'
|
|
|
|
def test_get_current_option_mid_line(self):
|
|
"""Test getting current option when cursor is mid-line."""
|
|
line = 'defensive --alignment normal --hold '
|
|
result = CompletionHelper.get_current_option(line, len('defensive --alignment '))
|
|
assert result == 'alignment'
|
|
|
|
|
|
class TestGameREPLCompletions:
|
|
"""Tests for GameREPLCompletions mixin."""
|
|
|
|
@pytest.fixture
|
|
def repl_completions(self):
|
|
"""Create GameREPLCompletions instance."""
|
|
return GameREPLCompletions()
|
|
|
|
def test_complete_new_game_options(self, repl_completions):
|
|
"""Test completing new_game options."""
|
|
result = repl_completions.complete_new_game(
|
|
'--', 'new_game --', 9, 11
|
|
)
|
|
assert '--league' in result
|
|
assert '--home-team' in result
|
|
assert '--away-team' in result
|
|
|
|
def test_complete_new_game_option_partial(self, repl_completions):
|
|
"""Test completing new_game option with partial match."""
|
|
result = repl_completions.complete_new_game(
|
|
'--ho', 'new_game --ho', 9, 13
|
|
)
|
|
assert '--home-team' in result
|
|
assert '--away-team' not in result
|
|
|
|
def test_complete_new_game_league_value(self, repl_completions):
|
|
"""Test completing league value."""
|
|
result = repl_completions.complete_new_game(
|
|
's', 'new_game --league s', 9, 20
|
|
)
|
|
assert 'sba' in result
|
|
assert 'pd' not in result
|
|
|
|
def test_complete_new_game_league_all_values(self, repl_completions):
|
|
"""Test showing all league values."""
|
|
result = repl_completions.complete_new_game(
|
|
'', 'new_game --league ', 0, 19
|
|
)
|
|
assert 'sba' in result
|
|
assert 'pd' in result
|
|
|
|
def test_complete_defensive_options(self, repl_completions):
|
|
"""Test completing defensive options."""
|
|
result = repl_completions.complete_defensive(
|
|
'--', 'defensive --', 10, 12
|
|
)
|
|
assert '--alignment' in result
|
|
assert '--infield' in result
|
|
assert '--outfield' in result
|
|
assert '--hold' in result
|
|
|
|
def test_complete_defensive_alignment(self, repl_completions):
|
|
"""Test completing defensive alignment values."""
|
|
result = repl_completions.complete_defensive(
|
|
'shift', 'defensive --alignment shift', 10, 30
|
|
)
|
|
assert 'shifted_left' in result
|
|
assert 'shifted_right' in result
|
|
assert 'normal' not in result
|
|
|
|
def test_complete_defensive_alignment_all(self, repl_completions):
|
|
"""Test showing all defensive alignment values."""
|
|
result = repl_completions.complete_defensive(
|
|
'', 'defensive --alignment ', 0, 24
|
|
)
|
|
assert 'normal' in result
|
|
assert 'shifted_left' in result
|
|
assert 'shifted_right' in result
|
|
assert 'extreme_shift' in result
|
|
|
|
def test_complete_defensive_infield(self, repl_completions):
|
|
"""Test completing defensive infield values."""
|
|
result = repl_completions.complete_defensive(
|
|
'dou', 'defensive --infield dou', 10, 25
|
|
)
|
|
assert 'double_play' in result
|
|
assert 'normal' not in result
|
|
|
|
def test_complete_defensive_hold_bases(self, repl_completions):
|
|
"""Test completing hold bases."""
|
|
result = repl_completions.complete_defensive(
|
|
'1,', 'defensive --hold 1,', 10, 19
|
|
)
|
|
assert '1,2' in result
|
|
assert '1,3' in result
|
|
|
|
def test_complete_defensive_hold_first_base(self, repl_completions):
|
|
"""Test completing first hold base."""
|
|
result = repl_completions.complete_defensive(
|
|
'', 'defensive --hold ', 0, 17
|
|
)
|
|
assert '1' in result
|
|
assert '2' in result
|
|
assert '3' in result
|
|
|
|
def test_complete_offensive_options(self, repl_completions):
|
|
"""Test completing offensive options."""
|
|
result = repl_completions.complete_offensive(
|
|
'--', 'offensive --', 10, 12
|
|
)
|
|
assert '--approach' in result
|
|
assert '--steal' in result
|
|
assert '--hit-run' in result
|
|
assert '--bunt' in result
|
|
|
|
def test_complete_offensive_approach(self, repl_completions):
|
|
"""Test completing offensive approach values."""
|
|
result = repl_completions.complete_offensive(
|
|
'p', 'offensive --approach p', 10, 22
|
|
)
|
|
assert 'power' in result
|
|
assert 'patient' in result
|
|
assert 'normal' not in result
|
|
|
|
def test_complete_offensive_approach_all(self, repl_completions):
|
|
"""Test showing all offensive approach values."""
|
|
result = repl_completions.complete_offensive(
|
|
'', 'offensive --approach ', 0, 21
|
|
)
|
|
assert 'normal' in result
|
|
assert 'contact' in result
|
|
assert 'power' in result
|
|
assert 'patient' in result
|
|
|
|
def test_complete_offensive_steal_bases(self, repl_completions):
|
|
"""Test completing steal bases."""
|
|
result = repl_completions.complete_offensive(
|
|
'', 'offensive --steal ', 0, 18
|
|
)
|
|
assert '2' in result
|
|
assert '3' in result
|
|
assert '1' not in result # Can't steal first
|
|
|
|
def test_complete_offensive_steal_multiple(self, repl_completions):
|
|
"""Test completing multiple steal bases."""
|
|
result = repl_completions.complete_offensive(
|
|
'2,', 'offensive --steal 2,', 10, 20
|
|
)
|
|
assert '2,3' in result
|
|
|
|
def test_complete_quick_play_counts(self, repl_completions):
|
|
"""Test completing quick_play with common counts."""
|
|
result = repl_completions.complete_quick_play(
|
|
'1', 'quick_play 1', 11, 12
|
|
)
|
|
assert '1' in result
|
|
assert '10' in result
|
|
assert '100' in result
|
|
|
|
def test_complete_quick_play_all_counts(self, repl_completions):
|
|
"""Test showing all quick_play counts."""
|
|
result = repl_completions.complete_quick_play(
|
|
'', 'quick_play ', 11, 11
|
|
)
|
|
assert '1' in result
|
|
assert '5' in result
|
|
assert '10' in result
|
|
assert '27' in result
|
|
assert '50' in result
|
|
assert '100' in result
|
|
|
|
@patch('app.core.state_manager.state_manager')
|
|
def test_complete_use_game_with_games(self, mock_sm, repl_completions):
|
|
"""Test completing use_game with active games."""
|
|
from uuid import uuid4
|
|
|
|
game_id1 = uuid4()
|
|
game_id2 = uuid4()
|
|
mock_sm.list_games.return_value = [game_id1, game_id2]
|
|
|
|
result = repl_completions.complete_use_game(
|
|
str(game_id1)[:8], f'use_game {str(game_id1)[:8]}', 9, 17
|
|
)
|
|
|
|
# Should return the matching game ID
|
|
assert any(str(game_id1) in r for r in result)
|
|
|
|
@patch('app.core.state_manager.state_manager')
|
|
def test_complete_use_game_no_games(self, mock_sm, repl_completions):
|
|
"""Test completing use_game with no active games."""
|
|
mock_sm.list_games.return_value = []
|
|
|
|
result = repl_completions.complete_use_game(
|
|
'', 'use_game ', 9, 9
|
|
)
|
|
|
|
assert result == []
|
|
|
|
def test_completedefault_with_option(self, repl_completions):
|
|
"""Test default completion with option prefix."""
|
|
result = repl_completions.completedefault(
|
|
'--', 'some_cmd --', 9, 11
|
|
)
|
|
assert '--help' in result
|
|
assert '--verbose' in result
|
|
assert '--debug' in result
|
|
|
|
def test_completedefault_without_option(self, repl_completions):
|
|
"""Test default completion without option prefix."""
|
|
result = repl_completions.completedefault(
|
|
'text', 'some_cmd text', 9, 13
|
|
)
|
|
assert result == []
|
|
|
|
def test_completenames_partial_command(self, repl_completions):
|
|
"""Test completing command name with partial text."""
|
|
# Mock get_names to return command list
|
|
repl_completions.get_names = lambda: ['do_new_game', 'do_defensive', 'do_offensive']
|
|
|
|
result = repl_completions.completenames('new', None)
|
|
assert 'new_game' in result
|
|
|
|
def test_completenames_with_aliases(self, repl_completions):
|
|
"""Test completing command name includes aliases."""
|
|
repl_completions.get_names = lambda: ['do_quit']
|
|
|
|
result = repl_completions.completenames('q', None)
|
|
assert 'quit' in result
|
|
|
|
def test_completenames_exit_alias(self, repl_completions):
|
|
"""Test completing command name includes exit alias."""
|
|
repl_completions.get_names = lambda: ['do_exit']
|
|
|
|
result = repl_completions.completenames('ex', None)
|
|
assert 'exit' in result
|