strat-gameplay-webapp/.claude/implementation/phase-3f-testing-integration.md
Cal Corum a1f42a93b8 CLAUDE: Implement Phase 3A - X-Check data models and enums
Add foundational data structures for X-Check play resolution system:

Models Added:
- PositionRating: Defensive ratings (range 1-5, error 0-88) for X-Check resolution
- XCheckResult: Dataclass tracking complete X-Check resolution flow with dice rolls,
  conversions (SPD test, G2#/G3#→SI2), error results, and final outcomes
- BasePlayer.active_position_rating: Optional field for current defensive position

Enums Extended:
- PlayOutcome.X_CHECK: New outcome type requiring special resolution
- PlayOutcome.is_x_check(): Helper method for type checking

Documentation Enhanced:
- Play.check_pos: Documented as X-Check position identifier
- Play.hit_type: Documented with examples (single_2_plus_error_1, etc.)

Utilities Added:
- app/core/cache.py: Redis cache key helpers for player positions and game state

Implementation Planning:
- Complete 6-phase implementation plan (3A-3F) documented in .claude/implementation/
- Phase 3A complete with all acceptance criteria met
- Zero breaking changes, all existing tests passing

Next: Phase 3B will add defense tables, error charts, and advancement logic

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 15:32:09 -05:00

24 KiB

Phase 3F: Testing & Integration for X-Check System

Status: Not Started Estimated Effort: 4-5 hours Dependencies: All previous phases (3A-3E)

Overview

Comprehensive testing strategy for X-Check system covering:

  • Unit tests for all components
  • Integration tests for complete flows
  • Test fixtures and mock data
  • End-to-end scenarios
  • Performance validation

Tasks

1. Create Test Fixtures

File: tests/fixtures/x_check_fixtures.py (NEW FILE)

"""
Test fixtures for X-Check system.

Provides mock players, position ratings, defense tables, etc.

Author: Claude
Date: 2025-11-01
"""
import pytest
from app.models.player_models import PdPlayer, PositionRating, PdBattingCard
from app.models.game_models import GameState, XCheckResult
from app.config.result_charts import PlayOutcome


@pytest.fixture
def mock_position_rating_ss():
    """Mock position rating for shortstop (good defender)."""
    return PositionRating(
        position='SS',
        innings=1200,
        range=2,  # Good range
        error=10,  # Average error rating
        arm=80,
        pb=None,
        overthrow=5,
    )


@pytest.fixture
def mock_position_rating_lf():
    """Mock position rating for left field (average defender)."""
    return PositionRating(
        position='LF',
        innings=800,
        range=3,  # Average range
        error=15,  # Below average error
        arm=70,
        pb=None,
        overthrow=8,
    )


@pytest.fixture
def mock_pd_player_with_positions():
    """Mock PD player with multiple positions cached."""
    from app.models.player_models import PdCardset, PdRarity

    player = PdPlayer(
        id=10932,
        name="Chipper Jones",
        cost=254,
        image="https://pd.manticorum.com/api/v2/players/10932/battingcard",
        cardset=PdCardset(id=21, name="1998 Promos", description="1998", ranked_legal=True),
        set_num=97,
        rarity=PdRarity(id=2, value=3, name="All-Star", color="FFD700"),
        mlbclub="Atlanta Braves",
        franchise="Atlanta Braves",
        pos_1="3B",
        description="April PotM",
        batting_card=PdBattingCard(
            steal_low=1,
            steal_high=12,
            steal_auto=False,
            steal_jump=0.5,
            bunting="C",
            hit_and_run="B",
            running=14,  # Speed for SPD test
            offense_col=1,
            hand="R",
            ratings={},
        ),
    )

    return player


@pytest.fixture
def mock_x_check_result_si2_e1():
    """Mock XCheckResult for SI2 + E1."""
    return XCheckResult(
        position='SS',
        d20_roll=15,
        d6_roll=12,
        defender_range=2,
        defender_error_rating=10,
        defender_id=5001,
        base_result='SI2',
        converted_result='SI2',
        error_result='E1',
        final_outcome=PlayOutcome.SINGLE_2,
        hit_type='si2_plus_error_1',
    )


@pytest.fixture
def mock_x_check_result_g2_no_error():
    """Mock XCheckResult for G2 with no error."""
    return XCheckResult(
        position='2B',
        d20_roll=10,
        d6_roll=8,
        defender_range=3,
        defender_error_rating=12,
        defender_id=5002,
        base_result='G2',
        converted_result='G2',
        error_result='NO',
        final_outcome=PlayOutcome.GROUNDBALL_B,
        hit_type='g2_no_error',
    )


@pytest.fixture
def mock_x_check_result_f2_e3():
    """Mock XCheckResult for F2 + E3 (out becomes error)."""
    return XCheckResult(
        position='LF',
        d20_roll=16,
        d6_roll=17,
        defender_range=4,
        defender_error_rating=18,
        defender_id=5003,
        base_result='F2',
        converted_result='F2',
        error_result='E3',
        final_outcome=PlayOutcome.ERROR,  # Out + error = ERROR
        hit_type='f2_plus_error_3',
    )


@pytest.fixture
def mock_x_check_result_spd_passed():
    """Mock XCheckResult for SPD test (passed)."""
    return XCheckResult(
        position='C',
        d20_roll=12,
        d6_roll=9,
        defender_range=2,
        defender_error_rating=8,
        defender_id=5004,
        base_result='SPD',
        converted_result='SI1',  # Passed speed test
        error_result='NO',
        final_outcome=PlayOutcome.SINGLE_1,
        hit_type='si1_no_error',
        spd_test_roll=13,
        spd_test_target=14,
        spd_test_passed=True,
    )


@pytest.fixture
def mock_game_state_r1():
    """Mock game state with runner on first."""
    # TODO: Create full GameState mock with R1
    pass


@pytest.fixture
def mock_game_state_bases_loaded():
    """Mock game state with bases loaded."""
    # TODO: Create full GameState mock with bases loaded
    pass

2. Unit Tests for Core Components

File: tests/core/test_x_check_resolution.py

"""
Unit tests for X-Check resolution logic.

Tests PlayResolver._resolve_x_check() and helper methods.

Author: Claude
Date: 2025-11-01
"""
import pytest
from app.core.play_resolver import PlayResolver
from app.config.result_charts import PlayOutcome


class TestDefenseTableLookup:
    """Test defense table lookups."""

    def test_infield_lookup_best_range(self, play_resolver):
        """Test infield lookup with range 1 (best)."""
        result = play_resolver._lookup_defense_table('SS', d20_roll=1, defense_range=1)
        assert result == 'G3#'

    def test_infield_lookup_worst_range(self, play_resolver):
        """Test infield lookup with range 5 (worst)."""
        result = play_resolver._lookup_defense_table('3B', d20_roll=1, defense_range=5)
        assert result == 'SI2'

    def test_outfield_lookup(self, play_resolver):
        """Test outfield lookup."""
        result = play_resolver._lookup_defense_table('LF', d20_roll=5, defense_range=2)
        assert result == 'DO2'

    def test_catcher_lookup(self, play_resolver):
        """Test catcher-specific table."""
        result = play_resolver._lookup_defense_table('C', d20_roll=10, defense_range=1)
        assert result == 'SPD'


class TestSpdTest:
    """Test SPD (speed test) resolution."""

    def test_spd_pass(self, play_resolver, mock_pd_player_with_positions, mocker):
        """Test passing speed test (roll <= speed)."""
        # Mock dice to roll 12 (player speed = 14)
        mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=12)

        result, roll, target, passed = play_resolver._resolve_spd_test(
            mock_pd_player_with_positions
        )

        assert result == 'SI1'
        assert roll == 12
        assert target == 14
        assert passed is True

    def test_spd_fail(self, play_resolver, mock_pd_player_with_positions, mocker):
        """Test failing speed test (roll > speed)."""
        # Mock dice to roll 16 (player speed = 14)
        mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=16)

        result, roll, target, passed = play_resolver._resolve_spd_test(
            mock_pd_player_with_positions
        )

        assert result == 'G3'
        assert roll == 16
        assert target == 14
        assert passed is False


