From 4bfd878486aae88963e15388bd86266d59a5f336 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 16:35:02 -0500 Subject: [PATCH] feat: add PlayerSeasonStats Peewee model (#67) Closes #67 Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 132 +++++++++++- app/models/__init__.py | 0 app/models/evolution.py | 12 ++ app/models/season_stats.py | 7 + tests/__init__.py | 0 tests/conftest.py | 14 ++ tests/test_evolution_models.py | 338 +++++++++++++++++++++++++++++ tests/test_season_stats_model.py | 355 +++++++++++++++++++++++++++++++ 8 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 app/models/__init__.py create mode 100644 app/models/evolution.py create mode 100644 app/models/season_stats.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_evolution_models.py create mode 100644 tests/test_season_stats_model.py diff --git a/app/db_engine.py b/app/db_engine.py index 30e7d7c..0ec6bec 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1050,8 +1050,75 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique= Decision.add_index(decision_index) +class PlayerSeasonStats(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + season = IntegerField() + + # Batting stats + games_batting = IntegerField(default=0) + pa = IntegerField(default=0) + ab = IntegerField(default=0) + hits = IntegerField(default=0) + hr = IntegerField(default=0) + doubles = IntegerField(default=0) + triples = IntegerField(default=0) + bb = IntegerField(default=0) + hbp = IntegerField(default=0) + so = IntegerField(default=0) + rbi = IntegerField(default=0) + runs = IntegerField(default=0) + sb = IntegerField(default=0) + cs = IntegerField(default=0) + + # Pitching stats + games_pitching = IntegerField(default=0) + outs = IntegerField(default=0) + k = IntegerField( + default=0 + ) # pitcher Ks; spec names this "so (K)" but renamed to avoid collision with batting so + bb_allowed = IntegerField(default=0) + hits_allowed = IntegerField(default=0) + hr_allowed = IntegerField(default=0) + wins = IntegerField(default=0) + losses = IntegerField(default=0) + saves = IntegerField(default=0) + holds = IntegerField(default=0) + blown_saves = IntegerField(default=0) + + # Meta + last_game = ForeignKeyField(StratGame, null=True) + last_updated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "player_season_stats" + + +pss_unique_index = ModelIndex( + PlayerSeasonStats, + (PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season), + unique=True, +) +PlayerSeasonStats.add_index(pss_unique_index) + +pss_team_season_index = ModelIndex( + PlayerSeasonStats, + (PlayerSeasonStats.team, PlayerSeasonStats.season), + unique=False, +) +PlayerSeasonStats.add_index(pss_team_season_index) + +pss_player_season_index = ModelIndex( + PlayerSeasonStats, + (PlayerSeasonStats.player, PlayerSeasonStats.season), + unique=False, +) +PlayerSeasonStats.add_index(pss_player_season_index) + + if not SKIP_TABLE_CREATION: - db.create_tables([StratGame, StratPlay, Decision], safe=True) + db.create_tables([StratGame, StratPlay, Decision, PlayerSeasonStats], safe=True) class ScoutOpportunity(BaseModel): @@ -1089,6 +1156,69 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) +class EvolutionTrack(BaseModel): + name = CharField() + card_type = CharField() # batter / sp / rp + formula = CharField() + t1_threshold = IntegerField() + t2_threshold = IntegerField() + t3_threshold = IntegerField() + t4_threshold = IntegerField() + + class Meta: + database = db + table_name = "evolution_track" + + +class EvolutionCardState(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + track = ForeignKeyField(EvolutionTrack) + current_tier = IntegerField(default=0) # valid range: 0–4 + current_value = FloatField(default=0.0) + fully_evolved = BooleanField(default=False) + last_evaluated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "evolution_card_state" + + +ecs_index = ModelIndex( + EvolutionCardState, + (EvolutionCardState.player, EvolutionCardState.team), + unique=True, +) +EvolutionCardState.add_index(ecs_index) + + +class EvolutionTierBoost(BaseModel): + """Phase 2 stub — minimal model, schema to be defined in phase 2.""" + + card_state = ForeignKeyField(EvolutionCardState) + + class Meta: + database = db + table_name = "evolution_tier_boost" + + +class EvolutionCosmetic(BaseModel): + """Phase 2 stub — minimal model, schema to be defined in phase 2.""" + + card_state = ForeignKeyField(EvolutionCardState) + + class Meta: + database = db + table_name = "evolution_cosmetic" + + +if not SKIP_TABLE_CREATION: + db.create_tables( + [EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic], + safe=True, + ) + + db.close() # scout_db = SqliteDatabase( diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/evolution.py b/app/models/evolution.py new file mode 100644 index 0000000..7763885 --- /dev/null +++ b/app/models/evolution.py @@ -0,0 +1,12 @@ +"""Evolution ORM models. + +Models are defined in db_engine alongside all other Peewee models; this +module re-exports them so callers can import from `app.models.evolution`. +""" + +from ..db_engine import ( # noqa: F401 + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +) diff --git a/app/models/season_stats.py b/app/models/season_stats.py new file mode 100644 index 0000000..bdd7ad1 --- /dev/null +++ b/app/models/season_stats.py @@ -0,0 +1,7 @@ +"""PlayerSeasonStats ORM model. + +Model is defined in db_engine alongside all other Peewee models; this +module re-exports it so callers can import from `app.models.season_stats`. +""" + +from ..db_engine import PlayerSeasonStats # noqa: F401 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8d61378 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +"""Pytest configuration for the paper-dynasty-database test suite. + +Sets DATABASE_TYPE=postgresql before any app module is imported so that +db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the +production SQLite file during test collection. Each test module is +responsible for binding models to its own in-memory database. +""" + +import os + +os.environ["DATABASE_TYPE"] = "postgresql" +# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated +# without raising a configuration error (it will not actually be used). +os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy") diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py new file mode 100644 index 0000000..1a6a4c9 --- /dev/null +++ b/tests/test_evolution_models.py @@ -0,0 +1,338 @@ +"""Tests for evolution Peewee models (WP-01). + +Unit tests verify model structure and defaults on unsaved instances without +touching a database. Integration tests use an in-memory SQLite database to +verify table creation, FK relationships, and unique constraints. +""" + +import pytest +from peewee import SqliteDatabase, IntegrityError +from playhouse.shortcuts import model_to_dict + +from app.models.evolution import ( + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +) +from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team + +# All models that must exist in the test database (dependency order). +_TEST_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +] + +_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) + + +@pytest.fixture(autouse=True) +def setup_test_db(): + """Bind all models to an in-memory SQLite database, create tables, and + tear them down after each test so each test starts from a clean state.""" + _test_db.bind(_TEST_MODELS) + _test_db.create_tables(_TEST_MODELS) + yield _test_db + _test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True) + + +# ── Fixture helpers ──────────────────────────────────────────────────────── + + +def make_rarity(): + return Rarity.create(value=1, name="Common", color="#ffffff") + + +def make_cardset(): + return Cardset.create(name="2025", description="2025 Season", total_cards=100) + + +def make_player(cardset, rarity): + return Player.create( + player_id=1, + p_name="Test Player", + cost=100, + image="test.png", + mlbclub="BOS", + franchise="Boston", + cardset=cardset, + set_num=1, + rarity=rarity, + pos_1="OF", + description="Test", + ) + + +def make_team(): + return Team.create( + abbrev="TEST", + sname="Test", + lname="Test Team", + gmid=123456789, + gmname="testuser", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + + +def make_track(card_type="batter"): + return EvolutionTrack.create( + name="Batter", + card_type=card_type, + formula="pa+tb*2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + + +# ── Unit: model field validation ─────────────────────────────────────────── + + +class TestEvolutionTrackFields: + """model_to_dict works on unsaved EvolutionTrack instances and all fields + are accessible with the correct values.""" + + def test_model_to_dict_unsaved(self): + """All EvolutionTrack fields appear in model_to_dict on an unsaved instance.""" + track = EvolutionTrack( + name="Batter", + card_type="batter", + formula="pa+tb*2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter" + assert data["card_type"] == "batter" + assert data["formula"] == "pa+tb*2" + assert data["t1_threshold"] == 37 + assert data["t2_threshold"] == 149 + assert data["t3_threshold"] == 448 + assert data["t4_threshold"] == 896 + + def test_all_threshold_fields_present(self): + """EvolutionTrack exposes all four tier threshold columns.""" + fields = EvolutionTrack._meta.fields + for col in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"): + assert col in fields, f"Missing column: {col}" + + +class TestEvolutionCardStateFields: + """model_to_dict works on unsaved EvolutionCardState instances and + default values match the spec.""" + + def test_model_to_dict_defaults(self): + """Defaults: current_tier=0, current_value=0.0, fully_evolved=False, + last_evaluated_at=None.""" + state = EvolutionCardState() + data = model_to_dict(state, recurse=False) + assert data["current_tier"] == 0 + assert data["current_value"] == 0.0 + assert data["fully_evolved"] is False + assert data["last_evaluated_at"] is None + + def test_no_progress_since_field(self): + """EvolutionCardState must not have a progress_since field (removed from spec).""" + assert "progress_since" not in EvolutionCardState._meta.fields + + +class TestEvolutionStubFields: + """Phase 2 stub models are importable and respond to model_to_dict.""" + + def test_tier_boost_importable(self): + assert EvolutionTierBoost is not None + + def test_cosmetic_importable(self): + assert EvolutionCosmetic is not None + + def test_tier_boost_model_to_dict_unsaved(self): + """model_to_dict on an unsaved EvolutionTierBoost returns a dict.""" + data = model_to_dict(EvolutionTierBoost(), recurse=False) + assert isinstance(data, dict) + + def test_cosmetic_model_to_dict_unsaved(self): + """model_to_dict on an unsaved EvolutionCosmetic returns a dict.""" + data = model_to_dict(EvolutionCosmetic(), recurse=False) + assert isinstance(data, dict) + + +# ── Unit: constraint definitions ────────────────────────────────────────── + + +class TestTierConstraints: + """current_tier defaults to 0 and valid tier values (0-4) can be saved.""" + + def test_tier_zero_is_default(self): + """EvolutionCardState.current_tier defaults to 0 on create.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + assert state.current_tier == 0 + + def test_tier_four_is_valid(self): + """Tier 4 (fully evolved cap) can be persisted without error.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create( + player=player, team=team, track=track, current_tier=4 + ) + assert state.current_tier == 4 + + +class TestUniqueConstraint: + """Unique index on (player_id, team_id) is enforced at the DB level.""" + + def test_duplicate_player_team_raises(self): + """A second EvolutionCardState for the same (player, team) raises IntegrityError, + even when a different track is used.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track1 = make_track("batter") + track2 = EvolutionTrack.create( + name="SP", + card_type="sp", + formula="ip+k", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + EvolutionCardState.create(player=player, team=team, track=track1) + with pytest.raises(IntegrityError): + EvolutionCardState.create(player=player, team=team, track=track2) + + def test_same_player_different_teams_allowed(self): + """One EvolutionCardState per team is allowed for the same player.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team1 = make_team() + team2 = Team.create( + abbrev="TM2", + sname="T2", + lname="Team Two", + gmid=987654321, + gmname="user2", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + track = make_track() + EvolutionCardState.create(player=player, team=team1, track=track) + state2 = EvolutionCardState.create(player=player, team=team2, track=track) + assert state2.id is not None + + +# ── Integration: table creation ──────────────────────────────────────────── + + +class TestTableCreation: + """All four evolution tables are created in the test DB and are queryable.""" + + def test_evolution_track_table_exists(self): + assert EvolutionTrack.select().count() == 0 + + def test_evolution_card_state_table_exists(self): + assert EvolutionCardState.select().count() == 0 + + def test_evolution_tier_boost_table_exists(self): + assert EvolutionTierBoost.select().count() == 0 + + def test_evolution_cosmetic_table_exists(self): + assert EvolutionCosmetic.select().count() == 0 + + +# ── Integration: FK enforcement ──────────────────────────────────────────── + + +class TestFKEnforcement: + """FK columns resolve to the correct related instances.""" + + def test_card_state_player_fk_resolves(self): + """EvolutionCardState.player_id matches the Player we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.player_id == player.player_id + + def test_card_state_team_fk_resolves(self): + """EvolutionCardState.team_id matches the Team we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.team_id == team.id + + def test_card_state_track_fk_resolves(self): + """EvolutionCardState.track_id matches the EvolutionTrack we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.track_id == track.id + + +# ── Integration: model_to_dict on saved instances ────────────────────────── + + +class TestModelToDictOnSaved: + """model_to_dict() works correctly on saved instances of all four models.""" + + def test_evolution_track_saved(self): + """Saved EvolutionTrack round-trips through model_to_dict correctly.""" + track = make_track() + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter" + assert data["card_type"] == "batter" + assert data["formula"] == "pa+tb*2" + assert data["t1_threshold"] == 37 + + def test_evolution_card_state_saved(self): + """Saved EvolutionCardState round-trips through model_to_dict correctly.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create( + player=player, team=team, track=track, current_value=42.5, current_tier=2 + ) + data = model_to_dict(state, recurse=False) + assert data["current_value"] == 42.5 + assert data["current_tier"] == 2 + assert data["fully_evolved"] is False diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py new file mode 100644 index 0000000..20fc3b8 --- /dev/null +++ b/tests/test_season_stats_model.py @@ -0,0 +1,355 @@ +"""Tests for PlayerSeasonStats Peewee model (WP-02). + +Unit tests verify model structure and defaults on unsaved instances without +touching a database. Integration tests use an in-memory SQLite database to +verify table creation, unique constraints, indexes, and the delta-update +(increment) pattern. + +Note on column naming: the spec labels the pitching strikeout column as +"so (K)". This model names it `k` to avoid collision with the batting +strikeout column `so`. +""" + +import pytest +from peewee import SqliteDatabase, IntegrityError + +from app.models.season_stats import PlayerSeasonStats +from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team, StratGame + +# Dependency order matters for FK resolution. +_TEST_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + StratGame, + PlayerSeasonStats, +] + +_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) + + +@pytest.fixture(autouse=True) +def setup_test_db(): + """Bind all models to an in-memory SQLite database, create tables, and + tear them down after each test so each test starts from a clean state.""" + _test_db.bind(_TEST_MODELS) + _test_db.create_tables(_TEST_MODELS) + yield _test_db + _test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True) + + +# ── Fixture helpers ───────────────────────────────────────────────────────── + + +def make_rarity(): + return Rarity.create(value=1, name="Common", color="#ffffff") + + +def make_cardset(): + return Cardset.create(name="2025", description="2025 Season", total_cards=100) + + +def make_player(cardset, rarity, player_id=1): + return Player.create( + player_id=player_id, + p_name="Test Player", + cost=100, + image="test.png", + mlbclub="BOS", + franchise="Boston", + cardset=cardset, + set_num=1, + rarity=rarity, + pos_1="OF", + description="Test", + ) + + +def make_team(abbrev="TEST", gmid=123456789): + return Team.create( + abbrev=abbrev, + sname=abbrev, + lname=f"Team {abbrev}", + gmid=gmid, + gmname="testuser", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + + +def make_game(home_team, away_team, season=10): + return StratGame.create( + season=season, + game_type="ranked", + away_team=away_team, + home_team=home_team, + ) + + +def make_stats(player, team, season=10, **kwargs): + return PlayerSeasonStats.create(player=player, team=team, season=season, **kwargs) + + +# ── Unit: column completeness ──────────────────────────────────────────────── + + +class TestColumnCompleteness: + """All required columns are present in the model's field definitions.""" + + BATTING_COLS = [ + "games_batting", + "pa", + "ab", + "hits", + "hr", + "doubles", + "triples", + "bb", + "hbp", + "so", + "rbi", + "runs", + "sb", + "cs", + ] + PITCHING_COLS = [ + "games_pitching", + "outs", + "k", + "bb_allowed", + "hits_allowed", + "hr_allowed", + "wins", + "losses", + "saves", + "holds", + "blown_saves", + ] + META_COLS = ["last_game", "last_updated_at"] + KEY_COLS = ["player", "team", "season"] + + def test_batting_columns_present(self): + """All batting aggregate columns defined in the spec are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.BATTING_COLS: + assert col in fields, f"Missing batting column: {col}" + + def test_pitching_columns_present(self): + """All pitching aggregate columns defined in the spec are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.PITCHING_COLS: + assert col in fields, f"Missing pitching column: {col}" + + def test_meta_columns_present(self): + """Meta columns last_game and last_updated_at are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.META_COLS: + assert col in fields, f"Missing meta column: {col}" + + def test_key_columns_present(self): + """player, team, and season columns are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.KEY_COLS: + assert col in fields, f"Missing key column: {col}" + + def test_excluded_columns_absent(self): + """team_wins and quality_starts are NOT in the model (removed from scope).""" + fields = PlayerSeasonStats._meta.fields + assert "team_wins" not in fields + assert "quality_starts" not in fields + + +# ── Unit: default values ───────────────────────────────────────────────────── + + +class TestDefaultValues: + """All integer stat columns default to 0; nullable meta fields default to None.""" + + INT_STAT_COLS = [ + "games_batting", + "pa", + "ab", + "hits", + "hr", + "doubles", + "triples", + "bb", + "hbp", + "so", + "rbi", + "runs", + "sb", + "cs", + "games_pitching", + "outs", + "k", + "bb_allowed", + "hits_allowed", + "hr_allowed", + "wins", + "losses", + "saves", + "holds", + "blown_saves", + ] + + def test_all_int_columns_default_to_zero(self): + """Every integer stat column defaults to 0 on an unsaved instance.""" + row = PlayerSeasonStats() + for col in self.INT_STAT_COLS: + val = getattr(row, col) + assert val == 0, f"Column {col!r} default is {val!r}, expected 0" + + def test_last_game_defaults_to_none(self): + """last_game FK is nullable and defaults to None.""" + row = PlayerSeasonStats() + assert row.last_game_id is None + + def test_last_updated_at_defaults_to_none(self): + """last_updated_at defaults to None.""" + row = PlayerSeasonStats() + assert row.last_updated_at is None + + +# ── Integration: unique constraint ─────────────────────────────────────────── + + +class TestUniqueConstraint: + """UNIQUE on (player_id, team_id, season) is enforced at the DB level.""" + + def test_duplicate_player_team_season_raises(self): + """Inserting a second row for the same (player, team, season) raises IntegrityError.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + make_stats(player, team, season=10) + with pytest.raises(IntegrityError): + make_stats(player, team, season=10) + + def test_same_player_different_season_allowed(self): + """Same (player, team) in a different season creates a separate row.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + make_stats(player, team, season=10) + row2 = make_stats(player, team, season=11) + assert row2.id is not None + + def test_same_player_different_team_allowed(self): + """Same (player, season) on a different team creates a separate row.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team1 = make_team("TM1", gmid=111) + team2 = make_team("TM2", gmid=222) + make_stats(player, team1, season=10) + row2 = make_stats(player, team2, season=10) + assert row2.id is not None + + +# ── Integration: delta update pattern ─────────────────────────────────────── + + +class TestDeltaUpdatePattern: + """Stats can be incremented (delta update) without replacing existing values.""" + + def test_increment_batting_stats(self): + """Updating pa and hits increments without touching pitching columns.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + row = make_stats(player, team, season=10, pa=5, hits=2) + + PlayerSeasonStats.update( + pa=PlayerSeasonStats.pa + 3, + hits=PlayerSeasonStats.hits + 1, + ).where( + (PlayerSeasonStats.player == player) + & (PlayerSeasonStats.team == team) + & (PlayerSeasonStats.season == 10) + ).execute() + + updated = PlayerSeasonStats.get_by_id(row.id) + assert updated.pa == 8 + assert updated.hits == 3 + assert updated.games_pitching == 0 # untouched + + def test_increment_pitching_stats(self): + """Updating outs and k increments without touching batting columns.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + row = make_stats(player, team, season=10, outs=9, k=3) + + PlayerSeasonStats.update( + outs=PlayerSeasonStats.outs + 6, + k=PlayerSeasonStats.k + 2, + ).where( + (PlayerSeasonStats.player == player) + & (PlayerSeasonStats.team == team) + & (PlayerSeasonStats.season == 10) + ).execute() + + updated = PlayerSeasonStats.get_by_id(row.id) + assert updated.outs == 15 + assert updated.k == 5 + assert updated.pa == 0 # untouched + + def test_last_game_fk_is_nullable(self): + """last_game FK can be set to a StratGame instance or left NULL.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + row = make_stats(player, team, season=10) + assert row.last_game_id is None + + game = make_game(home_team=team, away_team=team) + PlayerSeasonStats.update(last_game=game).where( + PlayerSeasonStats.id == row.id + ).execute() + + updated = PlayerSeasonStats.get_by_id(row.id) + assert updated.last_game_id == game.id + + +# ── Integration: index existence ───────────────────────────────────────────── + + +class TestIndexExistence: + """Required indexes on (team_id, season) and (player_id, season) exist in SQLite.""" + + def _get_index_columns(self, db, table): + """Return a set of frozensets, each being the column set of one index.""" + indexes = db.execute_sql(f"PRAGMA index_list({table})").fetchall() + result = set() + for idx in indexes: + idx_name = idx[1] + cols = db.execute_sql(f"PRAGMA index_info({idx_name})").fetchall() + result.add(frozenset(col[2] for col in cols)) + return result + + def test_unique_index_on_player_team_season(self, setup_test_db): + """A unique index covering (player_id, team_id, season) exists.""" + index_sets = self._get_index_columns(setup_test_db, "player_season_stats") + assert frozenset({"player_id", "team_id", "season"}) in index_sets + + def test_index_on_team_season(self, setup_test_db): + """An index covering (team_id, season) exists.""" + index_sets = self._get_index_columns(setup_test_db, "player_season_stats") + assert frozenset({"team_id", "season"}) in index_sets + + def test_index_on_player_season(self, setup_test_db): + """An index covering (player_id, season) exists.""" + index_sets = self._get_index_columns(setup_test_db, "player_season_stats") + assert frozenset({"player_id", "season"}) in index_sets