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..9c49aae --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,168 @@ +""" +Shared test fixtures for the Paper Dynasty database test suite. + +Uses in-memory SQLite with foreign_keys pragma enabled. Each test +gets a fresh set of tables via the setup_test_db fixture (autouse). + +All models are bound to the in-memory database before table creation +so that no connection to the real storage/pd_master.db occurs during +tests. +""" + +import os +import pytest +from peewee import SqliteDatabase + +# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION +# flag is True. This prevents db_engine.py from calling create_tables() +# against the real storage/pd_master.db during import — those calls would +# fail if indexes already exist and would also contaminate the dev database. +# The PooledPostgresqlDatabase object is created but never actually connects +# because our fixture rebinds all models to an in-memory SQLite db before +# any query is executed. +os.environ["DATABASE_TYPE"] = "postgresql" + +from app.db_engine import ( + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + PlayerSeasonStats, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, + ScoutOpportunity, + ScoutClaim, +) + +_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) + +# All models in dependency order (parents before children) so that +# create_tables and drop_tables work without FK violations. +_TEST_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + ScoutOpportunity, + ScoutClaim, + PlayerSeasonStats, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +] + + +@pytest.fixture(autouse=True) +def setup_test_db(): + """Bind all models to in-memory SQLite and create tables. + + The fixture is autouse so every test automatically gets a fresh, + isolated database schema without needing to request it explicitly. + Tables are dropped in reverse dependency order after each test to + keep the teardown clean and to catch any accidental FK reference + direction bugs early. + """ + _test_db.bind(_TEST_MODELS) + _test_db.connect() + _test_db.create_tables(_TEST_MODELS) + yield _test_db + _test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True) + _test_db.close() + + +# --------------------------------------------------------------------------- +# Minimal shared fixtures — create just enough data for FK dependencies +# --------------------------------------------------------------------------- + + +@pytest.fixture +def rarity(): + """A single Common rarity row used as FK seed for Player rows.""" + return Rarity.create(value=1, name="Common", color="#ffffff") + + +@pytest.fixture +def player(rarity): + """A minimal Player row with all required (non-nullable) columns filled. + + Player.p_name is the real column name (not 'name'). All FK and + non-nullable varchar fields are provided so SQLite's NOT NULL + constraints are satisfied even with foreign_keys=ON. + """ + cardset = Cardset.create( + name="Test Set", + description="Test cardset", + total_cards=100, + ) + return Player.create( + p_name="Test Player", + rarity=rarity, + cardset=cardset, + set_num=1, + pos_1="1B", + image="https://example.com/image.png", + mlbclub="TST", + franchise="TST", + description="A test player", + ) + + +@pytest.fixture +def team(): + """A minimal Team row. + + Team uses abbrev/lname/sname/gmid/gmname/gsheet/wallet/team_value/ + collection_value — not the 'name'/'user_id' shorthand described in + the spec, which referred to the real underlying columns by + simplified names. + """ + return Team.create( + abbrev="TST", + sname="Test", + lname="Test Team", + gmid=100000001, + gmname="testuser", + gsheet="https://docs.google.com/spreadsheets/test", + wallet=500, + team_value=1000, + collection_value=1000, + season=11, + is_ai=False, + ) + + +@pytest.fixture +def track(): + """A minimal EvolutionTrack for batter cards.""" + return EvolutionTrack.create( + name="Batter Track", + card_type="batter", + formula="pa + tb * 2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py new file mode 100644 index 0000000..62f5108 --- /dev/null +++ b/tests/test_evolution_models.py @@ -0,0 +1,332 @@ +""" +Tests for evolution-related models and PlayerSeasonStats. + +Covers WP-01 acceptance criteria: + - EvolutionTrack: CRUD and unique-name constraint + - EvolutionCardState: CRUD, defaults, unique-(player,team) constraint, + and FK resolution back to EvolutionTrack + - EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target) + - EvolutionCosmetic: CRUD and unique-name constraint + - PlayerSeasonStats: CRUD with defaults, unique-(player, team, season), + and in-place stat accumulation + +Each test class is self-contained: fixtures from conftest.py supply the +minimal parent rows needed to satisfy FK constraints, and every assertion +targets a single, clearly-named behaviour so failures are easy to trace. +""" + +import pytest +from peewee import IntegrityError +from playhouse.shortcuts import model_to_dict + +from app.db_engine import ( + EvolutionCardState, + EvolutionCosmetic, + EvolutionTierBoost, + EvolutionTrack, + PlayerSeasonStats, +) + +# --------------------------------------------------------------------------- +# EvolutionTrack +# --------------------------------------------------------------------------- + + +class TestEvolutionTrack: + """Tests for the EvolutionTrack model. + + EvolutionTrack defines a named progression path (formula + + tier thresholds) for a card type. The name column carries a + UNIQUE constraint so that accidental duplicates are caught at + the database level. + """ + + def test_create_track(self, track): + """Creating a track persists all fields and they round-trip correctly. + + Reads back via model_to_dict (recurse=False) to verify the raw + column values, not Python-object representations, match what was + inserted. + """ + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter Track" + 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_track_unique_name(self, track): + """Inserting a second track with the same name raises IntegrityError. + + The UNIQUE constraint on EvolutionTrack.name must prevent two + tracks from sharing the same identifier, as the name is used as + a human-readable key throughout the evolution system. + """ + with pytest.raises(IntegrityError): + EvolutionTrack.create( + name="Batter Track", # duplicate + card_type="sp", + formula="outs * 3", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + + +# --------------------------------------------------------------------------- +# EvolutionCardState +# --------------------------------------------------------------------------- + + +class TestEvolutionCardState: + """Tests for EvolutionCardState, which tracks per-player evolution progress. + + Each row represents one card (player) owned by one team, linked to a + specific EvolutionTrack. The model records the current tier (0-4), + accumulated progress value, and whether the card is fully evolved. + """ + + def test_create_card_state(self, player, team, track): + """Creating a card state stores all fields and defaults are correct. + + Defaults under test: + current_tier → 0 (fresh card, no tier unlocked yet) + current_value → 0.0 (no formula progress accumulated) + fully_evolved → False (evolution is not complete at creation) + last_evaluated_at → None (never evaluated yet) + """ + state = EvolutionCardState.create(player=player, team=team, track=track) + + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.player_id == player.player_id + assert fetched.team_id == team.id + assert fetched.track_id == track.id + assert fetched.current_tier == 0 + assert fetched.current_value == 0.0 + assert fetched.fully_evolved is False + assert fetched.last_evaluated_at is None + + def test_card_state_unique_player_team(self, player, team, track): + """A second card state for the same (player, team) pair raises IntegrityError. + + The unique index on (player, team) enforces that each player card + has at most one evolution state per team roster slot, preventing + duplicate evolution progress rows for the same physical card. + """ + EvolutionCardState.create(player=player, team=team, track=track) + with pytest.raises(IntegrityError): + EvolutionCardState.create(player=player, team=team, track=track) + + def test_card_state_fk_track(self, player, team, track): + """Accessing card_state.track returns the original EvolutionTrack instance. + + This confirms the FK is correctly wired and that Peewee resolves + the relationship, returning an object with the same primary key and + name as the track used during creation. + """ + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + resolved_track = fetched.track + assert resolved_track.id == track.id + assert resolved_track.name == "Batter Track" + + +# --------------------------------------------------------------------------- +# EvolutionTierBoost +# --------------------------------------------------------------------------- + + +class TestEvolutionTierBoost: + """Tests for EvolutionTierBoost, the per-tier stat/rating bonus table. + + Each row maps a (track, tier) combination to a single boost — the + specific stat or rating column to buff and by how much. The four- + column unique constraint prevents double-booking the same boost slot. + """ + + def test_create_tier_boost(self, track): + """Creating a boost row persists all fields accurately. + + Verifies boost_type, boost_target, and boost_value are stored + and retrieved without modification. + """ + boost = EvolutionTierBoost.create( + track=track, + tier=1, + boost_type="rating", + boost_target="contact_vl", + boost_value=1.5, + ) + fetched = EvolutionTierBoost.get_by_id(boost.id) + assert fetched.track_id == track.id + assert fetched.tier == 1 + assert fetched.boost_type == "rating" + assert fetched.boost_target == "contact_vl" + assert fetched.boost_value == 1.5 + + def test_tier_boost_unique_constraint(self, track): + """Duplicate (track, tier, boost_type, boost_target) raises IntegrityError. + + The four-column unique index ensures that a single boost slot + (e.g. Tier-1 contact_vl rating) cannot be defined twice for the + same track, which would create ambiguity during evolution evaluation. + """ + EvolutionTierBoost.create( + track=track, + tier=2, + boost_type="rating", + boost_target="power_vr", + boost_value=2.0, + ) + with pytest.raises(IntegrityError): + EvolutionTierBoost.create( + track=track, + tier=2, + boost_type="rating", + boost_target="power_vr", + boost_value=3.0, # different value, same identity columns + ) + + +# --------------------------------------------------------------------------- +# EvolutionCosmetic +# --------------------------------------------------------------------------- + + +class TestEvolutionCosmetic: + """Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers. + + Cosmetics are purely visual rewards (frames, badges, themes) that a + card unlocks when it reaches a required tier. The name column is + the stable identifier and carries a UNIQUE constraint. + """ + + def test_create_cosmetic(self): + """Creating a cosmetic persists all fields correctly. + + Verifies all columns including optional ones (css_class, asset_url) + are stored and retrieved. + """ + cosmetic = EvolutionCosmetic.create( + name="Gold Frame", + tier_required=2, + cosmetic_type="frame", + css_class="evo-frame-gold", + asset_url="https://cdn.example.com/frames/gold.png", + ) + fetched = EvolutionCosmetic.get_by_id(cosmetic.id) + assert fetched.name == "Gold Frame" + assert fetched.tier_required == 2 + assert fetched.cosmetic_type == "frame" + assert fetched.css_class == "evo-frame-gold" + assert fetched.asset_url == "https://cdn.example.com/frames/gold.png" + + def test_cosmetic_unique_name(self): + """Inserting a second cosmetic with the same name raises IntegrityError. + + The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate + cosmetic definitions that could cause ambiguous tier unlock lookups. + """ + EvolutionCosmetic.create( + name="Silver Badge", + tier_required=1, + cosmetic_type="badge", + ) + with pytest.raises(IntegrityError): + EvolutionCosmetic.create( + name="Silver Badge", # duplicate + tier_required=3, + cosmetic_type="badge", + ) + + +# --------------------------------------------------------------------------- +# PlayerSeasonStats +# --------------------------------------------------------------------------- + + +class TestPlayerSeasonStats: + """Tests for PlayerSeasonStats, the per-season accumulation table. + + Each row aggregates game-by-game batting and pitching stats for one + player on one team in one season. The three-column unique constraint + prevents double-counting and ensures a single authoritative row for + each (player, team, season) combination. + """ + + def test_create_season_stats(self, player, team): + """Creating a stats row with explicit values stores everything correctly. + + Also verifies the integer stat defaults (all 0) for columns that + are not provided, which is the initial state before any games are + processed. + """ + stats = PlayerSeasonStats.create( + player=player, + team=team, + season=11, + games_batting=5, + pa=20, + ab=18, + hits=6, + doubles=1, + triples=0, + hr=2, + bb=2, + hbp=0, + so=4, + rbi=5, + runs=3, + sb=1, + cs=0, + ) + fetched = PlayerSeasonStats.get_by_id(stats.id) + assert fetched.player_id == player.player_id + assert fetched.team_id == team.id + assert fetched.season == 11 + assert fetched.games_batting == 5 + assert fetched.pa == 20 + assert fetched.hits == 6 + assert fetched.hr == 2 + # Pitching fields were not set — confirm default zero values + assert fetched.games_pitching == 0 + assert fetched.outs == 0 + assert fetched.wins == 0 + assert fetched.saves == 0 + # Nullable meta fields + assert fetched.last_game is None + assert fetched.last_updated_at is None + + def test_season_stats_unique_constraint(self, player, team): + """A second row for the same (player, team, season) raises IntegrityError. + + The unique index on these three columns guarantees that each + player-team-season combination has exactly one accumulation row, + preventing duplicate stat aggregation that would inflate totals. + """ + PlayerSeasonStats.create(player=player, team=team, season=11) + with pytest.raises(IntegrityError): + PlayerSeasonStats.create(player=player, team=team, season=11) + + def test_season_stats_increment(self, player, team): + """Manually incrementing hits on an existing row persists the change. + + Simulates the common pattern used by the stats accumulator: + fetch the row, add the game delta, save. Verifies that save() + writes back to the database and that subsequent reads reflect the + updated value. + """ + stats = PlayerSeasonStats.create( + player=player, + team=team, + season=11, + hits=10, + ) + stats.hits += 3 + stats.save() + + refreshed = PlayerSeasonStats.get_by_id(stats.id) + assert refreshed.hits == 13 diff --git a/tests/test_evolution_seed.py b/tests/test_evolution_seed.py new file mode 100644 index 0000000..a3d1842 --- /dev/null +++ b/tests/test_evolution_seed.py @@ -0,0 +1,159 @@ +""" +Tests for app/seed/evolution_tracks.py — seed_evolution_tracks(). + +What: Verify that the JSON-driven seed function correctly creates, counts, +and idempotently updates EvolutionTrack rows in the database. + +Why: The seed is the single source of truth for track configuration. A +regression here (duplicates, wrong thresholds, missing formula) would +silently corrupt evolution scoring for every card in the system. + +Each test operates on a fresh in-memory SQLite database provided by the +autouse `setup_test_db` fixture in conftest.py. The seed reads its data +from `app/seed/evolution_tracks.json` on disk, so the tests also serve as +a light integration check between the JSON file and the Peewee model. +""" + +import json +from pathlib import Path + +import pytest + +from app.db_engine import EvolutionTrack +from app.seed.evolution_tracks import seed_evolution_tracks + +# Path to the JSON fixture that the seed reads from at runtime +_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json" + + +@pytest.fixture +def json_tracks(): + """Load the raw JSON definitions so tests can assert against them. + + This avoids hardcoding expected values — if the JSON changes, tests + automatically follow without needing manual updates. + """ + return json.loads(_JSON_PATH.read_text(encoding="utf-8")) + + +def test_seed_creates_three_tracks(json_tracks): + """After one seed call, exactly 3 EvolutionTrack rows must exist. + + Why: The JSON currently defines three card-type tracks (batter, sp, rp). + If the count is wrong the system would either be missing tracks + (evolution disabled for a card type) or have phantom extras. + """ + seed_evolution_tracks() + assert EvolutionTrack.select().count() == 3 + + +def test_seed_correct_card_types(json_tracks): + """The set of card_type values persisted must match the JSON exactly. + + Why: card_type is used as a discriminator throughout the evolution engine. + An unexpected value (e.g. 'pitcher' instead of 'sp') would cause + track-lookup misses and silently skip evolution scoring for that role. + """ + seed_evolution_tracks() + expected_types = {d["card_type"] for d in json_tracks} + actual_types = {t.card_type for t in EvolutionTrack.select()} + assert actual_types == expected_types + + +def test_seed_thresholds_ascending(): + """For every track, t1 < t2 < t3 < t4. + + Why: The evolution engine uses these thresholds to determine tier + boundaries. If they are not strictly ascending, tier comparisons + would produce incorrect or undefined results (e.g. a player could + simultaneously satisfy tier 3 and not satisfy tier 2). + """ + seed_evolution_tracks() + for track in EvolutionTrack.select(): + assert ( + track.t1_threshold < track.t2_threshold + ), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})" + assert ( + track.t2_threshold < track.t3_threshold + ), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})" + assert ( + track.t3_threshold < track.t4_threshold + ), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})" + + +def test_seed_thresholds_positive(): + """All tier threshold values must be strictly greater than zero. + + Why: A zero or negative threshold would mean a card starts the game + already evolved (tier >= 1 at 0 accumulated stat points), which would + bypass the entire progression system. + """ + seed_evolution_tracks() + for track in EvolutionTrack.select(): + assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive" + assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive" + assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive" + assert track.t4_threshold > 0, f"{track.name}: t4_threshold is not positive" + + +def test_seed_formula_present(): + """Every persisted track must have a non-empty formula string. + + Why: The formula is evaluated at runtime to compute a player's evolution + score. An empty formula would cause either a Python eval error or + silently produce 0 for every player, halting all evolution progress. + """ + seed_evolution_tracks() + for track in EvolutionTrack.select(): + assert ( + track.formula and track.formula.strip() + ), f"{track.name}: formula is empty or whitespace-only" + + +def test_seed_idempotent(): + """Calling seed_evolution_tracks() twice must still yield exactly 3 rows. + + Why: The seed is designed to be safe to re-run (e.g. as part of a + migration or CI bootstrap). If it inserts duplicates on a second call, + the unique constraint on EvolutionTrack.name would raise an IntegrityError + in PostgreSQL, and in SQLite it would silently create phantom rows that + corrupt tier-lookup joins. + """ + seed_evolution_tracks() + seed_evolution_tracks() + assert EvolutionTrack.select().count() == 3 + + +def test_seed_updates_on_rerun(json_tracks): + """A second seed call must restore any manually changed threshold to the JSON value. + + What: Seed once, manually mutate a threshold in the DB, then seed again. + Assert that the threshold is now back to the JSON-defined value. + + Why: The seed must act as the authoritative source of truth. If + re-seeding does not overwrite local changes, configuration drift can + build up silently and the production database would diverge from the + checked-in JSON without any visible error. + """ + seed_evolution_tracks() + + # Pick the first track and corrupt its t1_threshold + first_def = json_tracks[0] + track = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + original_t1 = track.t1_threshold + corrupted_value = original_t1 + 9999 + track.t1_threshold = corrupted_value + track.save() + + # Confirm the corruption took effect before re-seeding + track_check = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + assert track_check.t1_threshold == corrupted_value + + # Re-seed — should restore the JSON value + seed_evolution_tracks() + + restored = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + assert restored.t1_threshold == first_def["t1_threshold"], ( + f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, " + f"got {restored.t1_threshold}" + ) diff --git a/tests/test_season_stats_update.py b/tests/test_season_stats_update.py new file mode 100644 index 0000000..94f40e5 --- /dev/null +++ b/tests/test_season_stats_update.py @@ -0,0 +1,597 @@ +""" +Tests for app/services/season_stats.py — update_season_stats(). + +What: Verify that the incremental stat accumulation function correctly +aggregates StratPlay and Decision rows into PlayerSeasonStats, handles +duplicate calls idempotently, and accumulates stats across multiple games. + +Why: This is the core bookkeeping engine for card evolution scoring. A +double-count bug, a missed Decision merge, or a team-isolation failure +would silently produce wrong stats that would then corrupt every +evolution tier calculation downstream. + +Test data is created using real Peewee models (no mocking) against the +in-memory SQLite database provided by the autouse setup_test_db fixture +in conftest.py. All Player and Team creation uses the actual required +column set discovered from the model definition in db_engine.py. +""" + +import app.services.season_stats as _season_stats_module +import pytest + +from app.db_engine import ( + Cardset, + Decision, + Player, + PlayerSeasonStats, + Rarity, + StratGame, + StratPlay, + Team, +) +from app.services.season_stats import update_season_stats +from tests.conftest import _test_db + +# --------------------------------------------------------------------------- +# Module-level patch: redirect season_stats.db to the test database +# --------------------------------------------------------------------------- +# season_stats.py holds a module-level reference to the `db` object imported +# from db_engine. When test models are rebound to _test_db via bind(), the +# `db` object inside season_stats still points at the original production db +# (SQLite file or PostgreSQL). We replace it here so that db.atomic() in +# update_season_stats() operates on the same in-memory connection that the +# test fixtures write to. +_season_stats_module.db = _test_db + + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + + +def _make_cardset(): + """Return a reusable Cardset row (or fetch the existing one by name).""" + cs, _ = Cardset.get_or_create( + name="Test Set", + defaults={"description": "Test cardset", "total_cards": 100}, + ) + return cs + + +def _make_rarity(): + """Return the Common rarity singleton.""" + r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"}) + return r + + +def _make_player(name: str, pos: str = "1B") -> Player: + """Create a Player row with all required (non-nullable) columns satisfied. + + Why we need this helper: Player has many non-nullable varchar columns + (image, mlbclub, franchise, description) and a required FK to Cardset. + A single helper keeps test fixtures concise and consistent. + """ + return Player.create( + p_name=name, + rarity=_make_rarity(), + cardset=_make_cardset(), + set_num=1, + pos_1=pos, + image="https://example.com/image.png", + mlbclub="TST", + franchise="TST", + description=f"Test player: {name}", + ) + + +def _make_team(abbrev: str, gmid: int, season: int = 11) -> Team: + """Create a Team row with all required (non-nullable) columns satisfied.""" + return Team.create( + abbrev=abbrev, + sname=abbrev, + lname=f"Team {abbrev}", + gmid=gmid, + gmname=f"gm_{abbrev.lower()}", + gsheet="https://docs.google.com/spreadsheets/test", + wallet=500, + team_value=1000, + collection_value=1000, + season=season, + is_ai=False, + ) + + +def make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats): + """Create a StratPlay row with sensible defaults for all required fields. + + Why we provide defaults for every stat column: StratPlay has many + IntegerField columns with default=0 at the model level, but supplying + them explicitly makes it clear what the baseline state of each play is + and keeps the helper signature stable if defaults change. + """ + defaults = dict( + on_base_code="000", + inning_half="top", + inning_num=1, + batting_order=1, + starting_outs=0, + away_score=0, + home_score=0, + pa=0, + ab=0, + hit=0, + run=0, + hr=0, + double=0, + triple=0, + homerun=0, + bb=0, + so=0, + hbp=0, + rbi=0, + sb=0, + cs=0, + outs=0, + sac=0, + ibb=0, + gidp=0, + bphr=0, + bpfo=0, + bp1b=0, + bplo=0, + ) + defaults.update(stats) + return StratPlay.create( + game=game, + play_num=play_num, + batter=batter, + batter_team=batter_team, + pitcher=pitcher, + pitcher_team=pitcher_team, + **defaults, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def rarity(): + return Rarity.create(value=1, name="Common", color="#ffffff") + + +@pytest.fixture +def team_a(): + return _make_team("TMA", gmid=1001) + + +@pytest.fixture +def team_b(): + return _make_team("TMB", gmid=1002) + + +@pytest.fixture +def player_batter(rarity): + """A batter-type player for team A.""" + return _make_player("Batter One", pos="CF") + + +@pytest.fixture +def player_pitcher(rarity): + """A pitcher-type player for team B.""" + return _make_player("Pitcher One", pos="SP") + + +@pytest.fixture +def game(team_a, team_b): + return StratGame.create( + season=11, + game_type="ranked", + away_team=team_a, + home_team=team_b, + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher, game): + """Batting stat totals from StratPlay rows are correctly accumulated. + + What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a + home run) for one batter. After update_season_stats(), the + PlayerSeasonStats row should reflect the exact sum of all play fields. + + Why: The core of the batting aggregation pipeline. If any field mapping + is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution + scoring and leaderboards would silently report incorrect stats. + """ + # PA 1: single (hit=1, ab=1, pa=1) + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + ) + # PA 2: home run (hit=1, homerun=1, ab=1, pa=1, rbi=1, run=1) + make_play( + game, + 2, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + homerun=1, + rbi=1, + run=1, + outs=0, + ) + # PA 3: strikeout (ab=1, pa=1, so=1, outs=1) + make_play( + game, + 3, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + ) + # PA 4: walk (pa=1, bb=1) + make_play( + game, + 4, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + bb=1, + outs=0, + ) + + result = update_season_stats(game.id) + + assert result["batters_updated"] >= 1 + stats = PlayerSeasonStats.get( + PlayerSeasonStats.player == player_batter, + PlayerSeasonStats.team == team_a, + PlayerSeasonStats.season == 11, + ) + assert stats.pa == 4 + assert stats.ab == 3 + assert stats.hits == 2 + assert stats.hr == 1 + assert stats.so == 1 + assert stats.bb == 1 + assert stats.rbi == 1 + assert stats.runs == 1 + assert stats.games_batting == 1 + + +def test_single_game_pitching_stats( + team_a, team_b, player_batter, player_pitcher, game +): + """Pitching stat totals (outs, k, hits_allowed, bb_allowed) are correct. + + What: The same plays that create batting stats for the batter are also + the source for the pitcher's opposing stats. This test checks that + _build_pitching_groups() correctly inverts batter-perspective fields. + + Why: The batter's 'so' becomes the pitcher's 'k', the batter's 'hit' + becomes 'hits_allowed', etc. Any transposition in this mapping would + corrupt pitcher stats silently. + """ + # Play 1: strikeout — batter so=1, outs=1 + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + ) + # Play 2: single — batter hit=1 + make_play( + game, + 2, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + ) + # Play 3: walk — batter bb=1 + make_play( + game, + 3, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + bb=1, + outs=0, + ) + + update_season_stats(game.id) + + stats = PlayerSeasonStats.get( + PlayerSeasonStats.player == player_pitcher, + PlayerSeasonStats.team == team_b, + PlayerSeasonStats.season == 11, + ) + assert stats.outs == 1 # one strikeout = one out recorded + assert stats.k == 1 # batter's so → pitcher's k + assert stats.hits_allowed == 1 # batter's hit → pitcher hits_allowed + assert stats.bb_allowed == 1 # batter's bb → pitcher bb_allowed + assert stats.games_pitching == 1 + + +def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game): + """Decision.win=1 for a pitcher results in wins=1 in PlayerSeasonStats. + + What: Add a single StratPlay to establish the pitcher in pitching_groups, + then create a Decision row recording a win. Call update_season_stats() + and verify the wins column is 1. + + Why: Decisions are stored in a separate table from StratPlay. If + _apply_decisions() fails to merge them (wrong FK lookup, key mismatch), + pitchers would always show 0 wins/losses/saves regardless of actual game + outcomes, breaking standings and evolution criteria. + """ + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + Decision.create( + season=11, + game=game, + pitcher=player_pitcher, + pitcher_team=team_b, + win=1, + loss=0, + is_save=0, + hold=0, + b_save=0, + is_start=True, + ) + + update_season_stats(game.id) + + stats = PlayerSeasonStats.get( + PlayerSeasonStats.player == player_pitcher, + PlayerSeasonStats.team == team_b, + PlayerSeasonStats.season == 11, + ) + assert stats.wins == 1 + assert stats.losses == 0 + + +def test_double_count_prevention(team_a, team_b, player_batter, player_pitcher, game): + """Calling update_season_stats() twice for the same game must not double the stats. + + What: Process a game once (pa=3), then call the function again. The + second call should detect the already-processed state via the + PlayerSeasonStats.last_game FK check and return early with 'skipped'=True. + The resulting pa should still be 3, not 6. + + Why: The bot infrastructure may deliver game-complete events more than + once (network retries, message replays). Without idempotency, stats + would accumulate incorrectly and could not be corrected without a full + reset. + """ + for i in range(3): + make_play( + game, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + first_result = update_season_stats(game.id) + assert "skipped" not in first_result + + second_result = update_season_stats(game.id) + assert second_result.get("skipped") is True + assert second_result["batters_updated"] == 0 + assert second_result["pitchers_updated"] == 0 + + stats = PlayerSeasonStats.get( + PlayerSeasonStats.player == player_batter, + PlayerSeasonStats.team == team_a, + PlayerSeasonStats.season == 11, + ) + # Must still be 3, not 6 + assert stats.pa == 3 + + +def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher): + """Stats from two separate games are summed in a single PlayerSeasonStats row. + + What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team. + After both updates the stats row should show pa=5. + + Why: PlayerSeasonStats is a season-long accumulator, not a per-game + snapshot. If the upsert logic overwrites instead of increments, a player's + stats would always reflect only their most recent game. + """ + game1 = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + game2 = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + + # Game 1: 2 plate appearances + for i in range(2): + make_play( + game1, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + # Game 2: 3 plate appearances + for i in range(3): + make_play( + game2, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + update_season_stats(game1.id) + update_season_stats(game2.id) + + stats = PlayerSeasonStats.get( + PlayerSeasonStats.player == player_batter, + PlayerSeasonStats.team == team_a, + PlayerSeasonStats.season == 11, + ) + assert stats.pa == 5 + assert stats.games_batting == 2 + + +def test_two_team_game(team_a, team_b): + """Players from both teams in a game each get their own stats row. + + What: Create a batter+pitcher pair for team A and another pair for team B. + In the same game, team A bats against team B's pitcher and vice versa. + After update_season_stats(), both batters and both pitchers must have + correct, isolated stats rows. + + Why: A key correctness guarantee is that stats are attributed to the + correct (player, team) combination. If team attribution is wrong, + a player's stats could appear under the wrong franchise or be merged + with an opponent's row. + """ + batter_a = _make_player("Batter A", pos="CF") + pitcher_a = _make_player("Pitcher A", pos="SP") + batter_b = _make_player("Batter B", pos="CF") + pitcher_b = _make_player("Pitcher B", pos="SP") + + game = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + + # Team A bats against team B's pitcher (away half) + make_play( + game, + 1, + batter_a, + team_a, + pitcher_b, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + inning_half="top", + ) + make_play( + game, + 2, + batter_a, + team_a, + pitcher_b, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + inning_half="top", + ) + + # Team B bats against team A's pitcher (home half) + make_play( + game, + 3, + batter_b, + team_b, + pitcher_a, + team_a, + pa=1, + ab=1, + bb=1, + outs=0, + inning_half="bottom", + ) + + update_season_stats(game.id) + + # Team A's batter: 2 PA, 1 hit, 1 SO + stats_ba = PlayerSeasonStats.get( + PlayerSeasonStats.player == batter_a, + PlayerSeasonStats.team == team_a, + ) + assert stats_ba.pa == 2 + assert stats_ba.hits == 1 + assert stats_ba.so == 1 + + # Team B's batter: 1 PA, 1 BB + stats_bb = PlayerSeasonStats.get( + PlayerSeasonStats.player == batter_b, + PlayerSeasonStats.team == team_b, + ) + assert stats_bb.pa == 1 + assert stats_bb.bb == 1 + + # Team B's pitcher (faced team A's batter): 1 hit allowed, 1 K + stats_pb = PlayerSeasonStats.get( + PlayerSeasonStats.player == pitcher_b, + PlayerSeasonStats.team == team_b, + ) + assert stats_pb.hits_allowed == 1 + assert stats_pb.k == 1 + + # Team A's pitcher (faced team B's batter): 1 BB allowed + stats_pa = PlayerSeasonStats.get( + PlayerSeasonStats.player == pitcher_a, + PlayerSeasonStats.team == team_a, + ) + assert stats_pa.bb_allowed == 1