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:
parent
23d36e7903
commit
da9eaa1692
@ -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
|
Uses in-memory SQLite with foreign_keys pragma enabled. Each test
|
||||||
db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the
|
gets a fresh set of tables via the setup_test_db fixture (autouse).
|
||||||
production SQLite file during test collection. Each test module is
|
|
||||||
responsible for binding models to its own in-memory database.
|
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 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"
|
os.environ["DATABASE_TYPE"] = "postgresql"
|
||||||
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
||||||
# without raising a configuration error (it will not actually be used).
|
# without raising a configuration error (it will not actually be used).
|
||||||
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
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,
|
||||||
|
)
|
||||||
|
|||||||
332
tests/test_evolution_models.py
Normal file
332
tests/test_evolution_models.py
Normal 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
|
||||||
@ -1,119 +1,159 @@
|
|||||||
"""Tests for the evolution track seed data fixture (WP-03).
|
"""
|
||||||
|
Tests for app/seed/evolution_tracks.py — seed_evolution_tracks().
|
||||||
|
|
||||||
Unit tests verify the JSON fixture is correctly formed without touching any
|
What: Verify that the JSON-driven seed function correctly creates, counts,
|
||||||
database. The integration test binds a minimal in-memory EvolutionTrack
|
and idempotently updates EvolutionTrack rows in the database.
|
||||||
model (mirroring the schema WP-01 will add to db_engine) to an in-memory
|
|
||||||
SQLite database, calls seed(), and verifies idempotency.
|
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
|
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
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Path to the JSON fixture that the seed reads from at runtime
|
||||||
# Fixtures
|
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_test_db = SqliteDatabase(":memory:")
|
|
||||||
|
|
||||||
|
|
||||||
class EvolutionTrackStub(Model):
|
@pytest.fixture
|
||||||
"""Minimal EvolutionTrack model for integration tests.
|
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
|
This avoids hardcoding expected values — if the JSON changes, tests
|
||||||
test can run without WP-01 being merged.
|
automatically follow without needing manual updates.
|
||||||
"""
|
"""
|
||||||
|
return json.loads(_JSON_PATH.read_text(encoding="utf-8"))
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def test_seed_creates_three_tracks(json_tracks):
|
||||||
def _db():
|
"""After one seed call, exactly 3 EvolutionTrack rows must exist.
|
||||||
"""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])
|
|
||||||
|
|
||||||
|
Why: The JSON currently defines three card-type tracks (batter, sp, rp).
|
||||||
# ---------------------------------------------------------------------------
|
If the count is wrong the system would either be missing tracks
|
||||||
# Unit tests — JSON fixture only, no database
|
(evolution disabled for a card type) or have phantom extras.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
results_first = seed(model_class=EvolutionTrackStub)
|
seed_evolution_tracks()
|
||||||
assert len(results_first) == 3
|
assert EvolutionTrack.select().count() == 3
|
||||||
assert all(created for _, created in results_first)
|
|
||||||
|
|
||||||
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}"
|
||||||
|
)
|
||||||
|
|||||||
597
tests/test_season_stats_update.py
Normal file
597
tests/test_season_stats_update.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user