strat-gameplay-webapp/backend/tests/unit/config/test_result_charts.py
Cal Corum 76e24ab22b CLAUDE: Refactor ManualOutcomeSubmission to use PlayOutcome enum + comprehensive documentation
## Refactoring
- Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type
- Removed custom validator (Pydantic handles enum validation automatically)
- Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard)
- Updated tests to use enum values while maintaining backward compatibility

Benefits:
- Better type safety with IDE autocomplete
- Cleaner code (removed 15 lines of validator boilerplate)
- Backward compatible (Pydantic auto-converts strings to enum)
- Access to helper methods (is_hit(), is_out(), etc.)

Files modified:
- app/models/game_models.py: Enum type + import
- tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test

## Documentation
Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code.

Added 8,799 lines of documentation covering:
- api/ (906 lines): FastAPI routes, health checks, auth patterns
- config/ (906 lines): League configs, PlayOutcome enum, result charts
- core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system
- data/ (937 lines): API clients (planned), caching layer
- database/ (945 lines): Async sessions, operations, recovery
- models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns
- utils/ (959 lines): Logging, JWT auth, security
- websocket/ (1,588 lines): Socket.io handlers, real-time events
- tests/ (475 lines): Testing patterns and structure

Each CLAUDE.md includes:
- Purpose & architecture overview
- Key components with detailed explanations
- Patterns & conventions
- Integration points
- Common tasks (step-by-step guides)
- Troubleshooting with solutions
- Working code examples
- Testing guidance

Total changes: +9,294 lines / -24 lines
Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
2025-10-31 16:03:54 -05:00

482 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=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