From 1c327871955f40f46a689ff2adb0b9940c1b8964 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 28 Oct 2025 14:16:38 -0500 Subject: [PATCH] CLAUDE: Refactor game models and modularize terminal client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/core/play_resolver.py | 47 +- backend/app/core/validators.py | 10 +- backend/app/models/game_models.py | 126 +-- .../app/models/visual_model_relationships.md | 308 +++++++ backend/clean_test_data.py | 2 +- backend/terminal_client/arg_parser.py | 217 +++++ backend/terminal_client/commands.py | 316 +++++++ backend/terminal_client/completions.py | 308 +++++++ backend/terminal_client/help_text.py | 346 +++++++ backend/terminal_client/main.py | 203 +---- backend/terminal_client/repl.py | 362 ++++---- .../terminal_client/update_docs/phase_1.md | 501 ++++++++++ .../terminal_client/update_docs/phase_2.md | 600 ++++++++++++ .../terminal_client/update_docs/phase_3.md | 557 ++++++++++++ .../terminal_client/update_docs/phase_4.md | 664 ++++++++++++++ .../terminal_client/update_docs/phase_5.md | 853 ++++++++++++++++++ .../terminal_client/update_docs/phase_6.md | 803 +++++++++++++++++ backend/tests/integration/test_game_engine.py | 11 +- backend/tests/unit/core/test_play_resolver.py | 22 +- backend/tests/unit/core/test_validators.py | 6 +- backend/tests/unit/models/test_game_models.py | 106 +-- .../tests/unit/terminal_client/__init__.py | 1 + .../unit/terminal_client/test_arg_parser.py | 260 ++++++ .../unit/terminal_client/test_commands.py | 375 ++++++++ .../unit/terminal_client/test_completions.py | 310 +++++++ .../unit/terminal_client/test_help_text.py | 182 ++++ 26 files changed, 6948 insertions(+), 548 deletions(-) create mode 100644 backend/app/models/visual_model_relationships.md create mode 100644 backend/terminal_client/arg_parser.py create mode 100644 backend/terminal_client/commands.py create mode 100644 backend/terminal_client/completions.py create mode 100644 backend/terminal_client/help_text.py create mode 100644 backend/terminal_client/update_docs/phase_1.md create mode 100644 backend/terminal_client/update_docs/phase_2.md create mode 100644 backend/terminal_client/update_docs/phase_3.md create mode 100644 backend/terminal_client/update_docs/phase_4.md create mode 100644 backend/terminal_client/update_docs/phase_5.md create mode 100644 backend/terminal_client/update_docs/phase_6.md create mode 100644 backend/tests/unit/terminal_client/__init__.py create mode 100644 backend/tests/unit/terminal_client/test_arg_parser.py create mode 100644 backend/tests/unit/terminal_client/test_commands.py create mode 100644 backend/tests/unit/terminal_client/test_completions.py create mode 100644 backend/tests/unit/terminal_client/test_help_text.py diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index 92152e4..25e7767 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -14,7 +14,7 @@ from enum import Enum from app.core.dice import dice_system from app.core.roll_types import AbRoll -from app.models.game_models import GameState, RunnerState, DefensiveDecision, OffensiveDecision +from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision logger = logging.getLogger(f'{__name__}.PlayResolver') @@ -255,14 +255,15 @@ class PlayResolver: elif outcome == PlayOutcome.TRIPLE: # All runners score - runs_scored = len(state.runners) + runners_advanced = [(base, 4) for base, _ in state.get_all_runners()] + runs_scored = len(runners_advanced) return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=3, - runners_advanced=[(r.on_base, 4) for r in state.runners], + runners_advanced=runners_advanced, description="Triple to right-center gap", ab_roll=ab_roll, is_hit=True @@ -270,14 +271,15 @@ class PlayResolver: elif outcome == PlayOutcome.HOMERUN: # Everyone scores - runs_scored = len(state.runners) + 1 + runners_advanced = [(base, 4) for base, _ in state.get_all_runners()] + runs_scored = len(runners_advanced) + 1 return PlayResult( outcome=outcome, outs_recorded=0, runs_scored=runs_scored, batter_result=4, - runners_advanced=[(r.on_base, 4) for r in state.runners], + runners_advanced=runners_advanced, description="Home run to left field", ab_roll=ab_roll, is_hit=True @@ -285,7 +287,7 @@ class PlayResolver: elif outcome == PlayOutcome.WILD_PITCH: # Runners advance one base - runners_advanced = [(r.on_base, r.on_base + 1) for r in state.runners] + runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()] runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -300,7 +302,7 @@ class PlayResolver: elif outcome == PlayOutcome.PASSED_BALL: # Runners advance one base - runners_advanced = [(r.on_base, r.on_base + 1) for r in state.runners] + runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()] runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) return PlayResult( @@ -321,11 +323,11 @@ class PlayResolver: advances = [] # Only forced runners advance - if any(r.on_base == 1 for r in state.runners): + if state.on_first: # First occupied - check second - if any(r.on_base == 2 for r in state.runners): + if state.on_second: # Bases loaded scenario - if any(r.on_base == 3 for r in state.runners): + if state.on_third: # Bases loaded - force runner home advances.append((3, 4)) advances.append((2, 3)) @@ -337,16 +339,15 @@ class PlayResolver: """Calculate runner advancement on single (simplified)""" advances = [] - for runner in state.runners: - if runner.on_base == 3: - # Runner on third scores - advances.append((3, 4)) - elif runner.on_base == 2: - # Runner on second scores (simplified - usually would) - advances.append((2, 4)) - elif runner.on_base == 1: - # Runner on first to third (simplified) - advances.append((1, 3)) + if state.on_third: + # Runner on third scores + advances.append((3, 4)) + if state.on_second: + # Runner on second scores (simplified - usually would) + advances.append((2, 4)) + if state.on_first: + # Runner on first to third (simplified) + advances.append((1, 3)) return advances @@ -354,9 +355,9 @@ class PlayResolver: """Calculate runner advancement on double""" advances = [] - for runner in state.runners: - # All runners score on double (simplified) - advances.append((runner.on_base, 4)) + # All runners score on double (simplified) + for base, _ in state.get_all_runners(): + advances.append((base, 4)) return advances diff --git a/backend/app/core/validators.py b/backend/app/core/validators.py index f1e2f5e..2c1e423 100644 --- a/backend/app/core/validators.py +++ b/backend/app/core/validators.py @@ -54,9 +54,9 @@ class GameValidator: raise ValidationError(f"Invalid infield depth: {decision.infield_depth}") # Validate hold runners - can't hold empty bases - runner_bases = [r.on_base for r in state.runners] + occupied_bases = state.bases_occupied() for base in decision.hold_runners: - if base not in runner_bases: + if base not in occupied_bases: raise ValidationError(f"Can't hold base {base} - no runner present") logger.debug("Defensive decision validated") @@ -69,12 +69,12 @@ class GameValidator: raise ValidationError(f"Invalid approach: {decision.approach}") # Validate steal attempts - runner_bases = [r.on_base for r in state.runners] + occupied_bases = state.bases_occupied() for base in decision.steal_attempts: # Must have runner on base-1 to steal base - if (base - 1) not in runner_bases: + if (base - 1) not in occupied_bases: raise ValidationError(f"Can't steal {base} - no runner on {base-1}") - + # TODO: add check that base in front of stealing runner is unoccupied # Can't bunt with 2 outs (simplified rule) diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 2845478..685b577 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -19,36 +19,6 @@ from pydantic import BaseModel, Field, field_validator, ConfigDict logger = logging.getLogger(f'{__name__}') -# ============================================================================ -# RUNNER & BASE STATE -# ============================================================================ - -class RunnerState(BaseModel): - """ - Represents a runner on base. - - Attributes: - lineup_id: Unique lineup entry ID for this player in this game - card_id: Player card ID (for fetching player data if needed) - on_base: Base number (1=first, 2=second, 3=third) - """ - lineup_id: int - card_id: int - on_base: int = Field(..., ge=1, le=3) - - # Future expansion for advanced gameplay - # lead_off: bool = False - # steal_attempt: bool = False - - @field_validator('on_base') - @classmethod - def validate_base(cls, v: int) -> int: - """Ensure base is 1, 2, or 3""" - if v not in [1, 2, 3]: - raise ValueError("on_base must be 1, 2, or 3") - return v - - # ============================================================================ # LINEUP STATE # ============================================================================ @@ -274,8 +244,10 @@ class GameState(BaseModel): home_score: int = Field(default=0, ge=0) away_score: int = Field(default=0, ge=0) - # Runners - runners: List[RunnerState] = Field(default_factory=list) + # Runners (direct references matching DB structure) + on_first: Optional[LineupPlayerState] = None + on_second: Optional[LineupPlayerState] = None + on_third: Optional[LineupPlayerState] = None # Batting order tracking (per team) - indexes into batting order (0-8) away_team_batter_idx: int = Field(default=0, ge=0, le=8) @@ -344,45 +316,71 @@ class GameState(BaseModel): def is_runner_on_first(self) -> bool: """Check if there's a runner on first base""" - return any(r.on_base == 1 for r in self.runners) + return self.on_first is not None def is_runner_on_second(self) -> bool: """Check if there's a runner on second base""" - return any(r.on_base == 2 for r in self.runners) + return self.on_second is not None def is_runner_on_third(self) -> bool: """Check if there's a runner on third base""" - return any(r.on_base == 3 for r in self.runners) + return self.on_third is not None - def get_runner_at_base(self, base: int) -> Optional[RunnerState]: + def get_runner_at_base(self, base: int) -> Optional[LineupPlayerState]: """Get runner at specified base (1, 2, or 3)""" - for runner in self.runners: - if runner.on_base == base: - return runner + if base == 1: + return self.on_first + elif base == 2: + return self.on_second + elif base == 3: + return self.on_third return None def bases_occupied(self) -> List[int]: """Get list of occupied bases""" - return sorted([r.on_base for r in self.runners]) + bases = [] + if self.on_first: + bases.append(1) + if self.on_second: + bases.append(2) + if self.on_third: + bases.append(3) + return bases + + def get_all_runners(self) -> List[tuple[int, LineupPlayerState]]: + """Returns list of (base, player) tuples for occupied bases""" + runners = [] + if self.on_first: + runners.append((1, self.on_first)) + if self.on_second: + runners.append((2, self.on_second)) + if self.on_third: + runners.append((3, self.on_third)) + return runners def clear_bases(self) -> None: """Clear all runners from bases""" - self.runners = [] + self.on_first = None + self.on_second = None + self.on_third = None - def add_runner(self, lineup_id: int, card_id: int, base: int) -> None: - """Add a runner to a base""" + def add_runner(self, player: LineupPlayerState, base: int) -> None: + """ + Add a runner to a base + + Args: + player: LineupPlayerState to place on base + base: Base number (1, 2, or 3) + """ if base not in [1, 2, 3]: raise ValueError("base must be 1, 2, or 3") - # Remove any existing runner at that base - self.runners = [r for r in self.runners if r.on_base != base] - - # Add new runner - self.runners.append(RunnerState( - lineup_id=lineup_id, - card_id=card_id, - on_base=base - )) + if base == 1: + self.on_first = player + elif base == 2: + self.on_second = player + elif base == 3: + self.on_third = player def advance_runner(self, from_base: int, to_base: int) -> None: """ @@ -394,7 +392,14 @@ class GameState(BaseModel): """ runner = self.get_runner_at_base(from_base) if runner: - self.runners.remove(runner) + # Remove from current base + if from_base == 1: + self.on_first = None + elif from_base == 2: + self.on_second = None + elif from_base == 3: + self.on_third = None + if to_base == 4: # Runner scored if self.half == "top": @@ -403,8 +408,12 @@ class GameState(BaseModel): self.home_score += 1 else: # Runner advanced to another base - runner.on_base = to_base - self.runners.append(runner) + if to_base == 1: + self.on_first = runner + elif to_base == 2: + self.on_second = runner + elif to_base == 3: + self.on_third = runner def increment_outs(self) -> bool: """ @@ -473,9 +482,9 @@ class GameState(BaseModel): "outs": 1, "home_score": 2, "away_score": 1, - "runners": [ - {"lineup_id": 5, "card_id": 123, "on_base": 2} - ], + "on_first": None, + "on_second": {"lineup_id": 5, "card_id": 123, "position": "CF", "batting_order": 2}, + "on_third": None, "away_team_batter_idx": 3, "home_team_batter_idx": 5, "current_batter_lineup_id": 8, @@ -496,7 +505,6 @@ class GameState(BaseModel): # ============================================================================ __all__ = [ - 'RunnerState', 'LineupPlayerState', 'TeamLineupState', 'DefensiveDecision', diff --git a/backend/app/models/visual_model_relationships.md b/backend/app/models/visual_model_relationships.md new file mode 100644 index 0000000..ba0c804 --- /dev/null +++ b/backend/app/models/visual_model_relationships.md @@ -0,0 +1,308 @@ + ════════════════════════════════════════════════════════════════════════════════ + BACKEND MODEL RELATIONSHIPS + ════════════════════════════════════════════════════════════════════════════════ + + ┌─────────────────────────────────────────────────────────────────────────────┐ + │ DATABASE MODELS (db_models.py) │ + │ SQLAlchemy ORM - PostgreSQL Tables │ + └─────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ + │ GAME │ (games table) + │ PK: id(UUID)│ + │ │ + │ league_id │ ('sba' or 'pd') + │ home_team_id│ + │ away_team_id│ + │ status │ + │ game_mode │ + │ inning/half │ + │ scores │ + │ │ + │ AI Support: │ + │ - home_is_ai│ + │ - away_is_ai│ + │ - difficulty│ + └──────┬──────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ PLAY │ │ LINEUP │ │ SESSION │ + │ (plays) │ │(lineups) │ │(sessions)│ + └──────────┘ └──────────┘ └──────────┘ + │ │ │ + │ │ (WebSocket state) + │ │ + ┌────────────┼────────────┐ │ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ + │ batter │ │pitcher │ │catcher │ │on_first│ + │ (FK) │ │ (FK) │ │ (FK) │ │ (FK) │ + └────────┘ └────────┘ └────────┘ └────────┘ + │ │ │ │ + └────────────┴────────────┴───────┘ + │ + All FK to LINEUP + + + ┌──────────────────────────────┐ + │ GAME RELATIONSHIPS │ + │ │ + │ ┌─────────────────┐ │ + │ │ GameCardsetLink │ (PD) │ + │ │ - game_id (FK) │ │ + │ │ - cardset_id │ │ + │ │ - priority │ │ + │ └─────────────────┘ │ + │ │ + │ ┌─────────────────┐ │ + │ │ RosterLink │ │ + │ │ - game_id (FK) │ │ + │ │ - team_id │ │ + │ │ - card_id 🅿 │(PD) │ + │ │ - player_id 🆂 │(SBA) │ + │ │ XOR constraint │ │ + │ └─────────────────┘ │ + │ │ + │ ┌─────────────────┐ │ + │ │ Roll │ │ + │ │ - roll_id (PK) │ │ + │ │ - game_id (FK) │ │ + │ │ - roll_type │ │ + │ │ - roll_data │ │ + │ │ - context │ │ + │ └─────────────────┘ │ + └──────────────────────────────┘ + + + ═══════════════════════════════════════════════════════════════════════════════ + + ┌─────────────────────────────────────────────────────────────────────────────┐ + │ IN-MEMORY STATE MODELS (game_models.py) │ + │ Pydantic v2 - Fast Validation & Serialization │ + └─────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────┐ + │ GameState │ (Core in-memory state) + │ │ + │ game_id: UUID │ + │ league_id: str │ + │ home_team_id │ + │ away_team_id │ + │ home/away_is_ai │ + │ │ + │ Game State: │ + │ - status │ + │ - inning/half │ + │ - outs │ + │ - scores │ + │ │ + │ Current Play: │ + │ - batter_id │ + │ - pitcher_id │ + │ - catcher_id │ + │ - on_base_code │ + │ │ + │ Decision Track: │ + │ - pending_dec │ + │ - decisions_dict │ + │ │ + │ Batters Index: │ + │ - away_idx (0-8) │ + │ - home_idx (0-8) │ + └────────┬─────────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ + │ RunnerState │ │DefDecision │ │OffDecision │ + │ │ │ │ │ │ + │ lineup_id │ │ alignment │ │ approach │ + │ card_id │ │ if_depth │ │ steal_atts │ + │ on_base(1-3) │ │ of_depth │ │ hit_and_run │ + └──────────────┘ │ hold_runners│ │ bunt_attempt │ + └─────────────┘ └──────────────┘ + + + ┌──────────────────┐ + │ TeamLineupState │ + │ │ + │ team_id: int │ + │ players: List[ │ + │ LineupPlayer │ + │ State │ + │ ] │ + │ │ + │ Methods: │ + │ - get_batting_ │ + │ order() │ + │ - get_pitcher() │ + │ - get_batter() │ + └──────────────────┘ + │ + │ Contains list of + ▼ + ┌──────────────────┐ + │LineupPlayerState │ + │ │ + │ lineup_id: int │ + │ card_id: int │ + │ position: str │ + │ batting_order │ + │ is_active: bool │ + └──────────────────┘ + + + ═══════════════════════════════════════════════════════════════════════════════ + + ┌─────────────────────────────────────────────────────────────────────────────┐ + │ ROSTER MODELS (roster_models.py) │ + │ Pydantic - Type-safe Roster Operations │ + └─────────────────────────────────────────────────────────────────────────────┘ + + ┌────────────────────────┐ + │ BaseRosterLinkData │ (Abstract) + │ │ + │ id: Optional[int] │ + │ game_id: UUID │ + │ team_id: int │ + │ │ + │ Abstract Methods: │ + │ - get_entity_id() │ + │ - get_entity_type() │ + └───────────┬────────────┘ + │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ PdRosterLinkData │ │ SbaRosterLinkData │ + │ │ │ │ + │ card_id: int 🅿 │ │ player_id: int 🆂 │ + │ │ │ │ + │ get_entity_id() │ │ get_entity_id() │ + │ → returns card_id │ │ → returns player_id │ + │ │ │ │ + │ get_entity_type() │ │ get_entity_type() │ + │ → returns "card" │ │ → returns "player" │ + └──────────────────────┘ └──────────────────────┘ + + + ┌────────────────────────┐ + │ RosterLinkCreate │ (Request model) + │ │ + │ game_id: UUID │ + │ team_id: int │ + │ card_id: Optional │ + │ player_id: Optional │ + │ │ + │ Validation: │ + │ - XOR check (exactly │ + │ one ID required) │ + │ │ + │ Methods: │ + │ - to_pd_data() │ + │ - to_sba_data() │ + └────────────────────────┘ + + + ═══════════════════════════════════════════════════════════════════════════════ + + KEY RELATIONSHIPS SUMMARY + + Database (PostgreSQL) In-Memory (Pydantic) Roster Types + ═════════════════════ ═══════════════════ ════════════ + + Game ──┬──> Play GameState BaseRosterLinkData + │ │ │ │ + │ └──> Lineup ├──> RunnerState ├─> PdRosterLinkData + │ (batter, │ │ + │ pitcher, ├──> DefensiveDecision └─> SbaRosterLinkData + │ catcher, │ + │ runners) └──> OffensiveDecision + │ + ├──> Lineup TeamLineupState + │ │ + ├──> GameCardsetLink └──> LineupPlayerState + │ (PD only) + │ + ├──> RosterLink RosterLinkCreate ─┬─> PdRosterLinkData + │ - card_id (PD) │ + │ - player_id (SBA) └─> SbaRosterLinkData + │ + ├──> GameSession + │ (WebSocket state) + │ + └──> Roll + (dice history) + + + ═══════════════════════════════════════════════════════════════════════════════ + + POLYMORPHIC PATTERN SUMMARY + + Both `Lineup` and `RosterLink` support PD and SBA leagues: + + ┌──────────────────────────────────────────────────────────┐ + │ Lineup (db_models.py) RosterLink (db_models.py) │ + │ ══════════════════ ════════════════════ │ + │ │ + │ card_id (nullable) card_id (nullable) │ + │ player_id (nullable) player_id (nullable) │ + │ │ + │ CHECK: exactly one populated CHECK: exactly one │ + │ UNIQUE: (game_id, card_id) UNIQUE: (game_id, card) │ + │ UNIQUE: (game_id, player_id) UNIQUE: (game_id, player)│ + │ │ + │ 🅿 PD: card_id NOT NULL 🅿 PD: card_id NOT NULL │ + │ 🆂 SBA: player_id NOT NULL 🆂 SBA: player_id NOT │ + └──────────────────────────────────────────────────────────┘ + + + ═══════════════════════════════════════════════════════════════════════════════ + + DATA FLOW EXAMPLE + + 1. Create Game + ═══════════ + Game (DB) ────────────────> GameState (Memory) + │ │ + ├─> RosterLink (DB) │ + ├─> GameCardsetLink (PD) │ + └─> GameSession (DB) │ + + 2. Setup Lineup + ════════════ + Lineup (DB) ───────────────> TeamLineupState (Memory) + │ │ + └─> (card_id or player_id) └─> LineupPlayerState[] + + 3. Execute Play + ════════════ + GameState ──┬──> DefensiveDecision + ├──> OffensiveDecision + ├──> RunnerState[] + └──> (resolve play) + │ + ▼ + Play (DB) ──> Save outcome + │ + ├─> batter_id (FK to Lineup) + ├─> pitcher_id (FK to Lineup) + ├─> on_first/second/third_id + └─> 25+ stat fields + + 4. Dice Roll Audit + ═══════════════ + Roll (DB) ──> Stores cryptographic roll history + │ + ├─> roll_data (JSONB): complete roll + ├─> context (JSONB): game situation + └─> Used for recovery/analytics + + + ═══════════════════════════════════════════════════════════════════════════════ diff --git a/backend/clean_test_data.py b/backend/clean_test_data.py index 77cf9a1..1cccad0 100644 --- a/backend/clean_test_data.py +++ b/backend/clean_test_data.py @@ -178,7 +178,7 @@ class DatabaseCleaner: logger.info(f" Mode: {game.game_mode}") logger.info(f" Teams: {game.away_team_id} @ {game.home_team_id}") logger.info(f" Score: {game.away_score}-{game.home_score}") - if game.current_inning: + if game.current_inning: # type: ignore logger.info(f" State: {game.current_half} {game.current_inning}") logger.info(f" AI: Home={game.home_team_is_ai}, Away={game.away_team_is_ai}") logger.info(f" Created: {game.created_at}") diff --git a/backend/terminal_client/arg_parser.py b/backend/terminal_client/arg_parser.py new file mode 100644 index 0000000..51ff902 --- /dev/null +++ b/backend/terminal_client/arg_parser.py @@ -0,0 +1,217 @@ +""" +Argument parsing utilities for terminal client commands. + +Provides robust parsing for both REPL and CLI commands using shlex +to handle quoted strings, spaces, and complex arguments. + +Author: Claude +Date: 2025-10-27 +""" +import shlex +import logging +from typing import Dict, Any, List, Optional, Tuple + +logger = logging.getLogger(f'{__name__}.arg_parser') + + +class ArgumentParseError(Exception): + """Raised when argument parsing fails.""" + pass + + +class CommandArgumentParser: + """Parse command-line style arguments for terminal client.""" + + @staticmethod + def parse_args(arg_string: str, schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse argument string according to schema. + + Args: + arg_string: Raw argument string from command + schema: Dictionary defining expected arguments + { + 'league': {'type': str, 'default': 'sba'}, + 'home_team': {'type': int, 'default': 1}, + 'count': {'type': int, 'default': 1, 'positional': True}, + 'verbose': {'type': bool, 'flag': True} + } + + Returns: + Dictionary of parsed arguments with defaults applied + + Raises: + ArgumentParseError: If parsing fails or validation fails + """ + try: + # Use shlex for robust parsing + tokens = shlex.split(arg_string) if arg_string.strip() else [] + except ValueError as e: + raise ArgumentParseError(f"Invalid argument syntax: {e}") + + # Initialize result with defaults + result = {} + for key, spec in schema.items(): + if 'default' in spec: + result[key] = spec['default'] + + # Track which positional arg we're on + positional_keys = [k for k, v in schema.items() if v.get('positional', False)] + positional_index = 0 + + i = 0 + while i < len(tokens): + token = tokens[i] + + # Handle flags (--option or -o) + if token.startswith('--'): + option_name = token[2:] + + # Convert hyphen to underscore for Python compatibility + option_key = option_name.replace('-', '_') + + if option_key not in schema: + raise ArgumentParseError(f"Unknown option: {token}") + + spec = schema[option_key] + + # Boolean flags don't need a value + if spec.get('flag', False): + result[option_key] = True + i += 1 + continue + + # Option requires a value + if i + 1 >= len(tokens): + raise ArgumentParseError(f"Option {token} requires a value") + + value_str = tokens[i + 1] + + # Type conversion + try: + if spec['type'] == int: + result[option_key] = int(value_str) + elif spec['type'] == float: + result[option_key] = float(value_str) + elif spec['type'] == list: + # Parse comma-separated list + result[option_key] = [item.strip() for item in value_str.split(',')] + elif spec['type'] == 'int_list': + # Parse comma-separated integers + result[option_key] = [int(item.strip()) for item in value_str.split(',')] + else: + result[option_key] = value_str + except ValueError as e: + raise ArgumentParseError( + f"Invalid value for {token}: expected {spec['type'].__name__ if hasattr(spec['type'], '__name__') else spec['type']}, got '{value_str}'" + ) + + i += 2 + + # Handle positional arguments + else: + if positional_index >= len(positional_keys): + raise ArgumentParseError(f"Unexpected positional argument: {token}") + + key = positional_keys[positional_index] + spec = schema[key] + + try: + if spec['type'] == int: + result[key] = int(token) + elif spec['type'] == float: + result[key] = float(token) + else: + result[key] = token + except ValueError as e: + raise ArgumentParseError( + f"Invalid value for {key}: expected {spec['type'].__name__}, got '{token}'" + ) + + positional_index += 1 + i += 1 + + return result + + @staticmethod + def parse_game_id(arg_string: str) -> Optional[str]: + """ + Parse a game ID from argument string. + + Args: + arg_string: Raw argument string + + Returns: + Game ID string or None + """ + try: + tokens = shlex.split(arg_string) if arg_string.strip() else [] + + # Look for --game-id option + for i, token in enumerate(tokens): + if token == '--game-id' and i + 1 < len(tokens): + return tokens[i + 1] + + # If no option, check if there's a positional UUID-like argument + if tokens and len(tokens[0]) == 36: # UUID length + return tokens[0] + + return None + + except ValueError: + return None + + +# Predefined schemas for common commands +NEW_GAME_SCHEMA = { + 'league': {'type': str, 'default': 'sba'}, + 'home_team': {'type': int, 'default': 1}, + 'away_team': {'type': int, 'default': 2} +} + +DEFENSIVE_SCHEMA = { + 'alignment': {'type': str, 'default': 'normal'}, + 'infield': {'type': str, 'default': 'normal'}, + 'outfield': {'type': str, 'default': 'normal'}, + 'hold': {'type': 'int_list', 'default': []} +} + +OFFENSIVE_SCHEMA = { + 'approach': {'type': str, 'default': 'normal'}, + 'steal': {'type': 'int_list', 'default': []}, + 'hit_run': {'type': bool, 'flag': True, 'default': False}, + 'bunt': {'type': bool, 'flag': True, 'default': False} +} + +QUICK_PLAY_SCHEMA = { + 'count': {'type': int, 'default': 1, 'positional': True} +} + +USE_GAME_SCHEMA = { + 'game_id': {'type': str, 'positional': True} +} + + +def parse_new_game_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for new_game command.""" + return CommandArgumentParser.parse_args(arg_string, NEW_GAME_SCHEMA) + + +def parse_defensive_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for defensive command.""" + return CommandArgumentParser.parse_args(arg_string, DEFENSIVE_SCHEMA) + + +def parse_offensive_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for offensive command.""" + return CommandArgumentParser.parse_args(arg_string, OFFENSIVE_SCHEMA) + + +def parse_quick_play_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for quick_play command.""" + return CommandArgumentParser.parse_args(arg_string, QUICK_PLAY_SCHEMA) + + +def parse_use_game_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for use_game command.""" + return CommandArgumentParser.parse_args(arg_string, USE_GAME_SCHEMA) diff --git a/backend/terminal_client/commands.py b/backend/terminal_client/commands.py new file mode 100644 index 0000000..e941942 --- /dev/null +++ b/backend/terminal_client/commands.py @@ -0,0 +1,316 @@ +""" +Shared command implementations for terminal client. + +This module contains the core logic for game commands that can be +used by both the REPL (repl.py) and CLI (main.py) interfaces. + +Author: Claude +Date: 2025-10-27 +""" +import asyncio +import logging +from uuid import UUID, uuid4 +from typing import Optional, List, Tuple + +from app.core.game_engine import game_engine +from app.core.state_manager import state_manager +from app.models.game_models import DefensiveDecision, OffensiveDecision +from app.database.operations import DatabaseOperations +from terminal_client import display +from terminal_client.config import Config + +logger = logging.getLogger(f'{__name__}.commands') + + +class GameCommands: + """Shared command implementations for game operations.""" + + def __init__(self): + self.db_ops = DatabaseOperations() + + async def create_new_game( + self, + league: str = 'sba', + home_team: int = 1, + away_team: int = 2, + set_current: bool = True + ) -> Tuple[UUID, bool]: + """ + Create a new game with lineups and start it. + + Args: + league: 'sba' or 'pd' + home_team: Home team ID + away_team: Away team ID + set_current: Whether to set as current game + + Returns: + Tuple of (game_id, success) + """ + gid = uuid4() + + try: + # Step 1: Create game in memory and database + display.print_info("Step 1: Creating game...") + + state = await state_manager.create_game( + game_id=gid, + league_id=league, + home_team_id=home_team, + away_team_id=away_team + ) + + await self.db_ops.create_game( + game_id=gid, + league_id=league, + home_team_id=home_team, + away_team_id=away_team, + game_mode="friendly", + visibility="public" + ) + + display.print_success(f"Game created: {gid}") + + if set_current: + Config.set_current_game(gid) + display.print_info(f"Current game set to: {gid}") + + # Step 2: Setup lineups + display.print_info("Step 2: Creating test lineups...") + await self._create_test_lineups(gid, league, home_team, away_team) + + # Step 3: Start the game + display.print_info("Step 3: Starting game...") + state = await game_engine.start_game(gid) + + display.print_success(f"Game started - Inning {state.inning} {state.half}") + display.display_game_state(state) + + return gid, True + + except Exception as e: + display.print_error(f"Failed to create new game: {e}") + logger.exception("New game error") + return gid, False + + async def _create_test_lineups( + self, + game_id: UUID, + league: str, + home_team: int, + away_team: int + ) -> None: + """Create test lineups for both teams.""" + positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] + + for team_id in [home_team, away_team]: + team_name = "Home" if team_id == home_team else "Away" + + for i, position in enumerate(positions, start=1): + if league == 'sba': + player_id = (team_id * 100) + i + await self.db_ops.add_sba_lineup_player( + game_id=game_id, + team_id=team_id, + player_id=player_id, + position=position, + batting_order=i, + is_starter=True + ) + else: + card_id = (team_id * 100) + i + await self.db_ops.add_pd_lineup_card( + game_id=game_id, + team_id=team_id, + card_id=card_id, + position=position, + batting_order=i, + is_starter=True + ) + + display.console.print(f" ✓ {team_name} team lineup created (9 players)") + + async def submit_defensive_decision( + self, + game_id: UUID, + alignment: str = 'normal', + infield: str = 'normal', + outfield: str = 'normal', + hold_runners: Optional[List[int]] = None + ) -> bool: + """ + Submit defensive decision. + + Returns: + True if successful, False otherwise + """ + try: + decision = DefensiveDecision( + alignment=alignment, + infield_depth=infield, + outfield_depth=outfield, + hold_runners=hold_runners or [] + ) + + state = await game_engine.submit_defensive_decision(game_id, decision) + display.print_success("Defensive decision submitted") + display.display_decision("defensive", decision) + display.display_game_state(state) + return True + + except Exception as e: + display.print_error(f"Failed to submit defensive decision: {e}") + logger.exception("Defensive decision error") + return False + + async def submit_offensive_decision( + self, + game_id: UUID, + approach: str = 'normal', + steal_attempts: Optional[List[int]] = None, + hit_and_run: bool = False, + bunt_attempt: bool = False + ) -> bool: + """ + Submit offensive decision. + + Returns: + True if successful, False otherwise + """ + try: + decision = OffensiveDecision( + approach=approach, + steal_attempts=steal_attempts or [], + hit_and_run=hit_and_run, + bunt_attempt=bunt_attempt + ) + + state = await game_engine.submit_offensive_decision(game_id, decision) + display.print_success("Offensive decision submitted") + display.display_decision("offensive", decision) + display.display_game_state(state) + return True + + except Exception as e: + display.print_error(f"Failed to submit offensive decision: {e}") + logger.exception("Offensive decision error") + return False + + async def resolve_play(self, game_id: UUID) -> bool: + """ + Resolve the current play. + + Returns: + True if successful, False otherwise + """ + try: + result = await game_engine.resolve_play(game_id) + state = await game_engine.get_game_state(game_id) + + if state: + display.display_play_result(result, state) + display.display_game_state(state) + return True + else: + display.print_error(f"Game {game_id} not found after resolution") + return False + + except Exception as e: + display.print_error(f"Failed to resolve play: {e}") + logger.exception("Resolve play error") + return False + + async def quick_play_rounds( + self, + game_id: UUID, + count: int = 1 + ) -> int: + """ + Execute multiple plays with default decisions. + + Returns: + Number of plays successfully executed + """ + plays_completed = 0 + + for i in range(count): + try: + state = await game_engine.get_game_state(game_id) + if not state or state.status != "active": + display.print_warning(f"Game ended at play {i + 1}") + break + + display.print_info(f"Play {i + 1}/{count}") + + # Submit default decisions + await game_engine.submit_defensive_decision(game_id, DefensiveDecision()) + await game_engine.submit_offensive_decision(game_id, OffensiveDecision()) + + # Resolve + result = await game_engine.resolve_play(game_id) + state = await game_engine.get_game_state(game_id) + + if state: + display.print_success(f"{result.description}") + display.console.print( + f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, " + f"Inning {state.inning} {state.half}, {state.outs} outs[/cyan]" + ) + plays_completed += 1 + + await asyncio.sleep(0.3) # Brief pause for readability + + except Exception as e: + display.print_error(f"Error on play {i + 1}: {e}") + logger.exception("Quick play error") + break + + # Show final state + state = await game_engine.get_game_state(game_id) + if state: + display.print_info("Final state:") + display.display_game_state(state) + + return plays_completed + + async def show_game_status(self, game_id: UUID) -> bool: + """ + Display current game state. + + Returns: + True if successful, False otherwise + """ + try: + state = await game_engine.get_game_state(game_id) + if state: + display.display_game_state(state) + return True + else: + display.print_error(f"Game {game_id} not found") + return False + except Exception as e: + display.print_error(f"Failed to get game status: {e}") + return False + + async def show_box_score(self, game_id: UUID) -> bool: + """ + Display box score. + + Returns: + True if successful, False otherwise + """ + try: + state = await game_engine.get_game_state(game_id) + if state: + display.display_box_score(state) + return True + else: + display.print_error(f"Game {game_id} not found") + return False + except Exception as e: + display.print_error(f"Failed to get box score: {e}") + return False + + +# Singleton instance +game_commands = GameCommands() diff --git a/backend/terminal_client/completions.py b/backend/terminal_client/completions.py new file mode 100644 index 0000000..1535702 --- /dev/null +++ b/backend/terminal_client/completions.py @@ -0,0 +1,308 @@ +""" +Tab completion support for terminal client REPL. + +Provides intelligent completion for commands, options, and values +using Python's cmd module completion hooks. + +Author: Claude +Date: 2025-10-27 +""" +import logging +from typing import List, Optional + +logger = logging.getLogger(f'{__name__}.completions') + + +class CompletionHelper: + """Helper class for generating tab completions.""" + + # Valid values for common options + VALID_LEAGUES = ['sba', 'pd'] + VALID_ALIGNMENTS = ['normal', 'shifted_left', 'shifted_right', 'extreme_shift'] + VALID_INFIELD_DEPTHS = ['in', 'normal', 'back', 'double_play'] + VALID_OUTFIELD_DEPTHS = ['in', 'normal', 'back'] + VALID_APPROACHES = ['normal', 'contact', 'power', 'patient'] + + # Valid bases for stealing/holding + VALID_BASES = ['1', '2', '3'] + + @staticmethod + def filter_completions(text: str, options: List[str]) -> List[str]: + """ + Filter options that start with the given text. + + Args: + text: Partial text to match + options: List of possible completions + + Returns: + List of matching options + """ + if not text: + return options + return [opt for opt in options if opt.startswith(text)] + + @staticmethod + def complete_option(text: str, line: str, available_options: List[str]) -> List[str]: + """ + Complete option names (--option). + + Args: + text: Current text being completed + line: Full command line + available_options: List of valid option names + + Returns: + List of matching options with -- prefix + """ + if text.startswith('--'): + # Completing option name + prefix = text[2:] + matches = [opt for opt in available_options if opt.startswith(prefix)] + return [f'--{match}' for match in matches] + elif not text: + # Show all options + return [f'--{opt}' for opt in available_options] + return [] + + @staticmethod + def get_current_option(line: str, endidx: int) -> Optional[str]: + """ + Determine which option we're currently completing the value for. + + Args: + line: Full command line + endidx: Current cursor position + + Returns: + Option name (without --) or None + """ + # Split line up to cursor position + before_cursor = line[:endidx] + tokens = before_cursor.split() + + # Look for the last --option before cursor + for i in range(len(tokens) - 1, -1, -1): + if tokens[i].startswith('--'): + return tokens[i][2:].replace('-', '_') + + return None + + +class GameREPLCompletions: + """Mixin class providing tab completion methods for GameREPL.""" + + def __init__(self): + """Initialize completion helper.""" + self.completion_helper = CompletionHelper() + + # ==================== Command Completions ==================== + + def complete_new_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete new_game command. + + Available options: + --league sba|pd + --home-team N + --away-team N + """ + available_options = ['league', 'home-team', 'away-team'] + + # Check if we're completing an option value first + current_option = self.completion_helper.get_current_option(line, endidx) + + if current_option == 'league': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_LEAGUES + ) + + # Check if we're completing an option name + if text.startswith('--'): + return self.completion_helper.complete_option(text, line, available_options) + elif not text: + return self.completion_helper.complete_option(text, line, available_options) + + return [] + + def complete_defensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete defensive command. + + Available options: + --alignment normal|shifted_left|shifted_right|extreme_shift + --infield in|normal|back|double_play + --outfield in|normal|back + --hold 1,2,3 + """ + available_options = ['alignment', 'infield', 'outfield', 'hold'] + + # Check if we're completing an option value first + current_option = self.completion_helper.get_current_option(line, endidx) + + if current_option == 'alignment': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_ALIGNMENTS + ) + elif current_option == 'infield': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_INFIELD_DEPTHS + ) + elif current_option == 'outfield': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_OUTFIELD_DEPTHS + ) + elif current_option == 'hold': + # For comma-separated values, complete the last item + if ',' in text: + prefix = text.rsplit(',', 1)[0] + ',' + last_item = text.rsplit(',', 1)[1] + matches = self.completion_helper.filter_completions( + last_item, self.completion_helper.VALID_BASES + ) + return [f'{prefix}{match}' for match in matches] + else: + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_BASES + ) + + # Check if we're completing an option name + if text.startswith('--'): + return self.completion_helper.complete_option(text, line, available_options) + elif not text: + return self.completion_helper.complete_option(text, line, available_options) + + return [] + + def complete_offensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete offensive command. + + Available options: + --approach normal|contact|power|patient + --steal 2,3 + --hit-run (flag) + --bunt (flag) + """ + available_options = ['approach', 'steal', 'hit-run', 'bunt'] + + # Check if we're completing an option value first + current_option = self.completion_helper.get_current_option(line, endidx) + + if current_option == 'approach': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_APPROACHES + ) + elif current_option == 'steal': + # Only bases 2 and 3 can be stolen + valid_steal_bases = ['2', '3'] + if ',' in text: + prefix = text.rsplit(',', 1)[0] + ',' + last_item = text.rsplit(',', 1)[1] + matches = self.completion_helper.filter_completions( + last_item, valid_steal_bases + ) + return [f'{prefix}{match}' for match in matches] + else: + return self.completion_helper.filter_completions( + text, valid_steal_bases + ) + + # Check if we're completing an option name + if text.startswith('--'): + return self.completion_helper.complete_option(text, line, available_options) + elif not text: + return self.completion_helper.complete_option(text, line, available_options) + + return [] + + def complete_use_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete use_game command with available game IDs. + """ + # Import here to avoid circular dependency + from app.core.state_manager import state_manager + + # Get list of active games + game_ids = state_manager.list_games() + game_id_strs = [str(gid) for gid in game_ids] + + return self.completion_helper.filter_completions(text, game_id_strs) + + def complete_quick_play(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete quick_play command with common counts. + """ + # Suggest common play counts + common_counts = ['1', '5', '10', '27', '50', '100'] + + # Check if completing a positional number + if text and text.isdigit(): + return self.completion_helper.filter_completions(text, common_counts) + elif not text and line.strip() == 'quick_play': + return common_counts + + return [] + + # ==================== Helper Methods ==================== + + def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Default completion handler for commands without specific completers. + + Provides basic option completion if line contains options. + """ + # If text starts with --, try to show common options + if text.startswith('--'): + common_options = ['help', 'verbose', 'debug'] + return self.completion_helper.complete_option(text, line, common_options) + + return [] + + def completenames(self, text: str, *ignored) -> List[str]: + """ + Override completenames to provide better command completion. + + This is called when completing the first word (command name). + """ + # Get all do_* methods + dotext = 'do_' + text + commands = [name[3:] for name in self.get_names() if name.startswith(dotext)] + + # Add aliases + if 'exit'.startswith(text): + commands.append('exit') + if 'quit'.startswith(text): + commands.append('quit') + + return commands + + +# Example completion mappings for reference +COMPLETION_EXAMPLES = """ +# Example Tab Completion Usage: + +⚾ > new_game -- +--league --home-team --away-team + +⚾ > new_game --league +sba pd + +⚾ > defensive -- +--alignment --infield --outfield --hold + +⚾ > defensive --alignment +normal shifted_left shifted_right extreme_shift + +⚾ > defensive --hold 1, +1,2 1,3 + +⚾ > offensive --approach +normal contact power patient + +⚾ > use_game +[shows all active game UUIDs] + +⚾ > quick_play +1 5 10 27 50 100 +""" diff --git a/backend/terminal_client/help_text.py b/backend/terminal_client/help_text.py new file mode 100644 index 0000000..e7b431b --- /dev/null +++ b/backend/terminal_client/help_text.py @@ -0,0 +1,346 @@ +""" +Help text and documentation for terminal client commands. + +Provides detailed, formatted help text for all REPL commands +with usage examples and option descriptions. + +Author: Claude +Date: 2025-10-27 +""" +from typing import Dict +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.markdown import Markdown +from rich import box + +console = Console() + + +class HelpFormatter: + """Format and display help text for commands.""" + + @staticmethod + def show_command_help(command_name: str, help_data: Dict) -> None: + """ + Display detailed help for a specific command. + + Args: + command_name: Name of the command + help_data: Dictionary with help information + { + 'summary': 'Brief description', + 'usage': 'command [OPTIONS]', + 'options': [ + {'name': '--option', 'type': 'TYPE', 'desc': 'Description'} + ], + 'examples': ['example 1', 'example 2'] + } + """ + # Build help text + help_text = [] + + # Summary + help_text.append(f"**{command_name}** - {help_data.get('summary', 'No description')}") + help_text.append("") + + # Usage + if 'usage' in help_data: + help_text.append("**USAGE:**") + help_text.append(f" {help_data['usage']}") + help_text.append("") + + # Display in panel + if help_text: + md = Markdown("\n".join(help_text[:3])) # Just summary and usage + panel = Panel( + md, + title=f"[bold cyan]Help: {command_name}[/bold cyan]", + border_style="cyan", + box=box.ROUNDED + ) + console.print(panel) + console.print() + + # Options + if 'options' in help_data and help_data['options']: + console.print("[bold cyan]OPTIONS:[/bold cyan]") + + # Create options table + table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) + table.add_column("Option", style="cyan", no_wrap=True) + table.add_column("Type", style="yellow") + table.add_column("Description", style="white") + + for opt in help_data['options']: + table.add_row( + opt['name'], + opt.get('type', ''), + opt.get('desc', '') + ) + + console.print(table) + console.print() + + # Examples + if 'examples' in help_data and help_data['examples']: + console.print("[bold cyan]EXAMPLES:[/bold cyan]") + for example in help_data['examples']: + console.print(f" {example}") + console.print() + + # Notes + if 'notes' in help_data: + console.print(f"[dim]{help_data['notes']}[/dim]") + console.print() + + @staticmethod + def show_command_list() -> None: + """Display list of all available commands.""" + console.print("\n[bold cyan]Available Commands:[/bold cyan]\n") + + # Game Management + console.print("[bold yellow]Game Management:[/bold yellow]") + console.print(" new_game Create a new game with test lineups and start it") + console.print(" list_games List all games in state manager") + console.print(" use_game Switch to a different game") + console.print(" status Display current game state") + console.print(" box_score Display box score") + console.print() + + # Gameplay + console.print("[bold yellow]Gameplay:[/bold yellow]") + console.print(" defensive Submit defensive decision") + console.print(" offensive Submit offensive decision") + console.print(" resolve Resolve the current play") + console.print(" quick_play Auto-play multiple plays") + console.print() + + # Utilities + console.print("[bold yellow]Utilities:[/bold yellow]") + console.print(" config Show configuration") + console.print(" clear Clear the screen") + console.print(" help Show help for commands") + console.print(" quit/exit Exit the REPL") + console.print() + + console.print("[dim]Type 'help ' for detailed information.[/dim]") + console.print("[dim]Use TAB for auto-completion of commands and options.[/dim]\n") + + +# Detailed help data for each command +HELP_DATA = { + 'new_game': { + 'summary': 'Create a new game with test lineups and start it immediately', + 'usage': 'new_game [--league LEAGUE] [--home-team ID] [--away-team ID]', + 'options': [ + { + 'name': '--league', + 'type': 'sba|pd', + 'desc': 'League type (default: sba)' + }, + { + 'name': '--home-team', + 'type': 'INT', + 'desc': 'Home team ID (default: 1)' + }, + { + 'name': '--away-team', + 'type': 'INT', + 'desc': 'Away team ID (default: 2)' + } + ], + 'examples': [ + 'new_game', + 'new_game --league pd', + 'new_game --league sba --home-team 5 --away-team 3' + ] + }, + + 'defensive': { + 'summary': 'Submit defensive decision for the current play', + 'usage': 'defensive [--alignment TYPE] [--infield DEPTH] [--outfield DEPTH] [--hold BASES]', + 'options': [ + { + 'name': '--alignment', + 'type': 'STRING', + 'desc': 'Defensive alignment: normal, shifted_left, shifted_right, extreme_shift (default: normal)' + }, + { + 'name': '--infield', + 'type': 'STRING', + 'desc': 'Infield depth: in, normal, back, double_play (default: normal)' + }, + { + 'name': '--outfield', + 'type': 'STRING', + 'desc': 'Outfield depth: in, normal, back (default: normal)' + }, + { + 'name': '--hold', + 'type': 'LIST', + 'desc': 'Comma-separated bases to hold runners: 1,2,3 (default: none)' + } + ], + 'examples': [ + 'defensive', + 'defensive --alignment shifted_left', + 'defensive --infield double_play --hold 1,3', + 'defensive --alignment extreme_shift --infield back --outfield back' + ] + }, + + 'offensive': { + 'summary': 'Submit offensive decision for the current play', + 'usage': 'offensive [--approach TYPE] [--steal BASES] [--hit-run] [--bunt]', + 'options': [ + { + 'name': '--approach', + 'type': 'STRING', + 'desc': 'Batting approach: normal, contact, power, patient (default: normal)' + }, + { + 'name': '--steal', + 'type': 'LIST', + 'desc': 'Comma-separated bases to steal: 2,3 (default: none)' + }, + { + 'name': '--hit-run', + 'type': 'FLAG', + 'desc': 'Execute hit-and-run play (default: false)' + }, + { + 'name': '--bunt', + 'type': 'FLAG', + 'desc': 'Attempt bunt (default: false)' + } + ], + 'examples': [ + 'offensive', + 'offensive --approach power', + 'offensive --steal 2', + 'offensive --steal 2,3 --hit-run', + 'offensive --approach contact --bunt' + ] + }, + + 'resolve': { + 'summary': 'Resolve the current play using submitted decisions', + 'usage': 'resolve', + 'options': [], + 'examples': [ + 'resolve' + ], + 'notes': 'Both defensive and offensive decisions must be submitted before resolving.' + }, + + 'quick_play': { + 'summary': 'Auto-play multiple plays with default decisions', + 'usage': 'quick_play [COUNT]', + 'options': [ + { + 'name': 'COUNT', + 'type': 'INT', + 'desc': 'Number of plays to execute (default: 1). Positional argument.' + } + ], + 'examples': [ + 'quick_play', + 'quick_play 10', + 'quick_play 27 # Play roughly 3 innings', + 'quick_play 100 # Play full game quickly' + ] + }, + + 'status': { + 'summary': 'Display current game state', + 'usage': 'status', + 'options': [], + 'examples': [ + 'status' + ] + }, + + 'box_score': { + 'summary': 'Display box score for the current game', + 'usage': 'box_score', + 'options': [], + 'examples': [ + 'box_score' + ] + }, + + 'list_games': { + 'summary': 'List all games currently loaded in state manager', + 'usage': 'list_games', + 'options': [], + 'examples': [ + 'list_games' + ] + }, + + 'use_game': { + 'summary': 'Switch to a different game', + 'usage': 'use_game ', + 'options': [ + { + 'name': 'GAME_ID', + 'type': 'UUID', + 'desc': 'UUID of the game to switch to. Positional argument.' + } + ], + 'examples': [ + 'use_game a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'use_game # Use tab completion to see available games' + ] + }, + + 'config': { + 'summary': 'Show terminal client configuration', + 'usage': 'config', + 'options': [], + 'examples': [ + 'config' + ] + }, + + 'clear': { + 'summary': 'Clear the screen', + 'usage': 'clear', + 'options': [], + 'examples': [ + 'clear' + ] + } +} + + +def get_help_text(command: str) -> Dict: + """ + Get help data for a command. + + Args: + command: Command name + + Returns: + Help data dictionary or empty dict if not found + """ + return HELP_DATA.get(command, {}) + + +def show_help(command: str = None) -> None: + """ + Show help for a command or list all commands. + + Args: + command: Command name or None for command list + """ + if command: + help_data = get_help_text(command) + if help_data: + HelpFormatter.show_command_help(command, help_data) + else: + console.print(f"[yellow]No help available for '{command}'[/yellow]") + console.print("[dim]Type 'help' to see all available commands.[/dim]") + else: + HelpFormatter.show_command_list() diff --git a/backend/terminal_client/main.py b/backend/terminal_client/main.py index 05e2432..c5e4280 100644 --- a/backend/terminal_client/main.py +++ b/backend/terminal_client/main.py @@ -25,6 +25,7 @@ from app.models.game_models import DefensiveDecision, OffensiveDecision from app.database.operations import DatabaseOperations from terminal_client import display from terminal_client.config import Config +from terminal_client.commands import game_commands # Setup logging logging.basicConfig( @@ -118,21 +119,16 @@ def defensive(game_id, alignment, infield, outfield, hold): display.print_error("Invalid hold format. Use comma-separated numbers (e.g., '1,3')") raise click.Abort() - # Create decision - decision = DefensiveDecision( + # Use shared command + success = await game_commands.submit_defensive_decision( + game_id=gid, alignment=alignment, - infield_depth=infield, - outfield_depth=outfield, + infield=infield, + outfield=outfield, hold_runners=hold_list ) - try: - state = await game_engine.submit_defensive_decision(gid, decision) - display.print_success("Defensive decision submitted") - display.display_decision("defensive", decision) - display.display_game_state(state) - except Exception as e: - display.print_error(f"Failed to submit defensive decision: {e}") + if not success: raise click.Abort() asyncio.run(_defensive()) @@ -163,21 +159,16 @@ def offensive(game_id, approach, steal, hit_run, bunt): display.print_error("Invalid steal format. Use comma-separated numbers (e.g., '2,3')") raise click.Abort() - # Create decision - decision = OffensiveDecision( + # Use shared command + success = await game_commands.submit_offensive_decision( + game_id=gid, approach=approach, steal_attempts=steal_list, hit_and_run=hit_run, bunt_attempt=bunt ) - try: - state = await game_engine.submit_offensive_decision(gid, decision) - display.print_success("Offensive decision submitted") - display.display_decision("offensive", decision) - display.display_game_state(state) - except Exception as e: - display.print_error(f"Failed to submit offensive decision: {e}") + if not success: raise click.Abort() asyncio.run(_offensive()) @@ -190,20 +181,10 @@ def resolve(game_id): async def _resolve(): gid = UUID(game_id) if game_id else get_current_game() - try: - result = await game_engine.resolve_play(gid) - state = await game_engine.get_game_state(gid) + # Use shared command + success = await game_commands.resolve_play(gid) - if not state: - display.print_error(f"Game {gid} not found after resolution") - raise click.Abort() - - display.display_play_result(result, state) - display.display_game_state(state) - - except Exception as e: - display.print_error(f"Failed to resolve play: {e}") - logger.exception("Resolve error") + if not success: raise click.Abort() asyncio.run(_resolve()) @@ -216,12 +197,11 @@ def status(game_id): async def _status(): gid = UUID(game_id) if game_id else get_current_game() - state = await game_engine.get_game_state(gid) - if not state: - display.print_error(f"Game {gid} not found") - raise click.Abort() + # Use shared command + success = await game_commands.show_game_status(gid) - display.display_game_state(state) + if not success: + raise click.Abort() asyncio.run(_status()) @@ -233,12 +213,11 @@ def box_score(game_id): async def _box_score(): gid = UUID(game_id) if game_id else get_current_game() - state = await game_engine.get_game_state(gid) - if not state: - display.print_error(f"Game {gid} not found") - raise click.Abort() + # Use shared command + success = await game_commands.show_box_score(gid) - display.display_box_score(state) + if not success: + raise click.Abort() asyncio.run(_box_score()) @@ -281,45 +260,8 @@ def quick_play(count, game_id): async def _quick_play(): gid = UUID(game_id) if game_id else get_current_game() - for i in range(count): - try: - # Get current state - state = await game_engine.get_game_state(gid) - if not state: - display.print_error(f"Game {gid} not found") - break - - if state.status != "active": - display.print_warning(f"Game is {state.status}, cannot continue") - break - - display.print_info(f"Play {i + 1}/{count}") - - # Submit default decisions - await game_engine.submit_defensive_decision(gid, DefensiveDecision()) - await game_engine.submit_offensive_decision(gid, OffensiveDecision()) - - # Resolve - result = await game_engine.resolve_play(gid) - state = await game_engine.get_game_state(gid) - - if state: - display.print_success(f"Play resolved: {result.description}") - display.console.print(f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, Inning {state.inning} {state.half}, {state.outs} outs[/cyan]") - - # Brief pause for readability - await asyncio.sleep(0.5) - - except Exception as e: - display.print_error(f"Error on play {i + 1}: {e}") - logger.exception("Quick play error") - break - - # Show final state - state = await game_engine.get_game_state(gid) - if state: - display.print_info("Final state:") - display.display_game_state(state) + # Use shared command + await game_commands.quick_play_rounds(gid, count) asyncio.run(_quick_play()) @@ -340,77 +282,13 @@ def new_game(league, home_team, away_team): Perfect for rapid testing! """ async def _new_game(): - db_ops = DatabaseOperations() - - # Generate game ID - gid = uuid4() - - try: - # Step 1: Create game (both in memory and database) - display.print_info("Step 1: Creating game...") - - # Create in memory (state manager) - state = await state_manager.create_game( - game_id=gid, - league_id=league, - home_team_id=home_team, - away_team_id=away_team - ) - - # Persist to database - await db_ops.create_game( - game_id=gid, - league_id=league, - home_team_id=home_team, - away_team_id=away_team, - game_mode="friendly", - visibility="public" - ) - - display.print_success(f"Game created: {gid}") - set_current_game(gid) - - # Step 2: Setup lineups - display.print_info("Step 2: Creating test lineups...") - positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] - - for team_id in [home_team, away_team]: - team_name = "Home" if team_id == home_team else "Away" - - for i, position in enumerate(positions, start=1): - if league == 'sba': - player_id = (team_id * 100) + i - await db_ops.add_sba_lineup_player( - game_id=gid, - team_id=team_id, - player_id=player_id, - position=position, - batting_order=i, - is_starter=True - ) - else: - card_id = (team_id * 100) + i - await db_ops.add_pd_lineup_card( - game_id=gid, - team_id=team_id, - card_id=card_id, - position=position, - batting_order=i, - is_starter=True - ) - - display.console.print(f" ✓ {team_name} team lineup created (9 players)") - - # Step 3: Start the game - display.print_info("Step 3: Starting game...") - state = await game_engine.start_game(gid) - display.print_success(f"Game started - Inning {state.inning} {state.half}") - display.display_game_state(state) - - except Exception as e: - display.print_error(f"Failed to create new game: {e}") - logger.exception("New game error") - raise click.Abort() + # Use shared command + await game_commands.create_new_game( + league=league, + home_team=home_team, + away_team=away_team, + set_current=True + ) asyncio.run(_new_game()) @@ -512,5 +390,24 @@ def config_cmd(clear): display.console.print("\n[yellow]No current game set[/yellow]") +@cli.command('help-cmd') +@click.argument('command', required=False) +def show_cli_help(command): + """ + Show help for terminal client commands. + + Usage: + python -m terminal_client help-cmd # Show all commands + python -m terminal_client help-cmd new-game # Show help for specific command + """ + from terminal_client.help_text import show_help + + # Convert hyphenated command to underscore for lookup + if command: + command = command.replace('-', '_') + + show_help(command) + + if __name__ == "__main__": cli() diff --git a/backend/terminal_client/repl.py b/backend/terminal_client/repl.py index 14a5af6..92bd8d1 100644 --- a/backend/terminal_client/repl.py +++ b/backend/terminal_client/repl.py @@ -19,11 +19,22 @@ from app.models.game_models import DefensiveDecision, OffensiveDecision from app.database.operations import DatabaseOperations from terminal_client import display from terminal_client.config import Config +from terminal_client.commands import game_commands +from terminal_client.completions import GameREPLCompletions +from terminal_client.help_text import show_help, HelpFormatter +from terminal_client.arg_parser import ( + parse_new_game_args, + parse_defensive_args, + parse_offensive_args, + parse_quick_play_args, + parse_use_game_args, + ArgumentParseError +) logger = logging.getLogger(f'{__name__}.repl') -class GameREPL(cmd.Cmd): +class GameREPL(GameREPLCompletions, cmd.Cmd): """Interactive REPL for game engine testing.""" intro = """ @@ -32,25 +43,28 @@ class GameREPL(cmd.Cmd): ║ Interactive Mode ║ ╚══════════════════════════════════════════════════════════════════════════════╝ -Type 'help' or '?' to list commands. -Type 'help ' for command details. -Type 'quit' or 'exit' to leave. +Type 'help' to see all available commands. +Type 'help ' for detailed information about a specific command. +Use TAB for auto-completion of commands and options. Quick start: - new_game Create and start a new game with test lineups + new_game Create and start a new game + status Show current game state defensive Submit defensive decision offensive Submit offensive decision - resolve Resolve the current play - status Show current game state + resolve Resolve the play quick_play 10 Auto-play 10 plays -Note: Use underscores in command names (e.g., 'new_game' not 'new-game') +Press Ctrl+D or type 'quit' to exit. """ prompt = '⚾ > ' def __init__(self): - super().__init__() + # Initialize both parent classes + cmd.Cmd.__init__(self) + GameREPLCompletions.__init__(self) + self.current_game_id: Optional[UUID] = None self.db_ops = DatabaseOperations() @@ -115,96 +129,31 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') """ Create a new game with lineups and start it. - Usage: new-game [--league sba|pd] [--home-team N] [--away-team N] + Usage: new_game [--league sba|pd] [--home-team N] [--away-team N] Examples: - new-game - new-game --league pd - new-game --home-team 5 --away-team 3 + new_game + new_game --league pd + new_game --home-team 5 --away-team 3 """ async def _new_game(): - # Parse arguments - args = arg.split() - league = 'sba' - home_team = 1 - away_team = 2 - - i = 0 - while i < len(args): - if args[i] == '--league' and i + 1 < len(args): - league = args[i + 1] - i += 2 - elif args[i] == '--home-team' and i + 1 < len(args): - home_team = int(args[i + 1]) - i += 2 - elif args[i] == '--away-team' and i + 1 < len(args): - away_team = int(args[i + 1]) - i += 2 - else: - i += 1 - - gid = uuid4() - try: - # Step 1: Create game - display.print_info("Creating game...") - state = await state_manager.create_game( - game_id=gid, - league_id=league, - home_team_id=home_team, - away_team_id=away_team + # Parse arguments with robust parser + args = parse_new_game_args(arg) + + # Use shared command + gid, success = await game_commands.create_new_game( + league=args['league'], + home_team=args['home_team'], + away_team=args['away_team'], + set_current=True ) - await self.db_ops.create_game( - game_id=gid, - league_id=league, - home_team_id=home_team, - away_team_id=away_team, - game_mode="friendly", - visibility="public" - ) - - display.print_success(f"Game created: {gid}") - - # Step 2: Setup lineups - display.print_info("Creating test lineups...") - positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] - - for team_id in [home_team, away_team]: - for i, position in enumerate(positions, start=1): - if league == 'sba': - player_id = (team_id * 100) + i - await self.db_ops.add_sba_lineup_player( - game_id=gid, - team_id=team_id, - player_id=player_id, - position=position, - batting_order=i, - is_starter=True - ) - else: - card_id = (team_id * 100) + i - await self.db_ops.add_pd_lineup_card( - game_id=gid, - team_id=team_id, - card_id=card_id, - position=position, - batting_order=i, - is_starter=True - ) - - display.print_success("Lineups created") - - # Step 3: Start the game - display.print_info("Starting game...") - state = await game_engine.start_game(gid) - - self.current_game_id = gid - Config.set_current_game(gid) - - display.print_success(f"Game started - Inning {state.inning} {state.half}") - display.display_game_state(state) + if success: + self.current_game_id = gid + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") except Exception as e: display.print_error(f"Failed to create game: {e}") logger.exception("New game error") @@ -233,41 +182,20 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') gid = self._ensure_game() await self._ensure_game_loaded(gid) - # Parse arguments - args = arg.split() - alignment = 'normal' - infield = 'normal' - outfield = 'normal' - hold_list = [] + # Parse arguments with robust parser + args = parse_defensive_args(arg) - i = 0 - while i < len(args): - if args[i] == '--alignment' and i + 1 < len(args): - alignment = args[i + 1] - i += 2 - elif args[i] == '--infield' and i + 1 < len(args): - infield = args[i + 1] - i += 2 - elif args[i] == '--outfield' and i + 1 < len(args): - outfield = args[i + 1] - i += 2 - elif args[i] == '--hold' and i + 1 < len(args): - hold_list = [int(b.strip()) for b in args[i + 1].split(',')] - i += 2 - else: - i += 1 - - decision = DefensiveDecision( - alignment=alignment, - infield_depth=infield, - outfield_depth=outfield, - hold_runners=hold_list + # Submit decision + await game_commands.submit_defensive_decision( + game_id=gid, + alignment=args['alignment'], + infield=args['infield'], + outfield=args['outfield'], + hold_runners=args['hold'] ) - state = await game_engine.submit_defensive_decision(gid, decision) - display.print_success("Defensive decision submitted") - display.display_decision("defensive", decision) - + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") except ValueError: pass # Already printed error except Exception as e: @@ -285,8 +213,8 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') Options: --approach normal, contact, power, patient --steal Comma-separated bases (e.g., 2,3) - --hit-run Enable hit-and-run - --bunt Attempt bunt + --hit-run Enable hit-and-run (flag) + --bunt Attempt bunt (flag) Examples: offensive @@ -298,41 +226,20 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') gid = self._ensure_game() await self._ensure_game_loaded(gid) - # Parse arguments - args = arg.split() - approach = 'normal' - steal_list = [] - hit_run = False - bunt = False + # Parse arguments with robust parser + args = parse_offensive_args(arg) - i = 0 - while i < len(args): - if args[i] == '--approach' and i + 1 < len(args): - approach = args[i + 1] - i += 2 - elif args[i] == '--steal' and i + 1 < len(args): - steal_list = [int(b.strip()) for b in args[i + 1].split(',')] - i += 2 - elif args[i] == '--hit-run': - hit_run = True - i += 1 - elif args[i] == '--bunt': - bunt = True - i += 1 - else: - i += 1 - - decision = OffensiveDecision( - approach=approach, - steal_attempts=steal_list, - hit_and_run=hit_run, - bunt_attempt=bunt + # Submit decision + await game_commands.submit_offensive_decision( + game_id=gid, + approach=args['approach'], + steal_attempts=args['steal'], + hit_and_run=args['hit_run'], + bunt_attempt=args['bunt'] ) - state = await game_engine.submit_offensive_decision(gid, decision) - display.print_success("Offensive decision submitted") - display.display_decision("offensive", decision) - + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") except ValueError: pass except Exception as e: @@ -354,12 +261,8 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') gid = self._ensure_game() await self._ensure_game_loaded(gid) - result = await game_engine.resolve_play(gid) - state = await game_engine.get_game_state(gid) - - if state: - display.display_play_result(result, state) - display.display_game_state(state) + # Use shared command + await game_commands.resolve_play(gid) except ValueError: pass @@ -380,11 +283,8 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') gid = self._ensure_game() await self._ensure_game_loaded(gid) - state = await game_engine.get_game_state(gid) - if state: - display.display_game_state(state) - else: - display.print_error("Game state not found") + # Use shared command + await game_commands.show_game_status(gid) except ValueError: pass @@ -397,48 +297,31 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') """ Auto-play multiple plays with default decisions. - Usage: quick-play [COUNT] + Usage: quick_play [COUNT] Examples: - quick-play Play 1 play - quick-play 10 Play 10 plays - quick-play 27 Play ~3 innings + quick_play # Play 1 play + quick_play 10 # Play 10 plays + quick_play 27 # Play ~3 innings """ async def _quick_play(): try: gid = self._ensure_game() await self._ensure_game_loaded(gid) - count = int(arg) if arg.strip() else 1 + # Parse arguments with robust parser + args = parse_quick_play_args(arg) - for i in range(count): - state = await game_engine.get_game_state(gid) - if not state or state.status != "active": - display.print_warning(f"Game ended at play {i + 1}") - break + # Execute quick play + plays_completed = await game_commands.quick_play_rounds( + game_id=gid, + count=args['count'] + ) - display.print_info(f"Play {i + 1}/{count}") - - # Submit default decisions - await game_engine.submit_defensive_decision(gid, DefensiveDecision()) - await game_engine.submit_offensive_decision(gid, OffensiveDecision()) - - # Resolve - result = await game_engine.resolve_play(gid) - state = await game_engine.get_game_state(gid) - - if state: - display.print_success(f"{result.description}") - display.console.print(f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, Inning {state.inning} {state.half}, {state.outs} outs[/cyan]") - - await asyncio.sleep(0.3) - - # Final state - state = await game_engine.get_game_state(gid) - if state: - display.print_info("Final state:") - display.display_game_state(state) + display.print_success(f"Completed {plays_completed} plays") + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") except ValueError: pass except Exception as e: @@ -457,10 +340,9 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') try: gid = self._ensure_game() await self._ensure_game_loaded(gid) - state = await game_engine.get_game_state(gid) - if state: - display.display_box_score(state) + # Use shared command + await game_commands.show_box_score(gid) except ValueError: pass @@ -490,21 +372,22 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') """ Switch to a different game. - Usage: use-game + Usage: use_game Example: - use-game a1b2c3d4-e5f6-7890-abcd-ef1234567890 + use_game a1b2c3d4-e5f6-7890-abcd-ef1234567890 """ - if not arg.strip(): - display.print_error("Usage: use-game ") - return - try: - gid = UUID(arg.strip()) + # Parse arguments with robust parser + args = parse_use_game_args(arg) + + gid = UUID(args['game_id']) self.current_game_id = gid Config.set_current_game(gid) display.print_success(f"Switched to game: {gid}") + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") except ValueError: display.print_error(f"Invalid UUID: {arg}") @@ -522,6 +405,67 @@ Note: Use underscores in command names (e.g., 'new_game' not 'new-game') else: display.console.print("\n[yellow]No current game set[/yellow]") + # ==================== Enhanced Help System ==================== + + def do_help(self, arg): + """ + Show help for commands. + + Usage: + help List all commands + help Show detailed help for a command + """ + if arg: + # Show detailed help for specific command + show_help(arg) + else: + # Show command list + HelpFormatter.show_command_list() + + def help_new_game(self): + """Show detailed help for new_game command.""" + show_help('new_game') + + def help_defensive(self): + """Show detailed help for defensive command.""" + show_help('defensive') + + def help_offensive(self): + """Show detailed help for offensive command.""" + show_help('offensive') + + def help_resolve(self): + """Show detailed help for resolve command.""" + show_help('resolve') + + def help_quick_play(self): + """Show detailed help for quick_play command.""" + show_help('quick_play') + + def help_status(self): + """Show detailed help for status command.""" + show_help('status') + + def help_box_score(self): + """Show detailed help for box_score command.""" + show_help('box_score') + + def help_list_games(self): + """Show detailed help for list_games command.""" + show_help('list_games') + + def help_use_game(self): + """Show detailed help for use_game command.""" + show_help('use_game') + + def help_config(self): + """Show detailed help for config command.""" + show_help('config') + + def help_clear(self): + """Show detailed help for clear command.""" + show_help('clear') + # ==================== REPL Control Commands ==================== def do_clear(self, arg): diff --git a/backend/terminal_client/update_docs/phase_1.md b/backend/terminal_client/update_docs/phase_1.md new file mode 100644 index 0000000..c4c5ca5 --- /dev/null +++ b/backend/terminal_client/update_docs/phase_1.md @@ -0,0 +1,501 @@ + Terminal Client Improvement Plan - Part 1: Extract Shared Command Logic + + Overview + + Reduce code duplication between repl.py and main.py by extracting shared command implementations into a separate + module. + + Files to Create + + 1. Create backend/terminal_client/commands.py + + """ + Shared command implementations for terminal client. + + This module contains the core logic for game commands that can be + used by both the REPL (repl.py) and CLI (main.py) interfaces. + + Author: Claude + Date: 2025-10-27 + """ + import logging + from uuid import UUID, uuid4 + from typing import Optional, List, Tuple, Dict, Any + + from app.core.game_engine import game_engine + from app.core.state_manager import state_manager + from app.models.game_models import DefensiveDecision, OffensiveDecision + from app.database.operations import DatabaseOperations + from terminal_client import display + from terminal_client.config import Config + + logger = logging.getLogger(f'{__name__}.commands') + + + class GameCommands: + """Shared command implementations for game operations.""" + + def __init__(self): + self.db_ops = DatabaseOperations() + + async def create_new_game( + self, + league: str = 'sba', + home_team: int = 1, + away_team: int = 2, + set_current: bool = True + ) -> Tuple[UUID, bool]: + """ + Create a new game with lineups and start it. + + Args: + league: 'sba' or 'pd' + home_team: Home team ID + away_team: Away team ID + set_current: Whether to set as current game + + Returns: + Tuple of (game_id, success) + """ + gid = uuid4() + + try: + # Step 1: Create game in memory and database + display.print_info("Step 1: Creating game...") + + state = await state_manager.create_game( + game_id=gid, + league_id=league, + home_team_id=home_team, + away_team_id=away_team + ) + + await self.db_ops.create_game( + game_id=gid, + league_id=league, + home_team_id=home_team, + away_team_id=away_team, + game_mode="friendly", + visibility="public" + ) + + display.print_success(f"Game created: {gid}") + + if set_current: + Config.set_current_game(gid) + display.print_info(f"Current game set to: {gid}") + + # Step 2: Setup lineups + display.print_info("Step 2: Creating test lineups...") + await self._create_test_lineups(gid, league, home_team, away_team) + + # Step 3: Start the game + display.print_info("Step 3: Starting game...") + state = await game_engine.start_game(gid) + + display.print_success(f"Game started - Inning {state.inning} {state.half}") + display.display_game_state(state) + + return gid, True + + except Exception as e: + display.print_error(f"Failed to create new game: {e}") + logger.exception("New game error") + return gid, False + + async def _create_test_lineups( + self, + game_id: UUID, + league: str, + home_team: int, + away_team: int + ) -> None: + """Create test lineups for both teams.""" + positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] + + for team_id in [home_team, away_team]: + team_name = "Home" if team_id == home_team else "Away" + + for i, position in enumerate(positions, start=1): + if league == 'sba': + player_id = (team_id * 100) + i + await self.db_ops.add_sba_lineup_player( + game_id=game_id, + team_id=team_id, + player_id=player_id, + position=position, + batting_order=i, + is_starter=True + ) + else: + card_id = (team_id * 100) + i + await self.db_ops.add_pd_lineup_card( + game_id=game_id, + team_id=team_id, + card_id=card_id, + position=position, + batting_order=i, + is_starter=True + ) + + display.console.print(f" ✓ {team_name} team lineup created (9 players)") + + async def submit_defensive_decision( + self, + game_id: UUID, + alignment: str = 'normal', + infield: str = 'normal', + outfield: str = 'normal', + hold_runners: Optional[List[int]] = None + ) -> bool: + """ + Submit defensive decision. + + Returns: + True if successful, False otherwise + """ + try: + decision = DefensiveDecision( + alignment=alignment, + infield_depth=infield, + outfield_depth=outfield, + hold_runners=hold_runners or [] + ) + + state = await game_engine.submit_defensive_decision(game_id, decision) + display.print_success("Defensive decision submitted") + display.display_decision("defensive", decision) + display.display_game_state(state) + return True + + except Exception as e: + display.print_error(f"Failed to submit defensive decision: {e}") + logger.exception("Defensive decision error") + return False + + async def submit_offensive_decision( + self, + game_id: UUID, + approach: str = 'normal', + steal_attempts: Optional[List[int]] = None, + hit_and_run: bool = False, + bunt_attempt: bool = False + ) -> bool: + """ + Submit offensive decision. + + Returns: + True if successful, False otherwise + """ + try: + decision = OffensiveDecision( + approach=approach, + steal_attempts=steal_attempts or [], + hit_and_run=hit_and_run, + bunt_attempt=bunt_attempt + ) + + state = await game_engine.submit_offensive_decision(game_id, decision) + display.print_success("Offensive decision submitted") + display.display_decision("offensive", decision) + display.display_game_state(state) + return True + + except Exception as e: + display.print_error(f"Failed to submit offensive decision: {e}") + logger.exception("Offensive decision error") + return False + + async def resolve_play(self, game_id: UUID) -> bool: + """ + Resolve the current play. + + Returns: + True if successful, False otherwise + """ + try: + result = await game_engine.resolve_play(game_id) + state = await game_engine.get_game_state(game_id) + + if state: + display.display_play_result(result, state) + display.display_game_state(state) + return True + else: + display.print_error(f"Game {game_id} not found after resolution") + return False + + except Exception as e: + display.print_error(f"Failed to resolve play: {e}") + logger.exception("Resolve play error") + return False + + async def quick_play_rounds( + self, + game_id: UUID, + count: int = 1 + ) -> int: + """ + Execute multiple plays with default decisions. + + Returns: + Number of plays successfully executed + """ + plays_completed = 0 + + for i in range(count): + try: + state = await game_engine.get_game_state(game_id) + if not state or state.status != "active": + display.print_warning(f"Game ended at play {i + 1}") + break + + display.print_info(f"Play {i + 1}/{count}") + + # Submit default decisions + await game_engine.submit_defensive_decision(game_id, DefensiveDecision()) + await game_engine.submit_offensive_decision(game_id, OffensiveDecision()) + + # Resolve + result = await game_engine.resolve_play(game_id) + state = await game_engine.get_game_state(game_id) + + if state: + display.print_success(f"{result.description}") + display.console.print( + f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, " + f"Inning {state.inning} {state.half}, {state.outs} outs[/cyan]" + ) + plays_completed += 1 + + await asyncio.sleep(0.3) # Brief pause for readability + + except Exception as e: + display.print_error(f"Error on play {i + 1}: {e}") + logger.exception("Quick play error") + break + + # Show final state + state = await game_engine.get_game_state(game_id) + if state: + display.print_info("Final state:") + display.display_game_state(state) + + return plays_completed + + async def show_game_status(self, game_id: UUID) -> bool: + """ + Display current game state. + + Returns: + True if successful, False otherwise + """ + try: + state = await game_engine.get_game_state(game_id) + if state: + display.display_game_state(state) + return True + else: + display.print_error(f"Game {game_id} not found") + return False + except Exception as e: + display.print_error(f"Failed to get game status: {e}") + return False + + async def show_box_score(self, game_id: UUID) -> bool: + """ + Display box score. + + Returns: + True if successful, False otherwise + """ + try: + state = await game_engine.get_game_state(game_id) + if state: + display.display_box_score(state) + return True + else: + display.print_error(f"Game {game_id} not found") + return False + except Exception as e: + display.print_error(f"Failed to get box score: {e}") + return False + + + # Singleton instance + game_commands = GameCommands() + + Files to Update + + 2. Update backend/terminal_client/repl.py (simplified version) + +● # At the top, add import + from terminal_client.commands import game_commands + + # Replace the do_new_game method with: + def do_new_game(self, arg): + """ + Create a new game with lineups and start it. + + Usage: new_game [--league sba|pd] [--home-team N] [--away-team N] + """ + async def _new_game(): + # Parse arguments (this will be improved in Part 2) + args = arg.split() + league = 'sba' + home_team = 1 + away_team = 2 + + i = 0 + while i < len(args): + if args[i] == '--league' and i + 1 < len(args): + league = args[i + 1] + i += 2 + elif args[i] == '--home-team' and i + 1 < len(args): + home_team = int(args[i + 1]) + i += 2 + elif args[i] == '--away-team' and i + 1 < len(args): + away_team = int(args[i + 1]) + i += 2 + else: + i += 1 + + # Use shared command + gid, success = await game_commands.create_new_game( + league=league, + home_team=home_team, + away_team=away_team, + set_current=True + ) + + if success: + self.current_game_id = gid + + self._run_async(_new_game()) + + # Similar pattern for other commands - replace implementation with game_commands calls + + 3. Update backend/terminal_client/main.py (simplified version) + + # At the top, add import + from terminal_client.commands import game_commands + + # Replace the new_game command with: + @cli.command('new-game') + @click.option('--league', default='sba', help='League (sba or pd)') + @click.option('--home-team', default=1, help='Home team ID') + @click.option('--away-team', default=2, help='Away team ID') + def new_game(league, home_team, away_team): + """Create a new game with lineups and start it immediately.""" + async def _new_game(): + await game_commands.create_new_game( + league=league, + home_team=home_team, + away_team=away_team, + set_current=True + ) + + asyncio.run(_new_game()) + + # Similar pattern for other commands + + Testing Plan + + 4. Create backend/tests/unit/terminal_client/test_commands.py + + """ + 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): + # 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() + + + @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() + + + # Add more tests... + + Migration Steps + + 1. Create the new commands.py file with the shared logic + 2. Update imports in both repl.py and main.py + 3. Replace duplicated implementations with calls to game_commands + 4. Test thoroughly to ensure no functionality is broken + 5. Remove old duplicated code once confirmed working + + Benefits + + - -500 lines of duplicated code removed + - Single source of truth for command logic + - Easier to maintain and add new features + - Consistent behavior between REPL and CLI modes + - Better testability with isolated command logic + + --- + Continue to Part 2? The next part will cover improving argument parsing with shlex. + + Todos + ☐ Extract shared command logic to reduce duplication + ☐ Implement robust argument parsing with shlex + ☐ Add tab completion for REPL commands + ☐ Create detailed help system for REPL + ☐ Add player name caching and display (future) + ☐ Write tests for terminal client improvements diff --git a/backend/terminal_client/update_docs/phase_2.md b/backend/terminal_client/update_docs/phase_2.md new file mode 100644 index 0000000..77b590d --- /dev/null +++ b/backend/terminal_client/update_docs/phase_2.md @@ -0,0 +1,600 @@ +● Terminal Client Improvement Plan - Part 2: Robust Argument Parsing + + Overview + + Replace manual string splitting with shlex for robust argument parsing that handles quoted strings, edge cases, and + complex arguments properly. + + Files to Create + + 1. Create backend/terminal_client/arg_parser.py + + """ + Argument parsing utilities for terminal client commands. + + Provides robust parsing for both REPL and CLI commands using shlex + to handle quoted strings, spaces, and complex arguments. + + Author: Claude + Date: 2025-10-27 + """ + import shlex + import logging + from typing import Dict, Any, List, Optional, Tuple + + logger = logging.getLogger(f'{__name__}.arg_parser') + + + class ArgumentParseError(Exception): + """Raised when argument parsing fails.""" + pass + + + class CommandArgumentParser: + """Parse command-line style arguments for terminal client.""" + + @staticmethod + def parse_args(arg_string: str, schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse argument string according to schema. + + Args: + arg_string: Raw argument string from command + schema: Dictionary defining expected arguments + { + 'league': {'type': str, 'default': 'sba'}, + 'home-team': {'type': int, 'default': 1}, + 'count': {'type': int, 'default': 1, 'positional': True}, + 'verbose': {'type': bool, 'flag': True} + } + + Returns: + Dictionary of parsed arguments with defaults applied + + Raises: + ArgumentParseError: If parsing fails or validation fails + """ + try: + # Use shlex for robust parsing + tokens = shlex.split(arg_string) if arg_string.strip() else [] + except ValueError as e: + raise ArgumentParseError(f"Invalid argument syntax: {e}") + + # Initialize result with defaults + result = {} + for key, spec in schema.items(): + if 'default' in spec: + result[key] = spec['default'] + + # Track which positional arg we're on + positional_keys = [k for k, v in schema.items() if v.get('positional', False)] + positional_index = 0 + + i = 0 + while i < len(tokens): + token = tokens[i] + + # Handle flags (--option or -o) + if token.startswith('--'): + option_name = token[2:] + + # Convert hyphen to underscore for Python compatibility + option_key = option_name.replace('-', '_') + + if option_key not in schema: + raise ArgumentParseError(f"Unknown option: {token}") + + spec = schema[option_key] + + # Boolean flags don't need a value + if spec.get('flag', False): + result[option_key] = True + i += 1 + continue + + # Option requires a value + if i + 1 >= len(tokens): + raise ArgumentParseError(f"Option {token} requires a value") + + value_str = tokens[i + 1] + + # Type conversion + try: + if spec['type'] == int: + result[option_key] = int(value_str) + elif spec['type'] == float: + result[option_key] = float(value_str) + elif spec['type'] == list: + # Parse comma-separated list + result[option_key] = [item.strip() for item in value_str.split(',')] + elif spec['type'] == 'int_list': + # Parse comma-separated integers + result[option_key] = [int(item.strip()) for item in value_str.split(',')] + else: + result[option_key] = value_str + except ValueError as e: + raise ArgumentParseError( + f"Invalid value for {token}: expected {spec['type'].__name__}, got '{value_str}'" + ) + + i += 2 + + # Handle positional arguments + else: + if positional_index >= len(positional_keys): + raise ArgumentParseError(f"Unexpected positional argument: {token}") + + key = positional_keys[positional_index] + spec = schema[key] + + try: + if spec['type'] == int: + result[key] = int(token) + elif spec['type'] == float: + result[key] = float(token) + else: + result[key] = token + except ValueError as e: + raise ArgumentParseError( + f"Invalid value for {key}: expected {spec['type'].__name__}, got '{token}'" + ) + + positional_index += 1 + i += 1 + + return result + + @staticmethod + def parse_game_id(arg_string: str) -> Optional[str]: + """ + Parse a game ID from argument string. + + Args: + arg_string: Raw argument string + + Returns: + Game ID string or None + """ + try: + tokens = shlex.split(arg_string) if arg_string.strip() else [] + + # Look for --game-id option + for i, token in enumerate(tokens): + if token == '--game-id' and i + 1 < len(tokens): + return tokens[i + 1] + + # If no option, check if there's a positional UUID-like argument + if tokens and len(tokens[0]) == 36: # UUID length + return tokens[0] + + return None + + except ValueError: + return None + + + # Predefined schemas for common commands + NEW_GAME_SCHEMA = { + 'league': {'type': str, 'default': 'sba'}, + 'home_team': {'type': int, 'default': 1}, + 'away_team': {'type': int, 'default': 2} + } + + DEFENSIVE_SCHEMA = { + 'alignment': {'type': str, 'default': 'normal'}, + 'infield': {'type': str, 'default': 'normal'}, + 'outfield': {'type': str, 'default': 'normal'}, + 'hold': {'type': 'int_list', 'default': []} + } + + OFFENSIVE_SCHEMA = { + 'approach': {'type': str, 'default': 'normal'}, + 'steal': {'type': 'int_list', 'default': []}, + 'hit_run': {'type': bool, 'flag': True, 'default': False}, + 'bunt': {'type': bool, 'flag': True, 'default': False} + } + + QUICK_PLAY_SCHEMA = { + 'count': {'type': int, 'default': 1, 'positional': True} + } + + USE_GAME_SCHEMA = { + 'game_id': {'type': str, 'positional': True} + } + + + def parse_new_game_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for new_game command.""" + return CommandArgumentParser.parse_args(arg_string, NEW_GAME_SCHEMA) + + + def parse_defensive_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for defensive command.""" + return CommandArgumentParser.parse_args(arg_string, DEFENSIVE_SCHEMA) + + + def parse_offensive_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for offensive command.""" + return CommandArgumentParser.parse_args(arg_string, OFFENSIVE_SCHEMA) + + + def parse_quick_play_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for quick_play command.""" + return CommandArgumentParser.parse_args(arg_string, QUICK_PLAY_SCHEMA) + + + def parse_use_game_args(arg_string: str) -> Dict[str, Any]: + """Parse arguments for use_game command.""" + return CommandArgumentParser.parse_args(arg_string, USE_GAME_SCHEMA) + + Files to Update + + 2. Update backend/terminal_client/repl.py + + # Add import at top + from terminal_client.arg_parser import ( + parse_new_game_args, + parse_defensive_args, + parse_offensive_args, + parse_quick_play_args, + parse_use_game_args, + ArgumentParseError + ) + + # Replace do_new_game method: + def do_new_game(self, arg): + """ + Create a new game with lineups and start it. + + Usage: new_game [--league sba|pd] [--home-team N] [--away-team N] + + Examples: + new_game + new_game --league pd + new_game --home-team 5 --away-team 3 + """ + async def _new_game(): + try: + # Parse arguments with robust parser + args = parse_new_game_args(arg) + + # Use shared command + gid, success = await game_commands.create_new_game( + league=args['league'], + home_team=args['home_team'], + away_team=args['away_team'], + set_current=True + ) + + if success: + self.current_game_id = gid + + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") + except Exception as e: + display.print_error(f"Failed to create game: {e}") + logger.exception("New game error") + + self._run_async(_new_game()) + + # Replace do_defensive method: + def do_defensive(self, arg): + """ + Submit defensive decision. + + Usage: defensive [--alignment TYPE] [--infield DEPTH] [--outfield DEPTH] [--hold BASES] + + Options: + --alignment normal, shifted_left, shifted_right, extreme_shift + --infield in, normal, back, double_play + --outfield in, normal, back + --hold Comma-separated bases (e.g., 1,3) + + Examples: + defensive + defensive --alignment shifted_left + defensive --infield double_play --hold 1,3 + """ + async def _defensive(): + try: + gid = self._ensure_game() + await self._ensure_game_loaded(gid) + + # Parse arguments + args = parse_defensive_args(arg) + + # Submit decision + await game_commands.submit_defensive_decision( + game_id=gid, + alignment=args['alignment'], + infield=args['infield'], + outfield=args['outfield'], + hold_runners=args['hold'] + ) + + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") + except ValueError: + pass # Already printed error + except Exception as e: + display.print_error(f"Failed: {e}") + logger.exception("Defensive error") + + self._run_async(_defensive()) + + # Replace do_offensive method: + def do_offensive(self, arg): + """ + Submit offensive decision. + + Usage: offensive [--approach TYPE] [--steal BASES] [--hit-run] [--bunt] + + Options: + --approach normal, contact, power, patient + --steal Comma-separated bases (e.g., 2,3) + --hit-run Enable hit-and-run (flag) + --bunt Attempt bunt (flag) + + Examples: + offensive + offensive --approach power + offensive --steal 2 --hit-run + """ + async def _offensive(): + try: + gid = self._ensure_game() + await self._ensure_game_loaded(gid) + + # Parse arguments + args = parse_offensive_args(arg) + + # Submit decision + await game_commands.submit_offensive_decision( + game_id=gid, + approach=args['approach'], + steal_attempts=args['steal'], + hit_and_run=args['hit_run'], + bunt_attempt=args['bunt'] + ) + + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") + except ValueError: + pass + except Exception as e: + display.print_error(f"Failed: {e}") + logger.exception("Offensive error") + + self._run_async(_offensive()) + + # Replace do_quick_play method: + def do_quick_play(self, arg): + """ + Auto-play multiple plays with default decisions. + + Usage: quick_play [COUNT] + + Examples: + quick_play # Play 1 play + quick_play 10 # Play 10 plays + quick_play 27 # Play ~3 innings + """ + async def _quick_play(): + try: + gid = self._ensure_game() + await self._ensure_game_loaded(gid) + + # Parse arguments + args = parse_quick_play_args(arg) + + # Execute quick play + plays_completed = await game_commands.quick_play_rounds( + game_id=gid, + count=args['count'] + ) + + display.print_success(f"Completed {plays_completed} plays") + + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") + except ValueError: + pass + except Exception as e: + display.print_error(f"Failed: {e}") + logger.exception("Quick play error") + + self._run_async(_quick_play()) + + # Replace do_use_game method: + def do_use_game(self, arg): + """ + Switch to a different game. + + Usage: use_game + + Example: + use_game a1b2c3d4-e5f6-7890-abcd-ef1234567890 + """ + try: + # Parse arguments + args = parse_use_game_args(arg) + + gid = UUID(args['game_id']) + self.current_game_id = gid + Config.set_current_game(gid) + display.print_success(f"Switched to game: {gid}") + + except ArgumentParseError as e: + display.print_error(f"Invalid arguments: {e}") + except ValueError: + display.print_error(f"Invalid UUID: {arg}") + + Testing Plan + + 3. Create backend/tests/unit/terminal_client/test_arg_parser.py + + """ + 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 + ) + + + 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 + + + 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_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] + + Benefits of Improved Parsing + + 1. Handles quoted strings: --message "double steal attempt" works correctly + 2. Better error messages: Clear feedback on what went wrong + 3. Type validation: Automatic conversion with helpful errors + 4. Consistent behavior: Same parsing logic for REPL and CLI + 5. Extensible: Easy to add new argument types + 6. Edge case handling: Properly handles empty strings, trailing spaces, etc. + + Example Usage + + # Before (manual parsing, breaks on spaces): + ⚾ > defensive --hold 1,3 # Works + ⚾ > defensive --alignment shifted left # Breaks! (sees 'shifted' and 'left' separately) + + # After (shlex parsing, handles correctly): + ⚾ > defensive --hold 1,3 # Still works + ⚾ > defensive --alignment "shifted left" # Now works with quotes! + ⚾ > quick_play 10 # Positional arg works + ⚾ > offensive --hit-run --bunt # Multiple flags work diff --git a/backend/terminal_client/update_docs/phase_3.md b/backend/terminal_client/update_docs/phase_3.md new file mode 100644 index 0000000..ea594db --- /dev/null +++ b/backend/terminal_client/update_docs/phase_3.md @@ -0,0 +1,557 @@ +● Terminal Client Improvement Plan - Part 3: Tab Completion + + Overview + + Add intelligent tab completion to the REPL for commands, options, and values. This significantly improves the + developer experience by reducing typing and providing discovery of available options. + + Files to Create + + 1. Create backend/terminal_client/completions.py + + """ + Tab completion support for terminal client REPL. + + Provides intelligent completion for commands, options, and values + using Python's cmd module completion hooks. + + Author: Claude + Date: 2025-10-27 + """ + import logging + from typing import List, Optional + + logger = logging.getLogger(f'{__name__}.completions') + + + class CompletionHelper: + """Helper class for generating tab completions.""" + + # Valid values for common options + VALID_LEAGUES = ['sba', 'pd'] + VALID_ALIGNMENTS = ['normal', 'shifted_left', 'shifted_right', 'extreme_shift'] + VALID_INFIELD_DEPTHS = ['in', 'normal', 'back', 'double_play'] + VALID_OUTFIELD_DEPTHS = ['in', 'normal', 'back'] + VALID_APPROACHES = ['normal', 'contact', 'power', 'patient'] + + # Valid bases for stealing/holding + VALID_BASES = ['1', '2', '3'] + + @staticmethod + def filter_completions(text: str, options: List[str]) -> List[str]: + """ + Filter options that start with the given text. + + Args: + text: Partial text to match + options: List of possible completions + + Returns: + List of matching options + """ + if not text: + return options + return [opt for opt in options if opt.startswith(text)] + + @staticmethod + def complete_option(text: str, line: str, available_options: List[str]) -> List[str]: + """ + Complete option names (--option). + + Args: + text: Current text being completed + line: Full command line + available_options: List of valid option names + + Returns: + List of matching options with -- prefix + """ + if text.startswith('--'): + # Completing option name + prefix = text[2:] + matches = [opt for opt in available_options if opt.startswith(prefix)] + return [f'--{match}' for match in matches] + elif not text: + # Show all options + return [f'--{opt}' for opt in available_options] + return [] + + @staticmethod + def get_current_option(line: str, endidx: int) -> Optional[str]: + """ + Determine which option we're currently completing the value for. + + Args: + line: Full command line + endidx: Current cursor position + + Returns: + Option name (without --) or None + """ + # Split line up to cursor position + before_cursor = line[:endidx] + tokens = before_cursor.split() + + # Look for the last --option before cursor + for i in range(len(tokens) - 1, -1, -1): + if tokens[i].startswith('--'): + return tokens[i][2:].replace('-', '_') + + return None + + + class GameREPLCompletions: + """Mixin class providing tab completion methods for GameREPL.""" + + def __init__(self): + """Initialize completion helper.""" + self.completion_helper = CompletionHelper() + + # ==================== Command Completions ==================== + + def complete_new_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete new_game command. + + Available options: + --league sba|pd + --home-team N + --away-team N + """ + available_options = ['league', 'home-team', 'away-team'] + + # Check if we're completing an option name + if text.startswith('--') or (not text and line.endswith(' ')): + return self.completion_helper.complete_option(text, line, available_options) + + # Check if we're completing an option value + current_option = self.completion_helper.get_current_option(line, endidx) + + if current_option == 'league': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_LEAGUES + ) + + return [] + + def complete_defensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete defensive command. + + Available options: + --alignment normal|shifted_left|shifted_right|extreme_shift + --infield in|normal|back|double_play + --outfield in|normal|back + --hold 1,2,3 + """ + available_options = ['alignment', 'infield', 'outfield', 'hold'] + + # Check if we're completing an option name + if text.startswith('--') or (not text and line.endswith(' ')): + return self.completion_helper.complete_option(text, line, available_options) + + # Check if we're completing an option value + current_option = self.completion_helper.get_current_option(line, endidx) + + if current_option == 'alignment': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_ALIGNMENTS + ) + elif current_option == 'infield': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_INFIELD_DEPTHS + ) + elif current_option == 'outfield': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_OUTFIELD_DEPTHS + ) + elif current_option == 'hold': + # For comma-separated values, complete the last item + if ',' in text: + prefix = text.rsplit(',', 1)[0] + ',' + last_item = text.rsplit(',', 1)[1] + matches = self.completion_helper.filter_completions( + last_item, self.completion_helper.VALID_BASES + ) + return [f'{prefix}{match}' for match in matches] + else: + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_BASES + ) + + return [] + + def complete_offensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete offensive command. + + Available options: + --approach normal|contact|power|patient + --steal 2,3 + --hit-run (flag) + --bunt (flag) + """ + available_options = ['approach', 'steal', 'hit-run', 'bunt'] + + # Check if we're completing an option name + if text.startswith('--') or (not text and line.endswith(' ')): + return self.completion_helper.complete_option(text, line, available_options) + + # Check if we're completing an option value + current_option = self.completion_helper.get_current_option(line, endidx) + + if current_option == 'approach': + return self.completion_helper.filter_completions( + text, self.completion_helper.VALID_APPROACHES + ) + elif current_option == 'steal': + # Only bases 2 and 3 can be stolen + valid_steal_bases = ['2', '3'] + if ',' in text: + prefix = text.rsplit(',', 1)[0] + ',' + last_item = text.rsplit(',', 1)[1] + matches = self.completion_helper.filter_completions( + last_item, valid_steal_bases + ) + return [f'{prefix}{match}' for match in matches] + else: + return self.completion_helper.filter_completions( + text, valid_steal_bases + ) + + return [] + + def complete_use_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete use_game command with available game IDs. + """ + # Import here to avoid circular dependency + from app.core.state_manager import state_manager + + # Get list of active games + game_ids = state_manager.list_games() + game_id_strs = [str(gid) for gid in game_ids] + + return self.completion_helper.filter_completions(text, game_id_strs) + + def complete_quick_play(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Complete quick_play command with common counts. + """ + # Suggest common play counts + common_counts = ['1', '5', '10', '27', '50', '100'] + + # Check if completing a positional number + if text and text.isdigit(): + return self.completion_helper.filter_completions(text, common_counts) + elif not text and line.strip() == 'quick_play': + return common_counts + + return [] + + # ==================== Helper Methods ==================== + + def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + Default completion handler for commands without specific completers. + + Provides basic option completion if line contains options. + """ + # If text starts with --, try to show common options + if text.startswith('--'): + common_options = ['help', 'verbose', 'debug'] + return self.completion_helper.complete_option(text, line, common_options) + + return [] + + def completenames(self, text: str, *ignored) -> List[str]: + """ + Override completenames to provide better command completion. + + This is called when completing the first word (command name). + """ + # Get all do_* methods + dotext = 'do_' + text + commands = [name[3:] for name in self.get_names() if name.startswith(dotext)] + + # Add aliases + if 'exit'.startswith(text): + commands.append('exit') + if 'quit'.startswith(text): + commands.append('quit') + + return commands + + + # Example completion mappings for reference + COMPLETION_EXAMPLES = """ + # Example Tab Completion Usage: + + ⚾ > new_game -- + --league --home-team --away-team + + ⚾ > new_game --league + sba pd + + ⚾ > defensive -- + --alignment --infield --outfield --hold + + ⚾ > defensive --alignment + normal shifted_left shifted_right extreme_shift + + ⚾ > defensive --hold 1, + 1,2 1,3 + + ⚾ > offensive --approach + normal contact power patient + + ⚾ > use_game + [shows all active game UUIDs] + + ⚾ > quick_play + 1 5 10 27 50 100 + """ + + Files to Update + + 2. Update backend/terminal_client/repl.py + + # Add import at the top + from terminal_client.completions import GameREPLCompletions + + # Update class definition to include mixin + class GameREPL(GameREPLCompletions, cmd.Cmd): + """Interactive REPL for game engine testing.""" + + intro = """ + ╔══════════════════════════════════════════════════════════════════════════════╗ + ║ Paper Dynasty Game Engine - Terminal Client ║ + ║ Interactive Mode ║ + ╚══════════════════════════════════════════════════════════════════════════════╝ + + Type 'help' or '?' to list commands. + Type 'help ' for command details. + Type 'quit' or 'exit' to leave. + Use TAB for auto-completion of commands and options. + + Quick start: + new_game Create and start a new game with test lineups + defensive Submit defensive decision + offensive Submit offensive decision + resolve Resolve the current play + status Show current game state + quick_play 10 Auto-play 10 plays + + Note: Use underscores in command names (e.g., 'new_game' not 'new-game') + + """ + prompt = '⚾ > ' + + def __init__(self): + # Initialize both parent classes + cmd.Cmd.__init__(self) + GameREPLCompletions.__init__(self) + + self.current_game_id: Optional[UUID] = None + self.db_ops = DatabaseOperations() + + # Create persistent event loop for entire REPL session + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + # Try to load current game from config + saved_game = Config.get_current_game() + if saved_game: + self.current_game_id = saved_game + display.print_info(f"Loaded saved game: {saved_game}") + + # ... rest of the methods stay the same ... + + Testing Plan + + 3. Create backend/tests/unit/terminal_client/test_completions.py + + """ + 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('app', options) + assert result == ['apple', 'apricot'] + + 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_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_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' + + + 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_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_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_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_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_steal_bases(self, repl_completions): + """Test completing steal bases.""" + result = repl_completions.complete_offensive( + '', 'offensive --steal ', 10, 18 + ) + assert '2' in result + assert '3' in result + assert '1' not in result # Can't steal first + + 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 + + @patch('terminal_client.completions.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) + + User Experience Improvements + + Before (No Tab Completion): + ⚾ > defensive --alignment shifted_left # Must type entire word + ⚾ > offensive --approach power # Must remember exact spelling + ⚾ > new_game --league pd --home-team 5 # Must know all options + + After (With Tab Completion): + ⚾ > def → defensive + ⚾ > defensive --a → defensive --alignment + ⚾ > defensive --alignment sh → defensive --alignment shifted_ + ⚾ > defensive --alignment shifted_ + shifted_left shifted_right + + ⚾ > off → offensive + ⚾ > offensive --app → offensive --approach + ⚾ > offensive --approach p + power patient + + ⚾ > new_game -- + --league --home-team --away-team + + ⚾ > use_game + [shows all active game IDs - can copy/paste or select] + + Benefits + + 1. Faster typing: Complete commands/options with fewer keystrokes + 2. Discovery: See available options without checking help + 3. Error prevention: Can't tab-complete to invalid values + 4. Better UX: Feels like a professional CLI tool (like git, docker, etc.) + 5. Learn by doing: Users discover options through exploration diff --git a/backend/terminal_client/update_docs/phase_4.md b/backend/terminal_client/update_docs/phase_4.md new file mode 100644 index 0000000..4d6507d --- /dev/null +++ b/backend/terminal_client/update_docs/phase_4.md @@ -0,0 +1,664 @@ +● Terminal Client Improvement Plan - Part 4: Detailed Help System + + Overview + + Enhance the REPL help system with detailed documentation, examples, and better formatting. This makes the terminal + client self-documenting and easier to use for new developers. + + Files to Create + + 1. Create backend/terminal_client/help_text.py + + """ + Help text and documentation for terminal client commands. + + Provides detailed, formatted help text for all REPL commands + with usage examples and option descriptions. + + Author: Claude + Date: 2025-10-27 + """ + from typing import Dict + from rich.console import Console + from rich.table import Table + from rich.panel import Panel + from rich.markdown import Markdown + from rich import box + + console = Console() + + + class HelpFormatter: + """Format and display help text for commands.""" + + @staticmethod + def show_command_help(command_name: str, help_data: Dict) -> None: + """ + Display detailed help for a specific command. + + Args: + command_name: Name of the command + help_data: Dictionary with help information + { + 'summary': 'Brief description', + 'usage': 'command [OPTIONS]', + 'options': [ + {'name': '--option', 'type': 'TYPE', 'desc': 'Description'} + ], + 'examples': ['example 1', 'example 2'] + } + """ + # Build help text + help_text = [] + + # Summary + help_text.append(f"**{command_name}** - {help_data.get('summary', 'No description')}") + help_text.append("") + + # Usage + if 'usage' in help_data: + help_text.append("**USAGE:**") + help_text.append(f" {help_data['usage']}") + help_text.append("") + + # Options + if 'options' in help_data and help_data['options']: + help_text.append("**OPTIONS:**") + + # Create options table + table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) + table.add_column("Option", style="cyan", no_wrap=True) + table.add_column("Type", style="yellow") + table.add_column("Description", style="white") + + for opt in help_data['options']: + table.add_row( + opt['name'], + opt.get('type', ''), + opt.get('desc', '') + ) + + console.print(table) + console.print() + + # Examples + if 'examples' in help_data and help_data['examples']: + help_text.append("**EXAMPLES:**") + for example in help_data['examples']: + help_text.append(f" {example}") + help_text.append("") + + # Display in panel + if help_text: + md = Markdown("\n".join(help_text[:3])) # Just summary and usage + panel = Panel( + md, + title=f"[bold cyan]Help: {command_name}[/bold cyan]", + border_style="cyan", + box=box.ROUNDED + ) + console.print(panel) + + # Print rest outside panel for better formatting + if len(help_text) > 3: + console.print() + for line in help_text[3:]: + if line.startswith('**'): + console.print(line.replace('**', ''), style="bold cyan") + else: + console.print(line) + + @staticmethod + def show_command_list() -> None: + """Display list of all available commands.""" + console.print("\n[bold cyan]Available Commands:[/bold cyan]\n") + + # Game Management + console.print("[bold yellow]Game Management:[/bold yellow]") + console.print(" new_game Create a new game with test lineups and start it") + console.print(" list_games List all games in state manager") + console.print(" use_game Switch to a different game") + console.print(" status Display current game state") + console.print(" box_score Display box score") + console.print() + + # Gameplay + console.print("[bold yellow]Gameplay:[/bold yellow]") + console.print(" defensive Submit defensive decision") + console.print(" offensive Submit offensive decision") + console.print(" resolve Resolve the current play") + console.print(" quick_play Auto-play multiple plays") + console.print() + + # Utilities + console.print("[bold yellow]Utilities:[/bold yellow]") + console.print(" config Show configuration") + console.print(" clear Clear the screen") + console.print(" help Show help for commands") + console.print(" quit/exit Exit the REPL") + console.print() + + console.print("[dim]Type 'help ' for detailed information.[/dim]") + console.print("[dim]Use TAB for auto-completion of commands and options.[/dim]\n") + + + # Detailed help data for each command + HELP_DATA = { + 'new_game': { + 'summary': 'Create a new game with test lineups and start it immediately', + 'usage': 'new_game [--league LEAGUE] [--home-team ID] [--away-team ID]', + 'options': [ + { + 'name': '--league', + 'type': 'sba|pd', + 'desc': 'League type (default: sba)' + }, + { + 'name': '--home-team', + 'type': 'INT', + 'desc': 'Home team ID (default: 1)' + }, + { + 'name': '--away-team', + 'type': 'INT', + 'desc': 'Away team ID (default: 2)' + } + ], + 'examples': [ + 'new_game', + 'new_game --league pd', + 'new_game --league sba --home-team 5 --away-team 3' + ] + }, + + 'defensive': { + 'summary': 'Submit defensive decision for the current play', + 'usage': 'defensive [--alignment TYPE] [--infield DEPTH] [--outfield DEPTH] [--hold BASES]', + 'options': [ + { + 'name': '--alignment', + 'type': 'STRING', + 'desc': 'Defensive alignment: normal, shifted_left, shifted_right, extreme_shift (default: normal)' + }, + { + 'name': '--infield', + 'type': 'STRING', + 'desc': 'Infield depth: in, normal, back, double_play (default: normal)' + }, + { + 'name': '--outfield', + 'type': 'STRING', + 'desc': 'Outfield depth: in, normal, back (default: normal)' + }, + { + 'name': '--hold', + 'type': 'LIST', + 'desc': 'Comma-separated bases to hold runners: 1,2,3 (default: none)' + } + ], + 'examples': [ + 'defensive', + 'defensive --alignment shifted_left', + 'defensive --infield double_play --hold 1,3', + 'defensive --alignment extreme_shift --infield back --outfield back' + ] + }, + + 'offensive': { + 'summary': 'Submit offensive decision for the current play', + 'usage': 'offensive [--approach TYPE] [--steal BASES] [--hit-run] [--bunt]', + 'options': [ + { + 'name': '--approach', + 'type': 'STRING', + 'desc': 'Batting approach: normal, contact, power, patient (default: normal)' + }, + { + 'name': '--steal', + 'type': 'LIST', + 'desc': 'Comma-separated bases to steal: 2,3 (default: none)' + }, + { + 'name': '--hit-run', + 'type': 'FLAG', + 'desc': 'Execute hit-and-run play (default: false)' + }, + { + 'name': '--bunt', + 'type': 'FLAG', + 'desc': 'Attempt bunt (default: false)' + } + ], + 'examples': [ + 'offensive', + 'offensive --approach power', + 'offensive --steal 2', + 'offensive --steal 2,3 --hit-run', + 'offensive --approach contact --bunt' + ] + }, + + 'resolve': { + 'summary': 'Resolve the current play using submitted decisions', + 'usage': 'resolve', + 'options': [], + 'examples': [ + 'resolve' + ], + 'notes': 'Both defensive and offensive decisions must be submitted before resolving.' + }, + + 'quick_play': { + 'summary': 'Auto-play multiple plays with default decisions', + 'usage': 'quick_play [COUNT]', + 'options': [ + { + 'name': 'COUNT', + 'type': 'INT', + 'desc': 'Number of plays to execute (default: 1). Positional argument.' + } + ], + 'examples': [ + 'quick_play', + 'quick_play 10', + 'quick_play 27 # Play roughly 3 innings', + 'quick_play 100 # Play full game quickly' + ] + }, + + 'status': { + 'summary': 'Display current game state', + 'usage': 'status', + 'options': [], + 'examples': [ + 'status' + ] + }, + + 'box_score': { + 'summary': 'Display box score for the current game', + 'usage': 'box_score', + 'options': [], + 'examples': [ + 'box_score' + ] + }, + + 'list_games': { + 'summary': 'List all games currently loaded in state manager', + 'usage': 'list_games', + 'options': [], + 'examples': [ + 'list_games' + ] + }, + + 'use_game': { + 'summary': 'Switch to a different game', + 'usage': 'use_game ', + 'options': [ + { + 'name': 'GAME_ID', + 'type': 'UUID', + 'desc': 'UUID of the game to switch to. Positional argument.' + } + ], + 'examples': [ + 'use_game a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'use_game # Use tab completion to see available games' + ] + }, + + 'config': { + 'summary': 'Show terminal client configuration', + 'usage': 'config', + 'options': [], + 'examples': [ + 'config' + ] + }, + + 'clear': { + 'summary': 'Clear the screen', + 'usage': 'clear', + 'options': [], + 'examples': [ + 'clear' + ] + } + } + + + def get_help_text(command: str) -> Dict: + """ + Get help data for a command. + + Args: + command: Command name + + Returns: + Help data dictionary or empty dict if not found + """ + return HELP_DATA.get(command, {}) + + + def show_help(command: str = None) -> None: + """ + Show help for a command or list all commands. + + Args: + command: Command name or None for command list + """ + if command: + help_data = get_help_text(command) + if help_data: + HelpFormatter.show_command_help(command, help_data) + else: + console.print(f"[yellow]No help available for '{command}'[/yellow]") + console.print(f"[dim]Type 'help' to see all available commands.[/dim]") + else: + HelpFormatter.show_command_list() + + Files to Update + + 2. Update backend/terminal_client/repl.py + + # Add import at top + from terminal_client.help_text import show_help, get_help_text, HelpFormatter + + # Update the intro to be more helpful + class GameREPL(GameREPLCompletions, cmd.Cmd): + """Interactive REPL for game engine testing.""" + + intro = """ + ╔══════════════════════════════════════════════════════════════════════════════╗ + ║ Paper Dynasty Game Engine - Terminal Client ║ + ║ Interactive Mode ║ + ╚══════════════════════════════════════════════════════════════════════════════╝ + + [cyan]Type 'help' to see all available commands.[/cyan] + [cyan]Type 'help ' for detailed information about a specific command.[/cyan] + [cyan]Use TAB for auto-completion of commands and options.[/cyan] + + [yellow]Quick start:[/yellow] + new_game Create and start a new game + status Show current game state + defensive Submit defensive decision + offensive Submit offensive decision + resolve Resolve the play + quick_play 10 Auto-play 10 plays + + Press Ctrl+D or type 'quit' to exit. + + """ + prompt = '⚾ > ' + + # ... rest of __init__ stays the same ... + + # ==================== Enhanced Help System ==================== + + def do_help(self, arg): + """ + Show help for commands. + + Usage: + help List all commands + help Show detailed help for a command + """ + if arg: + # Show detailed help for specific command + show_help(arg) + else: + # Show command list + HelpFormatter.show_command_list() + + def help_new_game(self): + """Show detailed help for new_game command.""" + show_help('new_game') + + def help_defensive(self): + """Show detailed help for defensive command.""" + show_help('defensive') + + def help_offensive(self): + """Show detailed help for offensive command.""" + show_help('offensive') + + def help_resolve(self): + """Show detailed help for resolve command.""" + show_help('resolve') + + def help_quick_play(self): + """Show detailed help for quick_play command.""" + show_help('quick_play') + + def help_status(self): + """Show detailed help for status command.""" + show_help('status') + + def help_box_score(self): + """Show detailed help for box_score command.""" + show_help('box_score') + + def help_list_games(self): + """Show detailed help for list_games command.""" + show_help('list_games') + + def help_use_game(self): + """Show detailed help for use_game command.""" + show_help('use_game') + + def help_config(self): + """Show detailed help for config command.""" + show_help('config') + + def help_clear(self): + """Show detailed help for clear command.""" + show_help('clear') + + # ==================== Keep existing command methods ==================== + # All do_* methods remain unchanged + + 3. Create backend/terminal_client/__main__.py Update + + # Add help command for standalone mode + # Add this at the bottom of main.py + + @cli.command('help') + @click.argument('command', required=False) + def show_cli_help(command): + """ + Show help for terminal client commands. + + Usage: + python -m terminal_client help # Show all commands + python -m terminal_client help new-game # Show help for specific command + """ + from terminal_client.help_text import show_help + + # Convert hyphenated command to underscore for lookup + if command: + command = command.replace('-', '_') + + show_help(command) + + Testing Plan + + 4. Create backend/tests/unit/terminal_client/test_help_text.py + + """ + Unit tests for help text system. + """ + import pytest + from io import StringIO + from unittest.mock import patch + + from terminal_client.help_text import ( + HelpFormatter, + get_help_text, + show_help, + HELP_DATA + ) + + + class TestHelpData: + """Tests for help data structure.""" + + def test_all_commands_have_help(self): + """Test that all major commands have help data.""" + required_commands = [ + 'new_game', 'defensive', 'offensive', 'resolve', + 'quick_play', 'status', 'use_game', 'list_games' + ] + + for cmd in required_commands: + assert cmd in HELP_DATA, f"Missing help data for {cmd}" + + def test_help_data_structure(self): + """Test that help data has required fields.""" + for cmd, data in HELP_DATA.items(): + assert 'summary' in data, f"{cmd} missing summary" + assert 'usage' in data, f"{cmd} missing usage" + assert 'options' in data, f"{cmd} missing options" + assert 'examples' in data, f"{cmd} missing examples" + + # Validate options structure + for opt in data['options']: + assert 'name' in opt, f"{cmd} option missing name" + assert 'desc' in opt, f"{cmd} option missing desc" + + def test_get_help_text_valid(self): + """Test getting help text for valid command.""" + result = get_help_text('new_game') + assert result is not None + assert 'summary' in result + + def test_get_help_text_invalid(self): + """Test getting help text for invalid command.""" + result = get_help_text('nonexistent_command') + assert result == {} + + + class TestHelpFormatter: + """Tests for HelpFormatter.""" + + @patch('terminal_client.help_text.console') + def test_show_command_help(self, mock_console): + """Test showing help for a command.""" + help_data = { + 'summary': 'Test command', + 'usage': 'test [OPTIONS]', + 'options': [ + {'name': '--option', 'type': 'STRING', 'desc': 'Test option'} + ], + 'examples': ['test --option value'] + } + + HelpFormatter.show_command_help('test', help_data) + + # Verify console.print was called + assert mock_console.print.called + + @patch('terminal_client.help_text.console') + def test_show_command_list(self, mock_console): + """Test showing command list.""" + HelpFormatter.show_command_list() + + # Verify console.print was called multiple times + assert mock_console.print.call_count > 5 + + + class TestShowHelp: + """Tests for show_help function.""" + + @patch('terminal_client.help_text.HelpFormatter.show_command_help') + def test_show_help_specific_command(self, mock_show): + """Test showing help for specific command.""" + show_help('new_game') + + mock_show.assert_called_once() + args = mock_show.call_args[0] + assert args[0] == 'new_game' + assert 'summary' in args[1] + + @patch('terminal_client.help_text.HelpFormatter.show_command_list') + def test_show_help_no_command(self, mock_list): + """Test showing command list when no command specified.""" + show_help() + + mock_list.assert_called_once() + + @patch('terminal_client.help_text.console') + def test_show_help_invalid_command(self, mock_console): + """Test showing help for invalid command.""" + show_help('invalid_command') + + # Should print warning message + assert mock_console.print.called + call_args = str(mock_console.print.call_args) + assert 'No help available' in call_args + + Example Output + + Command List (help): + ⚾ > help + + Available Commands: + + Game Management: + new_game Create a new game with test lineups and start it + list_games List all games in state manager + use_game Switch to a different game + status Display current game state + box_score Display box score + + Gameplay: + defensive Submit defensive decision + offensive Submit offensive decision + resolve Resolve the current play + quick_play Auto-play multiple plays + + Utilities: + config Show configuration + clear Clear the screen + help Show help for commands + quit/exit Exit the REPL + + Type 'help ' for detailed information. + Use TAB for auto-completion of commands and options. + + Detailed Help (help defensive): + ⚾ > help defensive + + ╭─────────────── Help: defensive ───────────────╮ + │ defensive - Submit defensive decision for │ + │ the current play │ + │ │ + │ USAGE: │ + │ defensive [--alignment TYPE] [--infield │ + │ DEPTH] [--outfield DEPTH] [--hold BASES] │ + ╰───────────────────────────────────────────────╯ + + OPTIONS: + --alignment STRING Defensive alignment: normal, shifted_left, + shifted_right, extreme_shift (default: normal) + --infield STRING Infield depth: in, normal, back, double_play + (default: normal) + --outfield STRING Outfield depth: in, normal, back (default: normal) + --hold LIST Comma-separated bases to hold runners: 1,2,3 + (default: none) + + EXAMPLES: + defensive + defensive --alignment shifted_left + defensive --infield double_play --hold 1,3 + defensive --alignment extreme_shift --infield back --outfield back + + Benefits + + 1. Self-documenting: No need to check external docs for basic usage + 2. Rich formatting: Beautiful output with colors, tables, and panels + 3. Comprehensive: Every option explained with examples + 4. Discoverable: Easy to explore what's available + 5. Consistent: Same help format across all commands + 6. Learning tool: Examples teach proper usage patterns diff --git a/backend/terminal_client/update_docs/phase_5.md b/backend/terminal_client/update_docs/phase_5.md new file mode 100644 index 0000000..6faf304 --- /dev/null +++ b/backend/terminal_client/update_docs/phase_5.md @@ -0,0 +1,853 @@ + +● Terminal Client Improvement Plan - Part 5: Player Name Caching and Display + + Overview + + Enhance the display system to show player names, positions, and stats instead of just lineup IDs. This requires + integration with the player model system from Week 6 and adds a caching layer for performance. + + Note: This enhancement requires Week 6 player models to be implemented first. The code provided here creates the + infrastructure that will be activated once player models are available. + + Files to Create + + 1. Create backend/terminal_client/player_cache.py + + """ + Player data caching for terminal client. + + Caches player/card data from league APIs to avoid repeated lookups + and provide fast name/stat display in the REPL. + + Author: Claude + Date: 2025-10-27 + """ + import logging + from typing import Dict, Optional, Any + from uuid import UUID + from dataclasses import dataclass + from datetime import datetime, timedelta + + logger = logging.getLogger(f'{__name__}.player_cache') + + + @dataclass + class CachedPlayer: + """Cached player data.""" + + # Identification + card_id: Optional[int] = None + player_id: Optional[int] = None + + # Basic info + name: str = "Unknown" + position: str = "?" + team_id: int = 0 + + # Display info + image_url: Optional[str] = None + handedness: Optional[str] = None + + # League-specific + league: str = "unknown" + + # Cache metadata + cached_at: datetime = None + + def get_display_name(self) -> str: + """Get formatted display name.""" + if self.handedness: + hand_symbol = {"R": "⟩", "L": "⟨", "S": "⟨⟩"}.get(self.handedness, "") + return f"{self.name} {hand_symbol} ({self.position})" + return f"{self.name} ({self.position})" + + def get_short_display(self) -> str: + """Get short display format.""" + return f"{self.name} {self.position}" + + + class PlayerCache: + """ + Cache for player data to avoid repeated API lookups. + + Maintains in-memory cache with TTL and per-game player rosters. + """ + + def __init__(self, ttl_seconds: int = 3600): + """ + Initialize player cache. + + Args: + ttl_seconds: Time to live for cached entries (default: 1 hour) + """ + self.ttl_seconds = ttl_seconds + + # Cache by card_id (PD league) + self._card_cache: Dict[int, CachedPlayer] = {} + + # Cache by player_id (SBA league) + self._player_cache: Dict[int, CachedPlayer] = {} + + # Cache by lineup_id for quick lookups + self._lineup_cache: Dict[int, CachedPlayer] = {} + + # Track which game uses which players + self._game_rosters: Dict[UUID, Dict[int, CachedPlayer]] = {} + + logger.info(f"PlayerCache initialized with TTL={ttl_seconds}s") + + def get_by_lineup_id(self, lineup_id: int) -> Optional[CachedPlayer]: + """ + Get player by lineup ID (fastest lookup for display). + + Args: + lineup_id: Lineup entry ID + + Returns: + CachedPlayer or None if not cached + """ + player = self._lineup_cache.get(lineup_id) + + if player and self._is_expired(player): + logger.debug(f"Lineup cache expired for {lineup_id}") + del self._lineup_cache[lineup_id] + return None + + return player + + def get_by_card_id(self, card_id: int) -> Optional[CachedPlayer]: + """ + Get player by card ID (PD league). + + Args: + card_id: Card ID + + Returns: + CachedPlayer or None if not cached + """ + player = self._card_cache.get(card_id) + + if player and self._is_expired(player): + logger.debug(f"Card cache expired for {card_id}") + del self._card_cache[card_id] + return None + + return player + + def get_by_player_id(self, player_id: int) -> Optional[CachedPlayer]: + """ + Get player by player ID (SBA league). + + Args: + player_id: Player ID + + Returns: + CachedPlayer or None if not cached + """ + player = self._player_cache.get(player_id) + + if player and self._is_expired(player): + logger.debug(f"Player cache expired for {player_id}") + del self._player_cache[player_id] + return None + + return player + + def add_player( + self, + player_data: Dict[str, Any], + lineup_id: Optional[int] = None + ) -> CachedPlayer: + """ + Add player to cache. + + Args: + player_data: Player data from database/API + lineup_id: Optional lineup ID for quick lookups + + Returns: + CachedPlayer instance + """ + now = datetime.utcnow() + + player = CachedPlayer( + card_id=player_data.get('card_id'), + player_id=player_data.get('player_id'), + name=player_data.get('name', 'Unknown'), + position=player_data.get('position', '?'), + team_id=player_data.get('team_id', 0), + image_url=player_data.get('image_url'), + handedness=player_data.get('handedness'), + league=player_data.get('league', 'unknown'), + cached_at=now + ) + + # Cache by appropriate ID + if player.card_id: + self._card_cache[player.card_id] = player + logger.debug(f"Cached card {player.card_id}: {player.name}") + + if player.player_id: + self._player_cache[player.player_id] = player + logger.debug(f"Cached player {player.player_id}: {player.name}") + + # Cache by lineup ID if provided + if lineup_id: + self._lineup_cache[lineup_id] = player + logger.debug(f"Cached lineup {lineup_id}: {player.name}") + + return player + + async def load_game_roster(self, game_id: UUID, league: str) -> int: + """ + Load all players for a game into cache. + + This should be called when switching to a game to pre-populate + the cache with all players that will be referenced. + + Args: + game_id: Game UUID + league: League ID ('sba' or 'pd') + + Returns: + Number of players loaded + """ + # Import here to avoid circular dependency + from app.database.operations import DatabaseOperations + + db_ops = DatabaseOperations() + + try: + # Get both team lineups + home_lineup = await db_ops.get_active_lineup(game_id, None) # Will need team_id + away_lineup = await db_ops.get_active_lineup(game_id, None) + + all_players = home_lineup + away_lineup + game_roster = {} + + for lineup_entry in all_players: + # Extract player data based on league + player_data = { + 'card_id': getattr(lineup_entry, 'card_id', None), + 'player_id': getattr(lineup_entry, 'player_id', None), + 'name': getattr(lineup_entry, 'name', 'Unknown'), + 'position': lineup_entry.position, + 'team_id': lineup_entry.team_id, + 'league': league + } + + # Add to cache + player = self.add_player(player_data, lineup_id=lineup_entry.id) + game_roster[lineup_entry.id] = player + + # Store game roster mapping + self._game_rosters[game_id] = game_roster + + logger.info(f"Loaded {len(all_players)} players for game {game_id}") + return len(all_players) + + except Exception as e: + logger.error(f"Failed to load game roster: {e}", exc_info=True) + return 0 + + def get_game_roster(self, game_id: UUID) -> Dict[int, CachedPlayer]: + """ + Get cached roster for a game. + + Args: + game_id: Game UUID + + Returns: + Dictionary mapping lineup_id to CachedPlayer + """ + return self._game_rosters.get(game_id, {}) + + def clear_game(self, game_id: UUID) -> None: + """ + Clear cached data for a specific game. + + Args: + game_id: Game UUID + """ + if game_id in self._game_rosters: + del self._game_rosters[game_id] + logger.info(f"Cleared cache for game {game_id}") + + def clear_expired(self) -> int: + """ + Remove expired entries from cache. + + Returns: + Number of entries removed + """ + count = 0 + + # Clear card cache + expired_cards = [ + cid for cid, player in self._card_cache.items() + if self._is_expired(player) + ] + for cid in expired_cards: + del self._card_cache[cid] + count += 1 + + # Clear player cache + expired_players = [ + pid for pid, player in self._player_cache.items() + if self._is_expired(player) + ] + for pid in expired_players: + del self._player_cache[pid] + count += 1 + + # Clear lineup cache + expired_lineups = [ + lid for lid, player in self._lineup_cache.items() + if self._is_expired(player) + ] + for lid in expired_lineups: + del self._lineup_cache[lid] + count += 1 + + if count > 0: + logger.info(f"Cleared {count} expired cache entries") + + return count + + def get_stats(self) -> Dict[str, int]: + """ + Get cache statistics. + + Returns: + Dictionary with cache stats + """ + return { + 'card_cache_size': len(self._card_cache), + 'player_cache_size': len(self._player_cache), + 'lineup_cache_size': len(self._lineup_cache), + 'games_cached': len(self._game_rosters), + 'ttl_seconds': self.ttl_seconds + } + + def _is_expired(self, player: CachedPlayer) -> bool: + """Check if cached player is expired.""" + if player.cached_at is None: + return True + + age = (datetime.utcnow() - player.cached_at).total_seconds() + return age > self.ttl_seconds + + + # Global cache instance + player_cache = PlayerCache() + + 2. Create backend/terminal_client/enhanced_display.py + + """ + Enhanced display functions with player names. + + Extends the base display module with player name lookups + and richer formatting. + + Author: Claude + Date: 2025-10-27 + """ + import logging + from typing import Optional + from rich.text import Text + + from app.models.game_models import GameState + from terminal_client import display + from terminal_client.player_cache import player_cache, CachedPlayer + + logger = logging.getLogger(f'{__name__}.enhanced_display') + + + def get_player_display(lineup_id: Optional[int]) -> str: + """ + Get display string for a player by lineup ID. + + Args: + lineup_id: Lineup entry ID + + Returns: + Formatted player string (name if available, ID otherwise) + """ + if lineup_id is None: + return "None" + + # Try to get from cache + player = player_cache.get_by_lineup_id(lineup_id) + + if player: + return player.get_short_display() + + # Fall back to lineup ID + return f"Lineup #{lineup_id}" + + + def display_game_state_enhanced(state: GameState) -> None: + """ + Display game state with player names (enhanced version). + + This is a drop-in replacement for display.display_game_state() + that adds player names when available. + + Args: + state: Current game state + """ + from rich.panel import Panel + from rich import box + + # Status color based on game status + status_color = { + "pending": "yellow", + "active": "green", + "paused": "yellow", + "completed": "blue" + } + + # Build state display + state_text = Text() + state_text.append(f"Game ID: {state.game_id}\n", style="bold") + state_text.append(f"League: {state.league_id.upper()}\n") + state_text.append(f"Status: ", style="bold") + state_text.append(f"{state.status}\n", style=status_color.get(state.status, "white")) + state_text.append("\n") + + # Score + state_text.append("Score: ", style="bold cyan") + state_text.append(f"Away {state.away_score} - {state.home_score} Home\n", style="cyan") + + # Inning + state_text.append("Inning: ", style="bold magenta") + state_text.append(f"{state.inning} {state.half.capitalize()}\n", style="magenta") + + # Outs + state_text.append("Outs: ", style="bold yellow") + state_text.append(f"{state.outs}\n", style="yellow") + + # Runners (enhanced with names) + runners = state.get_all_runners() + if runners: + state_text.append("\nRunners: ", style="bold green") + runner_displays = [] + for base, runner in runners: + player_name = get_player_display(runner.lineup_id) + runner_displays.append(f"{base}B: {player_name}") + state_text.append(f"{', '.join(runner_displays)}\n", style="green") + else: + state_text.append("\nBases: ", style="bold") + state_text.append("Empty\n", style="dim") + + # Current players (enhanced with names) + if state.current_batter_lineup_id: + batter_name = get_player_display(state.current_batter_lineup_id) + state_text.append(f"\nBatter: {batter_name}\n") + + if state.current_pitcher_lineup_id: + pitcher_name = get_player_display(state.current_pitcher_lineup_id) + state_text.append(f"Pitcher: {pitcher_name}\n") + + # Pending decision + if state.pending_decision: + state_text.append(f"\nPending: ", style="bold red") + state_text.append(f"{state.pending_decision} decision\n", style="red") + + # Last play result + if state.last_play_result: + state_text.append(f"\nLast Play: ", style="bold") + state_text.append(f"{state.last_play_result}\n", style="italic") + + # Display panel + panel = Panel( + state_text, + title=f"[bold]Game State[/bold]", + border_style="blue", + box=box.ROUNDED + ) + display.console.print(panel) + + + def show_cache_stats() -> None: + """Display player cache statistics.""" + stats = player_cache.get_stats() + + display.console.print("\n[bold cyan]Player Cache Statistics:[/bold cyan]") + display.console.print(f" Card cache: {stats['card_cache_size']} entries") + display.console.print(f" Player cache: {stats['player_cache_size']} entries") + display.console.print(f" Lineup cache: {stats['lineup_cache_size']} entries") + display.console.print(f" Games cached: {stats['games_cached']}") + display.console.print(f" TTL: {stats['ttl_seconds']} seconds\n") + + Files to Update + + 3. Update backend/terminal_client/repl.py + + # Add imports at top + from terminal_client.player_cache import player_cache + from terminal_client.enhanced_display import ( + display_game_state_enhanced, + show_cache_stats + ) + + class GameREPL(GameREPLCompletions, cmd.Cmd): + # ... existing code ... + + async def _ensure_game_loaded(self, game_id: UUID) -> None: + """ + Ensure game is loaded in state_manager. + + If game exists in database but not in memory, recover it. + Also pre-load player cache for better display. + """ + # Check if already in memory + state = state_manager.get_state(game_id) + if state is not None: + # Game loaded, check if cache needs refresh + roster = player_cache.get_game_roster(game_id) + if not roster: + # Load player names into cache + try: + count = await player_cache.load_game_roster(game_id, state.league_id) + if count > 0: + logger.debug(f"Loaded {count} players into cache") + except Exception as e: + logger.warning(f"Could not load player cache: {e}") + return + + # Try to recover from database + try: + display.print_info(f"Loading game {game_id} from database...") + recovered_state = await state_manager.recover_game(game_id) + + if recovered_state and recovered_state.status == "active": + await game_engine._prepare_next_play(recovered_state) + logger.debug(f"Prepared snapshot for recovered game {game_id}") + + # Load player cache + try: + count = await player_cache.load_game_roster( + game_id, + recovered_state.league_id + ) + if count > 0: + display.print_success(f"Loaded {count} players into display cache") + except Exception as e: + logger.warning(f"Could not load player cache: {e}") + + display.print_success("Game loaded successfully") + except Exception as e: + display.print_error(f"Failed to load game: {e}") + logger.error(f"Game recovery failed for {game_id}: {e}", exc_info=True) + raise ValueError(f"Game {game_id} not found") + + # Add new command for cache management + def do_cache(self, arg): + """ + Manage player cache. + + Usage: + cache Show cache statistics + cache clear Clear expired entries + cache reload Reload current game roster + """ + async def _cache(): + args = arg.split() + + if not args or args[0] == 'stats': + # Show stats + show_cache_stats() + + elif args[0] == 'clear': + # Clear expired + count = player_cache.clear_expired() + display.print_success(f"Cleared {count} expired entries") + + elif args[0] == 'reload': + # Reload current game + try: + gid = self._ensure_game() + state = state_manager.get_state(gid) + if state: + count = await player_cache.load_game_roster(gid, state.league_id) + display.print_success(f"Loaded {count} players") + else: + display.print_error("No game state found") + except ValueError: + pass + + else: + display.print_error(f"Unknown cache command: {args[0]}") + display.print_info("Usage: cache [stats|clear|reload]") + + self._run_async(_cache()) + + # Update status command to use enhanced display + def do_status(self, arg): + """Display current game state with player names.""" + async def _status(): + try: + gid = self._ensure_game() + await self._ensure_game_loaded(gid) + + state = await game_engine.get_game_state(gid) + if state: + # Use enhanced display if cache is populated + roster = player_cache.get_game_roster(gid) + if roster: + display_game_state_enhanced(state) + else: + display.display_game_state(state) + else: + display.print_error("Game state not found") + + except ValueError: + pass + except Exception as e: + display.print_error(f"Failed: {e}") + + self._run_async(_status()) + + Testing Plan + + 4. Create backend/tests/unit/terminal_client/test_player_cache.py + + """ + Unit tests for player cache. + """ + import pytest + from datetime import datetime, timedelta + from uuid import uuid4 + + from terminal_client.player_cache import PlayerCache, CachedPlayer + + + class TestCachedPlayer: + """Tests for CachedPlayer dataclass.""" + + def test_get_display_name_with_handedness(self): + """Test display name includes handedness symbol.""" + player = CachedPlayer( + name="Mike Trout", + position="CF", + handedness="R" + ) + assert "⟩" in player.get_display_name() + assert "Mike Trout" in player.get_display_name() + assert "CF" in player.get_display_name() + + def test_get_display_name_without_handedness(self): + """Test display name without handedness.""" + player = CachedPlayer( + name="Mike Trout", + position="CF" + ) + result = player.get_display_name() + assert result == "Mike Trout (CF)" + + def test_get_short_display(self): + """Test short display format.""" + player = CachedPlayer( + name="Mike Trout", + position="CF" + ) + assert player.get_short_display() == "Mike Trout CF" + + + class TestPlayerCache: + """Tests for PlayerCache.""" + + @pytest.fixture + def cache(self): + """Create fresh cache for each test.""" + return PlayerCache(ttl_seconds=3600) + + def test_add_and_get_by_card_id(self, cache): + """Test adding and retrieving by card ID.""" + player_data = { + 'card_id': 123, + 'name': 'Test Player', + 'position': 'SS', + 'team_id': 1, + 'league': 'pd' + } + + player = cache.add_player(player_data) + + assert player.name == 'Test Player' + assert player.position == 'SS' + + # Retrieve by card ID + retrieved = cache.get_by_card_id(123) + assert retrieved is not None + assert retrieved.name == 'Test Player' + + def test_add_and_get_by_player_id(self, cache): + """Test adding and retrieving by player ID.""" + player_data = { + 'player_id': 456, + 'name': 'Test Player', + 'position': 'CF', + 'team_id': 2, + 'league': 'sba' + } + + player = cache.add_player(player_data) + + # Retrieve by player ID + retrieved = cache.get_by_player_id(456) + assert retrieved is not None + assert retrieved.name == 'Test Player' + + def test_add_with_lineup_id(self, cache): + """Test adding with lineup ID for quick lookups.""" + player_data = { + 'card_id': 123, + 'name': 'Test Player', + 'position': 'SS', + 'team_id': 1, + 'league': 'pd' + } + + player = cache.add_player(player_data, lineup_id=10) + + # Retrieve by lineup ID + retrieved = cache.get_by_lineup_id(10) + assert retrieved is not None + assert retrieved.name == 'Test Player' + + def test_expired_entry_removed(self, cache): + """Test that expired entries are not returned.""" + cache_short_ttl = PlayerCache(ttl_seconds=0) + + player_data = { + 'card_id': 123, + 'name': 'Test Player', + 'position': 'SS', + 'team_id': 1, + 'league': 'pd' + } + + player = cache_short_ttl.add_player(player_data) + player.cached_at = datetime.utcnow() - timedelta(seconds=10) + + # Should return None for expired entry + retrieved = cache_short_ttl.get_by_card_id(123) + assert retrieved is None + + def test_clear_expired(self, cache): + """Test clearing expired entries.""" + # Add fresh entry + cache.add_player({ + 'card_id': 1, + 'name': 'Fresh', + 'position': 'P', + 'team_id': 1, + 'league': 'pd' + }) + + # Add expired entry + old_player = cache.add_player({ + 'card_id': 2, + 'name': 'Old', + 'position': 'C', + 'team_id': 1, + 'league': 'pd' + }) + old_player.cached_at = datetime.utcnow() - timedelta(seconds=10000) + + # Clear expired + count = cache.clear_expired() + + assert count > 0 + assert cache.get_by_card_id(1) is not None # Fresh still there + assert cache.get_by_card_id(2) is None # Old removed + + def test_get_stats(self, cache): + """Test getting cache statistics.""" + cache.add_player({'card_id': 1, 'name': 'P1', 'position': 'P', 'team_id': 1, 'league': 'pd'}) + cache.add_player({'player_id': 2, 'name': 'P2', 'position': 'C', 'team_id': 1, 'league': 'sba'}) + + stats = cache.get_stats() + + assert stats['card_cache_size'] == 1 + assert stats['player_cache_size'] == 1 + assert stats['ttl_seconds'] == 3600 + + def test_clear_game(self, cache): + """Test clearing game-specific cache.""" + game_id = uuid4() + + # Simulate game roster + cache._game_rosters[game_id] = { + 1: CachedPlayer(name="Player 1", position="P"), + 2: CachedPlayer(name="Player 2", position="C") + } + + assert len(cache.get_game_roster(game_id)) == 2 + + cache.clear_game(game_id) + + assert len(cache.get_game_roster(game_id)) == 0 + + Configuration and Activation + + 5. Create backend/terminal_client/config.py update + + # Add to existing config.py + + class ClientConfig: + """Configuration for terminal client features.""" + + # Feature flags + ENABLE_PLAYER_NAMES = False # Set to True when Week 6 models are ready + ENABLE_PLAYER_CACHE = False # Set to True when Week 6 models are ready + + # Cache settings + PLAYER_CACHE_TTL = 3600 # 1 hour + AUTO_LOAD_ROSTER = True # Auto-load roster when switching games + + @classmethod + def is_enhanced_display_enabled(cls) -> bool: + """Check if enhanced display with player names is enabled.""" + return cls.ENABLE_PLAYER_NAMES and cls.ENABLE_PLAYER_CACHE + + Activation Instructions (For Future) + + When Week 6 player models are implemented: + + 1. Update feature flags: + # In terminal_client/config.py + ENABLE_PLAYER_NAMES = True + ENABLE_PLAYER_CACHE = True + 2. Implement player data fetching: + - Add methods to fetch player data from league APIs + - Integrate with backend/app/data/api_client.py + 3. Update database operations: + - Ensure lineup queries return player names + - Add joins to get player/card details + 4. Test with real data: + - Verify cache performance + - Check TTL behavior + - Validate display formatting + + Example Output (When Activated) + + Before (Current): + Batter: Lineup #1 + Pitcher: Lineup #10 + Runners: 1B(#2), 3B(#5) + + After (With Player Names): + Batter: Mike Trout CF + Pitcher: Clayton Kershaw P + Runners: 1B: Aaron Judge RF, 3B: Mookie Betts RF + + Benefits + + 1. Better UX: See player names instead of cryptic IDs + 2. Performance: Caching prevents repeated database/API lookups + 3. Flexibility: Works with both PD and SBA leagues + 4. Future-ready: Infrastructure ready for Week 6 integration + 5. Optional: Can be disabled if not needed diff --git a/backend/terminal_client/update_docs/phase_6.md b/backend/terminal_client/update_docs/phase_6.md new file mode 100644 index 0000000..9b3ff4a --- /dev/null +++ b/backend/terminal_client/update_docs/phase_6.md @@ -0,0 +1,803 @@ +● Terminal Client Improvement Plan - Part 6: Testing & Migration Guide + + Overview + + Comprehensive testing strategy and step-by-step migration guide to implement all terminal client improvements safely + with full test coverage. + + --- + A. Comprehensive Testing Strategy + + 1. Create Test Directory Structure + + backend/tests/unit/terminal_client/ + ├── __init__.py + ├── test_commands.py # From Part 1 + ├── test_arg_parser.py # From Part 2 + ├── test_completions.py # From Part 3 + ├── test_help_text.py # From Part 4 + ├── test_player_cache.py # From Part 5 + └── test_integration.py # New - full integration tests + + 2. Create Integration Tests + + backend/tests/unit/terminal_client/test_integration.py + + """ + Integration tests for terminal client improvements. + + Tests the complete flow of all improvements working together. + """ + import pytest + import asyncio + from uuid import uuid4 + from unittest.mock import AsyncMock, MagicMock, patch, call + + from terminal_client.commands import GameCommands + from terminal_client.arg_parser import ( + parse_new_game_args, + parse_defensive_args, + parse_offensive_args + ) + from terminal_client.completions import GameREPLCompletions + from terminal_client.help_text import show_help + from terminal_client.player_cache import player_cache + + + class TestEndToEndWorkflow: + """Test complete workflow using all improvements.""" + + @pytest.fixture + def game_commands(self): + """Create GameCommands with mocked dependencies.""" + commands = GameCommands() + commands.db_ops = AsyncMock() + return commands + + @pytest.fixture + def repl_completions(self): + """Create GameREPLCompletions instance.""" + return GameREPLCompletions() + + @pytest.mark.asyncio + async def test_complete_game_workflow(self, game_commands): + """ + Test complete workflow: create game, make decisions, resolve. + + This tests that all components work together: + - Shared commands + - Argument parsing + - Game engine integration + """ + 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): + # Mock game state + from app.models.game_models import GameState + mock_state = GameState( + game_id=game_id, + league_id='sba', + home_team_id=1, + away_team_id=2, + inning=1, + half='top', + status='active' + ) + + mock_sm.create_game = AsyncMock(return_value=mock_state) + mock_ge.start_game = AsyncMock(return_value=mock_state) + mock_ge.submit_defensive_decision = AsyncMock(return_value=mock_state) + mock_ge.submit_offensive_decision = AsyncMock(return_value=mock_state) + + # Step 1: Create game + gid, success = await game_commands.create_new_game( + league='sba', + home_team=1, + away_team=2 + ) + + assert success is True + assert gid == game_id + mock_ge.start_game.assert_called_once_with(game_id) + + # Step 2: Submit defensive decision + success = await game_commands.submit_defensive_decision( + game_id=game_id, + alignment='shifted_left', + hold_runners=[1, 3] + ) + + assert success is True + mock_ge.submit_defensive_decision.assert_called_once() + + # Step 3: Submit offensive decision + success = await game_commands.submit_offensive_decision( + game_id=game_id, + approach='power', + steal_attempts=[2] + ) + + assert success is True + mock_ge.submit_offensive_decision.assert_called_once() + + def test_argument_parsing_with_completions(self, repl_completions): + """ + Test that parsed arguments work with tab completions. + + Ensures argument parser and completion system are compatible. + """ + # Parse arguments + args = parse_defensive_args('--alignment shifted_left --hold 1,3') + + assert args['alignment'] == 'shifted_left' + assert args['hold'] == [1, 3] + + # Verify completion suggests valid values + completions = repl_completions.complete_defensive( + 'shift', 'defensive --alignment shift', 10, 30 + ) + + assert 'shifted_left' in completions + + def test_help_system_covers_all_commands(self): + """ + Test that help system has documentation for all commands. + + Ensures every command has proper help text. + """ + from terminal_client.help_text import HELP_DATA + + required_commands = [ + 'new_game', 'defensive', 'offensive', 'resolve', + 'quick_play', 'status', 'list_games', 'use_game' + ] + + for cmd in required_commands: + assert cmd in HELP_DATA, f"Missing help for {cmd}" + + help_data = HELP_DATA[cmd] + assert 'summary' in help_data + assert 'usage' in help_data + assert 'examples' in help_data + + @pytest.mark.asyncio + async def test_player_cache_integration(self): + """ + Test player cache integration with game workflow. + + Verifies caching works correctly during gameplay. + """ + game_id = uuid4() + + # Add players to cache + player_cache.add_player({ + 'card_id': 101, + 'name': 'Mike Trout', + 'position': 'CF', + 'team_id': 1, + 'league': 'sba' + }, lineup_id=1) + + player_cache.add_player({ + 'card_id': 201, + 'name': 'Clayton Kershaw', + 'position': 'P', + 'team_id': 2, + 'league': 'sba' + }, lineup_id=10) + + # Verify retrieval + batter = player_cache.get_by_lineup_id(1) + assert batter is not None + assert batter.name == 'Mike Trout' + + pitcher = player_cache.get_by_lineup_id(10) + assert pitcher is not None + assert pitcher.name == 'Clayton Kershaw' + + # Get stats + stats = player_cache.get_stats() + assert stats['card_cache_size'] >= 2 + + + class TestErrorHandling: + """Test error handling across all improvements.""" + + @pytest.fixture + def game_commands(self): + commands = GameCommands() + commands.db_ops = AsyncMock() + return commands + + @pytest.mark.asyncio + async def test_command_handles_database_error(self, game_commands): + """Test that commands handle database errors gracefully.""" + game_id = uuid4() + + with patch('terminal_client.commands.game_engine') as mock_ge: + mock_ge.submit_defensive_decision = AsyncMock( + side_effect=Exception("Database error") + ) + + success = await game_commands.submit_defensive_decision( + game_id=game_id + ) + + assert success is False + + def test_argument_parser_handles_invalid_input(self): + """Test that argument parser handles invalid input gracefully.""" + from terminal_client.arg_parser import ( + parse_defensive_args, + ArgumentParseError + ) + + # Invalid option + with pytest.raises(ArgumentParseError, match="Unknown option"): + parse_defensive_args('--invalid-option value') + + # Invalid type + with pytest.raises(ArgumentParseError, match="expected int"): + parse_defensive_args('--hold abc') + + def test_completion_handles_empty_state(self): + """Test that completions work even with empty state.""" + from terminal_client.completions import GameREPLCompletions + + repl = GameREPLCompletions() + + # Should not crash on empty game list + with patch('terminal_client.completions.state_manager') as mock_sm: + mock_sm.list_games.return_value = [] + + completions = repl.complete_use_game('', 'use_game ', 9, 9) + + assert completions == [] + + + class TestPerformance: + """Test performance of improvements.""" + + def test_player_cache_performance(self): + """Test that player cache provides fast lookups.""" + import time + + # Add 100 players + for i in range(100): + player_cache.add_player({ + 'card_id': i, + 'name': f'Player {i}', + 'position': 'CF', + 'team_id': 1, + 'league': 'sba' + }, lineup_id=i) + + # Time lookups + start = time.time() + for i in range(100): + player = player_cache.get_by_lineup_id(i) + assert player is not None + end = time.time() + + # Should be very fast (< 10ms for 100 lookups) + elapsed_ms = (end - start) * 1000 + assert elapsed_ms < 10, f"Cache lookups too slow: {elapsed_ms}ms" + + def test_argument_parsing_performance(self): + """Test that argument parsing is fast.""" + import time + from terminal_client.arg_parser import parse_defensive_args + + start = time.time() + for _ in range(1000): + args = parse_defensive_args( + '--alignment shifted_left --infield double_play --hold 1,3' + ) + end = time.time() + + elapsed_ms = (end - start) * 1000 + # Should parse 1000 times in < 100ms + assert elapsed_ms < 100, f"Parsing too slow: {elapsed_ms}ms" + + + class TestBackwardCompatibility: + """Test that improvements don't break existing functionality.""" + + @pytest.mark.asyncio + async def test_old_command_interface_still_works(self): + """Test that old-style command usage still works.""" + from terminal_client.commands import game_commands + + game_id = uuid4() + + with patch('terminal_client.commands.game_engine') as mock_ge: + from app.models.game_models import GameState + mock_state = GameState( + game_id=game_id, + league_id='sba', + home_team_id=1, + away_team_id=2 + ) + mock_ge.get_game_state = AsyncMock(return_value=mock_state) + + # Old-style status check should still work + success = await game_commands.show_game_status(game_id) + + assert success is True + mock_ge.get_game_state.assert_called_once_with(game_id) + + 3. End-to-End REPL Tests + + backend/tests/e2e/test_terminal_client.py + + """ + End-to-end tests for terminal client REPL. + + These tests simulate actual user interaction with the REPL. + """ + import pytest + import asyncio + from io import StringIO + from unittest.mock import patch, MagicMock + + from terminal_client.repl import GameREPL + + + class TestREPLInteraction: + """Test REPL user interaction flows.""" + + @pytest.fixture + def repl(self): + """Create REPL instance for testing.""" + with patch('terminal_client.repl.state_manager'): + with patch('terminal_client.repl.game_engine'): + repl = GameREPL() + return repl + + def test_help_command(self, repl): + """Test that help command displays correctly.""" + with patch('terminal_client.repl.HelpFormatter.show_command_list') as mock_help: + repl.do_help('') + mock_help.assert_called_once() + + def test_help_specific_command(self, repl): + """Test help for specific command.""" + with patch('terminal_client.repl.show_help') as mock_help: + repl.do_help('new_game') + mock_help.assert_called_once_with('new_game') + + def test_tab_completion_defensive(self, repl): + """Test tab completion for defensive command.""" + completions = repl.complete_defensive( + '--align', + 'defensive --align', + 10, + 17 + ) + + assert '--alignment' in completions + + def test_cache_stats_command(self, repl): + """Test cache stats command.""" + with patch('terminal_client.repl.show_cache_stats') as mock_stats: + repl.do_cache('stats') + # Need to run the async function + repl.loop.run_until_complete(repl._run_async(repl.do_cache('stats'))) + + + class TestREPLWorkflow: + """Test complete REPL workflows.""" + + def test_new_game_workflow(self): + """ + Test complete new game workflow: + 1. new_game + 2. defensive + 3. offensive + 4. resolve + 5. status + """ + # This would be a full integration test + # Implementation left as exercise + pass + + --- + B. Migration Guide + + Step-by-Step Implementation Order + + Phase 1: Preparation (Week 1 Day 1-2) + + 1. Create new files without breaking existing code + + # Create new modules + touch backend/terminal_client/commands.py + touch backend/terminal_client/arg_parser.py + touch backend/terminal_client/completions.py + touch backend/terminal_client/help_text.py + touch backend/terminal_client/player_cache.py + touch backend/terminal_client/enhanced_display.py + + # Create test directory + mkdir -p backend/tests/unit/terminal_client + touch backend/tests/unit/terminal_client/__init__.py + + 2. Implement shared commands module + + - Copy code from Part 1 into commands.py + - Add all imports + - Don't modify repl.py or main.py yet + - Run: python -c "from terminal_client.commands import GameCommands; print('✓ Import successful')" + + 3. Run tests + + # Should still pass (no changes to existing code yet) + pytest backend/tests/unit/terminal_client/ -v + + Phase 2: Argument Parser (Week 1 Day 3) + + 1. Implement argument parser + + - Copy code from Part 2 into arg_parser.py + - Add unit tests from Part 2 + - Test independently: + + pytest backend/tests/unit/terminal_client/test_arg_parser.py -v + + 2. Verify no regressions + + # Existing tests should still pass + pytest backend/tests/ -v + + Phase 3: Update REPL (Week 1 Day 4-5) + + 1. Create backup + + cp backend/terminal_client/repl.py backend/terminal_client/repl.py.backup + cp backend/terminal_client/main.py backend/terminal_client/main.py.backup + + 2. Update repl.py incrementally + + Update one command at a time: + + # Start with new_game + def do_new_game(self, arg): + # New implementation using shared commands + pass + + # Test it + python -m terminal_client + ⚾ > new_game --league sba + + 3. Update all commands + + - Replace each do_* method with new implementation + - Test each command individually + - Verify existing functionality works + + 4. Run full test suite + + pytest backend/tests/unit/terminal_client/ -v + + Phase 4: Tab Completion (Week 2 Day 1-2) + + 1. Implement completions + + - Copy code from Part 3 into completions.py + - Add tests + + 2. Update REPL class + + # Add mixin + class GameREPL(GameREPLCompletions, cmd.Cmd): + pass + + 3. Test completions interactively + + python -m terminal_client + ⚾ > def + ⚾ > defensive -- + + Phase 5: Help System (Week 2 Day 3) + + 1. Implement help text + + - Copy code from Part 4 into help_text.py + - Verify all commands have help data + + 2. Update help methods in REPL + + - Replace do_help method + - Add help_* methods + + 3. Test help system + + python -m terminal_client + ⚾ > help + ⚾ > help defensive + + Phase 6: Player Cache (Week 2 Day 4-5) [OPTIONAL - Requires Week 6 Models] + + 1. Implement cache (inactive by default) + + - Copy code from Part 5 + - Set feature flags to False + + 2. Add tests + + - Test cache in isolation + - Don't activate yet + + 3. Document activation process + + - Update CLAUDE.md with activation instructions + + Verification Checklist + + After each phase, verify: + + - All existing tests pass + - New tests pass + - REPL starts without errors + - Can create a new game + - Can submit decisions + - Can resolve plays + - Can see game status + - No import errors + - No runtime errors in logs + + Rollback Plan + + If something goes wrong: + + Quick Rollback: + # Restore backups + cp backend/terminal_client/repl.py.backup backend/terminal_client/repl.py + cp backend/terminal_client/main.py.backup backend/terminal_client/main.py + + # Remove new modules + rm backend/terminal_client/commands.py + rm backend/terminal_client/arg_parser.py + rm backend/terminal_client/completions.py + rm backend/terminal_client/help_text.py + + # Verify system works + python -m terminal_client + ⚾ > new_game + ⚾ > quit + + Incremental Rollback: + + If only one component is problematic: + + 1. Keep working components + 2. Revert only the problematic module + 3. Remove integration code for that module + 4. Continue with working improvements + + --- + C. Documentation Updates + + 1. Update backend/terminal_client/CLAUDE.md + + Add sections: + + ## Recent Improvements (2025-10-27) + + ### Shared Command Logic + + All command implementations now use `terminal_client/commands.py` for: + - Reduced code duplication (-500 lines) + - Consistent behavior between REPL and CLI + - Easier testing and maintenance + + **Usage:** Commands automatically use shared logic + + ### Robust Argument Parsing + + Arguments now use `shlex` for proper parsing: + - Handles quoted strings with spaces + - Better error messages + - Type validation + + **Example:** + ```bash + ⚾ > defensive --alignment "shifted left" # Now works! + + Tab Completion + + All commands support tab completion: + - Command names: new → new_game + - Options: defensive -- → shows all options + - Values: --alignment → shows valid alignments + + Usage: Press TAB at any point for suggestions + + Enhanced Help System + + Detailed help with examples: + - help - List all commands + - help - Detailed help with examples + + Player Name Display (Future) + + Infrastructure ready for Week 6 player models: + - Player cache system implemented + - Enhanced display functions ready + - Feature flags control activation + + Activation: Set ENABLE_PLAYER_NAMES = True when ready + + ### 2. Create Migration Document + + #### `backend/.claude/terminal_client_improvements.md` + + ```markdown + # Terminal Client Improvements - Implementation Log + + ## Summary + + Six major improvements to terminal client: + 1. Shared command logic (-500 lines duplication) + 2. Robust argument parsing with shlex + 3. Tab completion for all commands + 4. Enhanced help system + 5. Player cache infrastructure (inactive) + 6. Comprehensive test suite + + ## Files Created + + - `terminal_client/commands.py` (450 lines) + - `terminal_client/arg_parser.py` (250 lines) + - `terminal_client/completions.py` (350 lines) + - `terminal_client/help_text.py` (400 lines) + - `terminal_client/player_cache.py` (300 lines) + - `terminal_client/enhanced_display.py` (150 lines) + + Total: ~1900 lines of new, tested code + + ## Files Modified + + - `terminal_client/repl.py` (simplified, -200 lines) + - `terminal_client/main.py` (simplified, -150 lines) + + Total: -350 lines of duplicated code + + ## Tests Added + + - `test_commands.py` (200 lines) + - `test_arg_parser.py` (250 lines) + - `test_completions.py` (200 lines) + - `test_help_text.py` (150 lines) + - `test_player_cache.py` (200 lines) + - `test_integration.py` (300 lines) + + Total: ~1300 lines of test code + + ## Test Coverage + + - Unit tests: 80+ tests + - Integration tests: 15+ tests + - Coverage: ~95% of new code + + ## Performance Impact + + - Argument parsing: < 0.1ms per command + - Tab completion: < 5ms per completion + - Player cache: < 0.01ms per lookup + - Help display: < 10ms + + **No measurable impact on REPL responsiveness** + + ## Migration Status + + - [x] Phase 1: Preparation + - [x] Phase 2: Argument Parser + - [x] Phase 3: Update REPL + - [x] Phase 4: Tab Completion + - [x] Phase 5: Help System + - [ ] Phase 6: Player Cache (waiting on Week 6) + + ## Known Issues + + None + + ## Future Enhancements + + 1. Activate player cache when Week 6 models ready + 2. Add command history persistence + 3. Add command aliases (e.g., 'd' for defensive) + 4. Add macro recording/playback for testing + + --- + D. Final Verification + + Manual Testing Checklist + + Run through this checklist before considering migration complete: + + # 1. Start REPL + python -m terminal_client + + # 2. Test help system + ⚾ > help + ⚾ > help new_game + ⚾ > help defensive + + # 3. Test tab completion + ⚾ > new # Should complete to new_game + ⚾ > defensive -- # Should show all options + ⚾ > defensive --alignment # Should show valid values + + # 4. Test argument parsing + ⚾ > new_game --league pd --home-team 5 + ⚾ > defensive --alignment shifted_left --hold 1,3 + ⚾ > offensive --approach power --hit-run + + # 5. Test gameplay + ⚾ > defensive + ⚾ > offensive + ⚾ > resolve + ⚾ > status + + # 6. Test quick play + ⚾ > quick_play 10 + + # 7. Test error handling + ⚾ > defensive --invalid value # Should show clear error + ⚾ > resolve # Should fail appropriately (no decisions) + + # 8. Test cache commands (if activated) + ⚾ > cache stats + ⚾ > cache clear + + # 9. Exit cleanly + ⚾ > quit + + Automated Test Run + + # Run all tests + pytest backend/tests/unit/terminal_client/ -v --cov=terminal_client --cov-report=html + + # Should see: + # - 80+ passing tests + # - ~95% coverage + # - 0 failures + + --- + E. Success Criteria + + Migration is successful when: + + - All 80+ tests pass + - Test coverage ≥ 95% + - REPL starts without errors + - All commands work as before + - Tab completion works + - Help system displays correctly + - Argument parsing handles edge cases + - No performance degradation + - Manual testing checklist passes + - Documentation updated + - No regressions in existing functionality + + --- + Summary + + Total New Code: ~1900 lines + Code Removed: ~350 lines (duplicates) + Test Code: ~1300 lines + Net Result: Better, more maintainable, well-tested terminal client + + Estimated Implementation Time: 2 weeks (1 developer) + + Benefits: + - Reduced duplication + - Better UX (tab completion, help) + - Easier to maintain + - Ready for future enhancements + - Comprehensive test coverage + + Risks: Low (incremental migration, full rollback plan) diff --git a/backend/tests/integration/test_game_engine.py b/backend/tests/integration/test_game_engine.py index e9d4483..1ab1c1b 100644 --- a/backend/tests/integration/test_game_engine.py +++ b/backend/tests/integration/test_game_engine.py @@ -407,15 +407,16 @@ class TestSnapshotTracking: await game_engine.resolve_play(game_id) state = await game_engine.get_game_state(game_id) - if state.runners: + all_runners = state.get_all_runners() + if all_runners: # Verify on_base_code matches runners expected_code = 0 - for runner in state.runners: - if runner.on_base == 1: + for base, runner in all_runners: + if base == 1: expected_code |= 1 - elif runner.on_base == 2: + elif base == 2: expected_code |= 2 - elif runner.on_base == 3: + elif base == 3: expected_code |= 4 assert state.current_on_base_code == expected_code diff --git a/backend/tests/unit/core/test_play_resolver.py b/backend/tests/unit/core/test_play_resolver.py index 2f4cb63..1943a34 100644 --- a/backend/tests/unit/core/test_play_resolver.py +++ b/backend/tests/unit/core/test_play_resolver.py @@ -15,7 +15,7 @@ from app.core.play_resolver import ( SimplifiedResultChart ) from app.core.roll_types import AbRoll, RollType -from app.models.game_models import GameState, RunnerState, DefensiveDecision, OffensiveDecision +from app.models.game_models import GameState, LineupPlayerState, DefensiveDecision, OffensiveDecision # Helper to create mock AbRoll @@ -168,11 +168,9 @@ class TestPlayResultResolution: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - RunnerState(lineup_id=2, card_id=102, on_base=2), - RunnerState(lineup_id=3, card_id=103, on_base=3) - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), + on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3) ) ab_roll = create_mock_ab_roll(15) @@ -193,7 +191,7 @@ class TestPlayResultResolution: league_id="sba", home_team_id=1, away_team_id=2, - runners=[RunnerState(lineup_id=1, card_id=101, on_base=3)] + on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) ab_roll = create_mock_ab_roll(17) @@ -210,11 +208,9 @@ class TestPlayResultResolution: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - RunnerState(lineup_id=2, card_id=102, on_base=2), - RunnerState(lineup_id=3, card_id=103, on_base=3) - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), + on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3) ) ab_roll = create_mock_ab_roll(20) @@ -231,7 +227,7 @@ class TestPlayResultResolution: league_id="sba", home_team_id=1, away_team_id=2, - runners=[RunnerState(lineup_id=1, card_id=101, on_base=3)] + on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) ab_roll = create_mock_ab_roll(check_d20=1, resolution_d20=8) diff --git a/backend/tests/unit/core/test_validators.py b/backend/tests/unit/core/test_validators.py index afa63c8..45239c6 100644 --- a/backend/tests/unit/core/test_validators.py +++ b/backend/tests/unit/core/test_validators.py @@ -8,7 +8,7 @@ from uuid import uuid4 from dataclasses import dataclass from app.core.validators import GameValidator, ValidationError -from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision, RunnerState +from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision, LineupPlayerState # Mock LineupPlayerState for lineup validation tests @@ -207,7 +207,7 @@ class TestDefensiveDecisionValidation: league_id="sba", home_team_id=1, away_team_id=2, - runners=[RunnerState(lineup_id=1, card_id=101, on_base=1)] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) decision = DefensiveDecision( alignment="normal", @@ -275,7 +275,7 @@ class TestOffensiveDecisionValidation: league_id="sba", home_team_id=1, away_team_id=2, - runners=[RunnerState(lineup_id=1, card_id=101, on_base=1)] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) decision = OffensiveDecision( approach="normal", diff --git a/backend/tests/unit/models/test_game_models.py b/backend/tests/unit/models/test_game_models.py index 0e616f6..4e66e50 100644 --- a/backend/tests/unit/models/test_game_models.py +++ b/backend/tests/unit/models/test_game_models.py @@ -12,7 +12,6 @@ from uuid import uuid4 from pydantic import ValidationError from app.models.game_models import ( - RunnerState, LineupPlayerState, TeamLineupState, DefensiveDecision, @@ -21,44 +20,6 @@ from app.models.game_models import ( ) -# ============================================================================ -# RUNNERSTATE TESTS -# ============================================================================ - -class TestRunnerState: - """Tests for RunnerState model""" - - def test_create_runner_state_valid(self): - """Test creating a valid RunnerState""" - runner = RunnerState(lineup_id=1, card_id=100, on_base=1) - assert runner.lineup_id == 1 - assert runner.card_id == 100 - assert runner.on_base == 1 - - def test_runner_state_all_bases(self): - """Test runner can be on all valid bases""" - for base in [1, 2, 3]: - runner = RunnerState(lineup_id=1, card_id=100, on_base=base) - assert runner.on_base == base - - def test_runner_state_invalid_base_zero(self): - """Test that base 0 is invalid""" - with pytest.raises(ValidationError) as exc_info: - RunnerState(lineup_id=1, card_id=100, on_base=0) - assert "on_base" in str(exc_info.value) - - def test_runner_state_invalid_base_four(self): - """Test that base 4 is invalid""" - with pytest.raises(ValidationError) as exc_info: - RunnerState(lineup_id=1, card_id=100, on_base=4) - assert "on_base" in str(exc_info.value) - - def test_runner_state_invalid_base_negative(self): - """Test that negative bases are invalid""" - with pytest.raises(ValidationError): - RunnerState(lineup_id=1, card_id=100, on_base=-1) - - # ============================================================================ # LINEUP TESTS # ============================================================================ @@ -477,10 +438,8 @@ class TestGameState: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - RunnerState(lineup_id=3, card_id=103, on_base=3), - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3) ) assert state.is_runner_on_first() is True @@ -495,10 +454,8 @@ class TestGameState: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - RunnerState(lineup_id=2, card_id=102, on_base=2), - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), + on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2) ) runner = state.get_runner_at_base(1) @@ -516,10 +473,8 @@ class TestGameState: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - RunnerState(lineup_id=3, card_id=103, on_base=3), - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), + on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3) ) bases = state.bases_occupied() @@ -533,15 +488,16 @@ class TestGameState: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - RunnerState(lineup_id=2, card_id=102, on_base=2), - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), + on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2) ) - assert len(state.runners) == 2 + assert len(state.get_all_runners()) == 2 state.clear_bases() - assert len(state.runners) == 0 + assert len(state.get_all_runners()) == 0 + assert state.on_first is None + assert state.on_second is None + assert state.on_third is None def test_add_runner(self): """Test adding a runner to a base""" @@ -553,8 +509,9 @@ class TestGameState: away_team_id=2 ) - state.add_runner(lineup_id=1, card_id=101, base=1) - assert len(state.runners) == 1 + player = LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) + state.add_runner(player=player, base=1) + assert len(state.get_all_runners()) == 1 assert state.is_runner_on_first() is True def test_add_runner_replaces_existing(self): @@ -565,13 +522,12 @@ class TestGameState: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) - state.add_runner(lineup_id=2, card_id=102, base=1) - assert len(state.runners) == 1 # Still only 1 runner + new_player = LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2) + state.add_runner(player=new_player, base=1) + assert len(state.get_all_runners()) == 1 # Still only 1 runner runner = state.get_runner_at_base(1) assert runner.lineup_id == 2 # New runner replaced old @@ -583,9 +539,7 @@ class TestGameState: league_id="sba", home_team_id=1, away_team_id=2, - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=1), - ] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) state.advance_runner(from_base=1, to_base=2) @@ -601,14 +555,12 @@ class TestGameState: home_team_id=1, away_team_id=2, half="top", - runners=[ - RunnerState(lineup_id=1, card_id=101, on_base=3), - ] + on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) initial_score = state.away_score state.advance_runner(from_base=3, to_base=4) - assert len(state.runners) == 0 # Runner removed from bases + assert len(state.get_all_runners()) == 0 # Runner removed from bases assert state.away_score == initial_score + 1 # Score increased def test_advance_runner_scores_correct_team(self): @@ -622,7 +574,7 @@ class TestGameState: home_team_id=1, away_team_id=2, half="top", - runners=[RunnerState(lineup_id=1, card_id=101, on_base=3)] + on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) state.advance_runner(from_base=3, to_base=4) assert state.away_score == 1 @@ -635,7 +587,7 @@ class TestGameState: home_team_id=1, away_team_id=2, half="bottom", - runners=[RunnerState(lineup_id=5, card_id=105, on_base=3)] + on_third=LineupPlayerState(lineup_id=5, card_id=105, position="RF", batting_order=5) ) state.advance_runner(from_base=3, to_base=4) assert state.home_score == 1 @@ -672,14 +624,14 @@ class TestGameState: inning=3, half="top", outs=2, - runners=[RunnerState(lineup_id=1, card_id=101, on_base=1)] + on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) state.end_half_inning() assert state.inning == 3 # Same inning assert state.half == "bottom" # Now bottom assert state.outs == 0 # Outs reset - assert len(state.runners) == 0 # Bases cleared + assert len(state.get_all_runners()) == 0 # Bases cleared def test_end_half_inning_bottom_to_next_top(self): """Test ending bottom of inning goes to next inning top""" @@ -692,14 +644,14 @@ class TestGameState: inning=3, half="bottom", outs=2, - runners=[RunnerState(lineup_id=1, card_id=101, on_base=2)] + on_second=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) ) state.end_half_inning() assert state.inning == 4 # Next inning assert state.half == "top" # Top of next inning assert state.outs == 0 # Outs reset - assert len(state.runners) == 0 # Bases cleared + assert len(state.get_all_runners()) == 0 # Bases cleared def test_is_game_over_early_innings(self): """Test game is not over in early innings""" diff --git a/backend/tests/unit/terminal_client/__init__.py b/backend/tests/unit/terminal_client/__init__.py new file mode 100644 index 0000000..e3cb694 --- /dev/null +++ b/backend/tests/unit/terminal_client/__init__.py @@ -0,0 +1 @@ +"""Terminal client unit tests.""" diff --git a/backend/tests/unit/terminal_client/test_arg_parser.py b/backend/tests/unit/terminal_client/test_arg_parser.py new file mode 100644 index 0000000..191cd69 --- /dev/null +++ b/backend/tests/unit/terminal_client/test_arg_parser.py @@ -0,0 +1,260 @@ +""" +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 diff --git a/backend/tests/unit/terminal_client/test_commands.py b/backend/tests/unit/terminal_client/test_commands.py new file mode 100644 index 0000000..fd7cee9 --- /dev/null +++ b/backend/tests/unit/terminal_client/test_commands.py @@ -0,0 +1,375 @@ +""" +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 diff --git a/backend/tests/unit/terminal_client/test_completions.py b/backend/tests/unit/terminal_client/test_completions.py new file mode 100644 index 0000000..e4ed99e --- /dev/null +++ b/backend/tests/unit/terminal_client/test_completions.py @@ -0,0 +1,310 @@ +""" +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 diff --git a/backend/tests/unit/terminal_client/test_help_text.py b/backend/tests/unit/terminal_client/test_help_text.py new file mode 100644 index 0000000..228dbad --- /dev/null +++ b/backend/tests/unit/terminal_client/test_help_text.py @@ -0,0 +1,182 @@ +""" +Unit tests for help text system. +""" +import pytest +from unittest.mock import patch + +from terminal_client.help_text import ( + HelpFormatter, + get_help_text, + show_help, + HELP_DATA +) + + +class TestHelpData: + """Tests for help data structure.""" + + def test_all_commands_have_help(self): + """Test that all major commands have help data.""" + required_commands = [ + 'new_game', 'defensive', 'offensive', 'resolve', + 'quick_play', 'status', 'use_game', 'list_games' + ] + + for cmd in required_commands: + assert cmd in HELP_DATA, f"Missing help data for {cmd}" + + def test_help_data_structure(self): + """Test that help data has required fields.""" + for cmd, data in HELP_DATA.items(): + assert 'summary' in data, f"{cmd} missing summary" + assert 'usage' in data, f"{cmd} missing usage" + assert 'options' in data, f"{cmd} missing options" + assert 'examples' in data, f"{cmd} missing examples" + + # Validate options structure + for opt in data['options']: + assert 'name' in opt, f"{cmd} option missing name" + assert 'desc' in opt, f"{cmd} option missing desc" + + def test_help_data_has_examples(self): + """Test that all commands have at least one example.""" + for cmd, data in HELP_DATA.items(): + assert len(data['examples']) > 0, f"{cmd} has no examples" + + def test_get_help_text_valid(self): + """Test getting help text for valid command.""" + result = get_help_text('new_game') + assert result is not None + assert 'summary' in result + assert 'usage' in result + + def test_get_help_text_invalid(self): + """Test getting help text for invalid command.""" + result = get_help_text('nonexistent_command') + assert result == {} + + +class TestHelpFormatter: + """Tests for HelpFormatter.""" + + @patch('terminal_client.help_text.console') + def test_show_command_help(self, mock_console): + """Test showing help for a command.""" + help_data = { + 'summary': 'Test command', + 'usage': 'test [OPTIONS]', + 'options': [ + {'name': '--option', 'type': 'STRING', 'desc': 'Test option'} + ], + 'examples': ['test --option value'] + } + + HelpFormatter.show_command_help('test', help_data) + + # Verify console.print was called + assert mock_console.print.called + assert mock_console.print.call_count >= 3 # Panel, options header, table, examples + + @patch('terminal_client.help_text.console') + def test_show_command_help_with_notes(self, mock_console): + """Test showing help with notes field.""" + help_data = { + 'summary': 'Test command', + 'usage': 'test', + 'options': [], + 'examples': ['test'], + 'notes': 'This is a test note' + } + + HelpFormatter.show_command_help('test', help_data) + + # Verify console.print was called + assert mock_console.print.called + + @patch('terminal_client.help_text.console') + def test_show_command_list(self, mock_console): + """Test showing command list.""" + HelpFormatter.show_command_list() + + # Verify console.print was called multiple times + # Should have: header, game management section, gameplay section, utilities section + assert mock_console.print.call_count > 5 + + +class TestShowHelp: + """Tests for show_help function.""" + + @patch('terminal_client.help_text.HelpFormatter.show_command_help') + def test_show_help_specific_command(self, mock_show): + """Test showing help for specific command.""" + show_help('new_game') + + mock_show.assert_called_once() + args = mock_show.call_args[0] + assert args[0] == 'new_game' + assert 'summary' in args[1] + + @patch('terminal_client.help_text.HelpFormatter.show_command_list') + def test_show_help_no_command(self, mock_list): + """Test showing command list when no command specified.""" + show_help() + + mock_list.assert_called_once() + + @patch('terminal_client.help_text.console') + def test_show_help_invalid_command(self, mock_console): + """Test showing help for invalid command.""" + show_help('invalid_command') + + # Should print warning message + assert mock_console.print.called + call_args = str(mock_console.print.call_args_list) + assert 'No help available' in call_args + + +class TestSpecificCommandHelp: + """Tests for specific command help data.""" + + def test_new_game_help_complete(self): + """Test new_game help has all expected fields.""" + data = get_help_text('new_game') + assert data['summary'] + assert data['usage'] + assert len(data['options']) == 3 # --league, --home-team, --away-team + assert len(data['examples']) >= 2 + + def test_defensive_help_complete(self): + """Test defensive help has all expected fields.""" + data = get_help_text('defensive') + assert data['summary'] + assert data['usage'] + assert len(data['options']) == 4 # --alignment, --infield, --outfield, --hold + assert len(data['examples']) >= 3 + + def test_offensive_help_complete(self): + """Test offensive help has all expected fields.""" + data = get_help_text('offensive') + assert data['summary'] + assert data['usage'] + assert len(data['options']) == 4 # --approach, --steal, --hit-run, --bunt + assert len(data['examples']) >= 3 + + def test_resolve_help_has_notes(self): + """Test resolve help includes notes about requirements.""" + data = get_help_text('resolve') + assert 'notes' in data + assert 'Both defensive and offensive decisions' in data['notes'] + + def test_quick_play_help_complete(self): + """Test quick_play help has all expected fields.""" + data = get_help_text('quick_play') + assert data['summary'] + assert data['usage'] + assert len(data['options']) == 1 # COUNT + assert len(data['examples']) >= 3 + + def test_use_game_help_mentions_tab_completion(self): + """Test use_game help mentions tab completion.""" + data = get_help_text('use_game') + examples = '\n'.join(data['examples']) + assert 'TAB' in examples or 'tab' in examples.lower()