class TestHashConversion:
    """Test G2#/G3# → SI2 conversion logic."""

    def test_conversion_when_playing_in(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions):
        """Test # conversion when defender playing in."""
        result = play_resolver._apply_hash_conversion(
            result='G2#',
            position='3B',
            adjusted_range=3,  # Was 2, increased to 3 (playing in)
            base_range=2,
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
        )

        assert result == 'SI2'

    def test_conversion_when_holding_runner(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions, mocker):
        """Test # conversion when holding runner."""
        # Mock holding function to return 1B
        mocker.patch(
            'app.config.common_x_check_tables.get_fielders_holding_runners',
            return_value=['1B']
        )

        result = play_resolver._apply_hash_conversion(
            result='G3#',
            position='1B',
            adjusted_range=2,  # Same as base (not playing in)
            base_range=2,
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
        )

        assert result == 'SI2'

    def test_no_conversion(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions, mocker):
        """Test no conversion when conditions not met."""
        mocker.patch(
            'app.config.common_x_check_tables.get_fielders_holding_runners',
            return_value=[]
        )

        result = play_resolver._apply_hash_conversion(
            result='G2#',
            position='SS',
            adjusted_range=2,
            base_range=2,
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
        )

        assert result == 'G2'  # # removed, not converted to SI2


