""" Unit Tests for Result Charts Tests result chart abstraction, hit location calculation, and PD auto mode. """ import pytest from unittest.mock import Mock from pydantic import ValidationError from app.config.result_charts import ( PlayOutcome, calculate_hit_location, ResultChart, ManualResultChart, PdAutoResultChart, ) from app.models.game_models import ManualOutcomeSubmission # ============================================================================ # HELPER FUNCTION TESTS # ============================================================================ class TestCalculateHitLocation: """Test calculate_hit_location() helper function""" def test_groundball_returns_infield_position(self): """Test groundballs return infield positions""" outcome = PlayOutcome.GROUNDBALL_C # Run 100 times to test distribution locations = set() for _ in range(100): location = calculate_hit_location(outcome, 'R') locations.add(location) # Should only get infield positions valid_infield = {'1B', '2B', 'SS', '3B', 'P', 'C'} assert locations.issubset(valid_infield) def test_flyout_returns_outfield_position(self): """Test flyouts return outfield positions""" outcome = PlayOutcome.FLYOUT_B # Run 100 times to test distribution locations = set() for _ in range(100): location = calculate_hit_location(outcome, 'R') locations.add(location) # Should only get outfield positions valid_outfield = {'LF', 'CF', 'RF'} assert locations.issubset(valid_outfield) def test_returns_none_for_outcomes_not_requiring_location(self): """Test returns None for strikeouts, walks, etc.""" assert calculate_hit_location(PlayOutcome.STRIKEOUT, 'R') is None assert calculate_hit_location(PlayOutcome.WALK, 'R') is None assert calculate_hit_location(PlayOutcome.HOMERUN, 'R') is None assert calculate_hit_location(PlayOutcome.SINGLE_1, 'R') is None # ============================================================================ # PLAYOUTCOME HELPER TESTS # ============================================================================ class TestPlayOutcomeRequiresHitLocation: """Test PlayOutcome.requires_hit_location() method""" def test_groundballs_require_location(self): """Test all groundball variants require location""" assert PlayOutcome.GROUNDBALL_A.requires_hit_location() is True assert PlayOutcome.GROUNDBALL_B.requires_hit_location() is True assert PlayOutcome.GROUNDBALL_C.requires_hit_location() is True def test_flyouts_require_location(self): """Test all flyout variants require location""" assert PlayOutcome.FLYOUT_A.requires_hit_location() is True assert PlayOutcome.FLYOUT_B.requires_hit_location() is True assert PlayOutcome.FLYOUT_C.requires_hit_location() is True def test_other_outcomes_do_not_require_location(self): """Test strikes, walks, hits don't require location""" assert PlayOutcome.STRIKEOUT.requires_hit_location() is False assert PlayOutcome.WALK.requires_hit_location() is False assert PlayOutcome.HOMERUN.requires_hit_location() is False assert PlayOutcome.SINGLE_1.requires_hit_location() is False assert PlayOutcome.DOUBLE_2.requires_hit_location() is False # ============================================================================ # MANUAL RESULT CHART TESTS # ============================================================================ class TestManualResultChart: """Test ManualResultChart (should not be used)""" def test_raises_not_implemented_error(self): """Test ManualResultChart.get_outcome() raises NotImplementedError""" chart = ManualResultChart() with pytest.raises(NotImplementedError) as exc_info: chart.get_outcome( roll=Mock(), state=Mock(), batter=Mock(), pitcher=Mock() ) assert "manual mode" in str(exc_info.value).lower() assert "websocket" in str(exc_info.value).lower() # ============================================================================ # PD AUTO RESULT CHART TESTS # ============================================================================ class TestPdAutoResultChart: """Test PdAutoResultChart auto mode implementation""" def create_mock_player(self, name, card_type, ratings_data): """Helper to create mock player with card data""" player = Mock() player.name = name if card_type == 'batting': card = Mock() card.hand = 'R' card.ratings = { 'R': Mock(**ratings_data), 'L': Mock(**ratings_data) } player.batting_card = card player.pitching_card = None else: # pitching card = Mock() card.hand = 'R' card.ratings = { 'R': Mock(**ratings_data), 'L': Mock(**ratings_data) } player.pitching_card = card player.batting_card = Mock(hand='R') return player def test_select_card_coin_flip_distribution(self): """Test _select_card() has ~50/50 distribution""" chart = PdAutoResultChart() # Create mock players with valid cards batter = self.create_mock_player('Batter', 'batting', {'homerun': 10.0}) pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 5.0}) # Run 1000 trials batting_count = 0 for _ in range(1000): _, is_batting = chart._select_card(batter, pitcher) if is_batting: batting_count += 1 # Should be approximately 50% (allow 10% variance) assert 400 <= batting_count <= 600 def test_select_card_uses_correct_handedness(self): """Test _select_card() selects rating for correct handedness""" chart = PdAutoResultChart() # Create batter with batting card batter = self.create_mock_player('Batter', 'batting', {'homerun': 10.0}) # Create pitcher with specific hand pitcher = Mock() pitcher.name = 'Pitcher' pitcher.pitching_card = Mock(hand='L') # LHP pitcher.batting_card = None # Force batting card selection rating, is_batting = chart._select_card(batter, pitcher) # Should have selected rating vs LHP assert rating is not None def test_build_distribution_creates_cumulative_values(self): """Test _build_distribution() builds cumulative thresholds""" chart = PdAutoResultChart() # Create mock rating rating = Mock( homerun=5.0, triple=2.0, double_two=8.0, single_one=15.0, walk=10.0, strikeout=20.0, groundout_a=10.0, groundout_b=15.0, groundout_c=15.0, # Zero out others bp_homerun=0.0, double_three=0.0, single_two=0.0, bp_single=0.0, hbp=0.0, lineout=0.0, popout=0.0, flyout_a=0.0, flyout_bq=0.0, flyout_lf_b=0.0, flyout_rf_b=0.0, double_pull=0.0, single_center=0.0, ) distribution = chart._build_distribution(rating, is_batting_card=True) # Should have entries assert len(distribution) > 0 # Cumulative values should be increasing prev_threshold = 0 for threshold, outcome in distribution: assert threshold > prev_threshold prev_threshold = threshold # Total should be ~100% (within rounding) assert distribution[-1][0] >= 95.0 # Allow some missing outcomes def test_select_outcome_uses_distribution(self): """Test _select_outcome() selects based on cumulative distribution""" chart = PdAutoResultChart() # Simple distribution: 0-50 = STRIKEOUT, 50-100 = WALK distribution = [ (50.0, PlayOutcome.STRIKEOUT), (100.0, PlayOutcome.WALK), ] # Run 1000 trials results = {'strikeout': 0, 'walk': 0} for _ in range(1000): outcome = chart._select_outcome(distribution) if outcome == PlayOutcome.STRIKEOUT: results['strikeout'] += 1 elif outcome == PlayOutcome.WALK: results['walk'] += 1 # Should be approximately 50/50 (allow 10% variance) assert 400 <= results['strikeout'] <= 600 assert 400 <= results['walk'] <= 600 def test_get_outcome_returns_tuple(self): """Test get_outcome() returns (outcome, location) tuple""" chart = PdAutoResultChart() batter = self.create_mock_player('Batter', 'batting', { 'homerun': 100.0, # Always HR 'triple': 0.0, 'double_two': 0.0, 'single_one': 0.0, 'walk': 0.0, 'strikeout': 0.0, 'groundout_a': 0.0, 'groundout_b': 0.0, 'groundout_c': 0.0, 'bp_homerun': 0.0, 'double_three': 0.0, 'single_two': 0.0, 'bp_single': 0.0, 'hbp': 0.0, 'lineout': 0.0, 'popout': 0.0, 'flyout_a': 0.0, 'flyout_bq': 0.0, 'flyout_lf_b': 0.0, 'flyout_rf_b': 0.0, 'double_pull': 0.0, 'single_center': 0.0, }) pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 0.0}) outcome, location = chart.get_outcome( roll=Mock(), state=Mock(), batter=batter, pitcher=pitcher ) assert isinstance(outcome, PlayOutcome) assert outcome == PlayOutcome.HOMERUN assert location is None # HRs don't need location def test_get_outcome_calculates_location_for_groundballs(self): """Test get_outcome() calculates location for groundballs""" chart = PdAutoResultChart() batter = self.create_mock_player('Batter', 'batting', { 'groundout_c': 100.0, # Always groundball 'homerun': 0.0, 'triple': 0.0, 'double_two': 0.0, 'single_one': 0.0, 'walk': 0.0, 'strikeout': 0.0, 'groundout_a': 0.0, 'groundout_b': 0.0, 'bp_homerun': 0.0, 'double_three': 0.0, 'single_two': 0.0, 'bp_single': 0.0, 'hbp': 0.0, 'lineout': 0.0, 'popout': 0.0, 'flyout_a': 0.0, 'flyout_bq': 0.0, 'flyout_lf_b': 0.0, 'flyout_rf_b': 0.0, 'double_pull': 0.0, 'single_center': 0.0, }) pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 0.0}) outcome, location = chart.get_outcome( roll=Mock(), state=Mock(), batter=batter, pitcher=pitcher ) assert outcome == PlayOutcome.GROUNDBALL_C assert location is not None assert location in ['1B', '2B', 'SS', '3B', 'P', 'C'] def test_get_outcome_raises_error_with_no_cards(self): """Test get_outcome() raises error if both players missing cards""" chart = PdAutoResultChart() batter = Mock(name='Batter', batting_card=None, pitching_card=None) pitcher = Mock(name='Pitcher', batting_card=None, pitching_card=None) with pytest.raises(ValueError) as exc_info: chart.get_outcome( roll=Mock(), state=Mock(), batter=batter, pitcher=pitcher ) assert "cannot auto-resolve" in str(exc_info.value).lower() assert "missing card data" in str(exc_info.value).lower() def test_outcome_distribution_matches_ratings(self): """Test outcome distribution roughly matches rating percentages""" chart = PdAutoResultChart() # Create player with known distribution batter = self.create_mock_player('Batter', 'batting', { 'strikeout': 50.0, 'walk': 50.0, 'homerun': 0.0, 'triple': 0.0, 'double_two': 0.0, 'single_one': 0.0, 'groundout_a': 0.0, 'groundout_b': 0.0, 'groundout_c': 0.0, 'bp_homerun': 0.0, 'double_three': 0.0, 'single_two': 0.0, 'bp_single': 0.0, 'hbp': 0.0, 'lineout': 0.0, 'popout': 0.0, 'flyout_a': 0.0, 'flyout_bq': 0.0, 'flyout_lf_b': 0.0, 'flyout_rf_b': 0.0, 'double_pull': 0.0, 'single_center': 0.0, }) pitcher = self.create_mock_player('Pitcher', 'pitching', {'homerun': 0.0}) # Force batting card every time for consistent test original_select = chart._select_card chart._select_card = lambda b, p: (batter.batting_card.ratings['R'], True) try: # Run 1000 trials outcomes = {'strikeout': 0, 'walk': 0} for _ in range(1000): outcome, _ = chart.get_outcome( roll=Mock(), state=Mock(), batter=batter, pitcher=pitcher ) if outcome == PlayOutcome.STRIKEOUT: outcomes['strikeout'] += 1 elif outcome == PlayOutcome.WALK: outcomes['walk'] += 1 # Should be approximately 50/50 (allow 10% variance) assert 400 <= outcomes['strikeout'] <= 600 assert 400 <= outcomes['walk'] <= 600 finally: chart._select_card = original_select # ============================================================================ # MANUAL OUTCOME SUBMISSION TESTS # ============================================================================ class TestManualOutcomeSubmission: """Test ManualOutcomeSubmission model validation""" def test_valid_outcome_with_location(self): """Test valid submission with outcome and location""" submission = ManualOutcomeSubmission( outcome=PlayOutcome.GROUNDBALL_C, hit_location='SS' ) assert submission.outcome == PlayOutcome.GROUNDBALL_C assert submission.hit_location == 'SS' def test_valid_outcome_without_location(self): """Test valid submission without location""" submission = ManualOutcomeSubmission( outcome=PlayOutcome.STRIKEOUT ) assert submission.outcome == PlayOutcome.STRIKEOUT assert submission.hit_location is None def test_valid_outcome_from_string(self): """Test Pydantic converts string to enum automatically""" submission = ManualOutcomeSubmission( outcome='groundball_c', hit_location='SS' ) # Pydantic converts string to enum assert submission.outcome == PlayOutcome.GROUNDBALL_C assert submission.hit_location == 'SS' def test_invalid_outcome_raises_error(self): """Test invalid outcome value raises ValidationError""" with pytest.raises(ValidationError) as exc_info: ManualOutcomeSubmission(outcome='invalid_outcome') assert 'outcome' in str(exc_info.value).lower() def test_invalid_hit_location_raises_error(self): """Test invalid hit location raises ValidationError""" with pytest.raises(ValidationError) as exc_info: ManualOutcomeSubmission( outcome=PlayOutcome.GROUNDBALL_C, hit_location='INVALID' ) assert 'hit_location' in str(exc_info.value).lower() def test_all_valid_hit_locations_accepted(self): """Test all valid hit locations are accepted""" valid_locations = ['1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C'] for location in valid_locations: submission = ManualOutcomeSubmission( outcome=PlayOutcome.GROUNDBALL_C, hit_location=location ) assert submission.hit_location == location def test_none_hit_location_is_valid(self): """Test None for hit_location is valid""" submission = ManualOutcomeSubmission( outcome=PlayOutcome.STRIKEOUT, hit_location=None ) assert submission.hit_location is None