refactor: split PlayerSeasonStats into BattingSeasonStats and PitchingSeasonStats
Some checks failed
Build Docker Image / build (push) Has been cancelled

Separate batting and pitching into distinct tables with descriptive column
names. Eliminates naming collisions (so/k ambiguity) and column mismatches
between the ORM model and raw SQL. Each table now covers all aggregatable
fields from its source (BattingStat/PitchingStat) including sac, ibb, gidp,
earned_runs, runs_allowed, wild_pitches, balks, and games_started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-17 09:43:22 -05:00
parent 4ed62dea2c
commit bd8e4578cc
6 changed files with 433 additions and 265 deletions

View File

@ -1050,73 +1050,113 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique=
Decision.add_index(decision_index) Decision.add_index(decision_index)
class PlayerSeasonStats(BaseModel): class BattingSeasonStats(BaseModel):
player = ForeignKeyField(Player) player = ForeignKeyField(Player)
team = ForeignKeyField(Team) team = ForeignKeyField(Team)
season = IntegerField() season = IntegerField()
games = IntegerField(default=0)
# Batting stats
games_batting = IntegerField(default=0)
pa = IntegerField(default=0) pa = IntegerField(default=0)
ab = IntegerField(default=0) ab = IntegerField(default=0)
hits = IntegerField(default=0) hits = IntegerField(default=0)
hr = IntegerField(default=0)
doubles = IntegerField(default=0) doubles = IntegerField(default=0)
triples = IntegerField(default=0) triples = IntegerField(default=0)
bb = IntegerField(default=0) hr = IntegerField(default=0)
hbp = IntegerField(default=0)
so_batter = IntegerField(default=0)
rbi = IntegerField(default=0) rbi = IntegerField(default=0)
runs = 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) sb = IntegerField(default=0)
cs = 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_game = ForeignKeyField(StratGame, null=True)
last_updated_at = DateTimeField(null=True) last_updated_at = DateTimeField(null=True)
class Meta: class Meta:
database = db database = db
table_name = "player_season_stats" table_name = "batting_season_stats"
pss_unique_index = ModelIndex( bss_unique_index = ModelIndex(
PlayerSeasonStats, BattingSeasonStats,
(PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season), (BattingSeasonStats.player, BattingSeasonStats.team, BattingSeasonStats.season),
unique=True, unique=True,
) )
PlayerSeasonStats.add_index(pss_unique_index) BattingSeasonStats.add_index(bss_unique_index)
pss_team_season_index = ModelIndex( bss_team_season_index = ModelIndex(
PlayerSeasonStats, BattingSeasonStats,
(PlayerSeasonStats.team, PlayerSeasonStats.season), (BattingSeasonStats.team, BattingSeasonStats.season),
unique=False, unique=False,
) )
PlayerSeasonStats.add_index(pss_team_season_index) BattingSeasonStats.add_index(bss_team_season_index)
pss_player_season_index = ModelIndex( bss_player_season_index = ModelIndex(
PlayerSeasonStats, BattingSeasonStats,
(PlayerSeasonStats.player, PlayerSeasonStats.season), (BattingSeasonStats.player, BattingSeasonStats.season),
unique=False, 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: 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): class ScoutOpportunity(BaseModel):

View File

@ -1,7 +1,7 @@
"""PlayerSeasonStats ORM model. """Season stats ORM models.
Model is defined in db_engine alongside all other Peewee models; this Models are defined in db_engine alongside all other Peewee models; this
module re-exports it so callers can import from `app.models.season_stats`. 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

View File

@ -4,9 +4,8 @@ Covers WP-13 (Post-Game Callback Integration):
POST /api/v2/season-stats/update-game/{game_id} POST /api/v2/season-stats/update-game/{game_id}
Aggregates BattingStat and PitchingStat rows for a completed game and Aggregates BattingStat and PitchingStat rows for a completed game and
increments the corresponding player_season_stats rows via an additive upsert. increments the corresponding batting_season_stats / pitching_season_stats
rows via an additive upsert.
Lazy-imports PlayerSeasonStats so this module loads before WP-05 merges.
""" """
import logging import logging
@ -32,13 +31,14 @@ def _ip_to_outs(ip: float) -> int:
@router.post("/update-game/{game_id}") @router.post("/update-game/{game_id}")
async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): 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 Queries BattingStat and PitchingStat rows for game_id, aggregates by
(player_id, team_id, season), then performs an additive ON CONFLICT upsert (player_id, team_id, season), then performs an additive ON CONFLICT upsert
into player_season_stats. Idempotent: replaying the same game_id a second into batting_season_stats and pitching_season_stats respectively.
time will double-count stats, so callers must ensure this is only called once
per game. 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. 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, season,
pa, pa,
ab, ab,
r, runs,
hits, hits,
doubles, doubles,
triples, triples,
hr, hr,
rbi, rbi,
bb, bb,
so, strikeouts,
hbp, hbp,
sac, sac,
ibb, ibb,
@ -90,28 +90,28 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch
) = row ) = row
db.execute_sql( db.execute_sql(
""" """
INSERT INTO player_season_stats INSERT INTO batting_season_stats
(player_id, team_id, season, (player_id, team_id, season,
pa, ab, r, hits, doubles, triples, hr, rbi, pa, ab, runs, hits, doubles, triples, hr, rbi,
bb, so_batter, hbp, sac, ibb, gidp, sb, cs) 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) 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 ON CONFLICT (player_id, team_id, season) DO UPDATE SET
pa = player_season_stats.pa + EXCLUDED.pa, pa = batting_season_stats.pa + EXCLUDED.pa,
ab = player_season_stats.ab + EXCLUDED.ab, ab = batting_season_stats.ab + EXCLUDED.ab,
r = player_season_stats.r + EXCLUDED.r, runs = batting_season_stats.runs + EXCLUDED.runs,
hits = player_season_stats.hits + EXCLUDED.hits, hits = batting_season_stats.hits + EXCLUDED.hits,
doubles= player_season_stats.doubles+ EXCLUDED.doubles, doubles = batting_season_stats.doubles + EXCLUDED.doubles,
triples= player_season_stats.triples+ EXCLUDED.triples, triples = batting_season_stats.triples + EXCLUDED.triples,
hr = player_season_stats.hr + EXCLUDED.hr, hr = batting_season_stats.hr + EXCLUDED.hr,
rbi = player_season_stats.rbi + EXCLUDED.rbi, rbi = batting_season_stats.rbi + EXCLUDED.rbi,
bb = player_season_stats.bb + EXCLUDED.bb, bb = batting_season_stats.bb + EXCLUDED.bb,
so_batter= player_season_stats.so_batter+ EXCLUDED.so_batter, strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts,
hbp = player_season_stats.hbp + EXCLUDED.hbp, hbp = batting_season_stats.hbp + EXCLUDED.hbp,
sac = player_season_stats.sac + EXCLUDED.sac, sac = batting_season_stats.sac + EXCLUDED.sac,
ibb = player_season_stats.ibb + EXCLUDED.ibb, ibb = batting_season_stats.ibb + EXCLUDED.ibb,
gidp = player_season_stats.gidp + EXCLUDED.gidp, gidp = batting_season_stats.gidp + EXCLUDED.gidp,
sb = player_season_stats.sb + EXCLUDED.sb, sb = batting_season_stats.sb + EXCLUDED.sb,
cs = player_season_stats.cs + EXCLUDED.cs cs = batting_season_stats.cs + EXCLUDED.cs
""", """,
( (
player_id, player_id,
@ -119,14 +119,14 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch
season, season,
pa, pa,
ab, ab,
r, runs,
hits, hits,
doubles, doubles,
triples, triples,
hr, hr,
rbi, rbi,
bb, bb,
so, strikeouts,
hbp, hbp,
sac, sac,
ibb, ibb,
@ -161,72 +161,72 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch
team_id, team_id,
season, season,
ip, ip,
so_pitcher, strikeouts,
h_allowed, hits_allowed,
r_allowed, runs_allowed,
er, earned_runs,
bb_p, bb,
hbp_p, hbp,
wp, wild_pitches,
balk, balks,
hr_p, hr_allowed,
gs, games_started,
w, wins,
losses, losses,
hold, holds,
sv, saves,
bsv, blown_saves,
) = row ) = row
outs = _ip_to_outs(float(ip)) outs = _ip_to_outs(float(ip))
db.execute_sql( db.execute_sql(
""" """
INSERT INTO player_season_stats INSERT INTO pitching_season_stats
(player_id, team_id, season, (player_id, team_id, season,
outs, so_pitcher, h_allowed, r_allowed, er, outs, strikeouts, hits_allowed, runs_allowed, earned_runs,
bb_p, hbp_p, wp, balk, hr_p, bb, hbp, wild_pitches, balks, hr_allowed,
gs, w, l, hold, sv, bsv) 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) 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 ON CONFLICT (player_id, team_id, season) DO UPDATE SET
outs = player_season_stats.outs + EXCLUDED.outs, outs = pitching_season_stats.outs + EXCLUDED.outs,
so_pitcher= player_season_stats.so_pitcher+ EXCLUDED.so_pitcher, strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts,
h_allowed= player_season_stats.h_allowed+ EXCLUDED.h_allowed, hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed,
r_allowed= player_season_stats.r_allowed+ EXCLUDED.r_allowed, runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed,
er = player_season_stats.er + EXCLUDED.er, earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs,
bb_p = player_season_stats.bb_p + EXCLUDED.bb_p, bb = pitching_season_stats.bb + EXCLUDED.bb,
hbp_p = player_season_stats.hbp_p + EXCLUDED.hbp_p, hbp = pitching_season_stats.hbp + EXCLUDED.hbp,
wp = player_season_stats.wp + EXCLUDED.wp, wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches,
balk = player_season_stats.balk + EXCLUDED.balk, balks = pitching_season_stats.balks + EXCLUDED.balks,
hr_p = player_season_stats.hr_p + EXCLUDED.hr_p, hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed,
gs = player_season_stats.gs + EXCLUDED.gs, games_started= pitching_season_stats.games_started+ EXCLUDED.games_started,
w = player_season_stats.w + EXCLUDED.w, wins = pitching_season_stats.wins + EXCLUDED.wins,
l = player_season_stats.l + EXCLUDED.l, losses = pitching_season_stats.losses + EXCLUDED.losses,
hold = player_season_stats.hold + EXCLUDED.hold, holds = pitching_season_stats.holds + EXCLUDED.holds,
sv = player_season_stats.sv + EXCLUDED.sv, saves = pitching_season_stats.saves + EXCLUDED.saves,
bsv = player_season_stats.bsv + EXCLUDED.bsv blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves
""", """,
( (
player_id, player_id,
team_id, team_id,
season, season,
outs, outs,
so_pitcher, strikeouts,
h_allowed, hits_allowed,
r_allowed, runs_allowed,
er, earned_runs,
bb_p, bb,
hbp_p, hbp,
wp, wild_pitches,
balk, balks,
hr_p, hr_allowed,
gs, games_started,
w, wins,
losses, losses,
hold, holds,
sv, saves,
bsv, blown_saves,
), ),
) )
updated += 1 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} return {"updated": updated}

View File

@ -4,9 +4,9 @@ Three pure functions that compute a numeric evolution value from career stats,
plus helpers for formula dispatch and tier classification. plus helpers for formula dispatch and tier classification.
Stats attributes expected by each formula: Stats attributes expected by each formula:
compute_batter_value: pa, hits, doubles, triples, hr compute_batter_value: pa, hits, doubles, triples, hr (from BattingSeasonStats)
compute_sp_value: outs, so_pitcher (pitcher strikeouts, from PlayerSeasonStats) compute_sp_value: outs, strikeouts (from PitchingSeasonStats)
compute_rp_value: outs, so_pitcher compute_rp_value: outs, strikeouts (from PitchingSeasonStats)
""" """
from typing import Protocol from typing import Protocol
@ -22,7 +22,7 @@ class BatterStats(Protocol):
class PitcherStats(Protocol): class PitcherStats(Protocol):
outs: int outs: int
so_pitcher: int strikeouts: int
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -31,20 +31,20 @@ class PitcherStats(Protocol):
def compute_batter_value(stats) -> float: 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 singles = stats.hits - stats.doubles - stats.triples - stats.hr
tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr
return float(stats.pa + tb * 2) return float(stats.pa + tb * 2)
def compute_sp_value(stats) -> float: def compute_sp_value(stats) -> float:
"""IP + K where IP = outs / 3. Uses stats.so_pitcher (pitcher strikeouts).""" """IP + K where IP = outs / 3."""
return stats.outs / 3 + stats.so_pitcher return stats.outs / 3 + stats.strikeouts
def compute_rp_value(stats) -> float: def compute_rp_value(stats) -> float:
"""IP + K (same formula as SP; thresholds differ). Uses stats.so_pitcher.""" """IP + K (same formula as SP; thresholds differ)."""
return stats.outs / 3 + stats.so_pitcher 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: def tier_from_value(value: float, track) -> int:
"""Return the evolution tier (04) 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: Tier boundaries are inclusive on the lower end:
T0: value < t1 T0: value < t1

View File

@ -1,7 +1,7 @@
"""Tests for the formula engine (WP-09). """Tests for the formula engine (WP-09).
Unit tests only no database required. Stats inputs are simple namespace 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): Tier thresholds used (from evolution_tracks.json seed data):
Batter: t1=37, t2=149, t3=448, t4=896 Batter: t1=37, t2=149, t3=448, t4=896
@ -35,7 +35,7 @@ def batter_stats(**kwargs):
def pitcher_stats(**kwargs): def pitcher_stats(**kwargs):
"""Build a minimal pitcher stats object with all fields defaulting to 0.""" """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) defaults.update(kwargs)
return SimpleNamespace(**defaults) return SimpleNamespace(**defaults)
@ -84,7 +84,7 @@ def test_batter_formula_hr_heavy():
def test_sp_formula_standard(): def test_sp_formula_standard():
"""18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0.""" """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 assert compute_sp_value(stats) == 11.0
@ -95,7 +95,7 @@ def test_sp_formula_standard():
def test_rp_formula_standard(): def test_rp_formula_standard():
"""3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0.""" """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 assert compute_rp_value(stats) == 3.0
@ -132,13 +132,13 @@ def test_dispatch_batter():
def test_dispatch_sp(): def test_dispatch_sp():
"""compute_value_for_track('sp', ...) delegates to compute_sp_value.""" """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) assert compute_value_for_track("sp", stats) == compute_sp_value(stats)
def test_dispatch_rp(): def test_dispatch_rp():
"""compute_value_for_track('rp', ...) delegates to compute_rp_value.""" """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) assert compute_value_for_track("rp", stats) == compute_rp_value(stats)

