paper-dynasty-database/tests/test_evolution_models.py
Cal Corum 264c7dc73c 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>
2026-03-18 13:41:05 -05:00

333 lines
12 KiB
Python

"""
Tests for evolution-related models and BattingSeasonStats.
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
- 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
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 (
PlayerSeasonStats,
EvolutionCardState,
EvolutionCosmetic,
EvolutionTierBoost,
EvolutionTrack,
)
# ---------------------------------------------------------------------------
# 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",
)
# ---------------------------------------------------------------------------
# BattingSeasonStats
# ---------------------------------------------------------------------------
class TestPlayerSeasonStats:
"""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
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