- CRITICAL: Fix migration FK refs player(id) → player(player_id) - Remove dead is_start flag from pitching groups (no starts column) - Fix hr → homerun in test make_play helper - Add explanatory comment to ruff.toml - Replace print() with logging in seed script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
597 lines
16 KiB
Python
597 lines
16 KiB
Python
"""
|
|
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
|