diff --git a/app/db_engine.py b/app/db_engine.py index 217d0d6..4183bb9 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1050,73 +1050,113 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique= Decision.add_index(decision_index) -class PlayerSeasonStats(BaseModel): +class BattingSeasonStats(BaseModel): player = ForeignKeyField(Player) team = ForeignKeyField(Team) season = IntegerField() - - # Batting stats - games_batting = IntegerField(default=0) + games = 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_batter = IntegerField(default=0) + hr = IntegerField(default=0) rbi = IntegerField(default=0) runs = IntegerField(default=0) + bb = IntegerField(default=0) + strikeouts = IntegerField(default=0) + hbp = IntegerField(default=0) + sac = IntegerField(default=0) + ibb = IntegerField(default=0) + gidp = IntegerField(default=0) sb = IntegerField(default=0) cs = IntegerField(default=0) - - # Pitching stats - games_pitching = IntegerField(default=0) - outs = IntegerField(default=0) - so_pitcher = IntegerField(default=0) - 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" + table_name = "batting_season_stats" -pss_unique_index = ModelIndex( - PlayerSeasonStats, - (PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season), +bss_unique_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.player, BattingSeasonStats.team, BattingSeasonStats.season), unique=True, ) -PlayerSeasonStats.add_index(pss_unique_index) +BattingSeasonStats.add_index(bss_unique_index) -pss_team_season_index = ModelIndex( - PlayerSeasonStats, - (PlayerSeasonStats.team, PlayerSeasonStats.season), +bss_team_season_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.team, BattingSeasonStats.season), unique=False, ) -PlayerSeasonStats.add_index(pss_team_season_index) +BattingSeasonStats.add_index(bss_team_season_index) -pss_player_season_index = ModelIndex( - PlayerSeasonStats, - (PlayerSeasonStats.player, PlayerSeasonStats.season), +bss_player_season_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.player, BattingSeasonStats.season), unique=False, ) -PlayerSeasonStats.add_index(pss_player_season_index) +BattingSeasonStats.add_index(bss_player_season_index) + + +class PitchingSeasonStats(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + season = IntegerField() + games = IntegerField(default=0) + games_started = IntegerField(default=0) + outs = IntegerField(default=0) + strikeouts = IntegerField(default=0) + bb = IntegerField(default=0) + hits_allowed = IntegerField(default=0) + runs_allowed = IntegerField(default=0) + earned_runs = IntegerField(default=0) + hr_allowed = IntegerField(default=0) + hbp = IntegerField(default=0) + wild_pitches = IntegerField(default=0) + balks = IntegerField(default=0) + wins = IntegerField(default=0) + losses = IntegerField(default=0) + holds = IntegerField(default=0) + saves = IntegerField(default=0) + blown_saves = IntegerField(default=0) + last_game = ForeignKeyField(StratGame, null=True) + last_updated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "pitching_season_stats" + + +pitss_unique_index = ModelIndex( + PitchingSeasonStats, + (PitchingSeasonStats.player, PitchingSeasonStats.team, PitchingSeasonStats.season), + unique=True, +) +PitchingSeasonStats.add_index(pitss_unique_index) + +pitss_team_season_index = ModelIndex( + PitchingSeasonStats, + (PitchingSeasonStats.team, PitchingSeasonStats.season), + unique=False, +) +PitchingSeasonStats.add_index(pitss_team_season_index) + +pitss_player_season_index = ModelIndex( + PitchingSeasonStats, + (PitchingSeasonStats.player, PitchingSeasonStats.season), + unique=False, +) +PitchingSeasonStats.add_index(pitss_player_season_index) if not SKIP_TABLE_CREATION: - db.create_tables([StratGame, StratPlay, Decision, PlayerSeasonStats], safe=True) + db.create_tables( + [StratGame, StratPlay, Decision, BattingSeasonStats, PitchingSeasonStats], + safe=True, + ) class ScoutOpportunity(BaseModel): diff --git a/app/models/season_stats.py b/app/models/season_stats.py index bdd7ad1..b47dfec 100644 --- a/app/models/season_stats.py +++ b/app/models/season_stats.py @@ -1,7 +1,7 @@ -"""PlayerSeasonStats ORM model. +"""Season stats ORM models. -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`. +Models are defined in db_engine alongside all other Peewee models; this +module re-exports them so callers can import from `app.models.season_stats`. """ -from ..db_engine import PlayerSeasonStats # noqa: F401 +from ..db_engine import BattingSeasonStats, PitchingSeasonStats # noqa: F401 diff --git a/app/routers_v2/season_stats.py b/app/routers_v2/season_stats.py index d981af0..c5d48c3 100644 --- a/app/routers_v2/season_stats.py +++ b/app/routers_v2/season_stats.py @@ -4,9 +4,8 @@ Covers WP-13 (Post-Game Callback Integration): POST /api/v2/season-stats/update-game/{game_id} Aggregates BattingStat and PitchingStat rows for a completed game and -increments the corresponding player_season_stats rows via an additive upsert. - -Lazy-imports PlayerSeasonStats so this module loads before WP-05 merges. +increments the corresponding batting_season_stats / pitching_season_stats +rows via an additive upsert. """ import logging @@ -32,13 +31,14 @@ def _ip_to_outs(ip: float) -> int: @router.post("/update-game/{game_id}") async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): - """Increment player_season_stats with batting and pitching deltas from a game. + """Increment season stats with batting and pitching deltas from a game. Queries BattingStat and PitchingStat rows for game_id, aggregates by (player_id, team_id, season), then performs an additive ON CONFLICT upsert - into player_season_stats. Idempotent: replaying the same game_id a second - time will double-count stats, so callers must ensure this is only called once - per game. + into batting_season_stats and pitching_season_stats respectively. + + Replaying the same game_id will double-count stats, so callers must ensure + this is only called once per game. Response: {"updated": N} where N is the number of player rows touched. """ @@ -73,14 +73,14 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch season, pa, ab, - r, + runs, hits, doubles, triples, hr, rbi, bb, - so, + strikeouts, hbp, sac, ibb, @@ -90,28 +90,28 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch ) = row db.execute_sql( """ - INSERT INTO player_season_stats + INSERT INTO batting_season_stats (player_id, team_id, season, - pa, ab, r, hits, doubles, triples, hr, rbi, - bb, so_batter, hbp, sac, ibb, gidp, sb, cs) + pa, ab, runs, hits, doubles, triples, hr, rbi, + bb, strikeouts, hbp, sac, ibb, gidp, sb, cs) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (player_id, team_id, season) DO UPDATE SET - pa = player_season_stats.pa + EXCLUDED.pa, - ab = player_season_stats.ab + EXCLUDED.ab, - r = player_season_stats.r + EXCLUDED.r, - hits = player_season_stats.hits + EXCLUDED.hits, - doubles= player_season_stats.doubles+ EXCLUDED.doubles, - triples= player_season_stats.triples+ EXCLUDED.triples, - hr = player_season_stats.hr + EXCLUDED.hr, - rbi = player_season_stats.rbi + EXCLUDED.rbi, - bb = player_season_stats.bb + EXCLUDED.bb, - so_batter= player_season_stats.so_batter+ EXCLUDED.so_batter, - hbp = player_season_stats.hbp + EXCLUDED.hbp, - sac = player_season_stats.sac + EXCLUDED.sac, - ibb = player_season_stats.ibb + EXCLUDED.ibb, - gidp = player_season_stats.gidp + EXCLUDED.gidp, - sb = player_season_stats.sb + EXCLUDED.sb, - cs = player_season_stats.cs + EXCLUDED.cs + pa = batting_season_stats.pa + EXCLUDED.pa, + ab = batting_season_stats.ab + EXCLUDED.ab, + runs = batting_season_stats.runs + EXCLUDED.runs, + hits = batting_season_stats.hits + EXCLUDED.hits, + doubles = batting_season_stats.doubles + EXCLUDED.doubles, + triples = batting_season_stats.triples + EXCLUDED.triples, + hr = batting_season_stats.hr + EXCLUDED.hr, + rbi = batting_season_stats.rbi + EXCLUDED.rbi, + bb = batting_season_stats.bb + EXCLUDED.bb, + strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts, + hbp = batting_season_stats.hbp + EXCLUDED.hbp, + sac = batting_season_stats.sac + EXCLUDED.sac, + ibb = batting_season_stats.ibb + EXCLUDED.ibb, + gidp = batting_season_stats.gidp + EXCLUDED.gidp, + sb = batting_season_stats.sb + EXCLUDED.sb, + cs = batting_season_stats.cs + EXCLUDED.cs """, ( player_id, @@ -119,14 +119,14 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch season, pa, ab, - r, + runs, hits, doubles, triples, hr, rbi, bb, - so, + strikeouts, hbp, sac, ibb, @@ -161,72 +161,72 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch team_id, season, ip, - so_pitcher, - h_allowed, - r_allowed, - er, - bb_p, - hbp_p, - wp, - balk, - hr_p, - gs, - w, + strikeouts, + hits_allowed, + runs_allowed, + earned_runs, + bb, + hbp, + wild_pitches, + balks, + hr_allowed, + games_started, + wins, losses, - hold, - sv, - bsv, + holds, + saves, + blown_saves, ) = row outs = _ip_to_outs(float(ip)) db.execute_sql( """ - INSERT INTO player_season_stats + INSERT INTO pitching_season_stats (player_id, team_id, season, - outs, so_pitcher, h_allowed, r_allowed, er, - bb_p, hbp_p, wp, balk, hr_p, - gs, w, l, hold, sv, bsv) + outs, strikeouts, hits_allowed, runs_allowed, earned_runs, + bb, hbp, wild_pitches, balks, hr_allowed, + games_started, wins, losses, holds, saves, blown_saves) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (player_id, team_id, season) DO UPDATE SET - outs = player_season_stats.outs + EXCLUDED.outs, - so_pitcher= player_season_stats.so_pitcher+ EXCLUDED.so_pitcher, - h_allowed= player_season_stats.h_allowed+ EXCLUDED.h_allowed, - r_allowed= player_season_stats.r_allowed+ EXCLUDED.r_allowed, - er = player_season_stats.er + EXCLUDED.er, - bb_p = player_season_stats.bb_p + EXCLUDED.bb_p, - hbp_p = player_season_stats.hbp_p + EXCLUDED.hbp_p, - wp = player_season_stats.wp + EXCLUDED.wp, - balk = player_season_stats.balk + EXCLUDED.balk, - hr_p = player_season_stats.hr_p + EXCLUDED.hr_p, - gs = player_season_stats.gs + EXCLUDED.gs, - w = player_season_stats.w + EXCLUDED.w, - l = player_season_stats.l + EXCLUDED.l, - hold = player_season_stats.hold + EXCLUDED.hold, - sv = player_season_stats.sv + EXCLUDED.sv, - bsv = player_season_stats.bsv + EXCLUDED.bsv + outs = pitching_season_stats.outs + EXCLUDED.outs, + strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts, + hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed, + runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed, + earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs, + bb = pitching_season_stats.bb + EXCLUDED.bb, + hbp = pitching_season_stats.hbp + EXCLUDED.hbp, + wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches, + balks = pitching_season_stats.balks + EXCLUDED.balks, + hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed, + games_started= pitching_season_stats.games_started+ EXCLUDED.games_started, + wins = pitching_season_stats.wins + EXCLUDED.wins, + losses = pitching_season_stats.losses + EXCLUDED.losses, + holds = pitching_season_stats.holds + EXCLUDED.holds, + saves = pitching_season_stats.saves + EXCLUDED.saves, + blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves """, ( player_id, team_id, season, outs, - so_pitcher, - h_allowed, - r_allowed, - er, - bb_p, - hbp_p, - wp, - balk, - hr_p, - gs, - w, + strikeouts, + hits_allowed, + runs_allowed, + earned_runs, + bb, + hbp, + wild_pitches, + balks, + hr_allowed, + games_started, + wins, losses, - hold, - sv, - bsv, + holds, + saves, + blown_saves, ), ) updated += 1 - logging.info(f"update-game/{game_id}: updated {updated} player_season_stats rows") + logging.info(f"update-game/{game_id}: updated {updated} season stats rows") return {"updated": updated} diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index 0c45287..c2ae125 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -4,9 +4,9 @@ Three pure functions that compute a numeric evolution value from career stats, plus helpers for formula dispatch and tier classification. Stats attributes expected by each formula: - compute_batter_value: pa, hits, doubles, triples, hr - compute_sp_value: outs, so_pitcher (pitcher strikeouts, from PlayerSeasonStats) - compute_rp_value: outs, so_pitcher + compute_batter_value: pa, hits, doubles, triples, hr (from BattingSeasonStats) + compute_sp_value: outs, strikeouts (from PitchingSeasonStats) + compute_rp_value: outs, strikeouts (from PitchingSeasonStats) """ from typing import Protocol @@ -22,7 +22,7 @@ class BatterStats(Protocol): class PitcherStats(Protocol): outs: int - so_pitcher: int + strikeouts: int # --------------------------------------------------------------------------- @@ -31,20 +31,20 @@ class PitcherStats(Protocol): def compute_batter_value(stats) -> float: - """PA + (TB × 2) where TB = 1B + 2×2B + 3×3B + 4×HR.""" + """PA + (TB x 2) where TB = 1B + 2x2B + 3x3B + 4xHR.""" singles = stats.hits - stats.doubles - stats.triples - stats.hr tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr return float(stats.pa + tb * 2) def compute_sp_value(stats) -> float: - """IP + K where IP = outs / 3. Uses stats.so_pitcher (pitcher strikeouts).""" - return stats.outs / 3 + stats.so_pitcher + """IP + K where IP = outs / 3.""" + return stats.outs / 3 + stats.strikeouts def compute_rp_value(stats) -> float: - """IP + K (same formula as SP; thresholds differ). Uses stats.so_pitcher.""" - return stats.outs / 3 + stats.so_pitcher + """IP + K (same formula as SP; thresholds differ).""" + return stats.outs / 3 + stats.strikeouts # --------------------------------------------------------------------------- @@ -75,7 +75,7 @@ def compute_value_for_track(card_type: str, stats) -> float: def tier_from_value(value: float, track) -> int: - """Return the evolution tier (0–4) for a computed value against a track. + """Return the evolution tier (0-4) for a computed value against a track. Tier boundaries are inclusive on the lower end: T0: value < t1 diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index 310c123..67c14a9 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -1,7 +1,7 @@ """Tests for the formula engine (WP-09). Unit tests only — no database required. Stats inputs are simple namespace -objects whose attributes match what PlayerSeasonStats exposes. +objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose. Tier thresholds used (from evolution_tracks.json seed data): Batter: t1=37, t2=149, t3=448, t4=896 @@ -35,7 +35,7 @@ def batter_stats(**kwargs): def pitcher_stats(**kwargs): """Build a minimal pitcher stats object with all fields defaulting to 0.""" - defaults = {"outs": 0, "so_pitcher": 0} + defaults = {"outs": 0, "strikeouts": 0} defaults.update(kwargs) return SimpleNamespace(**defaults) @@ -84,7 +84,7 @@ def test_batter_formula_hr_heavy(): def test_sp_formula_standard(): """18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0.""" - stats = pitcher_stats(outs=18, so_pitcher=5) + stats = pitcher_stats(outs=18, strikeouts=5) assert compute_sp_value(stats) == 11.0 @@ -95,7 +95,7 @@ def test_sp_formula_standard(): def test_rp_formula_standard(): """3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0.""" - stats = pitcher_stats(outs=3, so_pitcher=2) + stats = pitcher_stats(outs=3, strikeouts=2) assert compute_rp_value(stats) == 3.0 @@ -132,13 +132,13 @@ def test_dispatch_batter(): def test_dispatch_sp(): """compute_value_for_track('sp', ...) delegates to compute_sp_value.""" - stats = pitcher_stats(outs=18, so_pitcher=5) + stats = pitcher_stats(outs=18, strikeouts=5) assert compute_value_for_track("sp", stats) == compute_sp_value(stats) def test_dispatch_rp(): """compute_value_for_track('rp', ...) delegates to compute_rp_value.""" - stats = pitcher_stats(outs=3, so_pitcher=2) + stats = pitcher_stats(outs=3, strikeouts=2) assert compute_value_for_track("rp", stats) == compute_rp_value(stats) diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py index 1387357..3876964 100644 --- a/tests/test_season_stats_model.py +++ b/tests/test_season_stats_model.py @@ -1,19 +1,15 @@ -"""Tests for PlayerSeasonStats Peewee model (WP-02). +"""Tests for BattingSeasonStats and PitchingSeasonStats Peewee models. 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.models.season_stats import BattingSeasonStats, PitchingSeasonStats from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team, StratGame # Dependency order matters for FK resolution. @@ -25,7 +21,8 @@ _TEST_MODELS = [ Player, Team, StratGame, - PlayerSeasonStats, + BattingSeasonStats, + PitchingSeasonStats, ] _test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) @@ -92,218 +89,278 @@ def make_game(home_team, away_team, season=10): ) -def make_stats(player, team, season=10, **kwargs): - return PlayerSeasonStats.create(player=player, team=team, season=season, **kwargs) +def make_batting_stats(player, team, season=10, **kwargs): + return BattingSeasonStats.create(player=player, team=team, season=season, **kwargs) + + +def make_pitching_stats(player, team, season=10, **kwargs): + return PitchingSeasonStats.create(player=player, team=team, season=season, **kwargs) # ── Unit: column completeness ──────────────────────────────────────────────── -class TestColumnCompleteness: - """All required columns are present in the model's field definitions.""" +class TestBattingColumnCompleteness: + """All required columns are present in BattingSeasonStats.""" - BATTING_COLS = [ - "games_batting", + EXPECTED_COLS = [ + "games", "pa", "ab", "hits", - "hr", "doubles", "triples", - "bb", - "hbp", - "so_batter", + "hr", "rbi", "runs", + "bb", + "strikeouts", + "hbp", + "sac", + "ibb", + "gidp", "sb", "cs", ] - PITCHING_COLS = [ - "games_pitching", - "outs", - "so_pitcher", - "bb_allowed", - "hits_allowed", - "hr_allowed", - "wins", - "losses", - "saves", - "holds", - "blown_saves", - ] - META_COLS = ["last_game", "last_updated_at"] KEY_COLS = ["player", "team", "season"] + META_COLS = ["last_game", "last_updated_at"] - 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: + def test_stat_columns_present(self): + """All batting aggregate columns are present.""" + fields = BattingSeasonStats._meta.fields + for col in self.EXPECTED_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 + fields = BattingSeasonStats._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 + def test_meta_columns_present(self): + """Meta columns last_game and last_updated_at are present.""" + fields = BattingSeasonStats._meta.fields + for col in self.META_COLS: + assert col in fields, f"Missing meta column: {col}" + + +class TestPitchingColumnCompleteness: + """All required columns are present in PitchingSeasonStats.""" + + EXPECTED_COLS = [ + "games", + "games_started", + "outs", + "strikeouts", + "bb", + "hits_allowed", + "runs_allowed", + "earned_runs", + "hr_allowed", + "hbp", + "wild_pitches", + "balks", + "wins", + "losses", + "holds", + "saves", + "blown_saves", + ] + KEY_COLS = ["player", "team", "season"] + META_COLS = ["last_game", "last_updated_at"] + + def test_stat_columns_present(self): + """All pitching aggregate columns are present.""" + fields = PitchingSeasonStats._meta.fields + for col in self.EXPECTED_COLS: + assert col in fields, f"Missing pitching column: {col}" + + def test_key_columns_present(self): + """player, team, and season columns are present.""" + fields = PitchingSeasonStats._meta.fields + for col in self.KEY_COLS: + assert col in fields, f"Missing key column: {col}" + + def test_meta_columns_present(self): + """Meta columns last_game and last_updated_at are present.""" + fields = PitchingSeasonStats._meta.fields + for col in self.META_COLS: + assert col in fields, f"Missing meta column: {col}" # ── Unit: default values ───────────────────────────────────────────────────── -class TestDefaultValues: +class TestBattingDefaultValues: """All integer stat columns default to 0; nullable meta fields default to None.""" INT_STAT_COLS = [ - "games_batting", + "games", "pa", "ab", "hits", - "hr", "doubles", "triples", - "bb", - "hbp", - "so_batter", + "hr", "rbi", "runs", + "bb", + "strikeouts", + "hbp", + "sac", + "ibb", + "gidp", "sb", "cs", - "games_pitching", - "outs", - "so_pitcher", - "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() + row = BattingSeasonStats() 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() + row = BattingSeasonStats() assert row.last_game_id is None def test_last_updated_at_defaults_to_none(self): """last_updated_at defaults to None.""" - row = PlayerSeasonStats() + row = BattingSeasonStats() + assert row.last_updated_at is None + + +class TestPitchingDefaultValues: + """All integer stat columns default to 0; nullable meta fields default to None.""" + + INT_STAT_COLS = [ + "games", + "games_started", + "outs", + "strikeouts", + "bb", + "hits_allowed", + "runs_allowed", + "earned_runs", + "hr_allowed", + "hbp", + "wild_pitches", + "balks", + "wins", + "losses", + "holds", + "saves", + "blown_saves", + ] + + def test_all_int_columns_default_to_zero(self): + """Every integer stat column defaults to 0 on an unsaved instance.""" + row = PitchingSeasonStats() + 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 = PitchingSeasonStats() + assert row.last_game_id is None + + def test_last_updated_at_defaults_to_none(self): + """last_updated_at defaults to None.""" + row = PitchingSeasonStats() assert row.last_updated_at is None # ── Integration: unique constraint ─────────────────────────────────────────── -class TestUniqueConstraint: +class TestBattingUniqueConstraint: """UNIQUE on (player_id, team_id, season) is enforced at the DB level.""" - def test_duplicate_player_team_season_raises(self): + def test_duplicate_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) + make_batting_stats(player, team, season=10) with pytest.raises(IntegrityError): - make_stats(player, team, season=10) + make_batting_stats(player, team, season=10) - def test_same_player_different_season_allowed(self): + def test_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) + make_batting_stats(player, team, season=10) + row2 = make_batting_stats(player, team, season=11) assert row2.id is not None - def test_same_player_different_team_allowed(self): + def test_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) + make_batting_stats(player, team1, season=10) + row2 = make_batting_stats(player, team2, season=10) + assert row2.id is not None + + +class TestPitchingUniqueConstraint: + """UNIQUE on (player_id, team_id, season) is enforced at the DB level.""" + + def test_duplicate_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_pitching_stats(player, team, season=10) + with pytest.raises(IntegrityError): + make_pitching_stats(player, team, season=10) + + def test_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_pitching_stats(player, team, season=10) + row2 = make_pitching_stats(player, team, season=11) assert row2.id is not None # ── Integration: delta update pattern ─────────────────────────────────────── -class TestDeltaUpdatePattern: - """Stats can be incremented (delta update) without replacing existing values.""" +class TestBattingDeltaUpdate: + """Batting 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.""" + """Updating pa and hits increments correctly.""" 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) + row = make_batting_stats(player, team, season=10, pa=5, hits=2) - PlayerSeasonStats.update( - pa=PlayerSeasonStats.pa + 3, - hits=PlayerSeasonStats.hits + 1, + BattingSeasonStats.update( + pa=BattingSeasonStats.pa + 3, + hits=BattingSeasonStats.hits + 1, ).where( - (PlayerSeasonStats.player == player) - & (PlayerSeasonStats.team == team) - & (PlayerSeasonStats.season == 10) + (BattingSeasonStats.player == player) + & (BattingSeasonStats.team == team) + & (BattingSeasonStats.season == 10) ).execute() - updated = PlayerSeasonStats.get_by_id(row.id) + updated = BattingSeasonStats.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 so_pitcher 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, so_pitcher=3) - - PlayerSeasonStats.update( - outs=PlayerSeasonStats.outs + 6, - so_pitcher=PlayerSeasonStats.so_pitcher + 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.so_pitcher == 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.""" @@ -311,45 +368,116 @@ class TestDeltaUpdatePattern: cardset = make_cardset() player = make_player(cardset, rarity) team = make_team() - row = make_stats(player, team, season=10) + row = make_batting_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 + BattingSeasonStats.update(last_game=game).where( + BattingSeasonStats.id == row.id ).execute() - updated = PlayerSeasonStats.get_by_id(row.id) + updated = BattingSeasonStats.get_by_id(row.id) + assert updated.last_game_id == game.id + + +class TestPitchingDeltaUpdate: + """Pitching stats can be incremented (delta update) without replacing existing values.""" + + def test_increment_pitching_stats(self): + """Updating outs and strikeouts increments correctly.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + row = make_pitching_stats(player, team, season=10, outs=9, strikeouts=3) + + PitchingSeasonStats.update( + outs=PitchingSeasonStats.outs + 6, + strikeouts=PitchingSeasonStats.strikeouts + 2, + ).where( + (PitchingSeasonStats.player == player) + & (PitchingSeasonStats.team == team) + & (PitchingSeasonStats.season == 10) + ).execute() + + updated = PitchingSeasonStats.get_by_id(row.id) + assert updated.outs == 15 + assert updated.strikeouts == 5 + + 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_pitching_stats(player, team, season=10) + assert row.last_game_id is None + + game = make_game(home_team=team, away_team=team) + PitchingSeasonStats.update(last_game=game).where( + PitchingSeasonStats.id == row.id + ).execute() + + updated = PitchingSeasonStats.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.""" +class TestBattingIndexExistence: + """Required indexes exist on batting_season_stats.""" - def _get_index_columns(self, db, table): + def _get_index_columns(self, db_conn, table): """Return a set of frozensets, each being the column set of one index.""" - indexes = db.execute_sql(f"PRAGMA index_list({table})").fetchall() + indexes = db_conn.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() + cols = db_conn.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") + index_sets = self._get_index_columns(setup_test_db, "batting_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") + index_sets = self._get_index_columns(setup_test_db, "batting_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") + index_sets = self._get_index_columns(setup_test_db, "batting_season_stats") + assert frozenset({"player_id", "season"}) in index_sets + + +class TestPitchingIndexExistence: + """Required indexes exist on pitching_season_stats.""" + + def _get_index_columns(self, db_conn, table): + """Return a set of frozensets, each being the column set of one index.""" + indexes = db_conn.execute_sql(f"PRAGMA index_list({table})").fetchall() + result = set() + for idx in indexes: + idx_name = idx[1] + cols = db_conn.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, "pitching_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, "pitching_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, "pitching_season_stats") assert frozenset({"player_id", "season"}) in index_sets