strat-gameplay-webapp/backend/tests/unit/config/test_result_charts.py
Cal Corum beb939b32a CLAUDE: Fix all unit test failures and implement 100% test requirement
Test Fixes (609/609 passing):
- Fixed DiceSystem API to accept team_id/player_id parameters for audit trails
- Fixed dice roll history timing issue in test
- Fixed terminal client mock to match resolve_play signature (X-Check params)
- Fixed result chart test mocks with missing pitching fields
- Fixed flaky test by using groundball_a (exists in both batting/pitching)

Documentation Updates:
- Added Testing Policy section to backend/CLAUDE.md
- Added Testing Policy section to tests/CLAUDE.md
- Documented 100% unit test requirement before commits
- Added git hook setup instructions

Git Hook System:
- Created .git-hooks/pre-commit script (enforces 100% test pass)
- Created .git-hooks/install-hooks.sh (easy installation)
- Created .git-hooks/README.md (hook documentation)
- Hook automatically runs all unit tests before each commit
- Blocks commits if any test fails

All 609 unit tests now passing (100%)
Integration tests have known asyncpg connection issues (documented)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 19:35:21 -06:00

494 lines
17 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': 100.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,
'groundout_c': 0.0, 'bp_homerun': 0.0, 'double_three': 0.0, 'single_two': 0.0,
'bp_single': 0.0, 'hbp': 0.0, 'double_pull': 0.0, 'single_center': 0.0,
'double_cf': 0.0, 'flyout_lf_b': 0.0, 'flyout_cf_b': 0.0, 'flyout_rf_b': 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_a': 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_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, 'triple': 0.0, 'double_two': 0.0, 'single_one': 0.0,
'walk': 0.0, 'strikeout': 0.0, 'groundout_a': 100.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, 'double_pull': 0.0, 'single_center': 0.0,
'double_cf': 0.0, 'flyout_lf_b': 0.0, 'flyout_cf_b': 0.0, 'flyout_rf_b': 0.0
})
outcome, location = chart.get_outcome(
roll=Mock(),
state=Mock(),
batter=batter,
pitcher=pitcher
)
assert outcome == PlayOutcome.GROUNDBALL_A
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