strat-gameplay-webapp/.claude/implementation/player-model-specs/testing-strategy.md
Cal Corum f9aa653c37 CLAUDE: Reorganize Week 6 documentation and separate player model specifications
Split player model architecture into dedicated documentation files for clarity
and maintainability. Added Phase 1 status tracking and comprehensive player
model specs covering API models, game models, mappers, and testing strategy.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 23:48:57 -05:00

17 KiB

Testing Strategy for Player Models

Purpose: Comprehensive test plan for Week 6 player model implementation


Overview

Testing will be organized into three layers:

  1. Unit Tests - Individual components in isolation
  2. Integration Tests - Components working together
  3. End-to-End Tests - Full pipeline from API to game

Target: 90%+ code coverage on all new modules


Unit Tests

1. API Models (test_api_models.py)

File: tests/unit/models/test_api_models.py

Coverage: Pydantic model deserialization with real JSON samples

import pytest
from pydantic import ValidationError
from app.models.api_models import (
    SbaPlayerApi,
    PdPlayerApi,
    PdBattingRatingsResponseApi,
    PdPitchingRatingsResponseApi
)

class TestSbaPlayerApi:
    """Test SBA API model deserialization"""

    def test_deserialize_sba_player(self, sba_player_json):
        """Test valid SBA player response"""
        player = SbaPlayerApi(**sba_player_json)

        assert player.id == 12288
        assert player.name == "Ronald Acuna Jr"
        assert player.team.lname == "West Virginia Black Bears"
        assert player.team.manager1.name == "Cal"
        assert player.team.division.division_name == "Big Chungus"

    def test_sba_player_positions(self, sba_player_json):
        """Test position fields"""
        player = SbaPlayerApi(**sba_player_json)

        assert player.pos_1 == "RF"
        assert player.pos_2 is None

    def test_sba_player_missing_required_field(self, sba_player_json):
        """Test validation fails on missing required field"""
        del sba_player_json["name"]

        with pytest.raises(ValidationError) as exc_info:
            SbaPlayerApi(**sba_player_json)

        assert "name" in str(exc_info.value)

class TestPdPlayerApi:
    """Test PD API model deserialization"""

    def test_deserialize_pd_player(self, pd_player_json):
        """Test valid PD player response"""
        player = PdPlayerApi(**pd_player_json)

        assert player.player_id == 11223
        assert player.p_name == "Matt Karchner"
        assert player.cardset.name == "1998 Season"
        assert player.rarity.name == "MVP"
        assert player.mlbplayer.first_name == "Matt"

    def test_pd_player_positions(self, pd_player_json):
        """Test position fields"""
        player = PdPlayerApi(**pd_player_json)

        assert player.pos_1 == "RP"
        assert player.pos_2 == "CP"
        assert player.pos_3 is None

class TestPdBattingRatingsApi:
    """Test PD batting ratings deserialization"""

    def test_deserialize_batting_ratings(self, pd_batting_ratings_json):
        """Test valid batting ratings response"""
        ratings = PdBattingRatingsResponseApi(**pd_batting_ratings_json)

        assert ratings.count == 2
        assert len(ratings.ratings) == 2

        # Check vs L rating
        vs_l = next(r for r in ratings.ratings if r.vs_hand == "L")
        assert vs_l.homerun == 0.0
        assert vs_l.walk == 18.25
        assert vs_l.strikeout == 9.75

        # Check vs R rating
        vs_r = next(r for r in ratings.ratings if r.vs_hand == "R")
        assert vs_r.homerun == 1.05
        assert vs_r.walk == 12.1

    def test_batting_probabilities_sum_to_100(self, pd_batting_ratings_json):
        """Test that all probabilities sum to ~100"""
        ratings = PdBattingRatingsResponseApi(**pd_batting_ratings_json)

        for rating in ratings.ratings:
            total = (
                rating.homerun + rating.bp_homerun +
                rating.triple +
                rating.double_three + rating.double_two + rating.double_pull +
                rating.single_two + rating.single_one + rating.single_center +
                rating.bp_single +
                rating.hbp + rating.walk + rating.strikeout +
                rating.lineout + rating.popout +
                rating.flyout_a + rating.flyout_bq + rating.flyout_lf_b + rating.flyout_rf_b +
                rating.groundout_a + rating.groundout_b + rating.groundout_c
            )

            # Allow small float rounding errors
            assert 99.0 <= total <= 101.0, f"Total probability: {total}"

