Completed Phase 3E-Final with Redis caching upgrade and WebSocket X-Check
integration for real-time defensive play resolution.
## Redis Caching System
### New Files
- app/services/redis_client.py - Async Redis client with connection pooling
* 10 connection pool size
* Automatic connect/disconnect lifecycle
* Ping health checks
* Environment-configurable via REDIS_URL
### Modified Files
- app/services/position_rating_service.py - Migrated from in-memory to Redis
* Redis key pattern: "position_ratings:{card_id}"
* TTL: 86400 seconds (24 hours)
* Graceful fallback if Redis unavailable
* Individual and bulk cache clearing (scan_iter)
* 760x performance improvement (0.274s API → 0.000361s Redis)
- app/main.py - Added Redis startup/shutdown events
* Connect on app startup with settings.redis_url
* Disconnect on shutdown
* Warning logged if Redis connection fails
- app/config.py - Added redis_url setting
* Default: "redis://localhost:6379/0"
* Override via REDIS_URL environment variable
- app/services/__init__.py - Export redis_client
### Testing
- test_redis_cache.py - Live integration test
* 10-step validation: connect, cache miss, cache hit, performance, etc.
* Verified 760x speedup with player 8807 (7 positions)
* Data integrity checks pass
## X-Check WebSocket Integration
### Modified Files
- app/websocket/handlers.py - Enhanced submit_manual_outcome handler
* Serialize XCheckResult to JSON when present
* Include x_check_details in play_resolved broadcast
* Fixed bug: Use result.outcome instead of submitted outcome
* Includes defender ratings, dice rolls, resolution steps
### New Files
- app/websocket/X_CHECK_FRONTEND_GUIDE.md - Comprehensive frontend documentation
* Event structure and field definitions
* Implementation examples (basic, enhanced, polished)
* Error handling and common pitfalls
* Test scenarios with expected data
* League differences (SBA vs PD)
* 500+ lines of frontend integration guide
- app/websocket/MANUAL_VS_AUTO_MODE.md - Workflow documentation
* Manual mode: Players read cards, submit outcomes
* Auto mode: System generates from ratings (PD only)
* X-Check resolution comparison
* UI recommendations for each mode
* Configuration reference
* Testing considerations
### Testing
- tests/integration/test_xcheck_websocket.py - WebSocket integration tests
* Test X-Check play includes x_check_details ✅
* Test non-X-Check plays don't include details ✅
* Full event structure validation
## Performance Impact
- Redis caching: 760x speedup for position ratings
- WebSocket: No performance impact (optional field)
- Graceful degradation: System works without Redis
## Phase 3E-Final Progress
- ✅ WebSocket event handlers for X-Check UI
- ✅ Frontend integration documentation
- ✅ Redis caching upgrade (from in-memory)
- ✅ Redis connection pool in app lifecycle
- ✅ Integration tests (2 WebSocket, 1 Redis)
- ✅ Manual vs Auto mode workflow documentation
Phase 3E-Final: 100% Complete
Phase 3 Overall: ~98% Complete
## Testing Results
All tests passing:
- X-Check table tests: 36/36 ✅
- WebSocket integration: 2/2 ✅
- Redis live test: 10/10 steps ✅
## Configuration
Development:
REDIS_URL=redis://localhost:6379/0 (Docker Compose)
Production options:
REDIS_URL=redis://10.10.0.42:6379/0 (DB server)
REDIS_URL=redis://your-redis-cloud.com:6379/0 (Managed)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
336 lines
11 KiB
Python
336 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,
|
|
current_batter_lineup_id=10,
|
|
current_pitcher_lineup_id=20,
|
|
current_catcher_lineup_id=21
|
|
)
|
|
|
|
# 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"
|