diff --git a/app/db_engine.py b/app/db_engine.py index 30e7d7c..bb9c9f0 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): diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 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/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_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