class TestPdPitchingRatingsApi:
    """Test PD pitching ratings deserialization"""

    def test_deserialize_pitching_ratings(self, pd_pitching_ratings_json):
        """Test valid pitching ratings response"""
        ratings = PdPitchingRatingsResponseApi(**pd_pitching_ratings_json)

        assert ratings.count == 2
        assert len(ratings.ratings) == 2

        # Verify xcheck fields exist
        vs_l = ratings.ratings[0]
        assert vs_l.xcheck_p == 1.0
        assert vs_l.xcheck_ss == 7.0

2. Game Models (test_player_models.py)

File: tests/unit/models/test_player_models.py

Coverage: Game model creation and methods

import pytest
from pydantic import ValidationError
from app.models.player_models import (
    SbaPlayer,
    PdPlayer,
    PdBattingRating,
    PdPitchingRating
)

class TestSbaPlayer:
    """Test SBA game player model"""

    def test_create_sba_player(self):
        """Test creating SBA player"""
        player = SbaPlayer(
            player_id=12288,
            name="Ronald Acuna Jr",
            positions=["RF", "CF"],
            image_url="https://example.com/image.png",
            team_name="West Virginia Black Bears",
            team_abbrev="WV"
        )

        assert player.player_id == 12288
        assert player.get_primary_position() == "RF"
        assert player.can_play_position("CF")
        assert not player.can_play_position("P")

    def test_sba_player_display_name(self):
        """Test display name formatting"""
        player = SbaPlayer(
            player_id=1,
            name="Test Player",
            positions=["SS"],
            image_url="https://example.com/image.png",
            team_name="Test Team",
            team_abbrev="TT"
        )

        assert player.get_display_name() == "Test Player (SS)"

    def test_sba_player_requires_positions(self):
        """Test validation fails without positions"""
        # Empty positions list should be invalid
        with pytest.raises(ValidationError):
            SbaPlayer(
                player_id=1,
                name="Test",
                positions=[],
                image_url="url",
                team_name="Team",
                team_abbrev="TM"
            )

class TestPdPlayer:
    """Test PD game player model"""

    def test_create_pd_player_with_batting(self):
        """Test creating PD position player"""
        batting_vs_lhp = PdBattingRating(
            vs_hand="L",
            homerun=2.0,
            triple=1.0,
            double=10.0,
            single=15.0,
            walk=12.0,
            hbp=2.0,
            strikeout=10.0,
            flyout=5.0,
            lineout=8.0,
            popout=15.0,
            groundout=20.0,
            bp_homerun=3.0,
            bp_single=5.0,
            avg=0.250,
            obp=0.350,
            slg=0.450
        )

        player = PdPlayer(
            player_id=10633,
            name="Chuck Knoblauch",
            positions=["2B"],
            image_url="https://example.com/image.png",
            team_name="New York Yankees",
            cardset_id=20,
            cardset_name="1998 Season",
            is_pitcher=False,
            hand="R",
            batting_vs_lhp=batting_vs_lhp,
            batting_vs_rhp=None
        )

        assert player.player_id == 10633
        assert not player.is_pitcher
        assert player.get_batting_rating("L") == batting_vs_lhp
        assert player.get_pitching_rating("L") is None

    def test_pd_player_display_name_with_handedness(self):
        """Test display name includes handedness symbol"""
        player = PdPlayer(
            player_id=1,
            name="Test Player",
            positions=["2B"],
            image_url="url",
            team_name="Team",
            cardset_id=1,
            cardset_name="2021",
            is_pitcher=False,
            hand="L"
        )

        display = player.get_display_name()
        assert "⟨" in display  # Left-handed symbol
        assert "(2B)" in display

