paper-dynasty-gameplay-webapp/tests/integration/services/test_ai_service_coverage.py
Cal Corum 7fd9079425 CLAUDE: Improve AI Service test coverage from 60% to 72% with comprehensive integration tests
Added targeted integration tests to cover previously uncovered conditional branches
and edge cases in AI decision-making logic:

- test_ai_service_focused_coverage.py: 11 tests for key missing branches
  * Steal opportunity conditions (lines 91, 93, 98-99, 108)
  * Steal to third/home scenarios (lines 129, 157, 161)
  * Defensive alignment logic (lines 438, 480)
  * Tag decision branches (lines 204, 253)

- test_ai_service_final_coverage.py: 10 tests for remaining gaps
  * Complex steal conditions (lines 95, 118-119, 132, 136-137, 141)
  * Late inning steal logic (lines 159, 163)
  * Uncapped advance bounds checking (lines 382-388)
  * Complex defensive scenarios (lines 440-449)

- test_ai_service_coverage.py: Comprehensive coverage tests (unused due to complexity)

Fixed:
- Player model relationship syntax (removed unsupported cascade_delete parameter)
- Existing test assertion in test_ai_service_simple.py for steal to home scenario

Coverage improvement: 369 statements, 147→105 missed lines (60%→72% coverage)
All 49 AI Service tests now pass with comprehensive integration testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 21:28:43 -05:00

713 lines
30 KiB
Python

