Complete X-Check resolution table system for defensive play outcomes. Components: - Defense range tables (20×5) for infield, outfield, catcher - Error charts for LF/RF and CF (ratings 0-25) - Placeholder error charts for P, C, 1B, 2B, 3B, SS (awaiting data) - get_fielders_holding_runners() - Complete implementation - get_error_chart_for_position() - Maps all 9 positions - 6 X-Check placeholder advancement functions (g1-g3, f1-f3) League Config Integration: - Both SbaConfig and PdConfig include X-Check tables - Shared common tables via league_configs.py - Attributes: x_check_defense_tables, x_check_error_charts, x_check_holding_runners Testing: - 36 tests for X-Check tables (all passing) - 9 tests for X-Check placeholders (all passing) - Total: 45/45 tests passing Documentation: - Updated backend/CLAUDE.md with Phase 3B section - Updated app/config/CLAUDE.md with X-Check tables documentation - Updated app/core/CLAUDE.md with X-Check placeholder functions - Updated tests/CLAUDE.md with new test counts (519 unit tests) - Updated phase-3b-league-config-tables.md (marked complete) - Updated NEXT_SESSION.md with Phase 3B completion What's Pending: - 6 infield error charts need actual data (P, C, 1B, 2B, 3B, SS) - Phase 3C will implement full X-Check resolution logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""
|
||
Unit tests for X-Check resolution tables.
|
||
|
||
Tests defense range tables, error charts, and helper functions
|
||
for X-Check play resolution.
|
||
|
||
Author: Claude
|
||
Date: 2025-11-01
|
||
"""
|
||
|
||
import pytest
|
||
from app.config.common_x_check_tables import (
|
||
INFIELD_DEFENSE_TABLE,
|
||
OUTFIELD_DEFENSE_TABLE,
|
||
CATCHER_DEFENSE_TABLE,
|
||
LF_RF_ERROR_CHART,
|
||
CF_ERROR_CHART,
|
||
PITCHER_ERROR_CHART,
|
||
CATCHER_ERROR_CHART,
|
||
FIRST_BASE_ERROR_CHART,
|
||
SECOND_BASE_ERROR_CHART,
|
||
THIRD_BASE_ERROR_CHART,
|
||
SHORTSTOP_ERROR_CHART,
|
||
get_fielders_holding_runners,
|
||
get_error_chart_for_position,
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# DEFENSE TABLE TESTS
|
||
# ============================================================================
|
||
|
||
|
||
class TestDefenseTables:
|
||
"""Test defense range table structure and content."""
|
||
|
||
def test_infield_defense_table_dimensions(self):
|
||
"""Infield table should be 20 rows × 5 columns."""
|
||
assert len(INFIELD_DEFENSE_TABLE) == 20
|
||
for row in INFIELD_DEFENSE_TABLE:
|
||
assert len(row) == 5
|
||
|
||
def test_outfield_defense_table_dimensions(self):
|
||
"""Outfield table should be 20 rows × 5 columns."""
|
||
assert len(OUTFIELD_DEFENSE_TABLE) == 20
|
||
for row in OUTFIELD_DEFENSE_TABLE:
|
||
assert len(row) == 5
|
||
|
||
def test_catcher_defense_table_dimensions(self):
|
||
"""Catcher table should be 20 rows × 5 columns."""
|
||
assert len(CATCHER_DEFENSE_TABLE) == 20
|
||
for row in CATCHER_DEFENSE_TABLE:
|
||
assert len(row) == 5
|
||
|
||
def test_infield_defense_table_valid_results(self):
|
||
"""All infield results should be valid codes."""
|
||
valid_codes = {'G1', 'G2', 'G2#', 'G3', 'G3#', 'SI1', 'SI2'}
|
||
|
||
for row_idx, row in enumerate(INFIELD_DEFENSE_TABLE):
|
||
for col_idx, result in enumerate(row):
|
||
assert result in valid_codes, (
|
||
f"Invalid infield result '{result}' at row {row_idx + 1}, "
|
||
f"col {col_idx + 1}"
|
||
)
|
||
|
||
def test_outfield_defense_table_valid_results(self):
|
||
"""All outfield results should be valid codes."""
|
||
valid_codes = {'F1', 'F2', 'F3', 'SI2', 'DO2', 'DO3', 'TR3'}
|
||
|
||
for row_idx, row in enumerate(OUTFIELD_DEFENSE_TABLE):
|
||
for col_idx, result in enumerate(row):
|
||
assert result in valid_codes, (
|
||
f"Invalid outfield result '{result}' at row {row_idx + 1}, "
|
||
f"col {col_idx + 1}"
|
||
)
|
||
|
||
def test_catcher_defense_table_valid_results(self):
|
||
"""All catcher results should be valid codes."""
|
||
valid_codes = {'G1', 'G2', 'G3', 'SI1', 'SPD', 'FO', 'PO'}
|
||
|
||
for row_idx, row in enumerate(CATCHER_DEFENSE_TABLE):
|
||
for col_idx, result in enumerate(row):
|
||
assert result in valid_codes, (
|
||
f"Invalid catcher result '{result}' at row {row_idx + 1}, "
|
||
f"col {col_idx + 1}"
|
||
)
|
||
|
||
def test_best_range_always_best(self):
|
||
"""Range 1 (best) should always be equal or better than range 5."""
|
||
# Infield: Lower code number = better (G1 > G2 > G3 > SI)
|
||
assert INFIELD_DEFENSE_TABLE[0][0] in {'G3#', 'G2#', 'G1'}
|
||
assert INFIELD_DEFENSE_TABLE[0][4] in {'SI2', 'SI1'}
|
||
|
||
# Outfield: Different codes
|
||
assert OUTFIELD_DEFENSE_TABLE[0][0] == 'TR3' # Best range
|
||
assert OUTFIELD_DEFENSE_TABLE[0][4] == 'DO3' # Worst range
|
||
|
||
def test_worst_range_consistent(self):
|
||
"""Range 5 (worst) should show worst outcomes consistently."""
|
||
# Last row (d20=20) with worst range should still be makeable
|
||
# but harder than best range
|
||
assert INFIELD_DEFENSE_TABLE[19][4] in {'G1', 'G2'}
|
||
assert OUTFIELD_DEFENSE_TABLE[19][4] in {'F2'}
|
||
|
||
|
||
# ============================================================================
|
||
# ERROR CHART TESTS
|
||
# ============================================================================
|
||
|
||
|
||
class TestErrorCharts:
|
||
"""Test error chart structure and content."""
|
||
|
||
def test_lf_rf_error_chart_structure(self):
|
||
"""LF/RF error chart should have ratings 0-25."""
|
||
assert len(LF_RF_ERROR_CHART) == 26 # 0 through 25
|
||
|
||
# Each rating should have 4 error types
|
||
for rating, chart in LF_RF_ERROR_CHART.items():
|
||
assert 'RP' in chart
|
||
assert 'E1' in chart
|
||
assert 'E2' in chart
|
||
assert 'E3' in chart
|
||
# Each type should be a list of 3d6 rolls (3-18)
|
||
for error_type, rolls in chart.items():
|
||
assert isinstance(rolls, list)
|
||
for roll in rolls:
|
||
assert 3 <= roll <= 18, f"Invalid 3d6 roll: {roll}"
|
||
|
||
def test_cf_error_chart_structure(self):
|
||
"""CF error chart should have ratings 0-25."""
|
||
assert len(CF_ERROR_CHART) == 26 # 0 through 25
|
||
|
||
for rating, chart in CF_ERROR_CHART.items():
|
||
assert 'RP' in chart
|
||
assert 'E1' in chart
|
||
assert 'E2' in chart
|
||
assert 'E3' in chart
|
||
|
||
def test_infield_error_charts_are_placeholder(self):
|
||
"""Infield error charts should be empty placeholders for now."""
|
||
assert len(PITCHER_ERROR_CHART) == 0
|
||
assert len(CATCHER_ERROR_CHART) == 0
|
||
assert len(FIRST_BASE_ERROR_CHART) == 0
|
||
assert len(SECOND_BASE_ERROR_CHART) == 0
|
||
assert len(THIRD_BASE_ERROR_CHART) == 0
|
||
assert len(SHORTSTOP_ERROR_CHART) == 0
|
||
|
||
def test_error_rating_0_has_minimal_errors(self):
|
||
"""Error rating 0 should have fewest error opportunities."""
|
||
# RP should always be on 5 (snake eyes + 3)
|
||
assert LF_RF_ERROR_CHART[0]['RP'] == [5]
|
||
assert CF_ERROR_CHART[0]['RP'] == [5]
|
||
|
||
# E1 should be empty
|
||
assert LF_RF_ERROR_CHART[0]['E1'] == []
|
||
assert CF_ERROR_CHART[0]['E1'] == []
|
||
|
||
def test_error_rating_25_has_most_errors(self):
|
||
"""Error rating 25 should have most error opportunities."""
|
||
# Should have multiple rolls for each error type
|
||
assert len(LF_RF_ERROR_CHART[25]['E1']) > 0
|
||
assert len(LF_RF_ERROR_CHART[25]['E2']) > 0
|
||
assert len(LF_RF_ERROR_CHART[25]['E3']) > 0
|
||
|
||
def test_error_rolls_unique_within_rating(self):
|
||
"""Same 3d6 roll shouldn't appear in multiple error types for one rating."""
|
||
for rating, chart in LF_RF_ERROR_CHART.items():
|
||
all_rolls = []
|
||
all_rolls.extend(chart['RP'])
|
||
all_rolls.extend(chart['E1'])
|
||
all_rolls.extend(chart['E2'])
|
||
all_rolls.extend(chart['E3'])
|
||
|
||
# Check for duplicates
|
||
assert len(all_rolls) == len(set(all_rolls)), (
|
||
f"Duplicate 3d6 roll in error rating {rating}"
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# HELPER FUNCTION TESTS
|
||
# ============================================================================
|
||
|
||
|
||
class TestGetErrorChartForPosition:
|
||
"""Test get_error_chart_for_position() function."""
|
||
|
||
def test_get_chart_for_lf(self):
|
||
"""LF should return LF/RF chart."""
|
||
chart = get_error_chart_for_position('LF')
|
||
assert chart == LF_RF_ERROR_CHART
|
||
|
||
def test_get_chart_for_rf(self):
|
||
"""RF should return LF/RF chart (same as LF)."""
|
||
chart = get_error_chart_for_position('RF')
|
||
assert chart == LF_RF_ERROR_CHART
|
||
|
||
def test_get_chart_for_cf(self):
|
||
"""CF should return CF chart."""
|
||
chart = get_error_chart_for_position('CF')
|
||
assert chart == CF_ERROR_CHART
|
||
|
||
def test_get_chart_for_infield_positions(self):
|
||
"""Infield positions should return empty placeholders."""
|
||
assert get_error_chart_for_position('P') == PITCHER_ERROR_CHART
|
||
assert get_error_chart_for_position('C') == CATCHER_ERROR_CHART
|
||
assert get_error_chart_for_position('1B') == FIRST_BASE_ERROR_CHART
|
||
assert get_error_chart_for_position('2B') == SECOND_BASE_ERROR_CHART
|
||
assert get_error_chart_for_position('3B') == THIRD_BASE_ERROR_CHART
|
||
assert get_error_chart_for_position('SS') == SHORTSTOP_ERROR_CHART
|
||
|
||
def test_invalid_position_raises_error(self):
|
||
"""Invalid position should raise ValueError."""
|
||
with pytest.raises(ValueError, match="Unknown position"):
|
||
get_error_chart_for_position('DH')
|
||
|
||
with pytest.raises(ValueError, match="Unknown position"):
|
||
get_error_chart_for_position('XX')
|
||
|
||
|
||
class TestGetFieldersHoldingRunners:
|
||
"""Test get_fielders_holding_runners() function."""
|
||
|
||
def test_empty_bases_no_holds(self):
|
||
"""No runners means no fielders holding."""
|
||
result = get_fielders_holding_runners([], 'R')
|
||
assert result == []
|
||
|
||
def test_runner_on_first_only_rhb(self):
|
||
"""R1 only with RHB: 1B and 2B hold."""
|
||
result = get_fielders_holding_runners([1], 'R')
|
||
assert '1B' in result
|
||
assert '2B' in result
|
||
|
||
def test_runner_on_first_only_lhb(self):
|
||
"""R1 only with LHB: 1B and SS hold."""
|
||
result = get_fielders_holding_runners([1], 'L')
|
||
assert '1B' in result
|
||
assert 'SS' in result
|
||
|
||
def test_runner_on_second_only_rhb(self):
|
||
"""R2 only with RHB: 2B holds."""
|
||
result = get_fielders_holding_runners([2], 'R')
|
||
assert result == ['2B']
|
||
|
||
def test_runner_on_second_only_lhb(self):
|
||
"""R2 only with LHB: SS holds."""
|
||
result = get_fielders_holding_runners([2], 'L')
|
||
assert result == ['SS']
|
||
|
||
def test_runner_on_third_only(self):
|
||
"""R3 only: 3B holds."""
|
||
result = get_fielders_holding_runners([3], 'R')
|
||
assert result == ['3B']
|
||
|
||
def test_first_and_third_rhb(self):
|
||
"""R1 and R3 with RHB: 1B, 2B, and 3B hold."""
|
||
result = get_fielders_holding_runners([1, 3], 'R')
|
||
assert '1B' in result
|
||
assert '2B' in result
|
||
assert '3B' in result
|
||
|
||
def test_first_and_third_lhb(self):
|
||
"""R1 and R3 with LHB: 1B, SS, and 3B hold."""
|
||
result = get_fielders_holding_runners([1, 3], 'L')
|
||
assert '1B' in result
|
||
assert 'SS' in result
|
||
assert '3B' in result
|
||
|
||
def test_first_and_second_rhb(self):
|
||
"""R1 and R2 with RHB: 1B and 2B hold (2B already added for R1)."""
|
||
result = get_fielders_holding_runners([1, 2], 'R')
|
||
assert '1B' in result
|
||
assert '2B' in result
|
||
|
||
def test_first_and_second_lhb(self):
|
||
"""R1 and R2 with LHB: 1B and SS hold (SS already added for R1)."""
|
||
result = get_fielders_holding_runners([1, 2], 'L')
|
||
assert '1B' in result
|
||
assert 'SS' in result
|
||
|
||
def test_bases_loaded_rhb(self):
|
||
"""Bases loaded with RHB: 1B, 2B, and 3B hold."""
|
||
result = get_fielders_holding_runners([1, 2, 3], 'R')
|
||
assert '1B' in result
|
||
assert '2B' in result
|
||
assert '3B' in result
|
||
|
||
def test_bases_loaded_lhb(self):
|
||
"""Bases loaded with LHB: 1B, SS, and 3B hold."""
|
||
result = get_fielders_holding_runners([1, 2, 3], 'L')
|
||
assert '1B' in result
|
||
assert 'SS' in result
|
||
assert '3B' in result
|
||
|
||
def test_second_and_third_rhb(self):
|
||
"""R2 and R3 with RHB: 2B and 3B hold (no runner on first)."""
|
||
result = get_fielders_holding_runners([2, 3], 'R')
|
||
assert '2B' in result
|
||
assert '3B' in result
|
||
|
||
def test_second_and_third_lhb(self):
|
||
"""R2 and R3 with LHB: SS and 3B hold (no runner on first)."""
|
||
result = get_fielders_holding_runners([2, 3], 'L')
|
||
assert 'SS' in result
|
||
assert '3B' in result
|
||
|
||
|
||
# ============================================================================
|
||
# INTEGRATION TESTS
|
||
# ============================================================================
|
||
|
||
|
||
class TestXCheckTablesIntegration:
|
||
"""Integration tests combining tables and helper functions."""
|
||
|
||
def test_defense_table_lookup(self):
|
||
"""Test looking up a defense result."""
|
||
# d20 roll = 10, defense range = 3 (average)
|
||
d20_roll = 10
|
||
defense_range = 3
|
||
|
||
# Row index = d20 - 1, col index = range - 1
|
||
result = INFIELD_DEFENSE_TABLE[d20_roll - 1][defense_range - 1]
|
||
|
||
# d20=10, range=3 should be G2
|
||
assert result == 'G2'
|
||
|
||
def test_error_check_workflow(self):
|
||
"""Test complete error check workflow."""
|
||
# Scenario: LF with error rating 10, 3d6 roll = 16
|
||
|
||
position = 'LF'
|
||
error_rating = 10
|
||
three_d6_roll = 16
|
||
|
||
# Get error chart for position
|
||
chart = get_error_chart_for_position(position)
|
||
|
||
# Get error chances for this rating
|
||
error_chances = chart[error_rating]
|
||
|
||
# Check each error type
|
||
if three_d6_roll in error_chances['RP']:
|
||
error_result = 'RP'
|
||
elif three_d6_roll in error_chances['E1']:
|
||
error_result = 'E1'
|
||
elif three_d6_roll in error_chances['E2']:
|
||
error_result = 'E2'
|
||
elif three_d6_roll in error_chances['E3']:
|
||
error_result = 'E3'
|
||
else:
|
||
error_result = 'NO'
|
||
|
||
# For rating 10, roll 16: E1 = [4, 16]
|
||
assert error_result == 'E1'
|
||
|
||
def test_holding_runner_affects_result(self):
|
||
"""Test how holding runners might affect play resolution."""
|
||
# Scenario: G2# result with runner on 1st
|
||
|
||
# Check if fielders are holding
|
||
fielders_holding = get_fielders_holding_runners([1], 'R')
|
||
|
||
# If fielder involved in play is holding, G2# → SI2
|
||
# This logic will be implemented in Phase 3C
|
||
assert '1B' in fielders_holding
|
||
assert '2B' in fielders_holding # RHB means 2B holds
|
||
|
||
# For now, just verify the function returns expected values
|
||
# Full integration will happen in Phase 3C
|