""" Integration tests for SubstitutionManager. Tests full flow: validation → DB → state sync for all substitution types. Author: Claude Date: 2025-11-04 """ import pytest from uuid import uuid4 from app.core.substitution_manager import SubstitutionManager from app.core.state_manager import state_manager from app.database.operations import DatabaseOperations from app.models.game_models import GameState, LineupPlayerState, TeamLineupState from app.database.session import init_db # Mark all tests in this module as integration tests pytestmark = pytest.mark.integration @pytest.fixture(scope="module") async def setup_database(): """Set up test database schema""" await init_db() yield @pytest.fixture async def game_with_lineups(): """ Create test game with full lineups in database and state. Returns: tuple: (game_id, lineup_ids, bench_ids, team_id) """ db_ops = DatabaseOperations() game_id = uuid4() team_id = 1 # Create game in DB await db_ops.create_game( game_id=game_id, league_id='sba', home_team_id=team_id, away_team_id=2, game_mode='friendly', visibility='public' ) # Create lineup entries in DB (9 active players) lineup_ids = {} positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] for i, position in enumerate(positions, start=1): lineup_id = await db_ops.add_sba_lineup_player( game_id=game_id, team_id=team_id, player_id=100 + i, position=position, batting_order=i, is_starter=True ) lineup_ids[position] = lineup_id # Add bench players (3 substitutes) bench_ids = {} bench_positions = ['CF', 'SS', 'P'] for i, position in enumerate(bench_positions, start=1): bench_id = await db_ops.add_sba_lineup_player( game_id=game_id, team_id=team_id, player_id=200 + i, position=position, batting_order=None, # Not in batting order is_starter=False ) bench_ids[position] = bench_id # Load lineup into state manager cache lineup_data = await db_ops.get_active_lineup(game_id, team_id) lineup_state = TeamLineupState( team_id=team_id, players=[ LineupPlayerState( lineup_id=p.id, # type: ignore[assignment] card_id=p.player_id, # type: ignore[assignment] position=p.position, batting_order=p.batting_order, is_active=p.is_active ) for p in lineup_data ] ) state_manager.set_lineup(game_id, team_id, lineup_state) # Create game state with current batter current_batter = lineup_state.players[7] # CF, batting 8th state = GameState( game_id=game_id, league_id='sba', home_team_id=team_id, away_team_id=2, current_batter=current_batter, play_count=5 # Pitcher has faced batters ) state_manager.update_state(game_id, state) yield game_id, lineup_ids, bench_ids, team_id # Cleanup state_manager.remove_game(game_id) # Note: Database cleanup happens automatically in test database class TestPinchHitIntegration: """Integration tests for pinch hitter substitutions""" async def test_pinch_hit_full_flow(self, setup_database, game_with_lineups): """Test complete pinch hit flow: validation → DB → state""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Get state before substitution state = state_manager.get_state(game_id) original_batter_lineup_id = state.current_batter.lineup_id # Execute substitution (pinch hit for CF) result = await manager.pinch_hit( game_id=game_id, player_out_lineup_id=lineup_ids['CF'], player_in_card_id=201, # Bench CF team_id=team_id ) # Verify result assert result.success assert result.new_lineup_id is not None assert result.player_out_lineup_id == lineup_ids['CF'] assert result.player_in_card_id == 201 assert result.new_position == 'CF' # Verify database updated lineup_data = await db_ops.get_active_lineup(game_id, team_id) active_lineup_ids = [p.id for p in lineup_data] assert result.new_lineup_id in active_lineup_ids assert lineup_ids['CF'] not in active_lineup_ids # Verify substitution metadata in DB (query directly) from app.database.session import AsyncSessionLocal from app.models.db_models import Lineup from sqlalchemy import select async with AsyncSessionLocal() as session: # Verify old player marked inactive old_result = await session.execute( select(Lineup).where(Lineup.id == lineup_ids['CF']) ) old_player = old_result.scalar_one() assert not old_player.is_active # Verify new player active new_result = await session.execute( select(Lineup).where(Lineup.id == result.new_lineup_id) ) new_player = new_result.scalar_one() assert new_player.is_active assert new_player.player_id == 201 # Verify state updated state = state_manager.get_state(game_id) if original_batter_lineup_id == lineup_ids['CF']: # If we substituted for current batter, state should update assert state.current_batter.lineup_id == result.new_lineup_id # Verify lineup cache updated lineup_state = state_manager.get_lineup(game_id, team_id) old_player_state = lineup_state.get_player_by_lineup_id(lineup_ids['CF']) assert old_player_state is not None assert not old_player_state.is_active new_player_state = lineup_state.get_player_by_lineup_id(result.new_lineup_id) assert new_player_state is not None assert new_player_state.is_active assert new_player_state.card_id == 201 async def test_pinch_hit_validation_failure(self, setup_database, game_with_lineups): """Test pinch hit fails validation""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Try to pinch hit with player already in game result = await manager.pinch_hit( game_id=game_id, player_out_lineup_id=lineup_ids['CF'], player_in_card_id=102, # Catcher - already active team_id=team_id ) # Verify validation failed assert not result.success assert result.error_code == "ALREADY_ACTIVE" assert "already in the game" in result.error_message.lower() # Verify no database changes lineup_data = await db_ops.get_active_lineup(game_id, team_id) assert lineup_ids['CF'] in [p.id for p in lineup_data] class TestDefensiveReplacementIntegration: """Integration tests for defensive replacement""" async def test_defensive_replace_full_flow(self, setup_database, game_with_lineups): """Test complete defensive replacement flow""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Execute substitution (replace SS with bench SS) result = await manager.defensive_replace( game_id=game_id, player_out_lineup_id=lineup_ids['SS'], player_in_card_id=202, # Bench SS new_position='SS', team_id=team_id ) # Verify result assert result.success assert result.new_lineup_id is not None assert result.player_out_lineup_id == lineup_ids['SS'] assert result.new_position == 'SS' # Verify database updated lineup_data = await db_ops.get_active_lineup(game_id, team_id) active_ids = [p.id for p in lineup_data] assert result.new_lineup_id in active_ids assert lineup_ids['SS'] not in active_ids # Verify lineup cache updated lineup_state = state_manager.get_lineup(game_id, team_id) new_player = lineup_state.get_player_by_lineup_id(result.new_lineup_id) assert new_player is not None assert new_player.position == 'SS' assert new_player.is_active async def test_defensive_replace_position_change(self, setup_database, game_with_lineups): """Test defensive replacement with position change""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Replace SS and move to 2B result = await manager.defensive_replace( game_id=game_id, player_out_lineup_id=lineup_ids['SS'], player_in_card_id=202, new_position='2B', # Different position team_id=team_id ) assert result.success assert result.new_position == '2B' # Verify new player has correct position in DB (query directly) from app.database.session import AsyncSessionLocal from app.models.db_models import Lineup from sqlalchemy import select async with AsyncSessionLocal() as session: result_query = await session.execute( select(Lineup).where(Lineup.id == result.new_lineup_id) ) new_player = result_query.scalar_one() assert new_player.position == '2B' class TestPitchingChangeIntegration: """Integration tests for pitching changes""" async def test_change_pitcher_full_flow(self, setup_database, game_with_lineups): """Test complete pitching change flow""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Get state and set current pitcher state = state_manager.get_state(game_id) lineup_state = state_manager.get_lineup(game_id, team_id) pitcher = lineup_state.get_player_by_lineup_id(lineup_ids['P']) state.current_pitcher = pitcher state_manager.update_state(game_id, state) # Execute pitching change result = await manager.change_pitcher( game_id=game_id, pitcher_out_lineup_id=lineup_ids['P'], pitcher_in_card_id=203, # Bench pitcher team_id=team_id ) # Verify result assert result.success assert result.new_lineup_id is not None assert result.new_position == 'P' # Verify database updated lineup_data = await db_ops.get_active_lineup(game_id, team_id) active_ids = [p.id for p in lineup_data] assert result.new_lineup_id in active_ids assert lineup_ids['P'] not in active_ids # Verify state updated (current_pitcher should change) state = state_manager.get_state(game_id) assert state.current_pitcher is not None assert state.current_pitcher.lineup_id == result.new_lineup_id # Verify lineup cache lineup_state = state_manager.get_lineup(game_id, team_id) new_pitcher = lineup_state.get_pitcher() assert new_pitcher is not None assert new_pitcher.lineup_id == result.new_lineup_id assert new_pitcher.card_id == 203 async def test_change_pitcher_min_batters_validation(self, setup_database, game_with_lineups): """Test pitching change fails when min batters not met""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Set play_count to 0 (no batters faced) state = state_manager.get_state(game_id) state.play_count = 0 state_manager.update_state(game_id, state) # Try to change pitcher result = await manager.change_pitcher( game_id=game_id, pitcher_out_lineup_id=lineup_ids['P'], pitcher_in_card_id=203, team_id=team_id, force_change=False ) # Verify validation failed assert not result.success assert result.error_code == "MIN_BATTERS_NOT_MET" async def test_change_pitcher_force_change(self, setup_database, game_with_lineups): """Test force_change bypasses min batters requirement""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Set play_count to 0 state = state_manager.get_state(game_id) state.play_count = 0 state_manager.update_state(game_id, state) # Force change (injury/emergency) result = await manager.change_pitcher( game_id=game_id, pitcher_out_lineup_id=lineup_ids['P'], pitcher_in_card_id=203, team_id=team_id, force_change=True ) # Should succeed assert result.success class TestSubstitutionStateSync: """Tests for state synchronization after substitutions""" async def test_multiple_substitutions_state_sync(self, setup_database, game_with_lineups): """Test state stays synced after multiple substitutions""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Substitution 1: Pinch hit result1 = await manager.pinch_hit( game_id=game_id, player_out_lineup_id=lineup_ids['CF'], player_in_card_id=201, team_id=team_id ) assert result1.success # Substitution 2: Defensive replacement result2 = await manager.defensive_replace( game_id=game_id, player_out_lineup_id=lineup_ids['SS'], player_in_card_id=202, new_position='SS', team_id=team_id ) assert result2.success # Verify both changes in database lineup_data = await db_ops.get_active_lineup(game_id, team_id) active_ids = [p.id for p in lineup_data] assert result1.new_lineup_id in active_ids assert result2.new_lineup_id in active_ids assert lineup_ids['CF'] not in active_ids assert lineup_ids['SS'] not in active_ids # Verify both changes in state cache lineup_state = state_manager.get_lineup(game_id, team_id) player1 = lineup_state.get_player_by_lineup_id(result1.new_lineup_id) player2 = lineup_state.get_player_by_lineup_id(result2.new_lineup_id) assert player1 is not None and player1.is_active assert player2 is not None and player2.is_active async def test_substitution_batting_order_preserved(self, setup_database, game_with_lineups): """Test substitutions preserve batting order""" game_id, lineup_ids, bench_ids, team_id = game_with_lineups db_ops = DatabaseOperations() manager = SubstitutionManager(db_ops) # Get original batting order lineup_state = state_manager.get_lineup(game_id, team_id) cf_player = lineup_state.get_player_by_lineup_id(lineup_ids['CF']) original_batting_order = cf_player.batting_order # Pinch hit for CF result = await manager.pinch_hit( game_id=game_id, player_out_lineup_id=lineup_ids['CF'], player_in_card_id=201, team_id=team_id ) assert result.success assert result.new_batting_order == original_batting_order # Verify in database (query directly) from app.database.session import AsyncSessionLocal from app.models.db_models import Lineup from sqlalchemy import select async with AsyncSessionLocal() as session: new_result = await session.execute( select(Lineup).where(Lineup.id == result.new_lineup_id) ) new_player = new_result.scalar_one() assert new_player.batting_order == original_batting_order # Verify in state cache lineup_state = state_manager.get_lineup(game_id, team_id) new_player_state = lineup_state.get_player_by_lineup_id(result.new_lineup_id) assert new_player_state.batting_order == original_batting_order