From 4bfd878486aae88963e15388bd86266d59a5f336 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 16:35:02 -0500 Subject: [PATCH 1/2] feat: add PlayerSeasonStats Peewee model (#67) Closes #67 Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 132 +++++++++++- app/models/__init__.py | 0 app/models/evolution.py | 12 ++ app/models/season_stats.py | 7 + tests/__init__.py | 0 tests/conftest.py | 14 ++ tests/test_evolution_models.py | 338 +++++++++++++++++++++++++++++ tests/test_season_stats_model.py | 355 +++++++++++++++++++++++++++++++ 8 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 app/models/__init__.py create mode 100644 app/models/evolution.py create mode 100644 app/models/season_stats.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_evolution_models.py create mode 100644 tests/test_season_stats_model.py diff --git a/app/db_engine.py b/app/db_engine.py index 30e7d7c..0ec6bec 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -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: - db.create_tables([StratGame, StratPlay, Decision], safe=True) + db.create_tables([StratGame, StratPlay, Decision, PlayerSeasonStats], safe=True) class ScoutOpportunity(BaseModel): @@ -1089,6 +1156,69 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) +class EvolutionTrack(BaseModel): + name = CharField() + card_type = CharField() # batter / sp / rp + formula = CharField() + t1_threshold = IntegerField() + t2_threshold = IntegerField() + t3_threshold = IntegerField() + t4_threshold = IntegerField() + + class Meta: + database = db + table_name = "evolution_track" + + +class EvolutionCardState(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + track = ForeignKeyField(EvolutionTrack) + current_tier = IntegerField(default=0) # valid range: 0–4 + current_value = FloatField(default=0.0) + fully_evolved = BooleanField(default=False) + last_evaluated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "evolution_card_state" + + +ecs_index = ModelIndex( + EvolutionCardState, + (EvolutionCardState.player, EvolutionCardState.team), + unique=True, +) +EvolutionCardState.add_index(ecs_index) + + +class EvolutionTierBoost(BaseModel): + """Phase 2 stub — minimal model, schema to be defined in phase 2.""" + + card_state = ForeignKeyField(EvolutionCardState) + + class Meta: + database = db + table_name = "evolution_tier_boost" + + +class EvolutionCosmetic(BaseModel): + """Phase 2 stub — minimal model, schema to be defined in phase 2.""" + + card_state = ForeignKeyField(EvolutionCardState) + + class Meta: + database = db + table_name = "evolution_cosmetic" + + +if not SKIP_TABLE_CREATION: + db.create_tables( + [EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic], + safe=True, + ) + + db.close() # scout_db = SqliteDatabase( diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/evolution.py b/app/models/evolution.py new file mode 100644 index 0000000..7763885 --- /dev/null +++ b/app/models/evolution.py @@ -0,0 +1,12 @@ +"""Evolution ORM models. + +Models are defined in db_engine alongside all other Peewee models; this +module re-exports them so callers can import from `app.models.evolution`. +""" + +from ..db_engine import ( # noqa: F401 + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +) diff --git a/app/models/season_stats.py b/app/models/season_stats.py new file mode 100644 index 0000000..bdd7ad1 --- /dev/null +++ b/app/models/season_stats.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8d61378 --- /dev/null +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py new file mode 100644 index 0000000..1a6a4c9 --- /dev/null +++ b/tests/test_evolution_models.py @@ -0,0 +1,338 @@ +"""Tests for evolution Peewee models (WP-01). + +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, FK relationships, and unique constraints. +""" + +import pytest +from peewee import SqliteDatabase, IntegrityError +from playhouse.shortcuts import model_to_dict + +from app.models.evolution import ( + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +) +from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team + +# All models that must exist in the test database (dependency order). +_TEST_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +] + +_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): + return Player.create( + player_id=1, + 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(): + return Team.create( + abbrev="TEST", + sname="Test", + lname="Test Team", + gmid=123456789, + gmname="testuser", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + + +def make_track(card_type="batter"): + return EvolutionTrack.create( + name="Batter", + card_type=card_type, + formula="pa+tb*2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + + +# ── Unit: model field validation ─────────────────────────────────────────── + + +class TestEvolutionTrackFields: + """model_to_dict works on unsaved EvolutionTrack instances and all fields + are accessible with the correct values.""" + + def test_model_to_dict_unsaved(self): + """All EvolutionTrack fields appear in model_to_dict on an unsaved instance.""" + track = EvolutionTrack( + name="Batter", + card_type="batter", + formula="pa+tb*2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter" + assert data["card_type"] == "batter" + assert data["formula"] == "pa+tb*2" + assert data["t1_threshold"] == 37 + assert data["t2_threshold"] == 149 + assert data["t3_threshold"] == 448 + assert data["t4_threshold"] == 896 + + def test_all_threshold_fields_present(self): + """EvolutionTrack exposes all four tier threshold columns.""" + fields = EvolutionTrack._meta.fields + for col in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"): + assert col in fields, f"Missing column: {col}" + + +class TestEvolutionCardStateFields: + """model_to_dict works on unsaved EvolutionCardState instances and + default values match the spec.""" + + def test_model_to_dict_defaults(self): + """Defaults: current_tier=0, current_value=0.0, fully_evolved=False, + last_evaluated_at=None.""" + state = EvolutionCardState() + data = model_to_dict(state, recurse=False) + assert data["current_tier"] == 0 + assert data["current_value"] == 0.0 + assert data["fully_evolved"] is False + assert data["last_evaluated_at"] is None + + def test_no_progress_since_field(self): + """EvolutionCardState must not have a progress_since field (removed from spec).""" + assert "progress_since" not in EvolutionCardState._meta.fields + + +class TestEvolutionStubFields: + """Phase 2 stub models are importable and respond to model_to_dict.""" + + def test_tier_boost_importable(self): + assert EvolutionTierBoost is not None + + def test_cosmetic_importable(self): + assert EvolutionCosmetic is not None + + def test_tier_boost_model_to_dict_unsaved(self): + """model_to_dict on an unsaved EvolutionTierBoost returns a dict.""" + data = model_to_dict(EvolutionTierBoost(), recurse=False) + assert isinstance(data, dict) + + def test_cosmetic_model_to_dict_unsaved(self): + """model_to_dict on an unsaved EvolutionCosmetic returns a dict.""" + data = model_to_dict(EvolutionCosmetic(), recurse=False) + assert isinstance(data, dict) + + +# ── Unit: constraint definitions ────────────────────────────────────────── + + +class TestTierConstraints: + """current_tier defaults to 0 and valid tier values (0-4) can be saved.""" + + def test_tier_zero_is_default(self): + """EvolutionCardState.current_tier defaults to 0 on create.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + assert state.current_tier == 0 + + def test_tier_four_is_valid(self): + """Tier 4 (fully evolved cap) can be persisted without error.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create( + player=player, team=team, track=track, current_tier=4 + ) + assert state.current_tier == 4 + + +class TestUniqueConstraint: + """Unique index on (player_id, team_id) is enforced at the DB level.""" + + def test_duplicate_player_team_raises(self): + """A second EvolutionCardState for the same (player, team) raises IntegrityError, + even when a different track is used.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track1 = make_track("batter") + track2 = EvolutionTrack.create( + name="SP", + card_type="sp", + formula="ip+k", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + EvolutionCardState.create(player=player, team=team, track=track1) + with pytest.raises(IntegrityError): + EvolutionCardState.create(player=player, team=team, track=track2) + + def test_same_player_different_teams_allowed(self): + """One EvolutionCardState per team is allowed for the same player.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team1 = make_team() + team2 = Team.create( + abbrev="TM2", + sname="T2", + lname="Team Two", + gmid=987654321, + gmname="user2", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + track = make_track() + EvolutionCardState.create(player=player, team=team1, track=track) + state2 = EvolutionCardState.create(player=player, team=team2, track=track) + assert state2.id is not None + + +# ── Integration: table creation ──────────────────────────────────────────── + + +class TestTableCreation: + """All four evolution tables are created in the test DB and are queryable.""" + + def test_evolution_track_table_exists(self): + assert EvolutionTrack.select().count() == 0 + + def test_evolution_card_state_table_exists(self): + assert EvolutionCardState.select().count() == 0 + + def test_evolution_tier_boost_table_exists(self): + assert EvolutionTierBoost.select().count() == 0 + + def test_evolution_cosmetic_table_exists(self): + assert EvolutionCosmetic.select().count() == 0 + + +# ── Integration: FK enforcement ──────────────────────────────────────────── + + +class TestFKEnforcement: + """FK columns resolve to the correct related instances.""" + + def test_card_state_player_fk_resolves(self): + """EvolutionCardState.player_id matches the Player we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.player_id == player.player_id + + def test_card_state_team_fk_resolves(self): + """EvolutionCardState.team_id matches the Team we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.team_id == team.id + + def test_card_state_track_fk_resolves(self): + """EvolutionCardState.track_id matches the EvolutionTrack we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.track_id == track.id + + +# ── Integration: model_to_dict on saved instances ────────────────────────── + + +class TestModelToDictOnSaved: + """model_to_dict() works correctly on saved instances of all four models.""" + + def test_evolution_track_saved(self): + """Saved EvolutionTrack round-trips through model_to_dict correctly.""" + track = make_track() + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter" + assert data["card_type"] == "batter" + assert data["formula"] == "pa+tb*2" + assert data["t1_threshold"] == 37 + + def test_evolution_card_state_saved(self): + """Saved EvolutionCardState round-trips through model_to_dict correctly.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create( + player=player, team=team, track=track, current_value=42.5, current_tier=2 + ) + data = model_to_dict(state, recurse=False) + assert data["current_value"] == 42.5 + assert data["current_tier"] == 2 + assert data["fully_evolved"] is False diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py new file mode 100644 index 0000000..20fc3b8 --- /dev/null +++ b/tests/test_season_stats_model.py @@ -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) + & (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 -- 2.25.1 From 8dfc5ef3716cf3cbb86557f4c00402df81ada70c Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 17:02:00 -0500 Subject: [PATCH 2/2] fix: remove evolution models from WP-02 PR (#82) Evolution models (EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic), their re-export module, and tests were included in this PR without disclosure. Removed to keep this PR scoped to PlayerSeasonStats (WP-02) only per review feedback. Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 63 ------ app/models/evolution.py | 12 -- tests/test_evolution_models.py | 338 --------------------------------- 3 files changed, 413 deletions(-) delete mode 100644 app/models/evolution.py delete mode 100644 tests/test_evolution_models.py diff --git a/app/db_engine.py b/app/db_engine.py index 0ec6bec..bb9c9f0 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1156,69 +1156,6 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) -class EvolutionTrack(BaseModel): - name = CharField() - card_type = CharField() # batter / sp / rp - formula = CharField() - t1_threshold = IntegerField() - t2_threshold = IntegerField() - t3_threshold = IntegerField() - t4_threshold = IntegerField() - - class Meta: - database = db - table_name = "evolution_track" - - -class EvolutionCardState(BaseModel): - player = ForeignKeyField(Player) - team = ForeignKeyField(Team) - track = ForeignKeyField(EvolutionTrack) - current_tier = IntegerField(default=0) # valid range: 0–4 - current_value = FloatField(default=0.0) - fully_evolved = BooleanField(default=False) - last_evaluated_at = DateTimeField(null=True) - - class Meta: - database = db - table_name = "evolution_card_state" - - -ecs_index = ModelIndex( - EvolutionCardState, - (EvolutionCardState.player, EvolutionCardState.team), - unique=True, -) -EvolutionCardState.add_index(ecs_index) - - -class EvolutionTierBoost(BaseModel): - """Phase 2 stub — minimal model, schema to be defined in phase 2.""" - - card_state = ForeignKeyField(EvolutionCardState) - - class Meta: - database = db - table_name = "evolution_tier_boost" - - -class EvolutionCosmetic(BaseModel): - """Phase 2 stub — minimal model, schema to be defined in phase 2.""" - - card_state = ForeignKeyField(EvolutionCardState) - - class Meta: - database = db - table_name = "evolution_cosmetic" - - -if not SKIP_TABLE_CREATION: - db.create_tables( - [EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic], - safe=True, - ) - - db.close() # scout_db = SqliteDatabase( diff --git a/app/models/evolution.py b/app/models/evolution.py deleted file mode 100644 index 7763885..0000000 --- a/app/models/evolution.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Evolution ORM models. - -Models are defined in db_engine alongside all other Peewee models; this -module re-exports them so callers can import from `app.models.evolution`. -""" - -from ..db_engine import ( # noqa: F401 - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, -) diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py deleted file mode 100644 index 1a6a4c9..0000000 --- a/tests/test_evolution_models.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Tests for evolution Peewee models (WP-01). - -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, FK relationships, and unique constraints. -""" - -import pytest -from peewee import SqliteDatabase, IntegrityError -from playhouse.shortcuts import model_to_dict - -from app.models.evolution import ( - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, -) -from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team - -# All models that must exist in the test database (dependency order). -_TEST_MODELS = [ - Rarity, - Event, - Cardset, - MlbPlayer, - Player, - Team, - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, -] - -_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): - return Player.create( - player_id=1, - 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(): - return Team.create( - abbrev="TEST", - sname="Test", - lname="Test Team", - gmid=123456789, - gmname="testuser", - gsheet="https://example.com", - wallet=1000, - team_value=1000, - collection_value=1000, - season=1, - ) - - -def make_track(card_type="batter"): - return EvolutionTrack.create( - name="Batter", - card_type=card_type, - formula="pa+tb*2", - t1_threshold=37, - t2_threshold=149, - t3_threshold=448, - t4_threshold=896, - ) - - -# ── Unit: model field validation ─────────────────────────────────────────── - - -class TestEvolutionTrackFields: - """model_to_dict works on unsaved EvolutionTrack instances and all fields - are accessible with the correct values.""" - - def test_model_to_dict_unsaved(self): - """All EvolutionTrack fields appear in model_to_dict on an unsaved instance.""" - track = EvolutionTrack( - name="Batter", - card_type="batter", - formula="pa+tb*2", - t1_threshold=37, - t2_threshold=149, - t3_threshold=448, - t4_threshold=896, - ) - data = model_to_dict(track, recurse=False) - assert data["name"] == "Batter" - assert data["card_type"] == "batter" - assert data["formula"] == "pa+tb*2" - assert data["t1_threshold"] == 37 - assert data["t2_threshold"] == 149 - assert data["t3_threshold"] == 448 - assert data["t4_threshold"] == 896 - - def test_all_threshold_fields_present(self): - """EvolutionTrack exposes all four tier threshold columns.""" - fields = EvolutionTrack._meta.fields - for col in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"): - assert col in fields, f"Missing column: {col}" - - -class TestEvolutionCardStateFields: - """model_to_dict works on unsaved EvolutionCardState instances and - default values match the spec.""" - - def test_model_to_dict_defaults(self): - """Defaults: current_tier=0, current_value=0.0, fully_evolved=False, - last_evaluated_at=None.""" - state = EvolutionCardState() - data = model_to_dict(state, recurse=False) - assert data["current_tier"] == 0 - assert data["current_value"] == 0.0 - assert data["fully_evolved"] is False - assert data["last_evaluated_at"] is None - - def test_no_progress_since_field(self): - """EvolutionCardState must not have a progress_since field (removed from spec).""" - assert "progress_since" not in EvolutionCardState._meta.fields - - -class TestEvolutionStubFields: - """Phase 2 stub models are importable and respond to model_to_dict.""" - - def test_tier_boost_importable(self): - assert EvolutionTierBoost is not None - - def test_cosmetic_importable(self): - assert EvolutionCosmetic is not None - - def test_tier_boost_model_to_dict_unsaved(self): - """model_to_dict on an unsaved EvolutionTierBoost returns a dict.""" - data = model_to_dict(EvolutionTierBoost(), recurse=False) - assert isinstance(data, dict) - - def test_cosmetic_model_to_dict_unsaved(self): - """model_to_dict on an unsaved EvolutionCosmetic returns a dict.""" - data = model_to_dict(EvolutionCosmetic(), recurse=False) - assert isinstance(data, dict) - - -# ── Unit: constraint definitions ────────────────────────────────────────── - - -class TestTierConstraints: - """current_tier defaults to 0 and valid tier values (0-4) can be saved.""" - - def test_tier_zero_is_default(self): - """EvolutionCardState.current_tier defaults to 0 on create.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - assert state.current_tier == 0 - - def test_tier_four_is_valid(self): - """Tier 4 (fully evolved cap) can be persisted without error.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create( - player=player, team=team, track=track, current_tier=4 - ) - assert state.current_tier == 4 - - -class TestUniqueConstraint: - """Unique index on (player_id, team_id) is enforced at the DB level.""" - - def test_duplicate_player_team_raises(self): - """A second EvolutionCardState for the same (player, team) raises IntegrityError, - even when a different track is used.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track1 = make_track("batter") - track2 = EvolutionTrack.create( - name="SP", - card_type="sp", - formula="ip+k", - t1_threshold=10, - t2_threshold=40, - t3_threshold=120, - t4_threshold=240, - ) - EvolutionCardState.create(player=player, team=team, track=track1) - with pytest.raises(IntegrityError): - EvolutionCardState.create(player=player, team=team, track=track2) - - def test_same_player_different_teams_allowed(self): - """One EvolutionCardState per team is allowed for the same player.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team1 = make_team() - team2 = Team.create( - abbrev="TM2", - sname="T2", - lname="Team Two", - gmid=987654321, - gmname="user2", - gsheet="https://example.com", - wallet=1000, - team_value=1000, - collection_value=1000, - season=1, - ) - track = make_track() - EvolutionCardState.create(player=player, team=team1, track=track) - state2 = EvolutionCardState.create(player=player, team=team2, track=track) - assert state2.id is not None - - -# ── Integration: table creation ──────────────────────────────────────────── - - -class TestTableCreation: - """All four evolution tables are created in the test DB and are queryable.""" - - def test_evolution_track_table_exists(self): - assert EvolutionTrack.select().count() == 0 - - def test_evolution_card_state_table_exists(self): - assert EvolutionCardState.select().count() == 0 - - def test_evolution_tier_boost_table_exists(self): - assert EvolutionTierBoost.select().count() == 0 - - def test_evolution_cosmetic_table_exists(self): - assert EvolutionCosmetic.select().count() == 0 - - -# ── Integration: FK enforcement ──────────────────────────────────────────── - - -class TestFKEnforcement: - """FK columns resolve to the correct related instances.""" - - def test_card_state_player_fk_resolves(self): - """EvolutionCardState.player_id matches the Player we inserted.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) - assert fetched.player_id == player.player_id - - def test_card_state_team_fk_resolves(self): - """EvolutionCardState.team_id matches the Team we inserted.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) - assert fetched.team_id == team.id - - def test_card_state_track_fk_resolves(self): - """EvolutionCardState.track_id matches the EvolutionTrack we inserted.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) - assert fetched.track_id == track.id - - -# ── Integration: model_to_dict on saved instances ────────────────────────── - - -class TestModelToDictOnSaved: - """model_to_dict() works correctly on saved instances of all four models.""" - - def test_evolution_track_saved(self): - """Saved EvolutionTrack round-trips through model_to_dict correctly.""" - track = make_track() - data = model_to_dict(track, recurse=False) - assert data["name"] == "Batter" - assert data["card_type"] == "batter" - assert data["formula"] == "pa+tb*2" - assert data["t1_threshold"] == 37 - - def test_evolution_card_state_saved(self): - """Saved EvolutionCardState round-trips through model_to_dict correctly.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create( - player=player, team=team, track=track, current_value=42.5, current_tier=2 - ) - data = model_to_dict(state, recurse=False) - assert data["current_value"] == 42.5 - assert data["current_tier"] == 2 - assert data["fully_evolved"] is False -- 2.25.1