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>
606 lines
17 KiB
Markdown
606 lines
17 KiB
Markdown
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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`)
|
|
|
|
```python
|
|
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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```python
|
|
# 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:
|
|
```bash
|
|
# 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!
|