test: add Phase 1a test suite (25 tests)

- test_evolution_models: 12 tests for EvolutionTrack, EvolutionCardState,
  EvolutionTierBoost, EvolutionCosmetic, and PlayerSeasonStats models
- test_evolution_seed: 7 tests for seed idempotency, thresholds, formulas
- test_season_stats_update: 6 tests for batting/pitching aggregation,
  Decision integration, double-count prevention, multi-game accumulation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-17 19:19:58 -05:00
parent 23d36e7903
commit da9eaa1692
4 changed files with 1234 additions and 108 deletions

View File

@ -1,14 +1,171 @@
"""Pytest configuration for the paper-dynasty-database test suite.
"""
Shared test fixtures 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.
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"
# 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")
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,
)

View File

@ -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

View File

@ -1,119 +1,159 @@
"""Tests for the evolution track seed data fixture (WP-03).
Unit tests verify the JSON fixture is correctly formed without touching any
database. The integration test binds a minimal in-memory EvolutionTrack
model (mirroring the schema WP-01 will add to db_engine) to an in-memory
SQLite database, calls seed(), and verifies idempotency.
"""
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 peewee import CharField, IntegerField, Model, SqliteDatabase
from app.seed.evolution_tracks import load_tracks, seed
from app.db_engine import EvolutionTrack
from app.seed.evolution_tracks import seed_evolution_tracks
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_test_db = SqliteDatabase(":memory:")
# Path to the JSON fixture that the seed reads from at runtime
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json"
class EvolutionTrackStub(Model):
"""Minimal EvolutionTrack model for integration tests.
@pytest.fixture
def json_tracks():
"""Load the raw JSON definitions so tests can assert against them.
Mirrors the schema that WP-01 will add to db_engine so the integration
test can run without WP-01 being merged.
This avoids hardcoding expected values if the JSON changes, tests
automatically follow without needing manual updates.
"""
name = CharField()
card_type = CharField(unique=True)
formula = CharField()
t1 = IntegerField()
t2 = IntegerField()
t3 = IntegerField()
t4 = IntegerField()
class Meta:
database = _test_db
table_name = "evolution_track"
return json.loads(_JSON_PATH.read_text(encoding="utf-8"))
@pytest.fixture(autouse=True)
def _db():
"""Bind and create the stub table; drop it after each test."""
_test_db.connect(reuse_if_open=True)
_test_db.create_tables([EvolutionTrackStub])
yield
_test_db.drop_tables([EvolutionTrackStub])
def test_seed_creates_three_tracks(json_tracks):
"""After one seed call, exactly 3 EvolutionTrack rows must exist.
# ---------------------------------------------------------------------------
# Unit tests — JSON fixture only, no database
# ---------------------------------------------------------------------------
def test_three_tracks_in_seed_data():
"""load_tracks() must return exactly 3 evolution tracks."""
assert len(load_tracks()) == 3
def test_card_types_are_exactly_batter_sp_rp():
"""The set of card_type values must be exactly {'batter', 'sp', 'rp'}."""
types = {t["card_type"] for t in load_tracks()}
assert types == {"batter", "sp", "rp"}
def test_all_thresholds_positive_and_ascending():
"""Each track must have t1 < t2 < t3 < t4, all positive."""
for track in load_tracks():
assert track["t1"] > 0
assert track["t1"] < track["t2"] < track["t3"] < track["t4"]
def test_all_tracks_have_non_empty_formula():
"""Every track must have a non-empty formula string."""
for track in load_tracks():
assert isinstance(track["formula"], str) and track["formula"].strip()
def test_tier_thresholds_match_locked_values():
"""Threshold values must exactly match the locked design spec."""
tracks = {t["card_type"]: t for t in load_tracks()}
assert tracks["batter"]["t1"] == 37
assert tracks["batter"]["t2"] == 149
assert tracks["batter"]["t3"] == 448
assert tracks["batter"]["t4"] == 896
assert tracks["sp"]["t1"] == 10
assert tracks["sp"]["t2"] == 40
assert tracks["sp"]["t3"] == 120
assert tracks["sp"]["t4"] == 240
assert tracks["rp"]["t1"] == 3
assert tracks["rp"]["t2"] == 12
assert tracks["rp"]["t3"] == 35
assert tracks["rp"]["t4"] == 70
# ---------------------------------------------------------------------------
# Integration test — uses the stub model + in-memory SQLite
# ---------------------------------------------------------------------------
def test_seed_is_idempotent():
"""Calling seed() twice must not create duplicate rows (get_or_create).
First call: all three tracks created (created=True for each).
Second call: all three already exist (created=False for each).
Both calls succeed without error.
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.
"""
results_first = seed(model_class=EvolutionTrackStub)
assert len(results_first) == 3
assert all(created for _, created in results_first)
seed_evolution_tracks()
assert EvolutionTrack.select().count() == 3
results_second = seed(model_class=EvolutionTrackStub)
assert len(results_second) == 3
assert not any(created for _, created in results_second)
assert EvolutionTrackStub.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}"
)

View File

@ -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