View File

@ -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 Unit tests verify model structure and defaults on unsaved instances without
touching a database. Integration tests use an in-memory SQLite database to touching a database. Integration tests use an in-memory SQLite database to
verify table creation, unique constraints, indexes, and the delta-update verify table creation, unique constraints, indexes, and the delta-update
(increment) pattern. (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 import pytest
from peewee import SqliteDatabase, IntegrityError 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 from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team, StratGame
# Dependency order matters for FK resolution. # Dependency order matters for FK resolution.
@ -25,7 +21,8 @@ _TEST_MODELS = [
Player, Player,
Team, Team,
StratGame, StratGame,
PlayerSeasonStats, BattingSeasonStats,
PitchingSeasonStats,
] ]
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) _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): def make_batting_stats(player, team, season=10, **kwargs):
return PlayerSeasonStats.create(player=player, team=team, season=season, **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 ──────────────────────────────────────────────── # ── Unit: column completeness ────────────────────────────────────────────────
class TestColumnCompleteness: class TestBattingColumnCompleteness:
"""All required columns are present in the model's field definitions.""" """All required columns are present in BattingSeasonStats."""
BATTING_COLS = [ EXPECTED_COLS = [
"games_batting", "games",
"pa", "pa",
"ab", "ab",
"hits", "hits",
"hr",
"doubles", "doubles",
"triples", "triples",
"bb", "hr",
"hbp",
"so_batter",
"rbi", "rbi",
"runs", "runs",
"bb",
"strikeouts",
"hbp",
"sac",
"ibb",
"gidp",
"sb", "sb",
"cs", "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"] KEY_COLS = ["player", "team", "season"]
META_COLS = ["last_game", "last_updated_at"]
def test_batting_columns_present(self): def test_stat_columns_present(self):
"""All batting aggregate columns defined in the spec are present.""" """All batting aggregate columns are present."""
fields = PlayerSeasonStats._meta.fields fields = BattingSeasonStats._meta.fields
for col in self.BATTING_COLS: for col in self.EXPECTED_COLS:
assert col in fields, f"Missing batting column: {col}" 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): def test_key_columns_present(self):
"""player, team, and season columns are present.""" """player, team, and season columns are present."""
fields = PlayerSeasonStats._meta.fields fields = BattingSeasonStats._meta.fields
for col in self.KEY_COLS: for col in self.KEY_COLS:
assert col in fields, f"Missing key column: {col}" assert col in fields, f"Missing key column: {col}"
def test_excluded_columns_absent(self): def test_meta_columns_present(self):
"""team_wins and quality_starts are NOT in the model (removed from scope).""" """Meta columns last_game and last_updated_at are present."""
fields = PlayerSeasonStats._meta.fields fields = BattingSeasonStats._meta.fields
assert "team_wins" not in fields for col in self.META_COLS:
assert "quality_starts" not in fields 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 ───────────────────────────────────────────────────── # ── Unit: default values ─────────────────────────────────────────────────────
class TestDefaultValues: class TestBattingDefaultValues:
"""All integer stat columns default to 0; nullable meta fields default to None.""" """All integer stat columns default to 0; nullable meta fields default to None."""
INT_STAT_COLS = [ INT_STAT_COLS = [
"games_batting", "games",
"pa", "pa",
"ab", "ab",
"hits", "hits",
"hr",
"doubles", "doubles",
"triples", "triples",
"bb", "hr",
"hbp",
"so_batter",
"rbi", "rbi",
"runs", "runs",
"bb",
"strikeouts",
"hbp",
"sac",
"ibb",
"gidp",
"sb", "sb",
"cs", "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): def test_all_int_columns_default_to_zero(self):
"""Every integer stat column defaults to 0 on an unsaved instance.""" """Every integer stat column defaults to 0 on an unsaved instance."""
row = PlayerSeasonStats() row = BattingSeasonStats()
for col in self.INT_STAT_COLS: for col in self.INT_STAT_COLS:
val = getattr(row, col) val = getattr(row, col)
assert val == 0, f"Column {col!r} default is {val!r}, expected 0" assert val == 0, f"Column {col!r} default is {val!r}, expected 0"
def test_last_game_defaults_to_none(self): def test_last_game_defaults_to_none(self):
"""last_game FK is nullable and defaults to None.""" """last_game FK is nullable and defaults to None."""
row = PlayerSeasonStats() row = BattingSeasonStats()
assert row.last_game_id is None assert row.last_game_id is None
def test_last_updated_at_defaults_to_none(self): def test_last_updated_at_defaults_to_none(self):
"""last_updated_at defaults to None.""" """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 assert row.last_updated_at is None
# ── Integration: unique constraint ─────────────────────────────────────────── # ── Integration: unique constraint ───────────────────────────────────────────
class TestUniqueConstraint: class TestBattingUniqueConstraint:
"""UNIQUE on (player_id, team_id, season) is enforced at the DB level.""" """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.""" """Inserting a second row for the same (player, team, season) raises IntegrityError."""
rarity = make_rarity() rarity = make_rarity()
cardset = make_cardset() cardset = make_cardset()
player = make_player(cardset, rarity) player = make_player(cardset, rarity)
team = make_team() team = make_team()
make_stats(player, team, season=10) make_batting_stats(player, team, season=10)
with pytest.raises(IntegrityError): 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.""" """Same (player, team) in a different season creates a separate row."""
rarity = make_rarity() rarity = make_rarity()
cardset = make_cardset() cardset = make_cardset()
player = make_player(cardset, rarity) player = make_player(cardset, rarity)
team = make_team() team = make_team()
make_stats(player, team, season=10) make_batting_stats(player, team, season=10)
row2 = make_stats(player, team, season=11) row2 = make_batting_stats(player, team, season=11)
assert row2.id is not None 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.""" """Same (player, season) on a different team creates a separate row."""
rarity = make_rarity() rarity = make_rarity()
cardset = make_cardset() cardset = make_cardset()
player = make_player(cardset, rarity) player = make_player(cardset, rarity)
team1 = make_team("TM1", gmid=111) team1 = make_team("TM1", gmid=111)
team2 = make_team("TM2", gmid=222) team2 = make_team("TM2", gmid=222)
make_stats(player, team1, season=10) make_batting_stats(player, team1, season=10)
row2 = make_stats(player, team2, 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 assert row2.id is not None
# ── Integration: delta update pattern ─────────────────────────────────────── # ── Integration: delta update pattern ───────────────────────────────────────
class TestDeltaUpdatePattern: class TestBattingDeltaUpdate:
"""Stats can be incremented (delta update) without replacing existing values.""" """Batting stats can be incremented (delta update) without replacing existing values."""
def test_increment_batting_stats(self): def test_increment_batting_stats(self):
"""Updating pa and hits increments without touching pitching columns.""" """Updating pa and hits increments correctly."""
rarity = make_rarity() rarity = make_rarity()
cardset = make_cardset() cardset = make_cardset()
player = make_player(cardset, rarity) player = make_player(cardset, rarity)
team = make_team() 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( BattingSeasonStats.update(
pa=PlayerSeasonStats.pa + 3, pa=BattingSeasonStats.pa + 3,
hits=PlayerSeasonStats.hits + 1, hits=BattingSeasonStats.hits + 1,
).where( ).where(
(PlayerSeasonStats.player == player) (BattingSeasonStats.player == player)
& (PlayerSeasonStats.team == team) & (BattingSeasonStats.team == team)
& (PlayerSeasonStats.season == 10) & (BattingSeasonStats.season == 10)
).execute() ).execute()
updated = PlayerSeasonStats.get_by_id(row.id) updated = BattingSeasonStats.get_by_id(row.id)
assert updated.pa == 8 assert updated.pa == 8
assert updated.hits == 3 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): def test_last_game_fk_is_nullable(self):
"""last_game FK can be set to a StratGame instance or left NULL.""" """last_game FK can be set to a StratGame instance or left NULL."""
@ -311,45 +368,116 @@ class TestDeltaUpdatePattern:
cardset = make_cardset() cardset = make_cardset()
player = make_player(cardset, rarity) player = make_player(cardset, rarity)
team = make_team() 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 assert row.last_game_id is None
game = make_game(home_team=team, away_team=team) game = make_game(home_team=team, away_team=team)
PlayerSeasonStats.update(last_game=game).where( BattingSeasonStats.update(last_game=game).where(
PlayerSeasonStats.id == row.id BattingSeasonStats.id == row.id
).execute() ).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 assert updated.last_game_id == game.id
# ── Integration: index existence ───────────────────────────────────────────── # ── Integration: index existence ─────────────────────────────────────────────
class TestIndexExistence: class TestBattingIndexExistence:
"""Required indexes on (team_id, season) and (player_id, season) exist in SQLite.""" """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.""" """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() result = set()
for idx in indexes: for idx in indexes:
idx_name = idx[1] 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)) result.add(frozenset(col[2] for col in cols))
return result return result
def test_unique_index_on_player_team_season(self, setup_test_db): def test_unique_index_on_player_team_season(self, setup_test_db):
"""A unique index covering (player_id, team_id, season) exists.""" """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 assert frozenset({"player_id", "team_id", "season"}) in index_sets
def test_index_on_team_season(self, setup_test_db): def test_index_on_team_season(self, setup_test_db):
"""An index covering (team_id, season) exists.""" """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 assert frozenset({"team_id", "season"}) in index_sets
def test_index_on_player_season(self, setup_test_db): def test_index_on_player_season(self, setup_test_db):
"""An index covering (player_id, season) exists.""" """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 assert frozenset({"player_id", "season"}) in index_sets