refactor: split PlayerSeasonStats into BattingSeasonStats and PitchingSeasonStats
Some checks failed
Build Docker Image / build (push) Has been cancelled
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:
parent
4ed62dea2c
commit
bd8e4578cc
114
app/db_engine.py
114
app/db_engine.py
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 (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:
|
Tier boundaries are inclusive on the lower end:
|
||||||
T0: value < t1
|
T0: value < t1
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user