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>
261 lines
11 KiB
Python
261 lines
11 KiB
Python
"""
|
|
Unit tests for argument parser.
|
|
"""
|
|
import pytest
|
|
|
|
from terminal_client.arg_parser import (
|
|
CommandArgumentParser,
|
|
ArgumentParseError,
|
|
parse_new_game_args,
|
|
parse_defensive_args,
|
|
parse_offensive_args,
|
|
parse_quick_play_args,
|
|
parse_use_game_args
|
|
)
|
|
|
|
|
|
class TestCommandArgumentParser:
|
|
"""Tests for CommandArgumentParser."""
|
|
|
|
def test_parse_simple_string_arg(self):
|
|
"""Test parsing simple string argument."""
|
|
schema = {'name': {'type': str, 'default': 'default'}}
|
|
result = CommandArgumentParser.parse_args('--name test', schema)
|
|
assert result['name'] == 'test'
|
|
|
|
def test_parse_integer_arg(self):
|
|
"""Test parsing integer argument."""
|
|
schema = {'count': {'type': int, 'default': 1}}
|
|
result = CommandArgumentParser.parse_args('--count 42', schema)
|
|
assert result['count'] == 42
|
|
|
|
def test_parse_flag_arg(self):
|
|
"""Test parsing boolean flag."""
|
|
schema = {
|
|
'verbose': {'type': bool, 'flag': True, 'default': False}
|
|
}
|
|
result = CommandArgumentParser.parse_args('--verbose', schema)
|
|
assert result['verbose'] is True
|
|
|
|
def test_parse_int_list(self):
|
|
"""Test parsing comma-separated integer list."""
|
|
schema = {'bases': {'type': 'int_list', 'default': []}}
|
|
result = CommandArgumentParser.parse_args('--bases 1,2,3', schema)
|
|
assert result['bases'] == [1, 2, 3]
|
|
|
|
def test_parse_quoted_string(self):
|
|
"""Test parsing quoted string with spaces."""
|
|
schema = {'message': {'type': str, 'default': ''}}
|
|
result = CommandArgumentParser.parse_args('--message "hello world"', schema)
|
|
assert result['message'] == 'hello world'
|
|
|
|
def test_parse_positional_arg(self):
|
|
"""Test parsing positional argument."""
|
|
schema = {
|
|
'count': {'type': int, 'positional': True, 'default': 1}
|
|
}
|
|
result = CommandArgumentParser.parse_args('10', schema)
|
|
assert result['count'] == 10
|
|
|
|
def test_parse_mixed_args(self):
|
|
"""Test parsing mix of options and positional."""
|
|
schema = {
|
|
'count': {'type': int, 'positional': True, 'default': 1},
|
|
'league': {'type': str, 'default': 'sba'}
|
|
}
|
|
result = CommandArgumentParser.parse_args('5 --league pd', schema)
|
|
assert result['count'] == 5
|
|
assert result['league'] == 'pd'
|
|
|
|
def test_parse_unknown_option_raises(self):
|
|
"""Test that unknown option raises error."""
|
|
schema = {'name': {'type': str, 'default': 'default'}}
|
|
with pytest.raises(ArgumentParseError, match="Unknown option"):
|
|
CommandArgumentParser.parse_args('--invalid test', schema)
|
|
|
|
def test_parse_missing_value_raises(self):
|
|
"""Test that missing value for option raises error."""
|
|
schema = {'name': {'type': str, 'default': 'default'}}
|
|
with pytest.raises(ArgumentParseError, match="requires a value"):
|
|
CommandArgumentParser.parse_args('--name', schema)
|
|
|
|
def test_parse_invalid_type_raises(self):
|
|
"""Test that invalid type conversion raises error."""
|
|
schema = {'count': {'type': int, 'default': 1}}
|
|
with pytest.raises(ArgumentParseError, match="expected int"):
|
|
CommandArgumentParser.parse_args('--count abc', schema)
|
|
|
|
def test_parse_empty_string(self):
|
|
"""Test parsing empty string returns defaults."""
|
|
schema = {
|
|
'name': {'type': str, 'default': 'default'},
|
|
'count': {'type': int, 'default': 1}
|
|
}
|
|
result = CommandArgumentParser.parse_args('', schema)
|
|
assert result['name'] == 'default'
|
|
assert result['count'] == 1
|
|
|
|
def test_parse_hyphen_to_underscore(self):
|
|
"""Test that hyphens in options convert to underscores."""
|
|
schema = {'home_team': {'type': int, 'default': 1}}
|
|
result = CommandArgumentParser.parse_args('--home-team 5', schema)
|
|
assert result['home_team'] == 5
|
|
|
|
def test_parse_float_arg(self):
|
|
"""Test parsing float argument."""
|
|
schema = {'ratio': {'type': float, 'default': 1.0}}
|
|
result = CommandArgumentParser.parse_args('--ratio 3.14', schema)
|
|
assert result['ratio'] == 3.14
|
|
|
|
def test_parse_string_list(self):
|
|
"""Test parsing comma-separated string list."""
|
|
schema = {'tags': {'type': list, 'default': []}}
|
|
result = CommandArgumentParser.parse_args('--tags one,two,three', schema)
|
|
assert result['tags'] == ['one', 'two', 'three']
|
|
|
|
def test_parse_multiple_flags(self):
|
|
"""Test parsing multiple boolean flags."""
|
|
schema = {
|
|
'verbose': {'type': bool, 'flag': True, 'default': False},
|
|
'debug': {'type': bool, 'flag': True, 'default': False}
|
|
}
|
|
result = CommandArgumentParser.parse_args('--verbose --debug', schema)
|
|
assert result['verbose'] is True
|
|
assert result['debug'] is True
|
|
|
|
def test_parse_unexpected_positional_raises(self):
|
|
"""Test that unexpected positional argument raises error."""
|
|
schema = {'name': {'type': str, 'default': 'default'}}
|
|
with pytest.raises(ArgumentParseError, match="Unexpected positional"):
|
|
CommandArgumentParser.parse_args('extra_arg', schema)
|
|
|
|
def test_parse_invalid_int_list_raises(self):
|
|
"""Test that invalid integer in list raises error."""
|
|
schema = {'bases': {'type': 'int_list', 'default': []}}
|
|
with pytest.raises(ArgumentParseError, match="expected int_list"):
|
|
CommandArgumentParser.parse_args('--bases 1,abc,3', schema)
|
|
|
|
def test_parse_invalid_syntax_raises(self):
|
|
"""Test that invalid shell syntax raises error."""
|
|
schema = {'name': {'type': str, 'default': 'default'}}
|
|
with pytest.raises(ArgumentParseError, match="Invalid argument syntax"):
|
|
CommandArgumentParser.parse_args('--name "unclosed quote', schema)
|
|
|
|
def test_parse_game_id_with_option(self):
|
|
"""Test parsing game ID from option."""
|
|
arg_string = '--game-id a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
result = CommandArgumentParser.parse_game_id(arg_string)
|
|
assert result == 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
|
|
def test_parse_game_id_positional(self):
|
|
"""Test parsing game ID as positional argument."""
|
|
arg_string = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
result = CommandArgumentParser.parse_game_id(arg_string)
|
|
assert result == 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
|
|
def test_parse_game_id_none(self):
|
|
"""Test parsing game ID returns None when not found."""
|
|
arg_string = '--other-option value'
|
|
result = CommandArgumentParser.parse_game_id(arg_string)
|
|
assert result is None
|
|
|
|
def test_parse_game_id_invalid_syntax(self):
|
|
"""Test parsing game ID with invalid syntax returns None."""
|
|
arg_string = '"unclosed quote'
|
|
result = CommandArgumentParser.parse_game_id(arg_string)
|
|
assert result is None
|
|
|
|
|
|
class TestPrebuiltParsers:
|
|
"""Tests for pre-built parser functions."""
|
|
|
|
def test_parse_new_game_args_defaults(self):
|
|
"""Test new_game parser with defaults."""
|
|
result = parse_new_game_args('')
|
|
assert result['league'] == 'sba'
|
|
assert result['home_team'] == 1
|
|
assert result['away_team'] == 2
|
|
|
|
def test_parse_new_game_args_custom(self):
|
|
"""Test new_game parser with custom values."""
|
|
result = parse_new_game_args('--league pd --home-team 5 --away-team 3')
|
|
assert result['league'] == 'pd'
|
|
assert result['home_team'] == 5
|
|
assert result['away_team'] == 3
|
|
|
|
def test_parse_defensive_args_defaults(self):
|
|
"""Test defensive parser with defaults."""
|
|
result = parse_defensive_args('')
|
|
assert result['alignment'] == 'normal'
|
|
assert result['infield'] == 'normal'
|
|
assert result['outfield'] == 'normal'
|
|
assert result['hold'] == []
|
|
|
|
def test_parse_defensive_args_with_hold(self):
|
|
"""Test defensive parser with hold runners."""
|
|
result = parse_defensive_args('--alignment shifted_left --hold 1,3')
|
|
assert result['alignment'] == 'shifted_left'
|
|
assert result['hold'] == [1, 3]
|
|
|
|
def test_parse_defensive_args_all_options(self):
|
|
"""Test defensive parser with all options."""
|
|
result = parse_defensive_args('--alignment extreme_shift --infield back --outfield in --hold 1,2,3')
|
|
assert result['alignment'] == 'extreme_shift'
|
|
assert result['infield'] == 'back'
|
|
assert result['outfield'] == 'in'
|
|
assert result['hold'] == [1, 2, 3]
|
|
|
|
def test_parse_offensive_args_defaults(self):
|
|
"""Test offensive parser with defaults."""
|
|
result = parse_offensive_args('')
|
|
assert result['approach'] == 'normal'
|
|
assert result['steal'] == []
|
|
assert result['hit_run'] is False
|
|
assert result['bunt'] is False
|
|
|
|
def test_parse_offensive_args_flags(self):
|
|
"""Test offensive parser with flags."""
|
|
result = parse_offensive_args('--approach power --hit-run --bunt')
|
|
assert result['approach'] == 'power'
|
|
assert result['hit_run'] is True
|
|
assert result['bunt'] is True
|
|
|
|
def test_parse_offensive_args_steal(self):
|
|
"""Test offensive parser with steal attempts."""
|
|
result = parse_offensive_args('--steal 2,3')
|
|
assert result['steal'] == [2, 3]
|
|
|
|
def test_parse_offensive_args_all_options(self):
|
|
"""Test offensive parser with all options."""
|
|
result = parse_offensive_args('--approach patient --steal 2 --hit-run')
|
|
assert result['approach'] == 'patient'
|
|
assert result['steal'] == [2]
|
|
assert result['hit_run'] is True
|
|
assert result['bunt'] is False
|
|
|
|
def test_parse_quick_play_args_default(self):
|
|
"""Test quick_play parser with default."""
|
|
result = parse_quick_play_args('')
|
|
assert result['count'] == 1
|
|
|
|
def test_parse_quick_play_args_positional(self):
|
|
"""Test quick_play parser with positional count."""
|
|
result = parse_quick_play_args('10')
|
|
assert result['count'] == 10
|
|
|
|
def test_parse_quick_play_args_large_count(self):
|
|
"""Test quick_play parser with large count."""
|
|
result = parse_quick_play_args('100')
|
|
assert result['count'] == 100
|
|
|
|
def test_parse_use_game_args_valid(self):
|
|
"""Test use_game parser with valid UUID."""
|
|
result = parse_use_game_args('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
|
|
assert result['game_id'] == 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
|
|
def test_parse_use_game_args_missing_returns_empty(self):
|
|
"""Test use_game parser returns empty dict when game_id missing."""
|
|
result = parse_use_game_args('')
|
|
# game_id is positional without default, so it won't be in result
|
|
assert 'game_id' not in result or result.get('game_id') is None
|