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)
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):

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

View File

@ -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}

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.
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 (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:
T0: value < t1

View File

@ -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)

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