feat(WP-10): pack opening hook — evolution_card_state initialization

Closes #75.

New file app/services/evolution_init.py:
- _determine_card_type(player): pure fn mapping pos_1 to 'batter'/'sp'/'rp'
- initialize_card_evolution(player_id, team_id, card_type): get_or_create
  EvolutionCardState with current_tier=0, current_value=0.0, fully_evolved=False
- Safe failure: all exceptions caught and logged, never raises
- Idempotent: duplicate calls for same (player_id, team_id) are no-ops
  and do NOT reset existing evolution progress

Modified app/routers_v2/cards.py:
- Add WP-10 hook after Card.bulk_create in the POST endpoint
- For each card posted, call _determine_card_type + initialize_card_evolution
- Wrapped in try/except so evolution failures cannot block pack opening
- Fix pre-existing lint violations (unused lc_id, bare f-string, unused e)

New file tests/test_evolution_init.py (16 tests, all passing):
- Unit: track assignment for batter / SP / RP / CP positions
- Integration: first card creates state with zeroed fields
- Integration: duplicate card is a no-op (progress not reset)
- Integration: different players on same team get separate states
- Integration: card_type routes to correct EvolutionTrack
- Integration: missing track returns None gracefully

Fix tests/test_evolution_models.py: correct PlayerSeasonStats import/usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-18 13:41:05 -05:00
parent f12aa858c1
commit 264c7dc73c
6 changed files with 1165 additions and 47 deletions

View File

@ -6,6 +6,7 @@ from pandas import DataFrame
from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist
from ..dependencies import oauth2_scheme, valid_token
from ..services.evolution_init import _determine_card_type, initialize_card_evolution
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
@ -80,7 +81,7 @@ async def get_cards(
raise HTTPException(
status_code=400, detail="Dupe checking must include a team_id"
)
logging.debug(f"dupe check")
logging.debug("dupe check")
p_query = Card.select(Card.player).where(Card.team_id == team_id)
seen = set()
dupes = []
@ -176,9 +177,6 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
status_code=401,
detail="You are not authorized to post cards. This event has been logged.",
)
last_card = Card.select(Card.id).order_by(-Card.id).limit(1)
lc_id = last_card[0].id
new_cards = []
player_ids = []
inc_dex = True
@ -209,6 +207,19 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
cost_query.execute()
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
# WP-10: initialize evolution state for each new card (fire-and-forget)
for x in cards.cards:
try:
this_player = Player.get_by_id(x.player_id)
card_type = _determine_card_type(this_player)
initialize_card_evolution(x.player_id, x.team_id, card_type)
except Exception:
logging.exception(
"evolution hook: unexpected error for player_id=%s team_id=%s",
x.player_id,
x.team_id,
)
raise HTTPException(
status_code=200, detail=f"{len(new_cards)} cards have been added"
)
@ -307,7 +318,7 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
try:
this_team = Team.get_by_id(team_id)
except DoesNotExist as e:
except DoesNotExist:
logging.error(f'/cards/wipe-team/{team_id} - could not find team')
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')

View File

@ -0,0 +1,138 @@
"""
WP-10: Pack opening hook evolution_card_state initialization.
Public API
----------
initialize_card_evolution(player_id, team_id, card_type)
Get-or-create an EvolutionCardState for the (player_id, team_id) pair.
Returns the state instance on success, or None if initialization fails
(missing track, integrity error, etc.). Never raises.
_determine_card_type(player)
Pure function: inspect player.pos_1 and return 'sp', 'rp', or 'batter'.
Exported so the cards router and tests can call it directly.
Design notes
------------
- The function is intentionally fire-and-forget from the caller's perspective.
All exceptions are caught and logged; pack opening is never blocked.
- No EvolutionProgress rows are created here. Progress accumulation is a
separate concern handled by the stats-update pipeline (WP-07/WP-08).
- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern);
we do NOT replicate that exclusion here all teams get an evolution state
so that future rule changes don't require back-filling.
"""
import logging
from typing import Optional
from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack
logger = logging.getLogger(__name__)
def _determine_card_type(player) -> str:
"""Map a player's primary position to an evolution card_type string.
Rules (from WP-10 spec):
- pos_1 contains 'SP' -> 'sp'
- pos_1 contains 'RP' or 'CP' -> 'rp'
- anything else -> 'batter'
Args:
player: Any object with a ``pos_1`` attribute (Player model or stub).
Returns:
One of the strings 'batter', 'sp', 'rp'.
"""
pos = (player.pos_1 or "").upper()
if "SP" in pos:
return "sp"
if "RP" in pos or "CP" in pos:
return "rp"
return "batter"
def initialize_card_evolution(
player_id: int,
team_id: int,
card_type: str,
) -> Optional[EvolutionCardState]:
"""Get-or-create an EvolutionCardState for a newly acquired card.
Called by the cards POST endpoint after each card is inserted. The
function is idempotent: if a state row already exists for the
(player_id, team_id) pair it is returned unchanged existing
evolution progress is never reset.
Args:
player_id: Primary key of the Player row (Player.player_id).
team_id: Primary key of the Team row (Team.id).
card_type: One of 'batter', 'sp', 'rp'. Determines which
EvolutionTrack is assigned to the new state.
Returns:
The existing or newly created EvolutionCardState instance, or
None if initialization could not complete (missing track seed
data, unexpected DB error, etc.).
"""
try:
track = EvolutionTrack.get(EvolutionTrack.card_type == card_type)
except DoesNotExist:
logger.warning(
"evolution_init: no EvolutionTrack found for card_type=%r "
"(player_id=%s, team_id=%s) — skipping state creation",
card_type,
player_id,
team_id,
)
return None
except Exception:
logger.exception(
"evolution_init: unexpected error fetching track "
"(card_type=%r, player_id=%s, team_id=%s)",
card_type,
player_id,
team_id,
)
return None
try:
state, created = EvolutionCardState.get_or_create(
player_id=player_id,
team_id=team_id,
defaults={
"track": track,
"current_tier": 0,
"current_value": 0.0,
"fully_evolved": False,
},
)
if created:
logger.debug(
"evolution_init: created EvolutionCardState id=%s "
"(player_id=%s, team_id=%s, card_type=%r)",
state.id,
player_id,
team_id,
card_type,
)
else:
logger.debug(
"evolution_init: state already exists id=%s "
"(player_id=%s, team_id=%s) — no-op",
state.id,
player_id,
team_id,
)
return state
except Exception:
logger.exception(
"evolution_init: failed to get_or_create state "
"(player_id=%s, team_id=%s, card_type=%r)",
player_id,
team_id,
card_type,
)
return None

