# 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) ```python """ 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` ```python """ 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` ```python """ 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` ```python """ 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` ```python """ 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!