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>
386 lines
16 KiB
Python
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"]
|