Add foundational data structures for X-Check play resolution system: Models Added: - PositionRating: Defensive ratings (range 1-5, error 0-88) for X-Check resolution - XCheckResult: Dataclass tracking complete X-Check resolution flow with dice rolls, conversions (SPD test, G2#/G3#→SI2), error results, and final outcomes - BasePlayer.active_position_rating: Optional field for current defensive position Enums Extended: - PlayOutcome.X_CHECK: New outcome type requiring special resolution - PlayOutcome.is_x_check(): Helper method for type checking Documentation Enhanced: - Play.check_pos: Documented as X-Check position identifier - Play.hit_type: Documented with examples (single_2_plus_error_1, etc.) Utilities Added: - app/core/cache.py: Redis cache key helpers for player positions and game state Implementation Planning: - Complete 6-phase implementation plan (3A-3F) documented in .claude/implementation/ - Phase 3A complete with all acceptance criteria met - Zero breaking changes, all existing tests passing Next: Phase 3B will add defense tables, error charts, and advancement logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
24 KiB
24 KiB
Phase 3F: Testing & Integration for X-Check System
Status: Not Started Estimated Effort: 4-5 hours Dependencies: All previous phases (3A-3E)
Overview
Comprehensive testing strategy for X-Check system covering:
- Unit tests for all components
- Integration tests for complete flows
- Test fixtures and mock data
- End-to-end scenarios
- Performance validation
Tasks
1. Create Test Fixtures
File: tests/fixtures/x_check_fixtures.py (NEW FILE)
"""
Test fixtures for X-Check system.
Provides mock players, position ratings, defense tables, etc.
Author: Claude
Date: 2025-11-01
"""
import pytest
from app.models.player_models import PdPlayer, PositionRating, PdBattingCard
from app.models.game_models import GameState, XCheckResult
from app.config.result_charts import PlayOutcome
@pytest.fixture
def mock_position_rating_ss():
"""Mock position rating for shortstop (good defender)."""
return PositionRating(
position='SS',
innings=1200,
range=2, # Good range
error=10, # Average error rating
arm=80,
pb=None,
overthrow=5,
)
@pytest.fixture
def mock_position_rating_lf():
"""Mock position rating for left field (average defender)."""
return PositionRating(
position='LF',
innings=800,
range=3, # Average range
error=15, # Below average error
arm=70,
pb=None,
overthrow=8,
)
@pytest.fixture
def mock_pd_player_with_positions():
"""Mock PD player with multiple positions cached."""
from app.models.player_models import PdCardset, PdRarity
player = PdPlayer(
id=10932,
name="Chipper Jones",
cost=254,
image="https://pd.manticorum.com/api/v2/players/10932/battingcard",
cardset=PdCardset(id=21, name="1998 Promos", description="1998", ranked_legal=True),
set_num=97,
rarity=PdRarity(id=2, value=3, name="All-Star", color="FFD700"),
mlbclub="Atlanta Braves",
franchise="Atlanta Braves",
pos_1="3B",
description="April PotM",
batting_card=PdBattingCard(
steal_low=1,
steal_high=12,
steal_auto=False,
steal_jump=0.5,
bunting="C",
hit_and_run="B",
running=14, # Speed for SPD test
offense_col=1,
hand="R",
ratings={},
),
)
return player
@pytest.fixture
def mock_x_check_result_si2_e1():
"""Mock XCheckResult for SI2 + E1."""
return XCheckResult(
position='SS',
d20_roll=15,
d6_roll=12,
defender_range=2,
defender_error_rating=10,
defender_id=5001,
base_result='SI2',
converted_result='SI2',
error_result='E1',
final_outcome=PlayOutcome.SINGLE_2,
hit_type='si2_plus_error_1',
)
@pytest.fixture
def mock_x_check_result_g2_no_error():
"""Mock XCheckResult for G2 with no error."""
return XCheckResult(
position='2B',
d20_roll=10,
d6_roll=8,
defender_range=3,
defender_error_rating=12,
defender_id=5002,
base_result='G2',
converted_result='G2',
error_result='NO',
final_outcome=PlayOutcome.GROUNDBALL_B,
hit_type='g2_no_error',
)
@pytest.fixture
def mock_x_check_result_f2_e3():
"""Mock XCheckResult for F2 + E3 (out becomes error)."""
return XCheckResult(
position='LF',
d20_roll=16,
d6_roll=17,
defender_range=4,
defender_error_rating=18,
defender_id=5003,
base_result='F2',
converted_result='F2',
error_result='E3',
final_outcome=PlayOutcome.ERROR, # Out + error = ERROR
hit_type='f2_plus_error_3',
)
@pytest.fixture
def mock_x_check_result_spd_passed():
"""Mock XCheckResult for SPD test (passed)."""
return XCheckResult(
position='C',
d20_roll=12,
d6_roll=9,
defender_range=2,
defender_error_rating=8,
defender_id=5004,
base_result='SPD',
converted_result='SI1', # Passed speed test
error_result='NO',
final_outcome=PlayOutcome.SINGLE_1,
hit_type='si1_no_error',
spd_test_roll=13,
spd_test_target=14,
spd_test_passed=True,
)
@pytest.fixture
def mock_game_state_r1():
"""Mock game state with runner on first."""
# TODO: Create full GameState mock with R1
pass
@pytest.fixture
def mock_game_state_bases_loaded():
"""Mock game state with bases loaded."""
# TODO: Create full GameState mock with bases loaded
pass
2. Unit Tests for Core Components
File: tests/core/test_x_check_resolution.py
"""
Unit tests for X-Check resolution logic.
Tests PlayResolver._resolve_x_check() and helper methods.
Author: Claude
Date: 2025-11-01
"""
import pytest
from app.core.play_resolver import PlayResolver
from app.config.result_charts import PlayOutcome
class TestDefenseTableLookup:
"""Test defense table lookups."""
def test_infield_lookup_best_range(self, play_resolver):
"""Test infield lookup with range 1 (best)."""
result = play_resolver._lookup_defense_table('SS', d20_roll=1, defense_range=1)
assert result == 'G3#'
def test_infield_lookup_worst_range(self, play_resolver):
"""Test infield lookup with range 5 (worst)."""
result = play_resolver._lookup_defense_table('3B', d20_roll=1, defense_range=5)
assert result == 'SI2'
def test_outfield_lookup(self, play_resolver):
"""Test outfield lookup."""
result = play_resolver._lookup_defense_table('LF', d20_roll=5, defense_range=2)
assert result == 'DO2'
def test_catcher_lookup(self, play_resolver):
"""Test catcher-specific table."""
result = play_resolver._lookup_defense_table('C', d20_roll=10, defense_range=1)
assert result == 'SPD'
class TestSpdTest:
"""Test SPD (speed test) resolution."""
def test_spd_pass(self, play_resolver, mock_pd_player_with_positions, mocker):
"""Test passing speed test (roll <= speed)."""
# Mock dice to roll 12 (player speed = 14)
mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=12)
result, roll, target, passed = play_resolver._resolve_spd_test(
mock_pd_player_with_positions
)
assert result == 'SI1'
assert roll == 12
assert target == 14
assert passed is True
def test_spd_fail(self, play_resolver, mock_pd_player_with_positions, mocker):
"""Test failing speed test (roll > speed)."""
# Mock dice to roll 16 (player speed = 14)
mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=16)
result, roll, target, passed = play_resolver._resolve_spd_test(
mock_pd_player_with_positions
)
assert result == 'G3'
assert roll == 16
assert target == 14
assert passed is False
class TestHashConversion:
"""Test G2#/G3# → SI2 conversion logic."""
def test_conversion_when_playing_in(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions):
"""Test # conversion when defender playing in."""
result = play_resolver._apply_hash_conversion(
result='G2#',
position='3B',
adjusted_range=3, # Was 2, increased to 3 (playing in)
base_range=2,
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
)
assert result == 'SI2'
def test_conversion_when_holding_runner(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions, mocker):
"""Test # conversion when holding runner."""
# Mock holding function to return 1B
mocker.patch(
'app.config.common_x_check_tables.get_fielders_holding_runners',
return_value=['1B']
)
result = play_resolver._apply_hash_conversion(
result='G3#',
position='1B',
adjusted_range=2, # Same as base (not playing in)
base_range=2,
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
)
assert result == 'SI2'
def test_no_conversion(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions, mocker):
"""Test no conversion when conditions not met."""
mocker.patch(
'app.config.common_x_check_tables.get_fielders_holding_runners',
return_value=[]
)
result = play_resolver._apply_hash_conversion(
result='G2#',
position='SS',
adjusted_range=2,
base_range=2,
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
)
assert result == 'G2' # # removed, not converted to SI2
class TestErrorChartLookup:
"""Test error chart lookups."""
def test_no_error(self, play_resolver):
"""Test 3d6 roll with no error."""
result = play_resolver._lookup_error_chart(
position='LF',
error_rating=0,
d6_roll=6 # Not in any error list for rating 0
)
assert result == 'NO'
def test_error_e1(self, play_resolver):
"""Test 3d6 roll resulting in E1."""
result = play_resolver._lookup_error_chart(
position='LF',
error_rating=1,
d6_roll=3 # In E1 list for rating 1
)
assert result == 'E1'
def test_rare_play(self, play_resolver):
"""Test 3d6 roll resulting in Rare Play."""
result = play_resolver._lookup_error_chart(
position='LF',
error_rating=10,
d6_roll=5 # Always RP
)
assert result == 'RP'
class TestFinalOutcomeDetermination:
"""Test final outcome and hit_type determination."""
def test_hit_no_error(self, play_resolver):
"""Test hit with no error."""
outcome, hit_type = play_resolver._determine_final_x_check_outcome(
converted_result='SI2',
error_result='NO'
)
assert outcome == PlayOutcome.SINGLE_2
assert hit_type == 'si2_no_error'
def test_hit_with_error(self, play_resolver):
"""Test hit with error (keep hit outcome)."""
outcome, hit_type = play_resolver._determine_final_x_check_outcome(
converted_result='DO2',
error_result='E1'
)
assert outcome == PlayOutcome.DOUBLE_2
assert hit_type == 'do2_plus_error_1'
def test_out_with_error(self, play_resolver):
"""Test out with error (becomes ERROR outcome)."""
outcome, hit_type = play_resolver._determine_final_x_check_outcome(
converted_result='F2',
error_result='E3'
)
assert outcome == PlayOutcome.ERROR
assert hit_type == 'f2_plus_error_3'
def test_rare_play(self, play_resolver):
"""Test rare play result."""
outcome, hit_type = play_resolver._determine_final_x_check_outcome(
converted_result='G1',
error_result='RP'
)
assert outcome == PlayOutcome.ERROR # RP treated like error
assert hit_type == 'g1_rare_play'
3. Integration Tests for Complete Flows
File: tests/integration/test_x_check_flows.py
"""
Integration tests for complete X-Check flows.
Tests end-to-end resolution from outcome to Play record.
Author: Claude
Date: 2025-11-01
"""
import pytest
from app.core.play_resolver import PlayResolver
from app.config.result_charts import PlayOutcome
class TestXCheckInfieldFlow:
"""Test complete X-Check flow for infield positions."""
@pytest.mark.asyncio
async def test_infield_groundball_no_error(
self,
play_resolver,
mock_game_state_r1,
mock_pd_player_with_positions,
mocker
):
"""Test infield X-Check resulting in groundout."""
# Mock dice rolls
mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=15)
mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=8)
# Mock defender with good range
defender = mock_pd_player_with_positions
defender.active_position_rating = pytest.fixtures.mock_position_rating_ss()
result = await play_resolver._resolve_x_check(
position='SS',
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions, # Reuse for simplicity
)
# Verify result
assert result.x_check_details is not None
assert result.x_check_details.position == 'SS'
assert result.x_check_details.error_result == 'NO'
assert result.outcome.is_out()
@pytest.mark.asyncio
async def test_infield_with_error(
self,
play_resolver,
mock_game_state_r1,
mock_pd_player_with_positions,
mocker
):
"""Test infield X-Check with error."""
# Mock dice rolls that produce error
mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=10)
mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=3) # E1
result = await play_resolver._resolve_x_check(
position='2B',
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions,
)
# Verify error applied
assert result.x_check_details.error_result == 'E1'
assert result.outcome == PlayOutcome.ERROR or result.outcome.is_hit()
class TestXCheckOutfieldFlow:
"""Test complete X-Check flow for outfield positions."""
@pytest.mark.asyncio
async def test_outfield_flyball_deep(
self,
play_resolver,
mock_game_state_bases_loaded,
mock_pd_player_with_positions,
mocker
):
"""Test deep flyball (F1) to outfield."""
mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=8)
mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=10)
result = await play_resolver._resolve_x_check(
position='CF',
state=mock_game_state_bases_loaded,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions,
)
# F1 should be deep fly with runner advancement
assert result.x_check_details.converted_result == 'F1'
assert result.advancement is not None
class TestXCheckCatcherSpdFlow:
"""Test X-Check flow for catcher with SPD test."""
@pytest.mark.asyncio
async def test_catcher_spd_pass(
self,
play_resolver,
mock_game_state_r1,
mock_pd_player_with_positions,
mocker
):
"""Test catcher SPD test with pass."""
# Roll SPD result
mocker.patch.object(play_resolver.dice, 'roll_d20', side_effect=[10, 12]) # Table, then SPD
mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=9)
result = await play_resolver._resolve_x_check(
position='C',
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions,
)
# Verify SPD test recorded
assert result.x_check_details.base_result == 'SPD'
assert result.x_check_details.spd_test_passed is not None
assert result.x_check_details.converted_result in ['SI1', 'G3']
class TestXCheckHashConversion:
"""Test G2#/G3# conversion scenarios."""
@pytest.mark.asyncio
async def test_hash_conversion_playing_in(
self,
play_resolver,
mock_game_state_r1,
mock_pd_player_with_positions,
mocker
):
"""Test # conversion when infield playing in."""
# Mock state with infield_in decision
mock_game_state_r1.current_defensive_decision.infield_in = True
# Mock rolls to produce G2#
mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=2)
mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=10)
result = await play_resolver._resolve_x_check(
position='2B',
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions,
)
# Should convert to SI2
assert result.x_check_details.base_result == 'G2#'
assert result.x_check_details.converted_result == 'SI2'
assert result.outcome == PlayOutcome.SINGLE_2
4. WebSocket Event Tests
File: tests/websocket/test_x_check_events.py
"""
Integration tests for X-Check WebSocket events.
Author: Claude
Date: 2025-11-01
"""
import pytest
from unittest.mock import AsyncMock
class TestXCheckAutoMode:
"""Test PD auto mode X-Check flow."""
@pytest.mark.asyncio
async def test_auto_result_broadcast(self, socket_client, mock_game_state_r1):
"""Test auto-resolved result broadcast."""
# Trigger X-Check
await socket_client.emit('action', {
'game_id': 1,
'action_type': 'swing',
# ... other params
})
# Should receive x_check_auto_result event
response = await socket_client.receive()
assert response['type'] == 'x_check_auto_result'
assert 'x_check' in response
assert response['x_check']['position'] in ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
@pytest.mark.asyncio
async def test_accept_auto_result(self, socket_client):
"""Test player accepting auto result."""
# Confirm result
await socket_client.emit('confirm_x_check_result', {
'game_id': 1,
'accepted': True,
})
# Should receive updated game state
response = await socket_client.receive()
assert response['type'] == 'game_update'
# Play should be recorded
@pytest.mark.asyncio
async def test_reject_auto_result(self, socket_client, mocker):
"""Test player rejecting auto result (logs override)."""
# Mock override logger
log_mock = mocker.patch('app.websocket.game_handlers.log_x_check_override')
# Reject result
await socket_client.emit('confirm_x_check_result', {
'game_id': 1,
'accepted': False,
'override_outcome': 'SI2_E1',
})
# Verify override logged
assert log_mock.called
class TestXCheckManualMode:
"""Test SBA manual mode X-Check flow."""
@pytest.mark.asyncio
async def test_manual_options_broadcast(self, socket_client):
"""Test manual mode dice + options broadcast."""
# Trigger X-Check
await socket_client.emit('action', {
'game_id': 1,
'action_type': 'swing',
# ... params
})
# Should receive manual options
response = await socket_client.receive()
assert response['type'] == 'x_check_manual_options'
assert 'd20' in response
assert 'd6' in response
assert 'options' in response
assert len(response['options']) > 0
@pytest.mark.asyncio
async def test_manual_submission(self, socket_client):
"""Test player submitting manual outcome."""
# Submit choice
await socket_client.emit('submit_x_check_manual', {
'game_id': 1,
'outcome': 'SI2_E1',
})
# Should receive updated game state
response = await socket_client.receive()
assert response['type'] == 'game_update'
5. Performance Tests
File: tests/performance/test_x_check_performance.py
"""
Performance tests for X-Check resolution.
Ensures resolution stays under latency targets.
Author: Claude
Date: 2025-11-01
"""
import pytest
import time
class TestXCheckPerformance:
"""Test X-Check resolution performance."""
@pytest.mark.asyncio
async def test_resolution_latency(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions):
"""Test single X-Check resolution completes under 100ms."""
start = time.time()
result = await play_resolver._resolve_x_check(
position='SS',
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions,
)
elapsed = (time.time() - start) * 1000 # Convert to ms
assert elapsed < 100, f"X-Check resolution took {elapsed}ms (target: <100ms)"
@pytest.mark.asyncio
async def test_batch_resolution(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions):
"""Test 100 X-Check resolutions complete under 5 seconds."""
start = time.time()
for _ in range(100):
await play_resolver._resolve_x_check(
position='LF',
state=mock_game_state_r1,
batter=mock_pd_player_with_positions,
pitcher=mock_pd_player_with_positions,
)
elapsed = time.time() - start
assert elapsed < 5.0, f"100 resolutions took {elapsed}s (target: <5s)"
Testing Checklist
Unit Tests
- Defense table lookup (all positions)
- SPD test (pass/fail)
- Hash conversion (playing in, holding runner, none)
- Error chart lookup (all error types)
- Final outcome determination (all combinations)
- Advancement table lookups
- Option generation
Integration Tests
- Complete infield X-Check (no error)
- Complete infield X-Check (with error)
- Complete outfield X-Check
- Complete catcher X-Check with SPD
- Hash conversion in game context
- Error overriding outs
- Rare play handling
WebSocket Tests
- Auto mode result broadcast
- Accept auto result
- Reject auto result (logs override)
- Manual mode options broadcast
- Manual submission
Performance Tests
- Single resolution < 100ms
- Batch resolution (100 plays) < 5s
Database Tests
- Play record created with check_pos
- Play record has correct hit_type
- Defender_id populated
- Error and hit flags correct
Acceptance Criteria
- All unit tests pass (>95% coverage for X-Check code)
- All integration tests pass
- All WebSocket tests pass
- Performance tests meet targets
- No regressions in existing tests
- Test fixtures complete and documented
- Mock data representative of real scenarios
Notes
- Use pytest fixtures for reusable test data
- Mock Redis for position rating tests
- Mock dice rolls for deterministic tests
- Test edge cases (range 1, range 5, error 0, error 25)
- Test all position types (P, C, IF, OF)
- Validate WebSocket message formats match frontend expectations
Final Integration Checklist
After all tests pass:
- Manual smoke test: Create PD game, trigger X-Check, verify UI
- Manual smoke test: Create SBA game, trigger X-Check, verify manual flow
- Verify Redis caching working (position ratings persisted)
- Verify override logging working (check database)
- Performance profiling (identify any bottlenecks)
- Code review: Check all imports present (no NameErrors)
- Documentation: Update API docs with X-Check events
- Frontend integration: Verify all event handlers working
Success Metrics
- Correctness: All test scenarios produce expected outcomes
- Performance: Sub-100ms resolution time
- Reliability: No exceptions in 1000-play test
- User Experience: Auto/manual flows work smoothly
- Debuggability: Override logs help diagnose issues
END OF PHASE 3F
Once all phases (3A-3F) are complete, the X-Check system will be fully functional and tested!