strat-gameplay-webapp/backend/tests/unit/config/test_result_charts.py
Cal Corum 9245b4e008 CLAUDE: Implement Week 7 Task 3 - Result chart abstraction and PD auto mode
Core Implementation:
- Added ResultChart abstract base class with get_outcome() method
- Implemented calculate_hit_location() helper for hit distribution
  - 45% pull, 35% center, 20% opposite field
  - RHB pulls left, LHB pulls right
  - Groundballs → infield positions, flyouts → outfield positions
- Added PlayOutcome.requires_hit_location() helper method
  - Returns True for groundballs and flyouts only

Manual Mode Support:
- Added ManualResultChart (passthrough for interface completeness)
- Manual mode doesn't use result charts - players submit directly
- Added ManualOutcomeSubmission model for WebSocket submissions
  - Validates PlayOutcome enum values
  - Validates hit location positions (1B, 2B, SS, 3B, LF, CF, RF, P, C)

PD Auto Mode Implementation:
- Implemented PdAutoResultChart for automated outcome generation
  - Coin flip (50/50) to choose batting or pitching card
  - Gets rating for correct handedness matchup
  - Builds cumulative distribution from rating percentages
  - Rolls 1d100 to select outcome
  - Calculates hit location using handedness and pull rates
- Maps rating fields to PlayOutcome enum:
  - Common: homerun, triple, doubles, singles, walks, strikeouts
  - Batting-specific: lineouts, popouts, flyout variants, groundout variants
  - Pitching-specific: uncapped singles/doubles, flyouts by location
- Proper error handling when card data missing

Testing:
- Created 21 comprehensive unit tests (all passing)
- Helper function tests (calculate_hit_location)
- PlayOutcome helper tests (requires_hit_location)
- ManualResultChart tests (NotImplementedError)
- PdAutoResultChart tests:
  - Coin flip distribution (~50/50)
  - Handedness matchup selection
  - Cumulative distribution building
  - Outcome selection from probabilities
  - Hit location calculation
  - Error handling for missing cards
  - Statistical distribution verification (1000 trials)
- ManualOutcomeSubmission validation tests
  - Valid/invalid outcomes
  - Valid/invalid hit locations
  - Optional location handling

Deferred to Future Tasks:
- PlayResolver integration (Phase 6 - Week 7 Task 3B)
- Terminal client manual outcome command (Phase 8)
- WebSocket handlers for manual submissions (Week 7 Task 6)
- Runner advancement logic using hit locations (Week 7 Task 4)

Files Modified:
- app/config/result_charts.py: Added base class, auto mode, and helpers
- app/models/game_models.py: Added ManualOutcomeSubmission model
- tests/unit/config/test_result_charts.py: 21 comprehensive tests

All tests passing, no regressions.
2025-10-30 12:42:41 -05:00

471 lines
16 KiB
Python

"""
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='groundball_c',
hit_location='SS'
)
assert submission.outcome == 'groundball_c'
assert submission.hit_location == 'SS'
def test_valid_outcome_without_location(self):
"""Test valid submission without location"""
submission = ManualOutcomeSubmission(
outcome='strikeout'
)
assert submission.outcome == 'strikeout'
assert submission.hit_location is None
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='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='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='strikeout',
hit_location=None
)
assert submission.hit_location is None