strat-gameplay-webapp/backend/tests/integration/test_xcheck_websocket.py
Cal Corum 0ebe72c09d CLAUDE: Phase 3F - Substitution System Testing Complete
This commit completes all Phase 3 work with comprehensive test coverage:

Test Coverage:
- 31 unit tests for SubstitutionRules (all validation paths)
- 10 integration tests for SubstitutionManager (DB + state sync)
- 679 total tests in test suite (609/609 unit tests passing - 100%)

Testing Scope:
- Pinch hitter validation and execution
- Defensive replacement validation and execution
- Pitching change validation and execution (min batters, force changes)
- Double switch validation
- Multiple substitutions in sequence
- Batting order preservation
- Database persistence verification
- State sync verification
- Lineup cache updates

All substitution system components are now production-ready:
 Core validation logic (SubstitutionRules)
 Orchestration layer (SubstitutionManager)
 Database operations
 WebSocket event handlers
 Comprehensive test coverage
 Complete documentation

Phase 3 Overall: 100% Complete
- Phase 3A-D (X-Check Core): 100%
- Phase 3E (Position Ratings + Redis): 100%
- Phase 3F (Substitutions): 100%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 15:25:53 -06:00

333 lines
11 KiB
Python

"""
Integration test for X-Check WebSocket flow.
Tests the complete flow from dice roll to X-Check result broadcast.
Author: Claude
Date: 2025-11-03
Phase: 3E-Final
"""
import pytest
import json
from uuid import uuid4
from unittest.mock import Mock, patch, AsyncMock
import pendulum
from app.websocket.handlers import register_handlers
from app.websocket.connection_manager import ConnectionManager
from app.core.state_manager import state_manager
from app.core.game_engine import game_engine
from app.models.game_models import GameState, LineupPlayerState
from app.models.player_models import PositionRating # Import for forward reference resolution
from app.config import PlayOutcome
# Rebuild GameState model to resolve forward references
GameState.model_rebuild()
@pytest.mark.integration
class TestXCheckWebSocket:
"""Test X-Check integration with WebSocket handlers"""
@pytest.fixture
def mock_sio(self):
"""Mock Socket.io server"""
sio = Mock()
sio.emit = AsyncMock()
return sio
@pytest.fixture
def mock_manager(self):
"""Mock ConnectionManager"""
manager = Mock(spec=ConnectionManager)
manager.user_sessions = {}
manager.game_rooms = {}
manager.emit_to_user = AsyncMock()
manager.broadcast_to_game = AsyncMock()
return manager
@pytest.fixture
def game_state(self):
"""Create test game state"""
game_id = uuid4()
# Create batter
batter = LineupPlayerState(
lineup_id=10,
card_id=123,
position="RF",
batting_order=3
)
# Create pitcher
pitcher = LineupPlayerState(
lineup_id=20,
card_id=456,
position="P",
batting_order=9
)
# Create catcher
catcher = LineupPlayerState(
lineup_id=21,
card_id=789,
position="C",
batting_order=2
)
state = GameState(
game_id=game_id,
league_id="pd", # PD league has position ratings
home_team_id=1,
away_team_id=2,
current_batter=batter,
current_pitcher=pitcher,
current_catcher=catcher
)
# Clear bases
state.on_first = None
state.on_second = None
state.on_third = None
# Register state in state manager
state_manager._states[game_id] = state
return state
async def test_submit_manual_xcheck_outcome(self, mock_sio, mock_manager, game_state):
"""
Test submitting manual X-Check outcome via WebSocket.
Flow:
1. Create game with position ratings loaded
2. Roll dice (stores pending_manual_roll)
3. Submit X-Check outcome
4. Verify play_resolved event includes x_check_details
"""
game_id = game_state.game_id
# Register handlers
register_handlers(mock_sio, mock_manager)
# Get the submit_manual_outcome handler
# It's registered as the 5th event (after connect, disconnect, join_game, leave_game, heartbeat, roll_dice)
handler_calls = [call for call in mock_sio.event.call_args_list]
submit_handler = None
for call in handler_calls:
if len(call[0]) > 0 and hasattr(call[0][0], '__name__'):
if call[0][0].__name__ == 'submit_manual_outcome':
submit_handler = call[0][0]
break
assert submit_handler is not None, "submit_manual_outcome handler not found"
# Mock pending roll (simulate roll_dice was called)
from app.core.roll_types import AbRoll
ab_roll = AbRoll(
roll_id="test-roll-123",
roll_type="AB",
league_id="pd",
game_id=game_id,
d6_one=4,
d6_two_a=3,
d6_two_b=4,
chaos_d20=12,
resolution_d20=8,
timestamp=pendulum.now('UTC')
)
game_state.pending_manual_roll = ab_roll
state_manager.update_state(game_id, game_state)
# Mock session
sid = "test-session-123"
mock_manager.user_sessions[sid] = "test-user"
# Submit X-Check outcome
data = {
"game_id": str(game_id),
"outcome": "x_check",
"hit_location": "SS"
}
# Mock game engine resolve_manual_play to return X-Check result
with patch('app.websocket.handlers.game_engine.resolve_manual_play') as mock_resolve:
from app.models.game_models import XCheckResult
from app.core.play_resolver import PlayResult
# Create mock X-Check result
xcheck_result = XCheckResult(
position="SS",
d20_roll=12,
d6_roll=10,
defender_range=4,
defender_error_rating=12,
defender_id=25,
base_result="G2",
converted_result="G2",
error_result="NO",
final_outcome=PlayOutcome.GROUNDBALL_B,
hit_type="g2_no_error"
)
play_result = PlayResult(
outcome=PlayOutcome.GROUNDBALL_B,
outs_recorded=1,
runs_scored=0,
batter_result=None,
runners_advanced=[],
description="X-Check SS: G2 → G2 + NO = groundball_b",
ab_roll=ab_roll,
hit_location="SS",
is_hit=False,
is_out=True,
x_check_details=xcheck_result
)
mock_resolve.return_value = play_result
# Call handler
await submit_handler(sid, data)
# Verify outcome_accepted was emitted to user
assert mock_manager.emit_to_user.called
emit_calls = mock_manager.emit_to_user.call_args_list
accepted_call = None
for call in emit_calls:
if call[0][1] == "outcome_accepted":
accepted_call = call
break
assert accepted_call is not None, "outcome_accepted not emitted"
# Verify play_resolved was broadcast with X-Check details
assert mock_manager.broadcast_to_game.called
broadcast_calls = mock_manager.broadcast_to_game.call_args_list
play_resolved_call = None
for call in broadcast_calls:
if call[0][1] == "play_resolved":
play_resolved_call = call
break
assert play_resolved_call is not None, "play_resolved not broadcast"
# Extract broadcast data
broadcast_data = play_resolved_call[0][2]
# Verify standard play data
assert broadcast_data["outcome"] == "groundball_b"
assert broadcast_data["hit_location"] == "SS"
assert broadcast_data["outs_recorded"] == 1
assert broadcast_data["runs_scored"] == 0
assert broadcast_data["is_out"] is True
# Verify X-Check details are included
assert "x_check_details" in broadcast_data, "x_check_details missing from broadcast"
xcheck_data = broadcast_data["x_check_details"]
# Verify X-Check structure
assert xcheck_data["position"] == "SS"
assert xcheck_data["d20_roll"] == 12
assert xcheck_data["d6_roll"] == 10
assert xcheck_data["defender_range"] == 4
assert xcheck_data["defender_error_rating"] == 12
assert xcheck_data["defender_id"] == 25
assert xcheck_data["base_result"] == "G2"
assert xcheck_data["converted_result"] == "G2"
assert xcheck_data["error_result"] == "NO"
assert xcheck_data["final_outcome"] == "groundball_b"
assert xcheck_data["hit_type"] == "g2_no_error"
# Verify optional SPD test fields
assert xcheck_data["spd_test_roll"] is None
assert xcheck_data["spd_test_target"] is None
assert xcheck_data["spd_test_passed"] is None
async def test_non_xcheck_play_has_no_xcheck_details(self, mock_sio, mock_manager, game_state):
"""
Test that non-X-Check plays don't include x_check_details.
"""
game_id = game_state.game_id
# Register handlers
register_handlers(mock_sio, mock_manager)
# Get the submit_manual_outcome handler
handler_calls = [call for call in mock_sio.event.call_args_list]
submit_handler = None
for call in handler_calls:
if len(call[0]) > 0 and hasattr(call[0][0], '__name__'):
if call[0][0].__name__ == 'submit_manual_outcome':
submit_handler = call[0][0]
break
assert submit_handler is not None
# Mock pending roll
from app.core.roll_types import AbRoll
ab_roll = AbRoll(
roll_id="test-roll-124",
roll_type="AB",
league_id="pd",
game_id=game_id,
d6_one=5,
d6_two_a=6,
d6_two_b=6,
chaos_d20=20,
resolution_d20=20,
timestamp=pendulum.now('UTC')
)
game_state.pending_manual_roll = ab_roll
state_manager.update_state(game_id, game_state)
# Mock session
sid = "test-session-124"
mock_manager.user_sessions[sid] = "test-user"
# Submit strikeout (not X-Check)
data = {
"game_id": str(game_id),
"outcome": "strikeout",
"hit_location": None
}
# Mock game engine resolve_manual_play to return strikeout result
with patch('app.websocket.handlers.game_engine.resolve_manual_play') as mock_resolve:
from app.core.play_resolver import PlayResult
play_result = PlayResult(
outcome=PlayOutcome.STRIKEOUT,
outs_recorded=1,
runs_scored=0,
batter_result=None,
runners_advanced=[],
description="Strikeout looking",
ab_roll=ab_roll,
hit_location=None,
is_hit=False,
is_out=True,
x_check_details=None # No X-Check for strikeout
)
mock_resolve.return_value = play_result
# Call handler
await submit_handler(sid, data)
# Verify play_resolved was broadcast
assert mock_manager.broadcast_to_game.called
broadcast_calls = mock_manager.broadcast_to_game.call_args_list
play_resolved_call = None
for call in broadcast_calls:
if call[0][1] == "play_resolved":
play_resolved_call = call
break
assert play_resolved_call is not None
# Extract broadcast data
broadcast_data = play_resolved_call[0][2]
# Verify X-Check details are NOT included for non-X-Check plays
assert "x_check_details" not in broadcast_data, \
"x_check_details should not be present for non-X-Check plays"