class TestErrorChartLookup:
    """Test error chart lookups."""

    def test_no_error(self, play_resolver):
        """Test 3d6 roll with no error."""
        result = play_resolver._lookup_error_chart(
            position='LF',
            error_rating=0,
            d6_roll=6  # Not in any error list for rating 0
        )

        assert result == 'NO'

    def test_error_e1(self, play_resolver):
        """Test 3d6 roll resulting in E1."""
        result = play_resolver._lookup_error_chart(
            position='LF',
            error_rating=1,
            d6_roll=3  # In E1 list for rating 1
        )

        assert result == 'E1'

    def test_rare_play(self, play_resolver):
        """Test 3d6 roll resulting in Rare Play."""
        result = play_resolver._lookup_error_chart(
            position='LF',
            error_rating=10,
            d6_roll=5  # Always RP
        )

        assert result == 'RP'


class TestFinalOutcomeDetermination:
    """Test final outcome and hit_type determination."""

    def test_hit_no_error(self, play_resolver):
        """Test hit with no error."""
        outcome, hit_type = play_resolver._determine_final_x_check_outcome(
            converted_result='SI2',
            error_result='NO'
        )

        assert outcome == PlayOutcome.SINGLE_2
        assert hit_type == 'si2_no_error'

    def test_hit_with_error(self, play_resolver):
        """Test hit with error (keep hit outcome)."""
        outcome, hit_type = play_resolver._determine_final_x_check_outcome(
            converted_result='DO2',
            error_result='E1'
        )

        assert outcome == PlayOutcome.DOUBLE_2
        assert hit_type == 'do2_plus_error_1'

    def test_out_with_error(self, play_resolver):
        """Test out with error (becomes ERROR outcome)."""
        outcome, hit_type = play_resolver._determine_final_x_check_outcome(
            converted_result='F2',
            error_result='E3'
        )

        assert outcome == PlayOutcome.ERROR
        assert hit_type == 'f2_plus_error_3'

    def test_rare_play(self, play_resolver):
        """Test rare play result."""
        outcome, hit_type = play_resolver._determine_final_x_check_outcome(
            converted_result='G1',
            error_result='RP'
        )

        assert outcome == PlayOutcome.ERROR  # RP treated like error
        assert hit_type == 'g1_rare_play'

3. Integration Tests for Complete Flows

File: tests/integration/test_x_check_flows.py

"""
Integration tests for complete X-Check flows.

Tests end-to-end resolution from outcome to Play record.

Author: Claude
Date: 2025-11-01
"""
import pytest
from app.core.play_resolver import PlayResolver
from app.config.result_charts import PlayOutcome


class TestXCheckInfieldFlow:
    """Test complete X-Check flow for infield positions."""

    @pytest.mark.asyncio
    async def test_infield_groundball_no_error(
        self,
        play_resolver,
        mock_game_state_r1,
        mock_pd_player_with_positions,
        mocker
    ):
        """Test infield X-Check resulting in groundout."""
        # Mock dice rolls
        mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=15)
        mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=8)

        # Mock defender with good range
        defender = mock_pd_player_with_positions
        defender.active_position_rating = pytest.fixtures.mock_position_rating_ss()

        result = await play_resolver._resolve_x_check(
            position='SS',
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
            pitcher=mock_pd_player_with_positions,  # Reuse for simplicity
        )

        # Verify result
        assert result.x_check_details is not None
        assert result.x_check_details.position == 'SS'
        assert result.x_check_details.error_result == 'NO'
        assert result.outcome.is_out()

    @pytest.mark.asyncio
    async def test_infield_with_error(
        self,
        play_resolver,
        mock_game_state_r1,
        mock_pd_player_with_positions,
        mocker
    ):
        """Test infield X-Check with error."""
        # Mock dice rolls that produce error
        mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=10)
        mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=3)  # E1

        result = await play_resolver._resolve_x_check(
            position='2B',
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
            pitcher=mock_pd_player_with_positions,
        )

        # Verify error applied
        assert result.x_check_details.error_result == 'E1'
        assert result.outcome == PlayOutcome.ERROR or result.outcome.is_hit()


