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>
794 lines
24 KiB
Markdown
794 lines
24 KiB
Markdown
# 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!
|