strat-gameplay-webapp/backend/tests/unit/models/test_player_models.py
Cal Corum aabb90feb5 CLAUDE: Implement player models and optimize database queries
This commit includes Week 6 player models implementation and critical
performance optimizations discovered during testing.

## Player Models (Week 6 - 50% Complete)

**New Files:**
- app/models/player_models.py (516 lines)
  - BasePlayer abstract class with polymorphic interface
  - SbaPlayer with API parsing factory method
  - PdPlayer with batting/pitching scouting data support
  - Supporting models: PdCardset, PdRarity, PdBattingCard, PdPitchingCard
- tests/unit/models/test_player_models.py (692 lines)
  - 32 comprehensive unit tests, all passing
  - Tests for BasePlayer, SbaPlayer, PdPlayer, polymorphism

**Architecture:**
- Simplified single-layer approach vs planned two-layer
- Factory methods handle API → Game transformation directly
- SbaPlayer.from_api_response(data) - parses SBA API inline
- PdPlayer.from_api_response(player_data, batting_data, pitching_data)
- Full Pydantic validation, type safety, and polymorphism

## Performance Optimizations

**Database Query Reduction (60% fewer queries per play):**
- Before: 5 queries per play (INSERT play, SELECT play with JOINs,
  SELECT games, 2x SELECT lineups)
- After: 2 queries per play (INSERT play, UPDATE games conditionally)

Changes:
1. Lineup caching (game_engine.py:384-425)
   - Check state_manager.get_lineup() cache before DB fetch
   - Eliminates 2 SELECT queries per play
2. Remove unnecessary refresh (operations.py:281-302)
   - Removed session.refresh(play) after INSERT
   - Eliminates 1 SELECT with 3 expensive LEFT JOINs
3. Direct UPDATE statement (operations.py:109-165)
   - Changed update_game_state() to use direct UPDATE
   - No longer does SELECT + modify + commit