class TestXCheckOutfieldFlow:
    """Test complete X-Check flow for outfield positions."""

    @pytest.mark.asyncio
    async def test_outfield_flyball_deep(
        self,
        play_resolver,
        mock_game_state_bases_loaded,
        mock_pd_player_with_positions,
        mocker
    ):
        """Test deep flyball (F1) to outfield."""
        mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=8)
        mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=10)

        result = await play_resolver._resolve_x_check(
            position='CF',
            state=mock_game_state_bases_loaded,
            batter=mock_pd_player_with_positions,
            pitcher=mock_pd_player_with_positions,
        )

        # F1 should be deep fly with runner advancement
        assert result.x_check_details.converted_result == 'F1'
        assert result.advancement is not None


class TestXCheckCatcherSpdFlow:
    """Test X-Check flow for catcher with SPD test."""

    @pytest.mark.asyncio
    async def test_catcher_spd_pass(
        self,
        play_resolver,
        mock_game_state_r1,
        mock_pd_player_with_positions,
        mocker
    ):
        """Test catcher SPD test with pass."""
        # Roll SPD result
        mocker.patch.object(play_resolver.dice, 'roll_d20', side_effect=[10, 12])  # Table, then SPD
        mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=9)

        result = await play_resolver._resolve_x_check(
            position='C',
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
            pitcher=mock_pd_player_with_positions,
        )

        # Verify SPD test recorded
        assert result.x_check_details.base_result == 'SPD'
        assert result.x_check_details.spd_test_passed is not None
        assert result.x_check_details.converted_result in ['SI1', 'G3']


class TestXCheckHashConversion:
    """Test G2#/G3# conversion scenarios."""

    @pytest.mark.asyncio
    async def test_hash_conversion_playing_in(
        self,
        play_resolver,
        mock_game_state_r1,
        mock_pd_player_with_positions,
        mocker
    ):
        """Test # conversion when infield playing in."""
        # Mock state with infield_in decision
        mock_game_state_r1.current_defensive_decision.infield_in = True

        # Mock rolls to produce G2#
        mocker.patch.object(play_resolver.dice, 'roll_d20', return_value=2)
        mocker.patch.object(play_resolver.dice, 'roll_3d6', return_value=10)

        result = await play_resolver._resolve_x_check(
            position='2B',
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
            pitcher=mock_pd_player_with_positions,
        )

        # Should convert to SI2
        assert result.x_check_details.base_result == 'G2#'
        assert result.x_check_details.converted_result == 'SI2'
        assert result.outcome == PlayOutcome.SINGLE_2

4. WebSocket Event Tests

File: tests/websocket/test_x_check_events.py

"""
Integration tests for X-Check WebSocket events.

Author: Claude
Date: 2025-11-01
"""
import pytest
from unittest.mock import AsyncMock


class TestXCheckAutoMode:
    """Test PD auto mode X-Check flow."""

    @pytest.mark.asyncio
    async def test_auto_result_broadcast(self, socket_client, mock_game_state_r1):
        """Test auto-resolved result broadcast."""
        # Trigger X-Check
        await socket_client.emit('action', {
            'game_id': 1,
            'action_type': 'swing',
            # ... other params
        })

        # Should receive x_check_auto_result event
        response = await socket_client.receive()
        assert response['type'] == 'x_check_auto_result'
        assert 'x_check' in response
        assert response['x_check']['position'] in ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']

    @pytest.mark.asyncio
    async def test_accept_auto_result(self, socket_client):
        """Test player accepting auto result."""
        # Confirm result
        await socket_client.emit('confirm_x_check_result', {
            'game_id': 1,
            'accepted': True,
        })

        # Should receive updated game state
        response = await socket_client.receive()
        assert response['type'] == 'game_update'
        # Play should be recorded

    @pytest.mark.asyncio
    async def test_reject_auto_result(self, socket_client, mocker):
        """Test player rejecting auto result (logs override)."""
        # Mock override logger
        log_mock = mocker.patch('app.websocket.game_handlers.log_x_check_override')

        # Reject result
        await socket_client.emit('confirm_x_check_result', {
            'game_id': 1,
            'accepted': False,
            'override_outcome': 'SI2_E1',
        })

        # Verify override logged
        assert log_mock.called


