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>
494 lines
17 KiB
Python
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
|