strat-gameplay-webapp/backend/tests/integration/test_xcheck_websocket.py
Cal Corum adf7c7646d CLAUDE: Phase 3E-Final - Redis Caching & X-Check WebSocket Integration
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>
2025-11-03 22:46:59 -06:00

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"