4. Conditional game state updates (game_engine.py:200-217)
   - Only UPDATE games table when score/inning/status changes
   - Captures state before/after and compares
   - ~40-60% fewer updates (many plays don't score)

## Bug Fixes

1. Fixed outs_before tracking (game_engine.py:551)
   - Was incorrectly calculating: state.outs - result.outs_recorded
   - Now correctly captures: state.outs (before applying result)
   - All play records now have accurate out counts

2. Fixed game recovery (state_manager.py:312-314)
   - AttributeError when recovering: 'GameState' has no attribute 'runners'
   - Changed to use state.get_all_runners() method
   - Games can now be properly recovered from database

## Enhanced Terminal Client

**Status Display Improvements (terminal_client/display.py:75-97):**
- Added "⚠️ WAITING FOR ACTION" section when play is pending
- Shows specific guidance:
  - "The defense needs to submit their decision" → Run defensive [OPTIONS]
  - "The offense needs to submit their decision" → Run offensive [OPTIONS]
  - "Ready to resolve play" → Run resolve
- Color-coded command hints for better UX

## Documentation Updates

**backend/CLAUDE.md:**
- Added comprehensive Player Models section (204 lines)
- Updated Current Phase status to Week 6 (~50% complete)
- Documented all optimizations and bug fixes
- Added integration examples and usage patterns

**New Files:**
- .claude/implementation/week6-status-assessment.md
  - Comprehensive Week 6 progress review
  - Architecture decision rationale (single-layer vs two-layer)
  - Completion status and next priorities
  - Updated roadmap for remaining Week 6 work

## Test Results

- Player models: 32/32 tests passing
- All existing tests continue to pass
- Performance improvements verified with terminal client

## Next Steps (Week 6 Remaining)

1. Configuration system (BaseConfig, SbaConfig, PdConfig)
2. Result charts & PD play resolution with ratings
3. API client for live roster data (deferred)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 14:08:56 -05:00

668 lines
24 KiB
Python

"""
Unit tests for player models.
Tests polymorphic player model system (BasePlayer, SbaPlayer, PdPlayer)
with factory methods and API response parsing.
Author: Claude
Date: 2025-10-28
"""
import pytest
from abc import ABC
from app.models.player_models import (
BasePlayer,
SbaPlayer,
PdPlayer,
PdCardset,
PdRarity,
PdBattingRating,
PdPitchingRating,
PdBattingCard,
PdPitchingCard,
)
# ==================== BasePlayer Tests ====================
class TestBasePlayer:
"""Test BasePlayer abstract base class."""
def test_cannot_instantiate_base_player(self):
"""BasePlayer is abstract and cannot be instantiated directly."""
with pytest.raises(TypeError, match="Can't instantiate abstract class"):
BasePlayer(id=1, name="Test")
def test_base_player_is_abc(self):
"""BasePlayer inherits from ABC."""
assert issubclass(BasePlayer, ABC)
def test_required_abstract_methods(self):
"""BasePlayer defines required abstract methods."""
abstract_methods = BasePlayer.__abstractmethods__
assert "get_image_url" in abstract_methods
assert "get_positions" in abstract_methods
assert "get_display_name" in abstract_methods
# ==================== SbaPlayer Tests ====================
class TestSbaPlayer:
"""Test SbaPlayer model and factory methods."""
@pytest.fixture
def minimal_sba_data(self):
"""Minimal valid SBA API response."""
return {
"id": 12288,
"name": "Ronald Acuna Jr",
"wara": 0.0,
}
@pytest.fixture
def full_sba_data(self):
"""Full SBA API response with all fields."""
return {
"id": 12288,
"name": "Ronald Acuna Jr",
"wara": 5.2,
"image": "https://example.com/acuna.png",
"image2": "https://example.com/acuna2.png",
"team": {
"id": 499,
"abbrev": "WV",
"lname": "West Virginia Black Bears",
},
"season": 12,
"pos_1": "RF",
"pos_2": "CF",
"pos_3": "LF",
"headshot": "https://example.com/headshot.jpg",
"vanity_card": "https://example.com/vanity.png",
"strat_code": "Acuna,R",
"bbref_id": "acunaro01",
"injury_rating": "5p30",
}
def test_create_from_minimal_api_response(self, minimal_sba_data):
"""Can create SbaPlayer with minimal required fields."""
player = SbaPlayer.from_api_response(minimal_sba_data)
assert player.id == 12288
assert player.name == "Ronald Acuna Jr"
assert player.wara == 0.0
assert player.image is None
assert player.team_id is None
def test_create_from_full_api_response(self, full_sba_data):
"""Can create SbaPlayer with all fields populated."""
player = SbaPlayer.from_api_response(full_sba_data)
assert player.id == 12288
assert player.name == "Ronald Acuna Jr"
assert player.wara == 5.2
assert player.image == "https://example.com/acuna.png"
assert player.image2 == "https://example.com/acuna2.png"
assert player.team_id == 499
assert player.team_name == "West Virginia Black Bears"
assert player.season == 12
assert player.pos_1 == "RF"
assert player.pos_2 == "CF"
assert player.pos_3 == "LF"
assert player.headshot == "https://example.com/headshot.jpg"
assert player.strat_code == "Acuna,R"
assert player.bbref_id == "acunaro01"
def test_get_positions_filters_none(self, full_sba_data):
"""get_positions() returns only non-None positions."""
player = SbaPlayer.from_api_response(full_sba_data)
positions = player.get_positions()
assert positions == ["RF", "CF", "LF"]
assert None not in positions
def test_get_positions_empty_when_no_positions(self, minimal_sba_data):
"""get_positions() returns empty list when no positions set."""
player = SbaPlayer.from_api_response(minimal_sba_data)
positions = player.get_positions()
assert positions == []
def test_get_image_url_primary(self, full_sba_data):
"""get_image_url() returns primary image when available."""
player = SbaPlayer.from_api_response(full_sba_data)
assert player.get_image_url() == "https://example.com/acuna.png"
def test_get_image_url_fallback_to_image2(self):
"""get_image_url() falls back to image2 when primary missing."""
data = {
"id": 1,
"name": "Test",
"wara": 0.0,
"image2": "https://example.com/image2.png",
}
player = SbaPlayer.from_api_response(data)
assert player.get_image_url() == "https://example.com/image2.png"
def test_get_image_url_fallback_to_headshot(self):
"""get_image_url() falls back to headshot when others missing."""
data = {
"id": 1,
"name": "Test",
"wara": 0.0,
"headshot": "https://example.com/headshot.jpg",
}
player = SbaPlayer.from_api_response(data)
assert player.get_image_url() == "https://example.com/headshot.jpg"
def test_get_image_url_empty_when_none(self, minimal_sba_data):
"""get_image_url() returns empty string when no images."""
player = SbaPlayer.from_api_response(minimal_sba_data)
assert player.get_image_url() == ""
def test_get_display_name(self, full_sba_data):
"""get_display_name() returns player name."""
player = SbaPlayer.from_api_response(full_sba_data)
assert player.get_display_name() == "Ronald Acuna Jr"
def test_team_extraction_from_nested_object(self, full_sba_data):
"""Team ID and name extracted from nested 'team' object."""
player = SbaPlayer.from_api_response(full_sba_data)
assert player.team_id == 499
assert player.team_name == "West Virginia Black Bears"
def test_team_none_when_missing(self, minimal_sba_data):
"""Team fields are None when team object missing."""
player = SbaPlayer.from_api_response(minimal_sba_data)
assert player.team_id is None
assert player.team_name is None
# ==================== PdPlayer Tests ====================
class TestPdPlayer:
"""Test PdPlayer model and factory methods."""
@pytest.fixture
def pd_player_data(self):
"""PD player API response (without scouting data)."""
return {
"player_id": 10633,
"p_name": "Chuck Knoblauch",
"cost": 77,
"image": "https://pd.example.com/players/10633/battingcard",
"cardset": {
"id": 20,
"name": "1998 Season",
"description": "Cards based on the 1998 MLB season",
"ranked_legal": True,
},
"set_num": 609,
"rarity": {
"id": 3,
"value": 2,
"name": "Starter",
"color": "C0C0C0",
},
"mlbclub": "New York Yankees",
"franchise": "New York Yankees",
"pos_1": "2B",
"pos_2": "SS",
"headshot": "https://example.com/headshot.jpg",
"strat_code": None,
"bbref_id": "knoblch01",
"fangr_id": "609",
"description": "1998 Season",
"quantity": 999,
}
@pytest.fixture
def pd_batting_data(self):
"""PD batting card API response."""
return {
"count": 2,
"ratings": [
{
"battingcard": {
"steal_low": 8,
"steal_high": 11,
"steal_auto": True,
"steal_jump": 0.25,
"bunting": "C",
"hit_and_run": "D",
"running": 13,
"offense_col": 1,
"hand": "R",
},
"vs_hand": "L",
"pull_rate": 0.29379,
"center_rate": 0.41243,
"slap_rate": 0.29378,
"homerun": 0.0,
"bp_homerun": 2.0,
"triple": 1.4,
"double_three": 0.0,
"double_two": 5.1,
"double_pull": 5.1,
"single_two": 3.5,
"single_one": 4.5,
"single_center": 1.35,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 18.25,
"strikeout": 9.75,
"lineout": 9.0,
"popout": 16.0,
"flyout_a": 0.0,
"flyout_bq": 1.65,
"flyout_lf_b": 1.9,
"flyout_rf_b": 2.0,
"groundout_a": 7.0,
"groundout_b": 10.5,
"groundout_c": 2.0,
"avg": 0.2263888888888889,
"obp": 0.41388888888888886,
"slg": 0.37453703703703706,
},
{
"battingcard": {
"steal_low": 8,
"steal_high": 11,
"steal_auto": True,
"steal_jump": 0.25,
"bunting": "C",
"hit_and_run": "D",
"running": 13,
"offense_col": 1,
"hand": "R",
},
"vs_hand": "R",
"pull_rate": 0.25824,
"center_rate": 0.1337,
"slap_rate": 0.60806,
"homerun": 1.05,
"bp_homerun": 3.0,
"triple": 1.2,
"double_three": 0.0,
"double_two": 3.5,
"double_pull": 3.5,
"single_two": 4.3,
"single_one": 6.4,
"single_center": 2.4,
"bp_single": 5.0,
"hbp": 3.0,
"walk": 12.1,
"strikeout": 9.9,
"lineout": 11.0,
"popout": 13.0,
"flyout_a": 0.0,
"flyout_bq": 1.6,
"flyout_lf_b": 1.5,
"flyout_rf_b": 1.95,
"groundout_a": 12.0,
"groundout_b": 11.6,
"groundout_c": 0.0,
"avg": 0.2439814814814815,
"obp": 0.3837962962962963,
"slg": 0.40185185185185185,
},
],
}
@pytest.fixture
def pd_pitching_data(self):
"""PD pitching card API response."""
return {
"count": 2,
"ratings": [
{
"pitchingcard": {
"balk": 0,
"wild_pitch": 20,
"hold": 9,
"starter_rating": 1,
"relief_rating": 2,
"closer_rating": None,
"batting": "#1WR-C",
"offense_col": 1,
"hand": "R",
},
"vs_hand": "L",
"homerun": 2.6,
"bp_homerun": 6.0,
"triple": 2.1,
"double_three": 0.0,
"double_two": 7.1,
"double_cf": 0.0,
"single_two": 1.0,
"single_one": 1.0,
"single_center": 0.0,
"bp_single": 5.0,
"hbp": 6.0,
"walk": 17.6,
"strikeout": 11.4,
"flyout_lf_b": 0.0,
"flyout_cf_b": 7.75,
"flyout_rf_b": 3.6,
"groundout_a": 1.75,
"groundout_b": 6.1,
"xcheck_p": 1.0,
"xcheck_c": 3.0,
"xcheck_1b": 2.0,
"xcheck_2b": 6.0,
"xcheck_3b": 3.0,
"xcheck_ss": 7.0,
"xcheck_lf": 2.0,
"xcheck_cf": 3.0,
"xcheck_rf": 2.0,
"avg": 0.17870370370370367,
"obp": 0.3972222222222222,
"slg": 0.4388888888888889,
},
{
"pitchingcard": {
"balk": 0,
"wild_pitch": 20,
"hold": 9,
"starter_rating": 1,
"relief_rating": 2,
"closer_rating": None,
"batting": "#1WR-C",
"offense_col": 1,
"hand": "R",
},
"vs_hand": "R",
"homerun": 5.0,
"bp_homerun": 2.0,
"triple": 1.0,
"double_three": 0.0,
"double_two": 0.0,
"double_cf": 6.15,
"single_two": 6.5,
"single_one": 0.0,
"single_center": 6.5,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 10.2,
"strikeout": 26.65,
"flyout_lf_b": 0.0,
"flyout_cf_b": 0.0,
"flyout_rf_b": 0.0,
"groundout_a": 6.0,
"groundout_b": 2.0,
"xcheck_p": 1.0,
"xcheck_c": 3.0,
"xcheck_1b": 2.0,
"xcheck_2b": 6.0,
"xcheck_3b": 3.0,
"xcheck_ss": 7.0,
"xcheck_lf": 2.0,
"xcheck_cf": 3.0,
"xcheck_rf": 2.0,
"avg": 0.2652777777777778,
"obp": 0.37824074074074077,
"slg": 0.5074074074074074,
},
],
}
def test_create_without_scouting_data(self, pd_player_data):
"""Can create PdPlayer without scouting data."""
player = PdPlayer.from_api_response(pd_player_data)
assert player.player_id == 10633
assert player.name == "Chuck Knoblauch"
assert player.cost == 77
assert player.pos_1 == "2B"
assert player.pos_2 == "SS"
assert player.batting_card is None
assert player.pitching_card is None
def test_create_with_batting_data(self, pd_player_data, pd_batting_data):
"""Can create PdPlayer with batting scouting data."""
player = PdPlayer.from_api_response(
pd_player_data,
batting_data=pd_batting_data
)
assert player.batting_card is not None
assert player.batting_card.hand == "R"
assert player.batting_card.steal_low == 8
assert player.batting_card.steal_high == 11
assert player.batting_card.bunting == "C"
assert "L" in player.batting_card.ratings
assert "R" in player.batting_card.ratings
assert player.pitching_card is None
def test_create_with_pitching_data(self, pd_player_data, pd_pitching_data):
"""Can create PdPlayer with pitching scouting data."""
player = PdPlayer.from_api_response(
pd_player_data,
pitching_data=pd_pitching_data
)
assert player.pitching_card is not None
assert player.pitching_card.hand == "R"
assert player.pitching_card.balk == 0
assert player.pitching_card.wild_pitch == 20
assert player.pitching_card.starter_rating == 1
assert "L" in player.pitching_card.ratings
assert "R" in player.pitching_card.ratings
assert player.batting_card is None
def test_create_with_full_scouting_data(
self, pd_player_data, pd_batting_data, pd_pitching_data
):
"""Can create PdPlayer with both batting and pitching data."""
player = PdPlayer.from_api_response(
pd_player_data,
batting_data=pd_batting_data,
pitching_data=pd_pitching_data
)
assert player.batting_card is not None
assert player.pitching_card is not None
def test_get_batting_rating_vs_lefties(self, pd_player_data, pd_batting_data):
"""get_batting_rating('L') returns rating vs left-handed pitchers."""
player = PdPlayer.from_api_response(
pd_player_data,
batting_data=pd_batting_data
)
rating = player.get_batting_rating('L')
assert rating is not None
assert rating.vs_hand == 'L'
assert rating.homerun == 0.0
assert rating.walk == 18.25
assert rating.strikeout == 9.75
assert rating.avg == pytest.approx(0.2263888888888889)
def test_get_batting_rating_vs_righties(self, pd_player_data, pd_batting_data):
"""get_batting_rating('R') returns rating vs right-handed pitchers."""
player = PdPlayer.from_api_response(
pd_player_data,
batting_data=pd_batting_data
)
rating = player.get_batting_rating('R')
assert rating is not None
assert rating.vs_hand == 'R'
assert rating.homerun == 1.05
assert rating.walk == 12.1
assert rating.strikeout == 9.9
def test_get_batting_rating_none_when_no_data(self, pd_player_data):
"""get_batting_rating() returns None when no batting data."""
player = PdPlayer.from_api_response(pd_player_data)
assert player.get_batting_rating('L') is None
assert player.get_batting_rating('R') is None
def test_get_pitching_rating_vs_lefties(self, pd_player_data, pd_pitching_data):
"""get_pitching_rating('L') returns rating vs left-handed batters."""
player = PdPlayer.from_api_response(
pd_player_data,
pitching_data=pd_pitching_data
)
rating = player.get_pitching_rating('L')
assert rating is not None
assert rating.vs_hand == 'L'
assert rating.homerun == 2.6
assert rating.walk == 17.6
assert rating.strikeout == 11.4
assert rating.xcheck_ss == 7.0
def test_get_pitching_rating_vs_righties(self, pd_player_data, pd_pitching_data):
"""get_pitching_rating('R') returns rating vs right-handed batters."""
player = PdPlayer.from_api_response(
pd_player_data,
pitching_data=pd_pitching_data
)
rating = player.get_pitching_rating('R')
assert rating is not None
assert rating.vs_hand == 'R'
assert rating.homerun == 5.0
assert rating.walk == 10.2
assert rating.strikeout == 26.65
def test_get_pitching_rating_none_when_no_data(self, pd_player_data):
"""get_pitching_rating() returns None when no pitching data."""
player = PdPlayer.from_api_response(pd_player_data)
assert player.get_pitching_rating('L') is None
assert player.get_pitching_rating('R') is None
def test_get_positions(self, pd_player_data):
"""get_positions() returns non-None positions."""
player = PdPlayer.from_api_response(pd_player_data)
positions = player.get_positions()
assert positions == ["2B", "SS"]
def test_get_display_name_with_description(self, pd_player_data):
"""get_display_name() includes description in parentheses."""
player = PdPlayer.from_api_response(pd_player_data)
assert player.get_display_name() == "Chuck Knoblauch (1998 Season)"
def test_get_image_url_primary(self, pd_player_data):
"""get_image_url() returns primary image."""
player = PdPlayer.from_api_response(pd_player_data)
assert player.get_image_url() == "https://pd.example.com/players/10633/battingcard"
def test_get_image_url_fallback_to_headshot(self):
"""get_image_url() falls back to headshot when primary missing."""
data = {
"player_id": 1,
"p_name": "Test",
"cost": 1,
"cardset": {"id": 1, "name": "Test", "description": "Test", "ranked_legal": True},
"set_num": 1,
"rarity": {"id": 1, "value": 1, "name": "Test", "color": "FFF"},
"mlbclub": "Test",
"franchise": "Test",
"description": "Test",
"headshot": "https://example.com/headshot.jpg",
}
player = PdPlayer.from_api_response(data)
assert player.get_image_url() == "https://example.com/headshot.jpg"
def test_cardset_parsing(self, pd_player_data):
"""Cardset is properly parsed into PdCardset model."""
player = PdPlayer.from_api_response(pd_player_data)
assert isinstance(player.cardset, PdCardset)
assert player.cardset.id == 20
assert player.cardset.name == "1998 Season"
assert player.cardset.ranked_legal is True
def test_rarity_parsing(self, pd_player_data):
"""Rarity is properly parsed into PdRarity model."""
player = PdPlayer.from_api_response(pd_player_data)
assert isinstance(player.rarity, PdRarity)
assert player.rarity.id == 3
assert player.rarity.value == 2
assert player.rarity.name == "Starter"
assert player.rarity.color == "C0C0C0"
# ==================== Polymorphism Tests ====================
class TestPolymorphism:
"""Test polymorphic behavior across player types."""
def test_both_implement_base_player_interface(self):
"""Both SbaPlayer and PdPlayer implement BasePlayer interface."""
sba_data = {"id": 1, "name": "SBA Player", "wara": 0.0, "pos_1": "1B"}
pd_data = {
"player_id": 1,
"p_name": "PD Player",
"cost": 1,
"cardset": {"id": 1, "name": "Test", "description": "Test", "ranked_legal": True},
"set_num": 1,
"rarity": {"id": 1, "value": 1, "name": "Test", "color": "FFF"},
"mlbclub": "Test",
"franchise": "Test",
"description": "Test",
"pos_1": "1B",
}
sba_player = SbaPlayer.from_api_response(sba_data)
pd_player = PdPlayer.from_api_response(pd_data)
# Both are BasePlayer instances
assert isinstance(sba_player, BasePlayer)
assert isinstance(pd_player, BasePlayer)
def test_polymorphic_function_works_with_both(self):
"""Function accepting BasePlayer works with both implementations."""
def process_player(player: BasePlayer) -> dict:
"""Example function that works with any BasePlayer."""
return {
"name": player.get_display_name(),
"positions": player.get_positions(),
"image": player.get_image_url(),
}
sba_data = {
"id": 1,
"name": "SBA Player",
"wara": 0.0,
"pos_1": "1B",
"image": "https://example.com/sba.png",
}
pd_data = {
"player_id": 1,
"p_name": "PD Player",
"cost": 1,
"cardset": {"id": 1, "name": "Test", "description": "Test", "ranked_legal": True},
"set_num": 1,
"rarity": {"id": 1, "value": 1, "name": "Test", "color": "FFF"},
"mlbclub": "Test",
"franchise": "Test",
"description": "2024",
"pos_1": "1B",
"image": "https://example.com/pd.png",
}
sba_result = process_player(SbaPlayer.from_api_response(sba_data))
pd_result = process_player(PdPlayer.from_api_response(pd_data))
assert sba_result["name"] == "SBA Player"
assert sba_result["positions"] == ["1B"]
assert sba_result["image"] == "https://example.com/sba.png"
assert pd_result["name"] == "PD Player (2024)"
assert pd_result["positions"] == ["1B"]
assert pd_result["image"] == "https://example.com/pd.png"