"""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