CLAUDE: Refactor game models and modularize terminal client
This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.
## Game Models Refactor
**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
- on_first: Optional[LineupPlayerState]
- on_second: Optional[LineupPlayerState]
- on_third: Optional[LineupPlayerState]
- Updated helper methods:
- get_runner_at_base() now returns LineupPlayerState directly
- get_all_runners() returns List[Tuple[int, LineupPlayerState]]
- is_runner_on_X() simplified to direct None checks
**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic
**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data
## Terminal Client Refactor
**Modularization (DRY principle):**
Created separate modules for better code organization:
1. **terminal_client/commands.py** (10,243 bytes)
- Shared command functions for game operations
- Used by both CLI (main.py) and REPL (repl.py)
- Functions: submit_defensive_decision, submit_offensive_decision,
resolve_play, quick_play_sequence
- Single source of truth for command logic
2. **terminal_client/arg_parser.py** (7,280 bytes)
- Centralized argument parsing and validation
- Handles defensive/offensive decision arguments
- Validates formats (alignment, depths, hold runners, steal attempts)
3. **terminal_client/completions.py** (10,357 bytes)
- TAB completion support for REPL mode
- Command completions, option completions, dynamic completions
- Game ID completions, defensive/offensive option suggestions
4. **terminal_client/help_text.py** (10,839 bytes)
- Centralized help text and command documentation
- Detailed command descriptions
- Usage examples for all commands
**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions
**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns
## Documentation
**New files:**
- app/models/visual_model_relationships.md
- Visual documentation of model relationships
- Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
- Phased documentation for terminal client evolution
- Historical context for implementation decisions
## Tests
**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py
**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure
## Summary
- ✅ Simplified game state model (removed RunnerState)
- ✅ Better alignment with database structure
- ✅ Modularized terminal client (DRY principle)
- ✅ Shared command logic between CLI and REPL
- ✅ Comprehensive test coverage
- ✅ Improved documentation
Total changes: 26 files modified/created
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
aabb90feb5
commit
1c32787195
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
308
backend/app/models/visual_model_relationships.md
Normal file
308
backend/app/models/visual_model_relationships.md
Normal file
@ -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
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
@ -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}")
|
||||
|
||||
217
backend/terminal_client/arg_parser.py
Normal file
217
backend/terminal_client/arg_parser.py
Normal file
@ -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)
|
||||
316
backend/terminal_client/commands.py
Normal file
316
backend/terminal_client/commands.py
Normal file
@ -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()
|
||||
308
backend/terminal_client/completions.py
Normal file
308
backend/terminal_client/completions.py
Normal file
@ -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 --<TAB>
|
||||
--league --home-team --away-team
|
||||
|
||||
⚾ > new_game --league <TAB>
|
||||
sba pd
|
||||
|
||||
⚾ > defensive --<TAB>
|
||||
--alignment --infield --outfield --hold
|
||||
|
||||
⚾ > defensive --alignment <TAB>
|
||||
normal shifted_left shifted_right extreme_shift
|
||||
|
||||
⚾ > defensive --hold 1,<TAB>
|
||||
1,2 1,3
|
||||
|
||||
⚾ > offensive --approach <TAB>
|
||||
normal contact power patient
|
||||
|
||||
⚾ > use_game <TAB>
|
||||
[shows all active game UUIDs]
|
||||
|
||||
⚾ > quick_play <TAB>
|
||||
1 5 10 27 50 100
|
||||
"""
|
||||
346
backend/terminal_client/help_text.py
Normal file
346
backend/terminal_client/help_text.py
Normal file
@ -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 <command>' 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 <GAME_ID>',
|
||||
'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 <TAB> # 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()
|
||||
@ -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()
|
||||
|
||||
@ -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 <command>' for command details.
|
||||
Type 'quit' or 'exit' to leave.
|
||||
Type 'help' to see all available commands.
|
||||
Type 'help <command>' 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 <game_id>
|
||||
Usage: use_game <game_id>
|
||||
|
||||
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 <game_id>")
|
||||
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 <command> 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):
|
||||
|
||||
501
backend/terminal_client/update_docs/phase_1.md
Normal file
501
backend/terminal_client/update_docs/phase_1.md
Normal file
@ -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
|
||||
600
backend/terminal_client/update_docs/phase_2.md
Normal file
600
backend/terminal_client/update_docs/phase_2.md
Normal file
@ -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 <game_id>
|
||||
|
||||
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
|
||||
557
backend/terminal_client/update_docs/phase_3.md
Normal file
557
backend/terminal_client/update_docs/phase_3.md
Normal file
@ -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 --<TAB>
|
||||
--league --home-team --away-team
|
||||
|
||||
⚾ > new_game --league <TAB>
|
||||
sba pd
|
||||
|
||||
⚾ > defensive --<TAB>
|
||||
--alignment --infield --outfield --hold
|
||||
|
||||
⚾ > defensive --alignment <TAB>
|
||||
normal shifted_left shifted_right extreme_shift
|
||||
|
||||
⚾ > defensive --hold 1,<TAB>
|
||||
1,2 1,3
|
||||
|
||||
⚾ > offensive --approach <TAB>
|
||||
normal contact power patient
|
||||
|
||||
⚾ > use_game <TAB>
|
||||
[shows all active game UUIDs]
|
||||
|
||||
⚾ > quick_play <TAB>
|
||||
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 <command>' 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<TAB> → defensive
|
||||
⚾ > defensive --a<TAB> → defensive --alignment
|
||||
⚾ > defensive --alignment sh<TAB> → defensive --alignment shifted_
|
||||
⚾ > defensive --alignment shifted_<TAB>
|
||||
shifted_left shifted_right
|
||||
|
||||
⚾ > off<TAB> → offensive
|
||||
⚾ > offensive --app<TAB> → offensive --approach
|
||||
⚾ > offensive --approach p<TAB>
|
||||
power patient
|
||||
|
||||
⚾ > new_game --<TAB>
|
||||
--league --home-team --away-team
|
||||
|
||||
⚾ > use_game <TAB>
|
||||
[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
|
||||
664
backend/terminal_client/update_docs/phase_4.md
Normal file
664
backend/terminal_client/update_docs/phase_4.md
Normal file
@ -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 <command>' 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 <GAME_ID>',
|
||||
'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 <TAB> # 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 <command>' 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 <command> 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 <command>' 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
|
||||
853
backend/terminal_client/update_docs/phase_5.md
Normal file
853
backend/terminal_client/update_docs/phase_5.md
Normal file
@ -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
|
||||
803
backend/terminal_client/update_docs/phase_6.md
Normal file
803
backend/terminal_client/update_docs/phase_6.md
Normal file
@ -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<TAB>
|
||||
⚾ > defensive --<TAB>
|
||||
|
||||
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<TAB> → new_game
|
||||
- Options: defensive --<TAB> → shows all options
|
||||
- Values: --alignment <TAB> → shows valid alignments
|
||||
|
||||
Usage: Press TAB at any point for suggestions
|
||||
|
||||
Enhanced Help System
|
||||
|
||||
Detailed help with examples:
|
||||
- help - List all commands
|
||||
- help <command> - 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<TAB> # Should complete to new_game
|
||||
⚾ > defensive --<TAB> # Should show all options
|
||||
⚾ > defensive --alignment <TAB> # 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)
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"""
|
||||
|
||||
1
backend/tests/unit/terminal_client/__init__.py
Normal file
1
backend/tests/unit/terminal_client/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Terminal client unit tests."""
|
||||
260
backend/tests/unit/terminal_client/test_arg_parser.py
Normal file
260
backend/tests/unit/terminal_client/test_arg_parser.py
Normal file
@ -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
|
||||
375
backend/tests/unit/terminal_client/test_commands.py
Normal file
375
backend/tests/unit/terminal_client/test_commands.py
Normal file
@ -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
|
||||
310
backend/tests/unit/terminal_client/test_completions.py
Normal file
310
backend/tests/unit/terminal_client/test_completions.py
Normal file
@ -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
|
||||
182
backend/tests/unit/terminal_client/test_help_text.py
Normal file
182
backend/tests/unit/terminal_client/test_help_text.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user