feat: PlayerSeasonStats Peewee model (#67) #82
@ -1050,8 +1050,75 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique=
|
||||
Decision.add_index(decision_index)
|
||||
|
||||
|
||||
class PlayerSeasonStats(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
season = IntegerField()
|
||||
|
||||
# Batting stats
|
||||
games_batting = 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 = IntegerField(default=0)
|
||||
rbi = IntegerField(default=0)
|
||||
runs = IntegerField(default=0)
|
||||
sb = IntegerField(default=0)
|
||||
cs = IntegerField(default=0)
|
||||
|
||||
# Pitching stats
|
||||
games_pitching = IntegerField(default=0)
|
||||
outs = IntegerField(default=0)
|
||||
|
|
||||
k = IntegerField(
|
||||
default=0
|
||||
) # pitcher Ks; spec names this "so (K)" but renamed to avoid collision with batting so
|
||||
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"
|
||||
|
||||
|
||||
pss_unique_index = ModelIndex(
|
||||
PlayerSeasonStats,
|
||||
(PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season),
|
||||
unique=True,
|
||||
)
|
||||
PlayerSeasonStats.add_index(pss_unique_index)
|
||||
|
||||
pss_team_season_index = ModelIndex(
|
||||
PlayerSeasonStats,
|
||||
(PlayerSeasonStats.team, PlayerSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PlayerSeasonStats.add_index(pss_team_season_index)
|
||||
|
||||
pss_player_season_index = ModelIndex(
|
||||
PlayerSeasonStats,
|
||||
(PlayerSeasonStats.player, PlayerSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PlayerSeasonStats.add_index(pss_player_season_index)
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
|
cal
commented
But more importantly: this line is moot in production since `PlayerSeasonStats` is appended to the existing `[StratGame, StratPlay, Decision]` `create_tables` group. Per the project pattern (scout models get their own `create_tables` block), this should be a separate call:
```python
if not SKIP_TABLE_CREATION:
db.create_tables([PlayerSeasonStats], safe=True)
```
But more importantly: this line is moot in production since `SKIP_TABLE_CREATION=True`. A SQL migration file is required.
|
||||
db.create_tables([StratGame, StratPlay, Decision], safe=True)
|
||||
db.create_tables([StratGame, StratPlay, Decision, PlayerSeasonStats], safe=True)
|
||||
|
||||
|
||||
class ScoutOpportunity(BaseModel):
|
||||
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
7
app/models/season_stats.py
Normal file
7
app/models/season_stats.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""PlayerSeasonStats ORM model.
|
||||
|
||||
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`.
|
||||
"""
|
||||
|
||||
from ..db_engine import PlayerSeasonStats # noqa: F401
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
14
tests/conftest.py
Normal file
14
tests/conftest.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Pytest configuration for the paper-dynasty-database test suite.
|
||||
|
||||
Sets DATABASE_TYPE=postgresql before any app module is imported so that
|
||||
db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the
|
||||
production SQLite file during test collection. Each test module is
|
||||
responsible for binding models to its own in-memory database.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["DATABASE_TYPE"] = "postgresql"
|
||||
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
||||
# without raising a configuration error (it will not actually be used).
|
||||
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
||||
355
tests/test_season_stats_model.py
Normal file
355
tests/test_season_stats_model.py
Normal file
@ -0,0 +1,355 @@
|
||||
"""Tests for PlayerSeasonStats Peewee model (WP-02).
|
||||
|
||||
Unit tests verify model structure and defaults on unsaved instances without
|
||||
touching a database. Integration tests use an in-memory SQLite database to
|
||||
verify table creation, unique constraints, indexes, and the delta-update
|
||||
(increment) pattern.
|
||||
|
||||
Note on column naming: the spec labels the pitching strikeout column as
|
||||
"so (K)". This model names it `k` to avoid collision with the batting
|
||||
strikeout column `so`.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from peewee import SqliteDatabase, IntegrityError
|
||||
|
||||
from app.models.season_stats import PlayerSeasonStats
|
||||
from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team, StratGame
|
||||
|
||||
# Dependency order matters for FK resolution.
|
||||
_TEST_MODELS = [
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
StratGame,
|
||||
PlayerSeasonStats,
|
||||
]
|
||||
|
||||
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_db():
|
||||
"""Bind all models to an in-memory SQLite database, create tables, and
|
||||
tear them down after each test so each test starts from a clean state."""
|
||||
_test_db.bind(_TEST_MODELS)
|
||||
_test_db.create_tables(_TEST_MODELS)
|
||||
yield _test_db
|
||||
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
||||
|
||||
|
||||
# ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_rarity():
|
||||
return Rarity.create(value=1, name="Common", color="#ffffff")
|
||||
|
||||
|
||||
def make_cardset():
|
||||
return Cardset.create(name="2025", description="2025 Season", total_cards=100)
|
||||
|
||||
|
||||
def make_player(cardset, rarity, player_id=1):
|
||||
return Player.create(
|
||||
player_id=player_id,
|
||||
p_name="Test Player",
|
||||
cost=100,
|
||||
image="test.png",
|
||||
mlbclub="BOS",
|
||||
franchise="Boston",
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
rarity=rarity,
|
||||
pos_1="OF",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
|
||||
def make_team(abbrev="TEST", gmid=123456789):
|
||||
return Team.create(
|
||||
abbrev=abbrev,
|
||||
sname=abbrev,
|
||||
lname=f"Team {abbrev}",
|
||||
gmid=gmid,
|
||||
gmname="testuser",
|
||||
gsheet="https://example.com",
|
||||
wallet=1000,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=1,
|
||||
)
|
||||
|
||||
|
||||
def make_game(home_team, away_team, season=10):
|
||||
return StratGame.create(
|
||||
season=season,
|
||||
game_type="ranked",
|
||||
away_team=away_team,
|
||||
home_team=home_team,
|
||||
)
|
||||
|
||||
|
||||
def make_stats(player, team, season=10, **kwargs):
|
||||
return PlayerSeasonStats.create(player=player, team=team, season=season, **kwargs)
|
||||
|
||||
|
||||
# ── Unit: column completeness ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestColumnCompleteness:
|
||||
"""All required columns are present in the model's field definitions."""
|
||||
|
||||
BATTING_COLS = [
|
||||
"games_batting",
|
||||
"pa",
|
||||
"ab",
|
||||
"hits",
|
||||
"hr",
|
||||
"doubles",
|
||||
"triples",
|
||||
"bb",
|
||||
"hbp",
|
||||
"so",
|
||||
"rbi",
|
||||
"runs",
|
||||
"sb",
|
||||
"cs",
|
||||
]
|
||||
PITCHING_COLS = [
|
||||
"games_pitching",
|
||||
"outs",
|
||||
"k",
|
||||
"bb_allowed",
|
||||
"hits_allowed",
|
||||
"hr_allowed",
|
||||
"wins",
|
||||
"losses",
|
||||
"saves",
|
||||
"holds",
|
||||
"blown_saves",
|
||||
]
|
||||
META_COLS = ["last_game", "last_updated_at"]
|
||||
KEY_COLS = ["player", "team", "season"]
|
||||
|
||||
def test_batting_columns_present(self):
|
||||
"""All batting aggregate columns defined in the spec are present."""
|
||||
fields = PlayerSeasonStats._meta.fields
|
||||
for col in self.BATTING_COLS:
|
||||
assert col in fields, f"Missing batting column: {col}"
|
||||
|
||||
def test_pitching_columns_present(self):
|
||||
"""All pitching aggregate columns defined in the spec are present."""
|
||||
fields = PlayerSeasonStats._meta.fields
|
||||
for col in self.PITCHING_COLS:
|
||||
assert col in fields, f"Missing pitching column: {col}"
|
||||
|
||||
def test_meta_columns_present(self):
|
||||
"""Meta columns last_game and last_updated_at are present."""
|
||||
fields = PlayerSeasonStats._meta.fields
|
||||
for col in self.META_COLS:
|
||||
assert col in fields, f"Missing meta column: {col}"
|
||||
|
||||
def test_key_columns_present(self):
|
||||
"""player, team, and season columns are present."""
|
||||
fields = PlayerSeasonStats._meta.fields
|
||||
for col in self.KEY_COLS:
|
||||
assert col in fields, f"Missing key column: {col}"
|
||||
|
||||
def test_excluded_columns_absent(self):
|
||||
"""team_wins and quality_starts are NOT in the model (removed from scope)."""
|
||||
fields = PlayerSeasonStats._meta.fields
|
||||
assert "team_wins" not in fields
|
||||
assert "quality_starts" not in fields
|
||||
|
||||
|
||||
# ── Unit: default values ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDefaultValues:
|
||||
"""All integer stat columns default to 0; nullable meta fields default to None."""
|
||||
|
||||
INT_STAT_COLS = [
|
||||
"games_batting",
|
||||
"pa",
|
||||
"ab",
|
||||
"hits",
|
||||
"hr",
|
||||
"doubles",
|
||||
"triples",
|
||||
"bb",
|
||||
"hbp",
|
||||
"so",
|
||||
"rbi",
|
||||
"runs",
|
||||
"sb",
|
||||
"cs",
|
||||
"games_pitching",
|
||||
"outs",
|
||||
"k",
|
||||
"bb_allowed",
|
||||
"hits_allowed",
|
||||
"hr_allowed",
|
||||
"wins",
|
||||
"losses",
|
||||
"saves",
|
||||
"holds",
|
||||
"blown_saves",
|
||||
]
|
||||
|
||||
def test_all_int_columns_default_to_zero(self):
|
||||
"""Every integer stat column defaults to 0 on an unsaved instance."""
|
||||
row = PlayerSeasonStats()
|
||||
for col in self.INT_STAT_COLS:
|
||||
val = getattr(row, col)
|
||||
assert val == 0, f"Column {col!r} default is {val!r}, expected 0"
|
||||
|
||||
def test_last_game_defaults_to_none(self):
|
||||
"""last_game FK is nullable and defaults to None."""
|
||||
row = PlayerSeasonStats()
|
||||
assert row.last_game_id is None
|
||||
|
||||
def test_last_updated_at_defaults_to_none(self):
|
||||
"""last_updated_at defaults to None."""
|
||||
row = PlayerSeasonStats()
|
||||
assert row.last_updated_at is None
|
||||
|
||||
|
||||
# ── Integration: unique constraint ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUniqueConstraint:
|
||||
"""UNIQUE on (player_id, team_id, season) is enforced at the DB level."""
|
||||
|
||||
def test_duplicate_player_team_season_raises(self):
|
||||
"""Inserting a second row for the same (player, team, season) raises IntegrityError."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_stats(player, team, season=10)
|
||||
with pytest.raises(IntegrityError):
|
||||
make_stats(player, team, season=10)
|
||||
|
||||
def test_same_player_different_season_allowed(self):
|
||||
"""Same (player, team) in a different season creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_stats(player, team, season=10)
|
||||
row2 = make_stats(player, team, season=11)
|
||||
assert row2.id is not None
|
||||
|
||||
def test_same_player_different_team_allowed(self):
|
||||
"""Same (player, season) on a different team creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team1 = make_team("TM1", gmid=111)
|
||||
team2 = make_team("TM2", gmid=222)
|
||||
make_stats(player, team1, season=10)
|
||||
row2 = make_stats(player, team2, season=10)
|
||||
assert row2.id is not None
|
||||
|
||||
|
||||
# ── Integration: delta update pattern ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeltaUpdatePattern:
|
||||
"""Stats can be incremented (delta update) without replacing existing values."""
|
||||
|
||||
def test_increment_batting_stats(self):
|
||||
"""Updating pa and hits increments without touching pitching columns."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_stats(player, team, season=10, pa=5, hits=2)
|
||||
|
||||
PlayerSeasonStats.update(
|
||||
pa=PlayerSeasonStats.pa + 3,
|
||||
hits=PlayerSeasonStats.hits + 1,
|
||||
).where(
|
||||
(PlayerSeasonStats.player == player)
|
||||
& (PlayerSeasonStats.team == team)
|
||||
& (PlayerSeasonStats.season == 10)
|
||||
).execute()
|
||||
|
||||
updated = PlayerSeasonStats.get_by_id(row.id)
|
||||
assert updated.pa == 8
|
||||
assert updated.hits == 3
|
||||
assert updated.games_pitching == 0 # untouched
|
||||
|
||||
def test_increment_pitching_stats(self):
|
||||
"""Updating outs and k increments without touching batting columns."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_stats(player, team, season=10, outs=9, k=3)
|
||||
|
||||
PlayerSeasonStats.update(
|
||||
outs=PlayerSeasonStats.outs + 6,
|
||||
k=PlayerSeasonStats.k + 2,
|
||||
).where(
|
||||
(PlayerSeasonStats.player == player)
|
||||
& (PlayerSeasonStats.team == team)
|
||||
|
cal
commented
This test only checks that This test only checks that `last_game_id is None` on a fresh row — it does not test a delta update. It belongs in `TestDefaultValues` alongside `test_last_game_defaults_to_none` and `test_last_updated_at_defaults_to_none`.
|
||||
& (PlayerSeasonStats.season == 10)
|
||||
).execute()
|
||||
|
||||
updated = PlayerSeasonStats.get_by_id(row.id)
|
||||
assert updated.outs == 15
|
||||
assert updated.k == 5
|
||||
assert updated.pa == 0 # untouched
|
||||
|
||||
def test_last_game_fk_is_nullable(self):
|
||||
"""last_game FK can be set to a StratGame instance or left NULL."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_stats(player, team, season=10)
|
||||
assert row.last_game_id is None
|
||||
|
||||
game = make_game(home_team=team, away_team=team)
|
||||
PlayerSeasonStats.update(last_game=game).where(
|
||||
PlayerSeasonStats.id == row.id
|
||||
).execute()
|
||||
|
||||
updated = PlayerSeasonStats.get_by_id(row.id)
|
||||
assert updated.last_game_id == game.id
|
||||
|
||||
|
||||
# ── Integration: index existence ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIndexExistence:
|
||||
"""Required indexes on (team_id, season) and (player_id, season) exist in SQLite."""
|
||||
|
||||
def _get_index_columns(self, db, table):
|
||||
"""Return a set of frozensets, each being the column set of one index."""
|
||||
indexes = db.execute_sql(f"PRAGMA index_list({table})").fetchall()
|
||||
result = set()
|
||||
for idx in indexes:
|
||||
idx_name = idx[1]
|
||||
cols = db.execute_sql(f"PRAGMA index_info({idx_name})").fetchall()
|
||||
result.add(frozenset(col[2] for col in cols))
|
||||
return result
|
||||
|
||||
def test_unique_index_on_player_team_season(self, setup_test_db):
|
||||
"""A unique index covering (player_id, team_id, season) exists."""
|
||||
index_sets = self._get_index_columns(setup_test_db, "player_season_stats")
|
||||
assert frozenset({"player_id", "team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_team_season(self, setup_test_db):
|
||||
"""An index covering (team_id, season) exists."""
|
||||
index_sets = self._get_index_columns(setup_test_db, "player_season_stats")
|
||||
assert frozenset({"team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_player_season(self, setup_test_db):
|
||||
"""An index covering (player_id, season) exists."""
|
||||
index_sets = self._get_index_columns(setup_test_db, "player_season_stats")
|
||||
assert frozenset({"player_id", "season"}) in index_sets
|
||||
Loading…
Reference in New Issue
Block a user
The
kfield multi-line format is inconsistent with every otherIntegerField(default=0)in this model. The comment can fit on one line: