strat-gameplay-webapp/backend/tests/unit/websocket/test_uncapped_hit_handlers.py
Cal Corum 529c5b1b99 CLAUDE: Implement uncapped hit interactive decision tree (Issue #6)
Add full multi-step decision workflow for SINGLE_UNCAPPED and DOUBLE_UNCAPPED
outcomes, replacing the previous stub that fell through to basic single/double
advancement. The decision tree follows the same interactive pattern as X-Check
resolution with 5 phases: lead runner advance, defensive throw, trail runner
advance, throw target selection, and safe/out speed check.

- game_models.py: PendingUncappedHit model, 5 new decision phases
- game_engine.py: initiate_uncapped_hit(), 5 submit methods, 3 result builders
- handlers.py: 5 new WebSocket event handlers
- ai_opponent.py: 5 AI decision stubs (conservative defaults)
- play_resolver.py: Updated TODO comments for fallback paths
- 80 new backend tests (2481 total): workflow (49), handlers (23), truth tables (8)
- Fix GameplayPanel.spec.ts: add missing Pinia setup, fix component references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:33:58 -06:00

386 lines
16 KiB
Python

"""
Tests: Uncapped Hit WebSocket Handlers
Verifies the 5 new WebSocket event handlers for uncapped hit decisions:
- submit_uncapped_lead_advance
- submit_uncapped_defensive_throw
- submit_uncapped_trail_advance
- submit_uncapped_throw_target
- submit_uncapped_safe_out
Tests cover:
- Missing/invalid game_id handling
- Missing/invalid field-specific input validation
- Successful submission forwarding to game engine
- State broadcast after successful submission
- ValueError propagation from game engine
Author: Claude
Date: 2025-02-11
"""
import pytest
from uuid import uuid4
from unittest.mock import AsyncMock, MagicMock, patch
from app.models.game_models import GameState, LineupPlayerState
from .conftest import get_handler, sio_with_mocks
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_game_state():
"""Create a mock active game state for handler tests."""
return GameState(
game_id=uuid4(),
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter=LineupPlayerState(
lineup_id=1, card_id=100, position="CF", batting_order=1
),
status="active",
inning=1,
half="top",
outs=0,
)
# =============================================================================
# Tests: submit_uncapped_lead_advance
# =============================================================================
class TestSubmitUncappedLeadAdvance:
"""Tests for the submit_uncapped_lead_advance WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
await handler("test_sid", {"advance": True})
mocks["manager"].emit_to_user.assert_called_once()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "game_id" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_invalid_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not a valid UUID."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
await handler("test_sid", {"game_id": "not-a-uuid", "advance": True})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_missing_advance_field(self, sio_with_mocks):
"""Handler emits error when advance field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "advance" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_invalid_advance_type(self, sio_with_mocks):
"""Handler emits error when advance is not a bool."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id, "advance": "yes"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_successful_submission(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine and broadcasts state on success."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_lead_advance = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "advance": True})
mocks["game_engine"].submit_uncapped_lead_advance.assert_called_once_with(
mock_game_state.game_id, True
)
mocks["manager"].broadcast_to_game.assert_called_once()
@pytest.mark.asyncio
async def test_value_error_from_engine(self, sio_with_mocks):
"""Handler emits error when game engine raises ValueError."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_lead_advance")
game_id = str(uuid4())
mocks["game_engine"].submit_uncapped_lead_advance = AsyncMock(
side_effect=ValueError("Wrong phase")
)
await handler("test_sid", {"game_id": game_id, "advance": True})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "Wrong phase" in call_args[2]["message"]
# =============================================================================
# Tests: submit_uncapped_defensive_throw
# =============================================================================
class TestSubmitUncappedDefensiveThrow:
"""Tests for the submit_uncapped_defensive_throw WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_defensive_throw")
await handler("test_sid", {"will_throw": True})
mocks["manager"].emit_to_user.assert_called_once()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_missing_will_throw_field(self, sio_with_mocks):
"""Handler emits error when will_throw field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_defensive_throw")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert "will_throw" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_successful_submission(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine and broadcasts state on success."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_defensive_throw")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_defensive_throw = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "will_throw": False})
mocks["game_engine"].submit_uncapped_defensive_throw.assert_called_once_with(
mock_game_state.game_id, False
)
mocks["manager"].broadcast_to_game.assert_called_once()
# =============================================================================
# Tests: submit_uncapped_trail_advance
# =============================================================================
class TestSubmitUncappedTrailAdvance:
"""Tests for the submit_uncapped_trail_advance WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_trail_advance")
await handler("test_sid", {"advance": True})
mocks["manager"].emit_to_user.assert_called_once()
@pytest.mark.asyncio
async def test_missing_advance_field(self, sio_with_mocks):
"""Handler emits error when advance field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_trail_advance")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert "advance" in call_args[2]["message"].lower()
@pytest.mark.asyncio
async def test_successful_submission(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine and broadcasts state on success."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_trail_advance")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_trail_advance = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "advance": True})
mocks["game_engine"].submit_uncapped_trail_advance.assert_called_once_with(
mock_game_state.game_id, True
)
# =============================================================================
# Tests: submit_uncapped_throw_target
# =============================================================================
class TestSubmitUncappedThrowTarget:
"""Tests for the submit_uncapped_throw_target WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
await handler("test_sid", {"target": "lead"})
mocks["manager"].emit_to_user.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_target(self, sio_with_mocks):
"""Handler emits error when target is not 'lead' or 'trail'."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id, "target": "middle"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
assert "lead" in call_args[2]["message"] or "trail" in call_args[2]["message"]
@pytest.mark.asyncio
async def test_missing_target(self, sio_with_mocks):
"""Handler emits error when target field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
@pytest.mark.asyncio
async def test_successful_submission_lead(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with target='lead' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_throw_target = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "target": "lead"})
mocks["game_engine"].submit_uncapped_throw_target.assert_called_once_with(
mock_game_state.game_id, "lead"
)
@pytest.mark.asyncio
async def test_successful_submission_trail(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with target='trail' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_throw_target")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_throw_target = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "target": "trail"})
mocks["game_engine"].submit_uncapped_throw_target.assert_called_once_with(
mock_game_state.game_id, "trail"
)
# =============================================================================
# Tests: submit_uncapped_safe_out
# =============================================================================
class TestSubmitUncappedSafeOut:
"""Tests for the submit_uncapped_safe_out WebSocket handler."""
@pytest.mark.asyncio
async def test_missing_game_id(self, sio_with_mocks):
"""Handler emits error when game_id is not provided."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
await handler("test_sid", {"result": "safe"})
mocks["manager"].emit_to_user.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_result(self, sio_with_mocks):
"""Handler emits error when result is not 'safe' or 'out'."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id, "result": "maybe"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert call_args[1] == "error"
@pytest.mark.asyncio
async def test_missing_result(self, sio_with_mocks):
"""Handler emits error when result field is missing."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(uuid4())
await handler("test_sid", {"game_id": game_id})
mocks["manager"].emit_to_user.assert_called()
@pytest.mark.asyncio
async def test_successful_safe(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with result='safe' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_safe_out = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "result": "safe"})
mocks["game_engine"].submit_uncapped_safe_out.assert_called_once_with(
mock_game_state.game_id, "safe"
)
mocks["manager"].broadcast_to_game.assert_called_once()
@pytest.mark.asyncio
async def test_successful_out(self, sio_with_mocks, mock_game_state):
"""Handler calls game_engine with result='out' and broadcasts state."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(mock_game_state.game_id)
mocks["game_engine"].submit_uncapped_safe_out = AsyncMock()
mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state)
await handler("test_sid", {"game_id": game_id, "result": "out"})
mocks["game_engine"].submit_uncapped_safe_out.assert_called_once_with(
mock_game_state.game_id, "out"
)
@pytest.mark.asyncio
async def test_value_error_propagation(self, sio_with_mocks):
"""Handler emits error when game engine raises ValueError."""
sio, mocks = sio_with_mocks
handler = get_handler(sio, "submit_uncapped_safe_out")
game_id = str(uuid4())
mocks["game_engine"].submit_uncapped_safe_out = AsyncMock(
side_effect=ValueError("No pending uncapped hit")
)
await handler("test_sid", {"game_id": game_id, "result": "safe"})
mocks["manager"].emit_to_user.assert_called()
call_args = mocks["manager"].emit_to_user.call_args[0]
assert "No pending uncapped hit" in call_args[2]["message"]