strat-gameplay-webapp/backend/clean_test_data.py
Cal Corum 1c32787195 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>
2025-10-28 14:16:38 -05:00

309 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Database Cleanup Script
Clear out test data from database tables.
Use with caution - this will DELETE data!
Usage:
python clean_test_data.py # Interactive mode
python clean_test_data.py --all # Delete all data
python clean_test_data.py --game <id> # Delete specific game
"""
import asyncio
import logging
import sys
from uuid import UUID
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import AsyncSessionLocal
from app.models.db_models import (
Game,
Play,
Lineup,
GameSession,
RosterLink,
GameCardsetLink
)
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class DatabaseCleaner:
"""Clean test data from database."""
async def count_records(self, session: AsyncSession) -> dict:
"""Count records in each table."""
counts = {}
# Count games
result = await session.execute(select(Game))
counts['games'] = len(list(result.scalars().all()))
# Count plays
result = await session.execute(select(Play))
counts['plays'] = len(list(result.scalars().all()))
# Count lineups
result = await session.execute(select(Lineup))
counts['lineups'] = len(list(result.scalars().all()))
# Count roster links
result = await session.execute(select(RosterLink))
counts['roster_links'] = len(list(result.scalars().all()))
# Count game sessions
result = await session.execute(select(GameSession))
counts['game_sessions'] = len(list(result.scalars().all()))
# Count cardset links
result = await session.execute(select(GameCardsetLink))
counts['game_cardset_links'] = len(list(result.scalars().all()))
return counts
async def delete_game(self, game_id: UUID) -> None:
"""
Delete a specific game and all related data.
Cascade deletes will automatically remove:
- Plays
- Lineups
- RosterLinks
- GameSession
- GameCardsetLinks
"""
async with AsyncSessionLocal() as session:
try:
# Check if game exists
result = await session.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
logger.warning(f"Game {game_id} not found")
return
logger.info(f"Deleting game {game_id} ({game.league_id})...")
# Delete game (cascade will handle related records)
await session.delete(game)
await session.commit()
logger.info(f"✅ Successfully deleted game {game_id}")
except Exception as e:
await session.rollback()
logger.error(f"❌ Error deleting game: {e}", exc_info=True)
raise
async def delete_all(self, confirm: bool = False) -> None:
"""
Delete ALL data from all tables.
WARNING: This is destructive and cannot be undone!
"""
if not confirm:
logger.warning("⚠️ delete_all() requires confirm=True parameter")
return
async with AsyncSessionLocal() as session:
try:
logger.info("🗑️ Deleting ALL data from database...")
# Get counts before deletion
before_counts = await self.count_records(session)
logger.info(f"Current record counts: {before_counts}")
# Delete in correct order (children first)
logger.info("Deleting plays...")
await session.execute(delete(Play))
logger.info("Deleting lineups...")
await session.execute(delete(Lineup))
logger.info("Deleting roster links...")
await session.execute(delete(RosterLink))
logger.info("Deleting game sessions...")
await session.execute(delete(GameSession))
logger.info("Deleting game cardset links...")
await session.execute(delete(GameCardsetLink))
logger.info("Deleting games...")
await session.execute(delete(Game))
await session.commit()
# Verify deletion
after_counts = await self.count_records(session)
logger.info(f"After deletion: {after_counts}")
logger.info("✅ Successfully deleted all data")
except Exception as e:
await session.rollback()
logger.error(f"❌ Error deleting data: {e}", exc_info=True)
raise
async def list_games(self) -> None:
"""List all games in the database."""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Game).order_by(Game.created_at.desc())
)
games = result.scalars().all()
if not games:
logger.info("No games found in database")
return
logger.info(f"\n{'='*80}")
logger.info(f"Found {len(games)} game(s) in database:")
logger.info(f"{'='*80}")
for game in games:
logger.info(f"\nGame ID: {game.id}")
logger.info(f" League: {game.league_id}")
logger.info(f" Status: {game.status}")
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: # 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}")
async def show_stats(self) -> None:
"""Show database statistics."""
async with AsyncSessionLocal() as session:
counts = await self.count_records(session)
logger.info(f"\n{'='*80}")
logger.info("DATABASE STATISTICS")
logger.info(f"{'='*80}")
logger.info(f"Games: {counts['games']:>6}")
logger.info(f"Plays: {counts['plays']:>6}")
logger.info(f"Lineups: {counts['lineups']:>6}")
logger.info(f"Roster Links: {counts['roster_links']:>6}")
logger.info(f"Game Sessions: {counts['game_sessions']:>6}")
logger.info(f"Cardset Links: {counts['game_cardset_links']:>6}")
logger.info(f"{'='*80}\n")
async def interactive_menu():
"""Interactive menu for cleanup operations."""
cleaner = DatabaseCleaner()
while True:
print("\n" + "="*60)
print("DATABASE CLEANUP MENU")
print("="*60)
print("1. Show database statistics")
print("2. List all games")
print("3. Delete specific game")
print("4. Delete ALL data (⚠️ DANGER)")
print("5. Exit")
print("="*60)
choice = input("\nEnter your choice (1-5): ").strip()
if choice == "1":
await cleaner.show_stats()
elif choice == "2":
await cleaner.list_games()
elif choice == "3":
game_id_str = input("Enter game UUID: ").strip()
try:
game_id = UUID(game_id_str)
confirm = input(f"Delete game {game_id}? (yes/no): ").strip().lower()
if confirm == "yes":
await cleaner.delete_game(game_id)
else:
logger.info("Cancelled")
except ValueError:
logger.error("Invalid UUID format")
elif choice == "4":
await cleaner.show_stats()
print("\n⚠️ WARNING: This will DELETE ALL DATA from the database!")
confirm1 = input("Are you absolutely sure? (type 'DELETE ALL'): ").strip()
if confirm1 == "DELETE ALL":
confirm2 = input("Type 'yes' to confirm: ").strip().lower()
if confirm2 == "yes":
await cleaner.delete_all(confirm=True)
else:
logger.info("Cancelled")
else:
logger.info("Cancelled")
elif choice == "5":
logger.info("Exiting...")
break
else:
logger.warning("Invalid choice")
async def main():
"""Main entry point."""
cleaner = DatabaseCleaner()
# Parse command line arguments
if len(sys.argv) == 1:
# No arguments - run interactive mode
await interactive_menu()
elif "--help" in sys.argv or "-h" in sys.argv:
print(__doc__)
elif "--stats" in sys.argv:
await cleaner.show_stats()
elif "--list" in sys.argv:
await cleaner.list_games()
elif "--all" in sys.argv:
await cleaner.show_stats()
print("\n⚠️ WARNING: This will DELETE ALL DATA!")
confirm = input("Type 'yes' to confirm: ").strip().lower()
if confirm == "yes":
await cleaner.delete_all(confirm=True)
else:
logger.info("Cancelled")
elif "--game" in sys.argv:
idx = sys.argv.index("--game")
if idx + 1 < len(sys.argv):
try:
game_id = UUID(sys.argv[idx + 1])
await cleaner.delete_game(game_id)
except ValueError:
logger.error("Invalid UUID format")
else:
logger.error("--game requires a UUID argument")
else:
print("Unknown arguments. Use --help for usage information.")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("\nInterrupted by user")
except Exception as e:
logger.error(f"Error: {e}", exc_info=True)
sys.exit(1)