View File

@ -1,14 +1,16 @@
-- Migration: Add card evolution tables and column extensions
-- Date: 2026-03-17
-- Issue: WP-04
-- Purpose: Support the Card Evolution system — tracks player season stats,
-- Purpose: Support the Card Evolution system — creates batting_season_stats
-- and pitching_season_stats for per-player stat accumulation, plus
-- evolution tracks with tier thresholds, per-card evolution state,
-- tier-based stat boosts, and cosmetic unlocks. Also extends the
-- card, battingcard, and pitchingcard tables with variant and
-- image_url columns required by the evolution display layer.
--
-- Run on dev first, verify with:
-- SELECT count(*) FROM player_season_stats;
-- SELECT count(*) FROM batting_season_stats;
-- SELECT count(*) FROM pitching_season_stats;
-- SELECT count(*) FROM evolution_track;
-- SELECT count(*) FROM evolution_card_state;
-- SELECT count(*) FROM evolution_tier_boost;
@ -27,62 +29,95 @@
BEGIN;
-- --------------------------------------------
-- Table 1: player_season_stats
-- Table 1: batting_season_stats
-- Accumulates per-player per-team per-season
-- batting and pitching totals for evolution
-- formula evaluation.
-- batting totals for evolution formula evaluation
-- and leaderboard queries.
-- --------------------------------------------
CREATE TABLE IF NOT EXISTS player_season_stats (
CREATE TABLE IF NOT EXISTS batting_season_stats (
id SERIAL PRIMARY KEY,
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
season INTEGER NOT NULL,
-- Batting stats
games_batting INTEGER NOT NULL DEFAULT 0,
games INTEGER NOT NULL DEFAULT 0,
pa INTEGER NOT NULL DEFAULT 0,
ab INTEGER NOT NULL DEFAULT 0,
hits INTEGER NOT NULL DEFAULT 0,
doubles INTEGER NOT NULL DEFAULT 0,
triples INTEGER NOT NULL DEFAULT 0,
hr INTEGER NOT NULL DEFAULT 0,
bb INTEGER NOT NULL DEFAULT 0,
hbp INTEGER NOT NULL DEFAULT 0,
so INTEGER NOT NULL DEFAULT 0,
rbi INTEGER NOT NULL DEFAULT 0,
runs INTEGER NOT NULL DEFAULT 0,
bb INTEGER NOT NULL DEFAULT 0,
strikeouts INTEGER NOT NULL DEFAULT 0,
hbp INTEGER NOT NULL DEFAULT 0,
sac INTEGER NOT NULL DEFAULT 0,
ibb INTEGER NOT NULL DEFAULT 0,
gidp INTEGER NOT NULL DEFAULT 0,
sb INTEGER NOT NULL DEFAULT 0,
cs INTEGER NOT NULL DEFAULT 0,
-- Pitching stats
games_pitching INTEGER NOT NULL DEFAULT 0,
outs INTEGER NOT NULL DEFAULT 0,
k INTEGER NOT NULL DEFAULT 0,
bb_allowed INTEGER NOT NULL DEFAULT 0,
hits_allowed INTEGER NOT NULL DEFAULT 0,
hr_allowed INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
saves INTEGER NOT NULL DEFAULT 0,
holds INTEGER NOT NULL DEFAULT 0,
blown_saves INTEGER NOT NULL DEFAULT 0,
-- Meta
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
last_updated_at TIMESTAMP
);
-- One row per player per team per season
CREATE UNIQUE INDEX IF NOT EXISTS player_season_stats_player_team_season_uniq
ON player_season_stats (player_id, team_id, season);
CREATE UNIQUE INDEX IF NOT EXISTS batting_season_stats_player_team_season_uniq
ON batting_season_stats (player_id, team_id, season);
-- Fast lookup by team + season (e.g. leaderboard queries)
CREATE INDEX IF NOT EXISTS player_season_stats_team_season_idx
ON player_season_stats (team_id, season);
CREATE INDEX IF NOT EXISTS batting_season_stats_team_season_idx
ON batting_season_stats (team_id, season);
-- Fast lookup by player across seasons
CREATE INDEX IF NOT EXISTS player_season_stats_player_season_idx
ON player_season_stats (player_id, season);
CREATE INDEX IF NOT EXISTS batting_season_stats_player_season_idx
ON batting_season_stats (player_id, season);
-- --------------------------------------------
-- Table 2: evolution_track
-- Table 2: pitching_season_stats
-- Accumulates per-player per-team per-season
-- pitching totals for evolution formula evaluation
-- and leaderboard queries.
-- --------------------------------------------
CREATE TABLE IF NOT EXISTS pitching_season_stats (
id SERIAL PRIMARY KEY,
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
season INTEGER NOT NULL,
games INTEGER NOT NULL DEFAULT 0,
games_started INTEGER NOT NULL DEFAULT 0,
outs INTEGER NOT NULL DEFAULT 0,
strikeouts INTEGER NOT NULL DEFAULT 0,
bb INTEGER NOT NULL DEFAULT 0,
hits_allowed INTEGER NOT NULL DEFAULT 0,
runs_allowed INTEGER NOT NULL DEFAULT 0,
earned_runs INTEGER NOT NULL DEFAULT 0,
hr_allowed INTEGER NOT NULL DEFAULT 0,
hbp INTEGER NOT NULL DEFAULT 0,
wild_pitches INTEGER NOT NULL DEFAULT 0,
balks INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
holds INTEGER NOT NULL DEFAULT 0,
saves INTEGER NOT NULL DEFAULT 0,
blown_saves INTEGER NOT NULL DEFAULT 0,
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
last_updated_at TIMESTAMP
);
-- One row per player per team per season
CREATE UNIQUE INDEX IF NOT EXISTS pitching_season_stats_player_team_season_uniq
ON pitching_season_stats (player_id, team_id, season);
-- Fast lookup by team + season (e.g. leaderboard queries)
CREATE INDEX IF NOT EXISTS pitching_season_stats_team_season_idx
ON pitching_season_stats (team_id, season);
-- Fast lookup by player across seasons
CREATE INDEX IF NOT EXISTS pitching_season_stats_player_season_idx
ON pitching_season_stats (player_id, season);
-- --------------------------------------------
-- Table 3: evolution_track
-- Defines the available evolution tracks
-- (e.g. "HR Mastery", "Ace SP"), their
-- metric formula, and the four tier thresholds.
@ -99,7 +134,7 @@ CREATE TABLE IF NOT EXISTS evolution_track (
);
-- --------------------------------------------
-- Table 3: evolution_card_state
-- Table 4: evolution_card_state
-- Records each card's current evolution tier,
-- running metric value, and the track it
-- belongs to. One state row per card (player
@ -122,7 +157,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS evolution_card_state_player_team_uniq
ON evolution_card_state (player_id, team_id);
-- --------------------------------------------
-- Table 4: evolution_tier_boost
-- Table 5: evolution_tier_boost
-- Defines the stat boosts unlocked at each
-- tier within a track. A single tier may
-- grant multiple boosts (e.g. +1 HR and
@ -142,7 +177,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS evolution_tier_boost_track_tier_type_target_un
ON evolution_tier_boost (track_id, tier, boost_type, boost_target);
-- --------------------------------------------
-- Table 5: evolution_cosmetic
-- Table 6: evolution_cosmetic
-- Catalogue of unlockable visual treatments
-- (borders, foils, badges, etc.) tied to
-- minimum tier requirements.
@ -173,14 +208,16 @@ COMMIT;
-- ============================================
-- VERIFICATION QUERIES
-- ============================================
-- \d player_season_stats
-- \d batting_season_stats
-- \d pitching_season_stats
-- \d evolution_track
-- \d evolution_card_state
-- \d evolution_tier_boost
-- \d evolution_cosmetic
-- SELECT indexname FROM pg_indexes
-- WHERE tablename IN (
-- 'player_season_stats',
-- 'batting_season_stats',
-- 'pitching_season_stats',
-- 'evolution_card_state',
-- 'evolution_tier_boost'
-- )
@ -200,4 +237,5 @@ COMMIT;
-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE;
-- DROP TABLE IF EXISTS evolution_card_state CASCADE;
-- DROP TABLE IF EXISTS evolution_track CASCADE;
-- DROP TABLE IF EXISTS player_season_stats CASCADE;
-- DROP TABLE IF EXISTS pitching_season_stats CASCADE;
-- DROP TABLE IF EXISTS batting_season_stats CASCADE;

View File

@ -0,0 +1,326 @@
"""
Tests for WP-10: evolution_card_state initialization on pack opening.
Covers `app/services/evolution_init.py` the `initialize_card_evolution`
function that creates an EvolutionCardState row when a card is first acquired.
Test strategy:
- Unit tests for `_determine_card_type` cover all three branches (batter,
SP, RP/CP) using plain objects so no database round-trip is needed.
- Integration tests run against the in-memory SQLite database (conftest.py
autouse fixture) and exercise the full get_or_create path.
Why we test idempotency:
Pack-opening can post duplicate cards (e.g. the same player ID appears in
two separate pack insertions). The get_or_create guarantee means the second
call must be a no-op it must not reset current_tier/current_value of a
card that has already started evolving.
Why we test cross-player isolation:
Two different players with the same team must each get their own
EvolutionCardState row. A bug that checked only team_id would share state
across players, so we assert that state.player_id matches.
"""
import pytest
from app.db_engine import (
Cardset,
EvolutionCardState,
EvolutionTrack,
Player,
)
from app.services.evolution_init import _determine_card_type, initialize_card_evolution
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakePlayer:
"""Minimal stand-in for a Player instance used in unit tests.
We only need pos_1 for card-type determination; real FK fields are
not required by the pure function under test.
"""
def __init__(self, pos_1: str):
self.pos_1 = pos_1
def _make_player(rarity, pos_1: str) -> Player:
"""Create a minimal Player row with the given pos_1 value.
A fresh Cardset is created per call so that players are independent
of each other and can be iterated over in separate test cases without
FK conflicts.
"""
cardset = Cardset.create(
name=f"Set-{pos_1}-{id(pos_1)}",
description="Test",
total_cards=1,
)
return Player.create(
p_name=f"Player {pos_1}",
rarity=rarity,
cardset=cardset,
set_num=1,
pos_1=pos_1,
image="https://example.com/img.png",
mlbclub="TST",
franchise="TST",
description="test",
)
def _make_track(card_type: str) -> EvolutionTrack:
"""Create an EvolutionTrack for the given card_type.
Thresholds are kept small and arbitrary; the unit under test only
cares about card_type when selecting the track.
"""
return EvolutionTrack.create(
name=f"Track-{card_type}",
card_type=card_type,
formula="pa",
t1_threshold=10,
t2_threshold=40,
t3_threshold=120,
t4_threshold=240,
)
# ---------------------------------------------------------------------------
# Unit tests — _determine_card_type (no DB required)
# ---------------------------------------------------------------------------
class TestDetermineCardType:
"""Unit tests for _determine_card_type, the pure position-to-type mapper.
The function receives a Player (or any object with a pos_1 attribute) and
returns one of the three strings 'batter', 'sp', or 'rp'. These unit
tests use _FakePlayer so no database is touched and failures are fast.
"""
def test_starting_pitcher(self):
"""pos_1 == 'SP' maps to card_type 'sp'.
SP is the canonical starting-pitcher position string stored in
Player.pos_1 by the card-creation pipeline.
"""
assert _determine_card_type(_FakePlayer("SP")) == "sp"
def test_relief_pitcher(self):
"""pos_1 == 'RP' maps to card_type 'rp'.
Relief pitchers carry the 'RP' position flag and must follow a
separate evolution track with lower thresholds.
"""
assert _determine_card_type(_FakePlayer("RP")) == "rp"
def test_closer_pitcher(self):
"""pos_1 == 'CP' maps to card_type 'rp'.
Closers share the RP evolution track; the spec explicitly lists 'CP'
as an rp-track position.
"""
assert _determine_card_type(_FakePlayer("CP")) == "rp"
def test_infielder_is_batter(self):
"""pos_1 == '1B' maps to card_type 'batter'.
Any non-pitcher position (1B, 2B, 3B, SS, OF, C, DH, etc.) should
fall through to the batter track.
"""
assert _determine_card_type(_FakePlayer("1B")) == "batter"
def test_catcher_is_batter(self):
"""pos_1 == 'C' maps to card_type 'batter'."""
assert _determine_card_type(_FakePlayer("C")) == "batter"
def test_dh_is_batter(self):
"""pos_1 == 'DH' maps to card_type 'batter'.
Designated hitters have no defensive rating but accumulate batting
stats, so they belong on the batter track.
"""
assert _determine_card_type(_FakePlayer("DH")) == "batter"
def test_outfielder_is_batter(self):
"""pos_1 == 'CF' maps to card_type 'batter'."""
assert _determine_card_type(_FakePlayer("CF")) == "batter"
# ---------------------------------------------------------------------------
# Integration tests — initialize_card_evolution
# ---------------------------------------------------------------------------
class TestInitializeCardEvolution:
"""Integration tests for initialize_card_evolution against in-memory SQLite.
Each test relies on the conftest autouse fixture to get a clean database.
We create tracks for all three card types so the function can always find
a matching track regardless of which player position is used.
"""
@pytest.fixture(autouse=True)
def seed_tracks(self):
"""Create one EvolutionTrack per card_type before each test.
initialize_card_evolution does a DB lookup for a track matching the
card_type. If no track exists the function must not crash (it should
log and return None), but having tracks present lets us verify the
happy path for all three types without repeating setup in every test.
"""
self.batter_track = _make_track("batter")
self.sp_track = _make_track("sp")
self.rp_track = _make_track("rp")
def test_first_card_creates_state(self, rarity, team):
"""First acquisition creates an EvolutionCardState with zeroed values.
Acceptance criteria from WP-10:
- current_tier == 0
- current_value == 0.0
- fully_evolved == False
- track matches the player's card_type (batter here)
"""
player = _make_player(rarity, "2B")
state = initialize_card_evolution(player.player_id, team.id, "batter")
assert state is not None
assert state.player_id == player.player_id
assert state.team_id == team.id
assert state.track_id == self.batter_track.id
assert state.current_tier == 0
assert state.current_value == 0.0
assert state.fully_evolved is False
def test_duplicate_card_skips_creation(self, rarity, team):
"""Second call for the same (player_id, team_id) is a no-op.
The get_or_create guarantee: if a state row already exists it must
not be overwritten. This protects cards that have already started
evolving their current_tier and current_value must be preserved.
"""
player = _make_player(rarity, "SS")
# First call creates the state
state1 = initialize_card_evolution(player.player_id, team.id, "batter")
assert state1 is not None
# Simulate partial evolution progress
state1.current_tier = 2
state1.current_value = 250.0
state1.save()
# Second call (duplicate card) must not reset progress
state2 = initialize_card_evolution(player.player_id, team.id, "batter")
assert state2 is not None
# Exactly one row in the database
count = (
EvolutionCardState.select()
.where(
EvolutionCardState.player == player,
EvolutionCardState.team == team,
)
.count()
)
assert count == 1
# Progress was NOT reset
refreshed = EvolutionCardState.get_by_id(state1.id)
assert refreshed.current_tier == 2
assert refreshed.current_value == 250.0
def test_different_player_creates_new_state(self, rarity, team):
"""Two different players on the same team each get their own state row.
Cross-player isolation: the (player_id, team_id) uniqueness means
player A and player B must have separate rows even though team_id is
the same.
"""
player_a = _make_player(rarity, "LF")
player_b = _make_player(rarity, "RF")
state_a = initialize_card_evolution(player_a.player_id, team.id, "batter")
state_b = initialize_card_evolution(player_b.player_id, team.id, "batter")
assert state_a is not None
assert state_b is not None
assert state_a.id != state_b.id
assert state_a.player_id == player_a.player_id
assert state_b.player_id == player_b.player_id
def test_sp_card_gets_sp_track(self, rarity, team):
"""A starting pitcher is assigned the 'sp' EvolutionTrack.
Track selection is driven by card_type, which in turn comes from
pos_1. This test passes card_type='sp' explicitly (mirroring the
router hook that calls _determine_card_type first) and confirms the
state links to the sp track, not the batter track.
"""
player = _make_player(rarity, "SP")
state = initialize_card_evolution(player.player_id, team.id, "sp")
assert state is not None
assert state.track_id == self.sp_track.id
def test_rp_card_gets_rp_track(self, rarity, team):
"""A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack."""
player = _make_player(rarity, "RP")
state = initialize_card_evolution(player.player_id, team.id, "rp")
assert state is not None
assert state.track_id == self.rp_track.id
def test_missing_track_returns_none(self, rarity, team):
"""If no track exists for the card_type, the function returns None.
This is the safe-failure path: the function must not raise an
exception if the evolution system is misconfigured (e.g. track seed
data missing). It logs the problem and returns None so that the
caller (the cards router) can proceed with pack opening unaffected.
We use a fictional card_type that has no matching seed row.
"""
player = _make_player(rarity, "SP")
# Delete the sp track to simulate missing seed data
self.sp_track.delete_instance()
result = initialize_card_evolution(player.player_id, team.id, "sp")
assert result is None
def test_card_type_from_pos1_batter(self, rarity, team):
"""_determine_card_type is wired correctly for a batter position.
End-to-end: pass the player object directly and verify the state
ends up on the batter track based solely on pos_1.
"""
player = _make_player(rarity, "3B")
card_type = _determine_card_type(player)
state = initialize_card_evolution(player.player_id, team.id, card_type)
assert state is not None
assert state.track_id == self.batter_track.id
def test_card_type_from_pos1_sp(self, rarity, team):
"""_determine_card_type is wired correctly for a starting pitcher."""
player = _make_player(rarity, "SP")
card_type = _determine_card_type(player)
state = initialize_card_evolution(player.player_id, team.id, card_type)
assert state is not None
assert state.track_id == self.sp_track.id
def test_card_type_from_pos1_rp(self, rarity, team):
"""_determine_card_type correctly routes CP to the rp track."""
player = _make_player(rarity, "CP")
card_type = _determine_card_type(player)
state = initialize_card_evolution(player.player_id, team.id, card_type)
assert state is not None
assert state.track_id == self.rp_track.id

View File

@ -1,5 +1,5 @@
"""
Tests for evolution-related models and PlayerSeasonStats.
Tests for evolution-related models and BattingSeasonStats.
Covers WP-01 acceptance criteria:
- EvolutionTrack: CRUD and unique-name constraint
@ -7,7 +7,7 @@ Covers WP-01 acceptance criteria:
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),
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
and in-place stat accumulation
Each test class is self-contained: fixtures from conftest.py supply the
@ -20,11 +20,11 @@ from peewee import IntegrityError
from playhouse.shortcuts import model_to_dict
from app.db_engine import (
PlayerSeasonStats,
EvolutionCardState,
EvolutionCosmetic,
EvolutionTierBoost,
EvolutionTrack,
PlayerSeasonStats,
)
# ---------------------------------------------------------------------------
@ -244,12 +244,12 @@ class TestEvolutionCosmetic:
# ---------------------------------------------------------------------------
# PlayerSeasonStats
# BattingSeasonStats
# ---------------------------------------------------------------------------
class TestPlayerSeasonStats:
"""Tests for PlayerSeasonStats, the per-season accumulation table.
"""Tests for BattingSeasonStats, 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

View File

@ -0,0 +1,605 @@
"""Integration tests for the evolution card state API endpoints (WP-07).
Tests cover:
GET /api/v2/teams/{team_id}/evolutions
GET /api/v2/evolution/cards/{card_id}
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and
assume the evolution schema migration (WP-04) has already been applied.
Tests auto-skip when POSTGRES_HOST is not set.
Test data is inserted via psycopg2 before each module fixture runs and
cleaned up in teardown so the tests are repeatable. ON CONFLICT / CASCADE
clauses keep the table clean even if a previous run did not complete teardown.
Object graph built by fixtures
-------------------------------
rarity_row -- a seeded rarity row
cardset_row -- a seeded cardset row
player_row -- a seeded player row (FK: rarity, cardset)
team_row -- a seeded team row
track_row -- a seeded evolution_track row (batter)
card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset)
state_row -- a seeded evolution_card_state row (FK: player, team, track)
Test matrix
-----------
test_list_team_evolutions -- baseline: returns count + items for a team
test_list_filter_by_card_type -- card_type query param filters by track.card_type
test_list_filter_by_tier -- tier query param filters by current_tier
test_list_pagination -- page/per_page params slice results correctly
test_get_card_state_shape -- single card returns all required response fields
test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current
test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState
test_get_card_404_no_state -- card with no EvolutionCardState returns 404
test_duplicate_cards_share_state -- two cards same player+team return the same state row
test_auth_required -- missing token returns 401 on both endpoints
"""
import os
import pytest
from fastapi.testclient import TestClient
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
_skip_no_pg = pytest.mark.skipif(
not POSTGRES_HOST, reason="POSTGRES_HOST not set — integration tests skipped"
)
AUTH_HEADER = {"Authorization": f"Bearer {os.environ.get('API_TOKEN', 'test-token')}"}
# ---------------------------------------------------------------------------
# Shared fixtures: seed and clean up the full object graph
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def seeded_data(pg_conn):
"""Insert all rows needed for state API tests; delete them after the module.
Returns a dict with the integer IDs of every inserted row so individual
test functions can reference them by key.
Insertion order respects FK dependencies:
rarity -> cardset -> player
pack_type (needs cardset) -> pack (needs team + pack_type) -> card
evolution_track -> evolution_card_state
"""
cur = pg_conn.cursor()
# Rarity
cur.execute(
"""
INSERT INTO rarity (value, name, color)
VALUES (99, 'WP07TestRarity', '#123456')
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value
RETURNING id
"""
)
rarity_id = cur.fetchone()[0]
# Cardset
cur.execute(
"""
INSERT INTO cardset (name, description, total_cards)
VALUES ('WP07 Test Set', 'evo state api tests', 1)
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description
RETURNING id
"""
)
cardset_id = cur.fetchone()[0]
# Player 1 (batter)
cur.execute(
"""
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
image, mlbclub, franchise, description)
VALUES ('WP07 Batter', %s, %s, 901, '1B',
'https://example.com/wp07_b.png', 'TST', 'TST', 'wp07 test batter')
RETURNING player_id
""",
(rarity_id, cardset_id),
)
player_id = cur.fetchone()[0]
# Player 2 (sp) for cross-card_type filter test
cur.execute(
"""
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
image, mlbclub, franchise, description)
VALUES ('WP07 Pitcher', %s, %s, 902, 'SP',
'https://example.com/wp07_p.png', 'TST', 'TST', 'wp07 test pitcher')
RETURNING player_id
""",
(rarity_id, cardset_id),
)
player2_id = cur.fetchone()[0]
# Team
cur.execute(
"""
INSERT INTO team (abbrev, sname, lname, gmid, gmname, gsheet,
wallet, team_value, collection_value, season, is_ai)
VALUES ('WP7', 'WP07', 'WP07 Test Team', 700000001, 'wp07user',
'https://docs.google.com/wp07', 0, 0, 0, 11, false)
RETURNING id
"""
)
team_id = cur.fetchone()[0]
# Evolution tracks
cur.execute(
"""
INSERT INTO evolution_track (name, card_type, formula,
t1_threshold, t2_threshold,
t3_threshold, t4_threshold)
VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896)
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
RETURNING id
"""
)
batter_track_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO evolution_track (name, card_type, formula,
t1_threshold, t2_threshold,
t3_threshold, t4_threshold)
VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240)
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
RETURNING id
"""
)
sp_track_id = cur.fetchone()[0]
# Pack type + pack (needed as FK parent for Card)
cur.execute(
"""
INSERT INTO pack_type (name, cost, card_count, cardset_id)
VALUES ('WP07 Pack Type', 100, 5, %s)
RETURNING id
""",
(cardset_id,),
)
pack_type_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO pack (team_id, pack_type_id)
VALUES (%s, %s)
RETURNING id
""",
(team_id, pack_type_id),
)
pack_id = cur.fetchone()[0]
# Card linking batter player to team
cur.execute(
"""
INSERT INTO card (player_id, team_id, pack_id, value)
VALUES (%s, %s, %s, 0)
RETURNING id
""",
(player_id, team_id, pack_id),
)
card_id = cur.fetchone()[0]
# Second card for same player+team (shared-state test)
cur.execute(
"""
INSERT INTO pack (team_id, pack_type_id)
VALUES (%s, %s)
RETURNING id
""",
(team_id, pack_type_id),
)
pack2_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO card (player_id, team_id, pack_id, value)
VALUES (%s, %s, %s, 0)
RETURNING id
""",
(player_id, team_id, pack2_id),
)
card2_id = cur.fetchone()[0]
# Card with NO state (404 test)
cur.execute(
"""
INSERT INTO pack (team_id, pack_type_id)
VALUES (%s, %s)
RETURNING id
""",
(team_id, pack_type_id),
)
pack3_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO card (player_id, team_id, pack_id, value)
VALUES (%s, %s, %s, 0)
RETURNING id
""",
(player2_id, team_id, pack3_id),
)
card_no_state_id = cur.fetchone()[0]
# Evolution card states
# Batter player at tier 1
cur.execute(
"""
INSERT INTO evolution_card_state
(player_id, team_id, track_id, current_tier, current_value,
fully_evolved, last_evaluated_at)
VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z')
RETURNING id
""",
(player_id, team_id, batter_track_id),
)
state_id = cur.fetchone()[0]
pg_conn.commit()
yield {
"rarity_id": rarity_id,
"cardset_id": cardset_id,
"player_id": player_id,
"player2_id": player2_id,
"team_id": team_id,
"batter_track_id": batter_track_id,
"sp_track_id": sp_track_id,
"pack_type_id": pack_type_id,
"card_id": card_id,
"card2_id": card2_id,
"card_no_state_id": card_no_state_id,
"state_id": state_id,
}
# Teardown: delete in reverse FK order
cur.execute(
"DELETE FROM evolution_card_state WHERE id = %s", (state_id,)
)
cur.execute(
"DELETE FROM card WHERE id = ANY(%s)",
([card_id, card2_id, card_no_state_id],),
)
cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],))
cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,))
cur.execute(
"DELETE FROM evolution_track WHERE id = ANY(%s)",
([batter_track_id, sp_track_id],),
)
cur.execute(
"DELETE FROM player WHERE player_id = ANY(%s)", ([player_id, player2_id],)
)
cur.execute("DELETE FROM team WHERE id = %s", (team_id,))
cur.execute("DELETE FROM cardset WHERE id = %s", (cardset_id,))
cur.execute("DELETE FROM rarity WHERE id = %s", (rarity_id,))
pg_conn.commit()
@pytest.fixture(scope="module")
def client():
"""FastAPI TestClient backed by the real PostgreSQL database."""
from app.main import app
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# Tests: GET /api/v2/teams/{team_id}/evolutions
# ---------------------------------------------------------------------------
@_skip_no_pg
def test_list_team_evolutions(client, seeded_data):
"""GET /teams/{id}/evolutions returns count=1 and one item for the seeded state.
Verifies the basic list response shape: a dict with 'count' and 'items',
and that the single item contains player_id, team_id, and current_tier.
"""
team_id = seeded_data["team_id"]
resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 1
assert len(data["items"]) == 1
item = data["items"][0]
assert item["player_id"] == seeded_data["player_id"]
assert item["team_id"] == team_id
assert item["current_tier"] == 1
@_skip_no_pg
def test_list_filter_by_card_type(client, seeded_data, pg_conn):
"""card_type filter includes states whose track.card_type matches and excludes others.
Seeds a second evolution_card_state for player2 (sp track) then queries
card_type=batter (returns 1) and card_type=sp (returns 1).
Verifies the JOIN to evolution_track and the WHERE predicate on card_type.
"""
cur = pg_conn.cursor()
# Add a state for the sp player so we have two types
cur.execute(
"""
INSERT INTO evolution_card_state
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
VALUES (%s, %s, %s, 0, 0.0, false)
RETURNING id
""",
(seeded_data["player2_id"], seeded_data["team_id"], seeded_data["sp_track_id"]),
)
sp_state_id = cur.fetchone()[0]
pg_conn.commit()
try:
team_id = seeded_data["team_id"]
resp_batter = client.get(
f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER
)
assert resp_batter.status_code == 200
batter_data = resp_batter.json()
assert batter_data["count"] == 1
assert batter_data["items"][0]["player_id"] == seeded_data["player_id"]
resp_sp = client.get(
f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER
)
assert resp_sp.status_code == 200
sp_data = resp_sp.json()
assert sp_data["count"] == 1
assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"]
finally:
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,))
pg_conn.commit()
@_skip_no_pg
def test_list_filter_by_tier(client, seeded_data, pg_conn):
"""tier filter includes only states at the specified current_tier.
The base fixture has player1 at tier=1. This test temporarily advances
it to tier=2, then queries tier=1 (should return 0) and tier=2 (should
return 1). Restores to tier=1 after assertions.
"""
cur = pg_conn.cursor()
# Advance to tier 2
cur.execute(
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s",
(seeded_data["state_id"],),
)
pg_conn.commit()
try:
team_id = seeded_data["team_id"]
resp_t1 = client.get(
f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER
)
assert resp_t1.status_code == 200
assert resp_t1.json()["count"] == 0
resp_t2 = client.get(
f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER
)
assert resp_t2.status_code == 200
t2_data = resp_t2.json()
assert t2_data["count"] == 1
assert t2_data["items"][0]["current_tier"] == 2
finally:
cur.execute(
"UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s",
(seeded_data["state_id"],),
)
pg_conn.commit()
@_skip_no_pg
def test_list_pagination(client, seeded_data, pg_conn):
"""page/per_page params slice the full result set correctly.
Temporarily inserts a second state (for player2 on the same team) so
the list has 2 items. With per_page=1, page=1 returns item 1 and
page=2 returns item 2; they must be different players.
"""
cur = pg_conn.cursor()
cur.execute(
"""
INSERT INTO evolution_card_state
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
VALUES (%s, %s, %s, 0, 0.0, false)
RETURNING id
""",
(seeded_data["player2_id"], seeded_data["team_id"], seeded_data["batter_track_id"]),
)
extra_state_id = cur.fetchone()[0]
pg_conn.commit()
try:
team_id = seeded_data["team_id"]
resp1 = client.get(
f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER
)
assert resp1.status_code == 200
data1 = resp1.json()
assert len(data1["items"]) == 1
resp2 = client.get(
f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER
)
assert resp2.status_code == 200
data2 = resp2.json()
assert len(data2["items"]) == 1
assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"]
finally:
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,))
pg_conn.commit()
# ---------------------------------------------------------------------------
# Tests: GET /api/v2/evolution/cards/{card_id}
# ---------------------------------------------------------------------------
@_skip_no_pg
def test_get_card_state_shape(client, seeded_data):
"""GET /evolution/cards/{card_id} returns all required fields.
Verifies the full response envelope:
player_id, team_id, current_tier, current_value, fully_evolved,
last_evaluated_at, next_threshold, and a nested 'track' dict
with id, name, card_type, formula, and t1-t4 thresholds.
"""
card_id = seeded_data["card_id"]
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["player_id"] == seeded_data["player_id"]
assert data["team_id"] == seeded_data["team_id"]
assert data["current_tier"] == 1
assert data["current_value"] == 87.5
assert data["fully_evolved"] is False
t = data["track"]
assert t["id"] == seeded_data["batter_track_id"]
assert t["name"] == "WP07 Batter Track"
assert t["card_type"] == "batter"
assert t["formula"] == "pa + tb * 2"
assert t["t1_threshold"] == 37
assert t["t2_threshold"] == 149
assert t["t3_threshold"] == 448
assert t["t4_threshold"] == 896
# tier=1 -> next is t2_threshold
assert data["next_threshold"] == 149
@_skip_no_pg
def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
"""next_threshold reflects the threshold for the tier immediately above current.
Tier mapping:
0 -> t1_threshold (37)
1 -> t2_threshold (149)
2 -> t3_threshold (448)
3 -> t4_threshold (896)
4 -> null (fully evolved)
This test advances the state to tier=2, confirms next_threshold=448,
then to tier=4 (fully_evolved=True) and confirms next_threshold=null.
Restores original state after assertions.
"""
cur = pg_conn.cursor()
card_id = seeded_data["card_id"]
state_id = seeded_data["state_id"]
# Advance to tier 2
cur.execute(
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,)
)
pg_conn.commit()
try:
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
assert resp.json()["next_threshold"] == 448
# Advance to tier 4 (fully evolved)
cur.execute(
"UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true WHERE id = %s",
(state_id,),
)
pg_conn.commit()
resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
assert resp2.status_code == 200
assert resp2.json()["next_threshold"] is None
finally:
cur.execute(
"UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false WHERE id = %s",
(state_id,),
)
pg_conn.commit()
@_skip_no_pg
def test_get_card_id_resolves_player(client, seeded_data):
"""card_id is resolved via the Card table to obtain (player_id, team_id).
The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState.
Verifies that card_id correctly maps to the right player's evolution state.
"""
card_id = seeded_data["card_id"]
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["player_id"] == seeded_data["player_id"]
assert data["team_id"] == seeded_data["team_id"]
@_skip_no_pg
def test_get_card_404_no_state(client, seeded_data):
"""GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists.
card_no_state_id is a card row for player2 on the team, but no
evolution_card_state row was created for player2. The endpoint must
return 404, not 500 or an empty response.
"""
card_id = seeded_data["card_no_state_id"]
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 404
@_skip_no_pg
def test_duplicate_cards_share_state(client, seeded_data):
"""Two Card rows for the same player+team share one EvolutionCardState.
card_id and card2_id both belong to player_id on team_id. Because the
unique-(player,team) constraint means only one state row can exist, both
card IDs must resolve to the same state data.
"""
card1_id = seeded_data["card_id"]
card2_id = seeded_data["card2_id"]
resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER)
resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER)
assert resp1.status_code == 200
assert resp2.status_code == 200
data1 = resp1.json()
data2 = resp2.json()
assert data1["player_id"] == data2["player_id"] == seeded_data["player_id"]
assert data1["current_tier"] == data2["current_tier"] == 1
assert data1["current_value"] == data2["current_value"] == 87.5
# ---------------------------------------------------------------------------
# Auth tests
# ---------------------------------------------------------------------------
@_skip_no_pg
def test_auth_required(client, seeded_data):
"""Both endpoints return 401 when no Bearer token is provided.
Verifies that the valid_token dependency is enforced on:
GET /api/v2/teams/{id}/evolutions
GET /api/v2/evolution/cards/{id}
"""
team_id = seeded_data["team_id"]
card_id = seeded_data["card_id"]
resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions")
assert resp_list.status_code == 401
resp_card = client.get(f"/api/v2/evolution/cards/{card_id}")
assert resp_card.status_code == 401