"""
AIService Integration Tests for Coverage Improvement
Focused on covering specific uncovered lines identified in coverage report.
These tests target conditional branches and edge cases not covered by unit tests.
"""
import pytest
from unittest.mock import Mock
from app.models.ai_responses import JumpResponse, TagResponse, ThrowResponse, UncappedRunResponse, DefenseResponse, RunResponse
from app.models.position_rating import PositionRating
from app.services.ai_service import AIService
from tests.factories.manager_ai_factory import ManagerAiFactory
from tests.factories.player_factory import PlayerFactory
class TestAIServiceStealCoverageBranches:
"""Test specific steal logic branches for coverage improvement."""
def test_steal_branch_line_91_steal_over_8_run_diff_under_5(self, db_session):
"""Test steal > 8 and run_diff <= 5 branch (line 91)."""
# Create AI with steal = 9 (> 8)
high_steal_ai = ManagerAiFactory.create(db_session, steal=9)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=5)
mock_game = self._create_steal_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
# Set scores so run_diff will be 3 for home team AI: away=2, home=5, ai_team='home' -> (2-5)*-1 = 3
mock_play.away_score = 2
mock_play.home_score = 5
mock_play.starting_outs = 1
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(high_steal_ai, mock_game, 2)
# Should hit line 91: min_safe = 13 + num_outs = 14
assert result.min_safe == 14
def test_steal_branch_line_93_steal_over_6_run_diff_under_5(self, db_session):
"""Test steal > 6 and run_diff <= 5 branch (line 93)."""
# Create AI with steal = 7 (> 6 but <= 8)
medium_steal_ai = ManagerAiFactory.create(db_session, steal=7)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=4)
mock_game = self._create_steal_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.ai_run_diff = 4 # <= 5
mock_play.starting_outs = 0
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(medium_steal_ai, mock_game, 2)
# Should hit line 93: min_safe = 14 + num_outs = 14
assert result.min_safe == 14
def test_steal_branch_line_95_steal_over_4_under_2_outs(self, db_session):
"""Test steal > 4 and num_outs < 2 and run_diff <= 5 branch (line 95)."""
medium_steal_ai = ManagerAiFactory.create(db_session, steal=5)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=6)
mock_game = self._create_steal_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.ai_run_diff = 2 # <= 5
mock_play.starting_outs = 1 # < 2
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(medium_steal_ai, mock_game, 2)
# Should hit line 95: min_safe = 15 + num_outs = 16
assert result.min_safe == 16
def test_steal_branch_lines_98_99_default_case(self, db_session):
"""Test default case in steal logic (lines 98-99)."""
low_steal_ai = ManagerAiFactory.create(db_session, steal=1) # Very low steal
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=5)
mock_game = self._create_steal_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.ai_run_diff = 3 # <= 5
mock_play.starting_outs = 0
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(low_steal_ai, mock_game, 2)
# Should hit default case: min_safe = 17 + num_outs = 17
assert result.min_safe == 17
def test_steal_auto_jump_logic_line_108(self, db_session):
"""Test run_if_auto_jump and steal_auto logic (line 108)."""
high_steal_ai = ManagerAiFactory.create(db_session, steal=8) # > 7
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=6)
mock_game = self._create_steal_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.ai_run_diff = 2 # <= 5
mock_play.starting_outs = 1 # < 2
# Set runner to have steal_auto = True
mock_play.on_first.card.batterscouting.battingcard.steal_auto = True
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(high_steal_ai, mock_game, 2)
# Should hit line 108: ai_note with "WILL SEND"
assert result.run_if_auto_jump is True
assert "WILL SEND" in result.ai_note
def test_steal_lines_118_119_jump_safe_range_logic(self, db_session):
"""Test jump safe range calculation and note assignment (lines 118-119)."""
balanced_ai = ManagerAiFactory.create(db_session, steal=6)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=3) # Weak arm
mock_game = self._create_steal_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.ai_run_diff = 1
mock_play.starting_outs = 0
# Set up runner for jump safe range calculation
mock_play.on_first.card.batterscouting.battingcard.steal_auto = False
mock_play.on_first.card.batterscouting.battingcard.steal_high = 16
mock_play.on_first.card.batterscouting.battingcard.steal_low = 12
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(balanced_ai, mock_game, 2)
# Should generate a steal note with specific conditions
assert result.ai_note is not None
assert "SEND" in result.ai_note
class TestAIServiceStealToThirdCoverage:
"""Test steal to third base logic coverage (lines 124, 129-132, 136-137, 141, 143)."""
def test_steal_to_third_steal_10_branch(self, db_session):
"""Test steal to third with steal = 10 (line 129)."""
max_steal_ai = ManagerAiFactory.create(db_session, steal=10)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=7)
mock_game = self._create_steal_to_third_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.starting_outs = 1
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(max_steal_ai, mock_game, 3)
# Should hit line 129: min_safe = 12 + num_outs = 13
assert result.min_safe == 13
def test_steal_to_third_steal_over_6_branch(self, db_session):
"""Test steal to third with steal > 6 and conditions (lines 130-131)."""
high_steal_ai = ManagerAiFactory.create(db_session, steal=7)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=5)
mock_game = self._create_steal_to_third_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.starting_outs = 1 # < 2
mock_play.ai_run_diff = 3 # <= 5
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(high_steal_ai, mock_game, 3)
# Should hit lines 130-131: min_safe = 15 + num_outs = 16
assert result.min_safe == 16
def test_steal_to_third_default_none_case(self, db_session):
"""Test steal to third default case setting min_safe = None (line 132)."""
low_steal_ai = ManagerAiFactory.create(db_session, steal=3)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=4)
mock_game = self._create_steal_to_third_mock(catcher, db_session)
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(low_steal_ai, mock_game, 3)
# Should hit line 132: min_safe = None
assert result.min_safe is None
def test_steal_to_third_auto_jump_logic(self, db_session):
"""Test steal to third auto jump conditions (lines 136-137, 141)."""
max_steal_ai = ManagerAiFactory.create(db_session, steal=10)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=6)
mock_game = self._create_steal_to_third_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.starting_outs = 1 # < 2
mock_play.ai_run_diff = 2 # <= 5
# Set runner to have steal_auto = True
mock_play.on_second.card.batterscouting.battingcard.steal_auto = True
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(max_steal_ai, mock_game, 3)
# Should hit lines 136-137, 141: run_if_auto_jump = True and ai_note set
assert result.run_if_auto_jump is True
assert "SEND" in result.ai_note
assert "third" in result.ai_note
class TestAIServiceStealToHomeCoverage:
"""Test steal to home logic coverage (lines 151-171)."""
def test_steal_to_home_steal_10_branch(self, db_session):
"""Test steal to home with steal = 10 (lines 156-157)."""
max_steal_ai = ManagerAiFactory.create(db_session, steal=10)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=5)
mock_game = self._create_steal_to_home_mock(catcher, db_session)
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(max_steal_ai, mock_game, 4) # Home = 4
# Should hit lines 156-157: min_safe = 5
assert result.min_safe == 5
def test_steal_to_home_late_inning_branch(self, db_session):
"""Test steal to home late inning logic (lines 158-159)."""
medium_steal_ai = ManagerAiFactory.create(db_session, steal=6) # >= 5
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=4)
mock_game = self._create_steal_to_home_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.inning_num = 8 # > 7
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(medium_steal_ai, mock_game, 4)
# Should hit lines 158-159: min_safe = 6
assert result.min_safe == 6
def test_steal_to_home_medium_steal_branch(self, db_session):
"""Test steal to home with steal > 5 (lines 160-161)."""
medium_steal_ai = ManagerAiFactory.create(db_session, steal=6)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=6)
mock_game = self._create_steal_to_home_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
mock_play.inning_num = 5 # <= 7
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(medium_steal_ai, mock_game, 4)
# Should hit lines 160-161: min_safe = 7
assert result.min_safe == 7
def test_steal_to_home_low_steal_branches(self, db_session):
"""Test steal to home with different low steal values (lines 162-165)."""
# Test steal > 2 branch (lines 162-163)
low_steal_ai = ManagerAiFactory.create(db_session, steal=3)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=3)
mock_game = self._create_steal_to_home_mock(catcher, db_session)
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(low_steal_ai, mock_game, 4)
# Should hit lines 162-163: min_safe = 8
assert result.min_safe == 8
def test_steal_to_home_very_low_steal_default(self, db_session):
"""Test steal to home default case (lines 164-165)."""
very_low_steal_ai = ManagerAiFactory.create(db_session, steal=1) # <= 2
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=4)
mock_game = self._create_steal_to_home_mock(catcher, db_session)
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(very_low_steal_ai, mock_game, 4)
# Should hit lines 164-165: min_safe = 10
assert result.min_safe == 10
def test_steal_to_home_jump_safe_range_logic(self, db_session):
"""Test steal to home jump safe range calculation (lines 167-171)."""
balanced_ai = ManagerAiFactory.create(db_session, steal=6)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=2) # Weak arm
mock_game = self._create_steal_to_home_mock(catcher, db_session)
mock_play = mock_game.current_play_or_none.return_value
# Set up runner with specific steal_low for calculation
mock_play.on_third.card.batterscouting.battingcard.steal_low = 18
ai_service = AIService(db_session)
result = ai_service.check_steal_opportunity(balanced_ai, mock_game, 4)
# Should execute jump safe range logic (lines 167-171)
# jump_safe_range = steal_low - 9 = 18 - 9 = 9
# min_safe = 7 (from steal > 5), so 7 <= 9 should trigger ai_note
assert result.min_safe == 7
if result.ai_note:
assert "SEND" in result.ai_note
class TestAIServiceStealErrorHandling:
"""Test steal error handling branches."""
def test_steal_to_second_missing_runner_error(self, db_session):
"""Test error when no runner on first for steal to second."""
ai = ManagerAiFactory.create(db_session, steal=5)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session)
mock_game = self._create_steal_mock(catcher, db_session)
mock_game.current_play_or_none.return_value.on_first = None # No runner
ai_service = AIService(db_session)
with pytest.raises(ValueError, match="no runner found on first"):
ai_service.check_steal_opportunity(ai, mock_game, 2)
def test_steal_to_third_missing_runner_error(self, db_session):
"""Test error when no runner on second for steal to third."""
ai = ManagerAiFactory.create(db_session, steal=5)
catcher = PlayerFactory.create_catcher(db_session)
mock_game = self._create_steal_to_third_mock(catcher, db_session)
mock_game.current_play_or_none.return_value.on_second = None # No runner
ai_service = AIService(db_session)
with pytest.raises(ValueError, match="no runner found on second"):
ai_service.check_steal_opportunity(ai, mock_game, 3)
def test_steal_to_home_missing_runner_error(self, db_session):
"""Test error when no runner on third for steal to home."""
ai = ManagerAiFactory.create(db_session, steal=5)
catcher = PlayerFactory.create_catcher(db_session)
mock_game = self._create_steal_to_home_mock(catcher, db_session)
mock_game.current_play_or_none.return_value.on_third = None # No runner
ai_service = AIService(db_session)
with pytest.raises(ValueError, match="no runner found on third"):
ai_service.check_steal_opportunity(ai, mock_game, 4)
# Helper methods
def _create_catcher_defense(self, catcher, db_session, arm=5):
"""Create position rating for catcher."""
defense = PositionRating(
player_id=catcher.id,
variant=0,
position='C',
arm=arm,
range=5,
error=2
)
db_session.add(defense)
db_session.commit()
return defense
def _create_steal_mock(self, catcher, db_session):
"""Create basic steal scenario mock."""
mock_game = Mock()
mock_play = Mock()
runner = PlayerFactory.create(db_session, name="Test Runner")
mock_runner = Mock()
mock_runner.player.name = runner.name
mock_runner.card.batterscouting.battingcard.steal_auto = False
mock_runner.card.batterscouting.battingcard.steal_high = 14
mock_runner.card.batterscouting.battingcard.steal_low = 11
mock_play.on_first = mock_runner
mock_play.away_score = 5
mock_play.home_score = 3
mock_play.ai_run_diff = 2
mock_play.starting_outs = 0
mock_play.catcher = Mock()
mock_play.catcher.player_id = catcher.id
mock_play.catcher.card.variant = 0
mock_play.pitcher.card.pitcherscouting.pitchingcard.hold = 5
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 12345
mock_game.ai_team = 'home'
return mock_game
def _create_steal_to_third_mock(self, catcher, db_session):
"""Create steal to third scenario mock."""
mock_game = Mock()
mock_play = Mock()
runner = PlayerFactory.create(db_session, name="Runner on Second")
mock_runner = Mock()
mock_runner.player.name = runner.name
mock_runner.card.batterscouting.battingcard.steal_auto = False
mock_runner.card.batterscouting.battingcard.steal_low = 12
mock_play.on_second = mock_runner
mock_play.away_score = 4
mock_play.home_score = 3
mock_play.ai_run_diff = 1
mock_play.starting_outs = 0
mock_play.catcher = Mock()
mock_play.catcher.player_id = catcher.id
mock_play.catcher.card.variant = 0
mock_play.pitcher.card.pitcherscouting.pitchingcard.hold = 4
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 23456
mock_game.ai_team = 'home'
return mock_game
def _create_steal_to_home_mock(self, catcher, db_session):
"""Create steal to home scenario mock."""
mock_game = Mock()
mock_play = Mock()
runner = PlayerFactory.create(db_session, name="Runner on Third")
mock_runner = Mock()
mock_runner.player.name = runner.name
mock_runner.card.batterscouting.battingcard.steal_low = 15
mock_play.on_third = mock_runner
mock_play.away_score = 3
mock_play.home_score = 3 # Tied game
mock_play.ai_run_diff = 0 # This triggers home steal logic
mock_play.starting_outs = 1
mock_play.inning_num = 7
mock_play.catcher = Mock()
mock_play.catcher.player_id = catcher.id
mock_play.catcher.card.variant = 0
mock_play.pitcher.card.pitcherscouting.pitchingcard.hold = 3
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 34567
mock_game.ai_team = 'home'
return mock_game
class TestAIServiceDefenseCoverageBranches:
"""Test defensive alignment logic coverage (lines 437-449, 455-461, 469-477, 480)."""
def test_defense_two_outs_different_base_codes(self, db_session):
"""Test defensive holds with 2 outs and different on_base_codes."""
ai = ManagerAiFactory.create(db_session, defense=5)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=7)
# Test on_base_code = 2 (runner on second only)
mock_game = self._create_defense_mock_two_outs(catcher, db_session, on_base_code=2)
ai_service = AIService(db_session)
result = ai_service.set_defensive_alignment(ai, mock_game)
assert result.hold_second is True
assert "hold" in result.ai_note
def test_defense_two_outs_runners_on_both(self, db_session):
"""Test defensive holds with 2 outs and runners on first and second."""
ai = ManagerAiFactory.create(db_session, defense=6)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=6)
# Test on_base_code = 4 (runners on first and second)
mock_game = self._create_defense_mock_two_outs(catcher, db_session, on_base_code=4)
ai_service = AIService(db_session)
result = ai_service.set_defensive_alignment(ai, mock_game)
assert result.hold_first is True
assert result.hold_second is True
assert result.ai_note.count("hold") >= 2
def test_defense_hold_first_base_auto_steal_conditions(self, db_session):
"""Test hold first base with auto steal conditions (lines 455-461)."""
ai = ManagerAiFactory.create(db_session, ahead_aggression=3, behind_aggression=7)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=8)
mock_game = self._create_defense_mock_with_auto_steal_runner(catcher, db_session)
ai_service = AIService(db_session)
result = ai_service.set_defensive_alignment(ai, mock_game)
assert result.hold_first is True
assert "hold" in result.ai_note
def test_defense_straight_up_with_runners(self, db_session):
"""Test 'play straight up' message (line 480)."""
ai = ManagerAiFactory.create(db_session, defense=5)
catcher = PlayerFactory.create_catcher(db_session)
self._create_catcher_defense(catcher, db_session, arm=4)
# Create scenario with runners but no special defensive alignment
mock_game = self._create_defense_mock_straight_up(catcher, db_session)
ai_service = AIService(db_session)
result = ai_service.set_defensive_alignment(ai, mock_game)
assert "play straight up" in result.ai_note
def _create_defense_mock_two_outs(self, catcher, db_session, on_base_code=2):
"""Create defense mock with 2 outs and specific base code."""
mock_game = Mock()
mock_play = Mock()
runner = PlayerFactory.create(db_session, name="Base Runner")
mock_runner = Mock()
mock_runner.player.name = runner.name
mock_play.starting_outs = 2 # Key: 2 outs
mock_play.on_base_code = on_base_code
mock_play.ai_run_diff = 1
if on_base_code == 2: # Runner on second only
mock_play.on_second = mock_runner
mock_play.on_first = None
mock_play.on_third = None
elif on_base_code == 4: # Runners on first and second
mock_play.on_first = mock_runner
mock_play.on_second = mock_runner
mock_play.on_third = None
mock_play.catcher = Mock()
mock_play.catcher.player_id = catcher.id
mock_play.catcher.card.variant = 0
mock_play.pitcher.card.pitcherscouting.pitchingcard.hold = 5
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 45678
return mock_game
def _create_defense_mock_with_auto_steal_runner(self, catcher, db_session):
"""Create defense mock with auto steal runner on first."""
mock_game = Mock()
mock_play = Mock()
runner = PlayerFactory.create(db_session, name="Fast Runner")
mock_runner = Mock()
mock_runner.player.name = runner.name
mock_runner.card.batterscouting.battingcard.steal_auto = True
mock_runner.card.batterscouting.battingcard.steal_high = 16
mock_play.starting_outs = 1
mock_play.on_base_code = 1 # Runner on first only
mock_play.on_first = mock_runner
mock_play.on_second = None
mock_play.on_third = None
mock_play.ai_run_diff = -2 # Behind, so behind_aggression applies
mock_play.catcher = Mock()
mock_play.catcher.player_id = catcher.id
mock_play.catcher.card.variant = 0
mock_play.pitcher.card.pitcherscouting.pitchingcard.hold = 6
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 56789
return mock_game
def _create_defense_mock_straight_up(self, catcher, db_session):
"""Create defense mock that results in 'play straight up'."""
mock_game = Mock()
mock_play = Mock()
runner = PlayerFactory.create(db_session, name="Regular Runner")
mock_runner = Mock()
mock_runner.player.name = runner.name
mock_play.starting_outs = 1
mock_play.on_base_code = 1 # Runner on first
mock_play.on_first = mock_runner
mock_play.on_second = None
mock_play.on_third = None # No runner on third, so no special alignment
mock_play.ai_run_diff = 0
# Setup conditions that don't trigger holds
mock_runner.card.batterscouting.battingcard.steal_auto = False
mock_play.catcher = Mock()
mock_play.catcher.player_id = catcher.id
mock_play.catcher.card.variant = 0
mock_play.pitcher.card.pitcherscouting.pitchingcard.hold = 3
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 67890
return mock_game
class TestAIServicePitcherReplacementCoverageBranches:
"""Test pitcher replacement logic coverage (lines 605-606, 616-638, 649-675)."""
def test_pitcher_replacement_starter_over_pow_plus_3(self, db_session):
"""Test starter replacement when outs >= POW * 3 + 6 (lines 605-606)."""
ai = ManagerAiFactory.create(db_session, ahead_aggression=5, behind_aggression=5)
mock_game = self._create_pitcher_replacement_mock(db_session, is_starter=True, outs=24, pitcher_pow=6)
# 24 >= 6 * 3 + 6 = 24, so should be pulled
ai_service = AIService(db_session)
ai_service.session.exec = self._mock_pitcher_queries(outs=24, allowed_runners=3)
result = ai_service.should_replace_pitcher(ai, mock_game)
assert result is True
def test_pitcher_replacement_starter_cooking_with_few_runners(self, db_session):
"""Test starter staying in when cooking with few runners (lines 608-610)."""
ai = ManagerAiFactory.create(db_session, ahead_aggression=5, behind_aggression=5)
mock_game = self._create_pitcher_replacement_mock(db_session, is_starter=True, outs=15, pitcher_pow=6)
ai_service = AIService(db_session)
ai_service.session.exec = self._mock_pitcher_queries(outs=15, allowed_runners=3) # < 5 runners
result = ai_service.should_replace_pitcher(ai, mock_game)
assert result is False
def test_pitcher_replacement_starter_fatigued_with_runners(self, db_session):
"""Test fatigued starter replacement with runners (lines 612-614)."""
ai = ManagerAiFactory.create(db_session, ahead_aggression=5, behind_aggression=5)
mock_game = self._create_pitcher_replacement_mock(
db_session, is_starter=True, outs=18, pitcher_pow=6, is_fatigued=True, on_base_code=3
)
ai_service = AIService(db_session)
ai_service.session.exec = self._mock_pitcher_queries(outs=18, allowed_runners=6)
result = ai_service.should_replace_pitcher(ai, mock_game)
assert result is True
def test_pitcher_replacement_reliever_over_pow_plus_1(self, db_session):
"""Test reliever replacement when outs >= POW * 3 + 3 (lines 645-647)."""
ai = ManagerAiFactory.create(db_session, ahead_aggression=5, behind_aggression=5)
mock_game = self._create_pitcher_replacement_mock(
db_session, is_starter=False, outs=15, pitcher_pow=4 # 15 >= 4 * 3 + 3 = 15
)
ai_service = AIService(db_session)
ai_service.session.exec = self._mock_pitcher_queries(outs=15, allowed_runners=4)
result = ai_service.should_replace_pitcher(ai, mock_game)
assert result is True
def test_pitcher_replacement_reliever_fatigued_new_inning(self, db_session):
"""Test reliever replacement when fatigued at start of inning (lines 649-651)."""
ai = ManagerAiFactory.create(db_session, ahead_aggression=5, behind_aggression=5)
mock_game = self._create_pitcher_replacement_mock(
db_session, is_starter=False, outs=9, pitcher_pow=5, is_fatigued=True, is_new_inning=True
)
ai_service = AIService(db_session)
ai_service.session.exec = self._mock_pitcher_queries(outs=9, allowed_runners=5)
result = ai_service.should_replace_pitcher(ai, mock_game)
assert result is True
def _create_pitcher_replacement_mock(self, db_session, is_starter=True, outs=15, pitcher_pow=6,
is_fatigued=False, on_base_code=1, is_new_inning=False):
"""Create pitcher replacement scenario mock."""
mock_game = Mock()
mock_play = Mock()
mock_pitcher = Mock()
mock_pitcher.replacing_id = None if is_starter else 123
mock_pitcher.is_fatigued = is_fatigued
mock_pitcher.card.player.name_with_desc = f"Test {'Starter' if is_starter else 'Reliever'}"
if is_starter:
mock_pitcher.card.pitcherscouting.pitchingcard.starter_rating = pitcher_pow
else:
mock_pitcher.card.pitcherscouting.pitchingcard.relief_rating = pitcher_pow
mock_play.pitcher = mock_pitcher
mock_play.on_base_code = on_base_code
mock_play.ai_run_diff = 0
mock_play.inning_num = 6
mock_play.is_new_inning = is_new_inning
mock_play.starting_outs = 0
mock_game.current_play_or_none.return_value = mock_play
mock_game.id = 78901
return mock_game
def _mock_pitcher_queries(self, outs=15, allowed_runners=5):
"""Mock database queries for pitcher stats."""
def mock_exec(query):
mock_result = Mock()
mock_result.one.side_effect = [outs, allowed_runners]
return mock_result
return mock_exec