Complete rename of the card progression system from "Evolution" to "Refractor" across all code, routes, models, services, seeds, and tests. - Route prefix: /api/v2/evolution → /api/v2/refractor - Model classes: EvolutionTrack → RefractorTrack, etc. - 12 files renamed, 8 files content-edited - New migration to rename DB tables - 117 tests pass, no logic changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""
|
|
Tests for refractor-related models and BattingSeasonStats.
|
|
|
|
Covers WP-01 acceptance criteria:
|
|
- RefractorTrack: CRUD and unique-name constraint
|
|
- RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
|
|
and FK resolution back to RefractorTrack
|
|
- RefractorTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
|
|
- RefractorCosmetic: 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 (
|
|
BattingSeasonStats,
|
|
RefractorCardState,
|
|
RefractorCosmetic,
|
|
RefractorTierBoost,
|
|
RefractorTrack,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RefractorTrack
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRefractorTrack:
|
|
"""Tests for the RefractorTrack model.
|
|
|
|
RefractorTrack 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 RefractorTrack.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):
|
|
RefractorTrack.create(
|
|
name="Batter Track", # duplicate
|
|
card_type="sp",
|
|
formula="outs * 3",
|
|
t1_threshold=10,
|
|
t2_threshold=40,
|
|
t3_threshold=120,
|
|
t4_threshold=240,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RefractorCardState
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRefractorCardState:
|
|
"""Tests for RefractorCardState, which tracks per-player refractor progress.
|
|
|
|
Each row represents one card (player) owned by one team, linked to a
|
|
specific RefractorTrack. 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 = RefractorCardState.create(player=player, team=team, track=track)
|
|
|
|
fetched = RefractorCardState.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 refractor state per team roster slot, preventing
|
|
duplicate refractor progress rows for the same physical card.
|
|
"""
|
|
RefractorCardState.create(player=player, team=team, track=track)
|
|
with pytest.raises(IntegrityError):
|
|
RefractorCardState.create(player=player, team=team, track=track)
|
|
|
|
def test_card_state_fk_track(self, player, team, track):
|
|
"""Accessing card_state.track returns the original RefractorTrack 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 = RefractorCardState.create(player=player, team=team, track=track)
|
|
fetched = RefractorCardState.get_by_id(state.id)
|
|
resolved_track = fetched.track
|
|
assert resolved_track.id == track.id
|
|
assert resolved_track.name == "Batter Track"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RefractorTierBoost
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRefractorTierBoost:
|
|
"""Tests for RefractorTierBoost, 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 = RefractorTierBoost.create(
|
|
track=track,
|
|
tier=1,
|
|
boost_type="rating",
|
|
boost_target="contact_vl",
|
|
boost_value=1.5,
|
|
)
|
|
fetched = RefractorTierBoost.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.
|
|
"""
|
|
RefractorTierBoost.create(
|
|
track=track,
|
|
tier=2,
|
|
boost_type="rating",
|
|
boost_target="power_vr",
|
|
boost_value=2.0,
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
RefractorTierBoost.create(
|
|
track=track,
|
|
tier=2,
|
|
boost_type="rating",
|
|
boost_target="power_vr",
|
|
boost_value=3.0, # different value, same identity columns
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RefractorCosmetic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRefractorCosmetic:
|
|
"""Tests for RefractorCosmetic, 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 = RefractorCosmetic.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 = RefractorCosmetic.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 RefractorCosmetic.name prevents duplicate
|
|
cosmetic definitions that could cause ambiguous tier unlock lookups.
|
|
"""
|
|
RefractorCosmetic.create(
|
|
name="Silver Badge",
|
|
tier_required=1,
|
|
cosmetic_type="badge",
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
RefractorCosmetic.create(
|
|
name="Silver Badge", # duplicate
|
|
tier_required=3,
|
|
cosmetic_type="badge",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BattingSeasonStats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBattingSeasonStats:
|
|
"""Tests for BattingSeasonStats, the per-season batting accumulation table.
|
|
|
|
Each row aggregates game-by-game batting 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 = BattingSeasonStats.create(
|
|
player=player,
|
|
team=team,
|
|
season=11,
|
|
games=5,
|
|
pa=20,
|
|
ab=18,
|
|
hits=6,
|
|
doubles=1,
|
|
triples=0,
|
|
hr=2,
|
|
bb=2,
|
|
hbp=0,
|
|
strikeouts=4,
|
|
rbi=5,
|
|
runs=3,
|
|
sb=1,
|
|
cs=0,
|
|
)
|
|
fetched = BattingSeasonStats.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 == 5
|
|
assert fetched.pa == 20
|
|
assert fetched.hits == 6
|
|
assert fetched.hr == 2
|
|
assert fetched.strikeouts == 4
|
|
# 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.
|
|
"""
|
|
BattingSeasonStats.create(player=player, team=team, season=11)
|
|
with pytest.raises(IntegrityError):
|
|
BattingSeasonStats.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 = BattingSeasonStats.create(
|
|
player=player,
|
|
team=team,
|
|
season=11,
|
|
hits=10,
|
|
)
|
|
stats.hits += 3
|
|
stats.save()
|
|
|
|
refreshed = BattingSeasonStats.get_by_id(stats.id)
|
|
assert refreshed.hits == 13
|