class TestXCheckManualMode:
    """Test SBA manual mode X-Check flow."""

    @pytest.mark.asyncio
    async def test_manual_options_broadcast(self, socket_client):
        """Test manual mode dice + options broadcast."""
        # Trigger X-Check
        await socket_client.emit('action', {
            'game_id': 1,
            'action_type': 'swing',
            # ... params
        })

        # Should receive manual options
        response = await socket_client.receive()
        assert response['type'] == 'x_check_manual_options'
        assert 'd20' in response
        assert 'd6' in response
        assert 'options' in response
        assert len(response['options']) > 0

    @pytest.mark.asyncio
    async def test_manual_submission(self, socket_client):
        """Test player submitting manual outcome."""
        # Submit choice
        await socket_client.emit('submit_x_check_manual', {
            'game_id': 1,
            'outcome': 'SI2_E1',
        })

        # Should receive updated game state
        response = await socket_client.receive()
        assert response['type'] == 'game_update'

5. Performance Tests

File: tests/performance/test_x_check_performance.py

"""
Performance tests for X-Check resolution.

Ensures resolution stays under latency targets.

Author: Claude
Date: 2025-11-01
"""
import pytest
import time


class TestXCheckPerformance:
    """Test X-Check resolution performance."""

    @pytest.mark.asyncio
    async def test_resolution_latency(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions):
        """Test single X-Check resolution completes under 100ms."""
        start = time.time()

        result = await play_resolver._resolve_x_check(
            position='SS',
            state=mock_game_state_r1,
            batter=mock_pd_player_with_positions,
            pitcher=mock_pd_player_with_positions,
        )

        elapsed = (time.time() - start) * 1000  # Convert to ms

        assert elapsed < 100, f"X-Check resolution took {elapsed}ms (target: <100ms)"

    @pytest.mark.asyncio
    async def test_batch_resolution(self, play_resolver, mock_game_state_r1, mock_pd_player_with_positions):
        """Test 100 X-Check resolutions complete under 5 seconds."""
        start = time.time()

        for _ in range(100):
            await play_resolver._resolve_x_check(
                position='LF',
                state=mock_game_state_r1,
                batter=mock_pd_player_with_positions,
                pitcher=mock_pd_player_with_positions,
            )

        elapsed = time.time() - start

        assert elapsed < 5.0, f"100 resolutions took {elapsed}s (target: <5s)"

Testing Checklist

Unit Tests

  • Defense table lookup (all positions)
  • SPD test (pass/fail)
  • Hash conversion (playing in, holding runner, none)
  • Error chart lookup (all error types)
  • Final outcome determination (all combinations)
  • Advancement table lookups
  • Option generation

Integration Tests

  • Complete infield X-Check (no error)
  • Complete infield X-Check (with error)
  • Complete outfield X-Check
  • Complete catcher X-Check with SPD
  • Hash conversion in game context
  • Error overriding outs
  • Rare play handling

WebSocket Tests

  • Auto mode result broadcast
  • Accept auto result
  • Reject auto result (logs override)
  • Manual mode options broadcast
  • Manual submission

Performance Tests

  • Single resolution < 100ms
  • Batch resolution (100 plays) < 5s

Database Tests

  • Play record created with check_pos
  • Play record has correct hit_type
  • Defender_id populated
  • Error and hit flags correct

Acceptance Criteria

  • All unit tests pass (>95% coverage for X-Check code)
  • All integration tests pass
  • All WebSocket tests pass
  • Performance tests meet targets
  • No regressions in existing tests
  • Test fixtures complete and documented
  • Mock data representative of real scenarios

Notes

  • Use pytest fixtures for reusable test data
  • Mock Redis for position rating tests
  • Mock dice rolls for deterministic tests
  • Test edge cases (range 1, range 5, error 0, error 25)
  • Test all position types (P, C, IF, OF)
  • Validate WebSocket message formats match frontend expectations

Final Integration Checklist

After all tests pass:

  • Manual smoke test: Create PD game, trigger X-Check, verify UI
  • Manual smoke test: Create SBA game, trigger X-Check, verify manual flow
  • Verify Redis caching working (position ratings persisted)
  • Verify override logging working (check database)
  • Performance profiling (identify any bottlenecks)
  • Code review: Check all imports present (no NameErrors)
  • Documentation: Update API docs with X-Check events
  • Frontend integration: Verify all event handlers working

Success Metrics

  • Correctness: All test scenarios produce expected outcomes
  • Performance: Sub-100ms resolution time
  • Reliability: No exceptions in 1000-play test
  • User Experience: Auto/manual flows work smoothly
  • Debuggability: Override logs help diagnose issues

END OF PHASE 3F

Once all phases (3A-3F) are complete, the X-Check system will be fully functional and tested!