class TestPdBattingRating:
    """Test PD batting rating model"""

    def test_total_probability_calculation(self):
        """Test probability sum calculation"""
        rating = PdBattingRating(
            vs_hand="L",
            homerun=2.0,
            triple=1.0,
            double=10.0,
            single=15.0,
            walk=12.0,
            hbp=2.0,
            strikeout=10.0,
            flyout=5.0,
            lineout=8.0,
            popout=15.0,
            groundout=20.0,
            bp_homerun=3.0,
            bp_single=5.0,
            avg=0.250,
            obp=0.350,
            slg=0.450
        )

        total = rating.total_probability()
        assert total == 108.0  # Sum of all outcomes

3. Mappers (test_mappers.py)

File: tests/unit/models/test_mappers.py

Coverage: API → Game model transformation

import pytest
from app.models.api_models import SbaPlayerApi, PdPlayerApi
from app.models.player_models import PlayerMapper

class TestPlayerMapper:
    """Test player model mapping"""

    def test_extract_positions_multiple(self):
        """Test extracting multiple positions"""
        positions = PlayerMapper.extract_positions(
            "RF", "CF", "DH", None, None, None, None, None
        )
        assert positions == ["RF", "CF", "DH"]

    def test_extract_positions_single(self):
        """Test extracting single position"""
        positions = PlayerMapper.extract_positions(
            "P", None, None, None, None, None, None, None
        )
        assert positions == ["P"]

    def test_extract_positions_all_none(self):
        """Test with all None (edge case)"""
        positions = PlayerMapper.extract_positions(
            None, None, None, None, None, None, None, None
        )
        assert positions == []

    def test_map_sba_player(self, sba_player_api):
        """Test SBA player mapping"""
        game_player = PlayerMapper.map_sba_player(sba_player_api)

        assert game_player.player_id == sba_player_api.id
        assert game_player.name == sba_player_api.name
        assert game_player.team_name == sba_player_api.team.lname
        assert game_player.team_abbrev == sba_player_api.team.abbrev
        assert len(game_player.positions) > 0

    def test_map_pd_player_position_player(
        self,
        pd_player_api,
        pd_batting_ratings_api
    ):
        """Test PD position player mapping"""
        game_player = PlayerMapper.map_pd_player(
            pd_player_api,
            pd_batting_ratings_api,
            None  # No pitching ratings
        )

        assert game_player.player_id == pd_player_api.player_id
        assert game_player.name == pd_player_api.p_name
        assert not game_player.is_pitcher
        assert game_player.batting_vs_lhp is not None
        assert game_player.batting_vs_rhp is not None
        assert game_player.pitching_vs_lhb is None

    def test_flatten_batting_rating_combines_doubles(
        self,
        pd_batting_rating_api
    ):
        """Test that double outcomes are combined"""
        # Assume API has: double_three=1.0, double_two=2.0, double_pull=3.0
        game_rating = PlayerMapper._flatten_batting_rating(pd_batting_rating_api)

        # Should be combined to single "double" field
        assert game_rating.double == 6.0  # 1.0 + 2.0 + 3.0

Integration Tests

1. API Client (test_api_client.py)

File: tests/integration/data/test_api_client.py

Coverage: HTTP calls with mocked responses

import pytest
from unittest.mock import AsyncMock, patch
from app.data.api_client import LeagueApiClient

@pytest.mark.integration
@pytest.mark.asyncio
class TestLeagueApiClient:
    """Test API client with mocked HTTP responses"""

    async def test_fetch_sba_player_success(self, mock_httpx_response, sba_player_json):
        """Test successful SBA player fetch"""
        with patch('httpx.AsyncClient') as mock_client:
            mock_client.return_value.get = AsyncMock(
                return_value=mock_httpx_response(sba_player_json)
            )

            async with LeagueApiClient("sba") as client:
                player = await client.get_player(12288)

                assert player.name == "Ronald Acuna Jr"

    async def test_fetch_pd_player_with_ratings(
        self,
        mock_httpx_response,
        pd_player_json,
        pd_batting_ratings_json
    ):
        """Test fetching PD player with ratings"""
        with patch('httpx.AsyncClient') as mock_client:
            # Mock responses for 3 API calls
            mock_client.return_value.get = AsyncMock(
                side_effect=[
                    mock_httpx_response(pd_player_json),
                    mock_httpx_response(pd_batting_ratings_json),
                    httpx.HTTPStatusError("Not a pitcher")
                ]
            )

            async with LeagueApiClient("pd") as client:
                player = await client.get_player(10633)
                batting = await client.get_batting_ratings(10633)

                with pytest.raises(httpx.HTTPStatusError):
                    await client.get_pitching_ratings(10633)

    async def test_api_client_context_manager_closes(self):
        """Test that context manager closes client"""
        client = LeagueApiClient("sba")
        assert client.client is not None

        async with client:
            pass  # Use context manager

        # Client should be closed (aclose called)
        # Verify by checking if subsequent calls fail

