paper-dynasty-database/tests/test_refractor_init.py
Cal Corum b7dec3f231 refactor: rename evolution system to refractor
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>
2026-03-23 13:31:55 -05:00

327 lines
12 KiB
Python

"""
Tests for WP-10: refractor_card_state initialization on pack opening.
Covers `app/services/refractor_init.py` — the `initialize_card_evolution`
function that creates an RefractorCardState 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
RefractorCardState 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,
RefractorCardState,
RefractorTrack,
Player,
)
from app.services.refractor_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) -> RefractorTrack:
"""Create an RefractorTrack 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 RefractorTrack.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 refractor 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 refractor 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 RefractorTrack 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 RefractorCardState 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 = (
RefractorCardState.select()
.where(
RefractorCardState.player == player,
RefractorCardState.team == team,
)
.count()
)
assert count == 1
# Progress was NOT reset
refreshed = RefractorCardState.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' RefractorTrack.
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' RefractorTrack."""
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