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