2. Full Pipeline (test_player_pipeline.py)

File: tests/integration/test_player_pipeline.py

Coverage: API → Mapper → Game Model

import pytest
from app.models.player_models import PlayerFactory

@pytest.mark.integration
@pytest.mark.asyncio
class TestPlayerPipeline:
    """Test complete pipeline from API to game model"""

    async def test_sba_player_full_pipeline(self, mock_sba_api):
        """Test fetching and mapping SBA player"""
        player = await PlayerFactory.create_player("sba", 12288)

        # Verify game model
        assert player.player_id == 12288
        assert player.name == "Ronald Acuna Jr"
        assert "RF" in player.positions
        assert player.can_play_position("RF")

    async def test_pd_position_player_full_pipeline(self, mock_pd_api):
        """Test fetching and mapping PD position player"""
        player = await PlayerFactory.create_player("pd", 10633)

        # Verify game model
        assert player.player_id == 10633
        assert player.name == "Chuck Knoblauch"
        assert not player.is_pitcher

        # Verify ratings attached
        assert player.batting_vs_lhp is not None
        assert player.batting_vs_rhp is not None
        assert player.pitching_vs_lhb is None

    async def test_pd_pitcher_full_pipeline(self, mock_pd_api):
        """Test fetching and mapping PD pitcher"""
        player = await PlayerFactory.create_player("pd", 11223)

        # Verify game model
        assert player.player_id == 11223
        assert player.is_pitcher

        # Verify both batting and pitching ratings
        assert player.batting_vs_lhp is not None
        assert player.pitching_vs_lhb is not None
        assert player.pitching_vs_rhb is not None

Test Fixtures

Shared Fixtures (conftest.py)

import pytest
import json
from pathlib import Path

@pytest.fixture
def sba_player_json():
    """Load SBA player JSON from file"""
    path = Path(__file__).parent / "fixtures" / "sba_player.json"
    with open(path) as f:
        return json.load(f)

@pytest.fixture
def pd_player_json():
    """Load PD player JSON from file"""
    path = Path(__file__).parent / "fixtures" / "pd_player.json"
    with open(path) as f:
        return json.load(f)

@pytest.fixture
def pd_batting_ratings_json():
    """Load PD batting ratings JSON from file"""
    path = Path(__file__).parent / "fixtures" / "pd_batting_ratings.json"
    with open(path) as f:
        return json.load(f)

@pytest.fixture
def pd_pitching_ratings_json():
    """Load PD pitching ratings JSON from file"""
    path = Path(__file__).parent / "fixtures" / "pd_pitching_ratings.json"
    with open(path) as f:
        return json.load(f)

Fixture JSON Files

Store real API responses in tests/fixtures/:

  • sba_player.json - From user's example
  • pd_player.json - From user's example
  • pd_batting_ratings.json - From user's example
  • pd_pitching_ratings.json - From user's example

Coverage Goals

Module Target Priority
api_models.py 95%+ High
player_models.py 95%+ High
api_client.py 85%+ Medium
Mappers 95%+ High
Integration 80%+ Medium

Running Tests

# All tests
pytest tests/ -v

# Only unit tests
pytest tests/unit/ -v

# Only integration tests
pytest tests/integration/ -v -m integration

# With coverage
pytest tests/ --cov=app.models --cov=app.data --cov-report=html

# Specific file
pytest tests/unit/models/test_api_models.py -v

Test Markers

# pytest.ini
[pytest]
markers =
    integration: marks tests as integration tests (slow, may require network)
    unit: marks tests as unit tests (fast, no external dependencies)

Usage:

# Run only unit tests
pytest -m unit

# Run only integration tests
pytest -m integration

# Skip integration tests
pytest -m "not integration"

Status: Week 6 testing strategy complete. Ready for implementation!