Implements all gap tests identified in the PO review for the refractor
card progression system (Phase 1 foundation).
TIER 1 (critical):
- T1-1: Negative singles guard in compute_batter_value — documents that
hits=1, doubles=1, triples=1 produces singles=-1 and flows through
unclamped (value=8.0, not 10.0)
- T1-2: SP tier boundary precision with floats — outs=29 (IP=9.666) stays
T0, outs=30 (IP=10.0) promotes to T1; also covers T2 float boundary
- T1-3: evaluate-game with non-existent game_id returns 200 with empty results
- T1-4: Seed threshold ordering + positivity invariant (t1<t2<t3<t4, all >0)
TIER 2 (high):
- T2-1: fully_evolved=True persists when stats are zeroed or drop below
previous tier — no-regression applies to both tier and fully_evolved flag
- T2-2: Parametrized edge cases for _determine_card_type: DH, C, 2B, empty
string, None, and compound "SP/RP" (resolves to "sp", SP checked first)
- T2-3: evaluate-game with zero StratPlay rows returns empty batch result
- T2-4: GET /teams/{id}/refractors with valid team and zero states is empty
- T2-5: GET /teams/99999/refractors documents 200+empty (no team existence check)
- T2-6: POST /cards/{id}/evaluate with zero season stats stays at T0 value=0.0
- T2-9: Per-player error isolation — patches source module so router's local
from-import picks up the patched version; one failure, one success = evaluated=1
- T2-10: Each card_type has exactly one RefractorTrack after seeding
All 101 tests pass (15 PostgreSQL-only tests skip without POSTGRES_HOST).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
371 lines
14 KiB
Python
371 lines
14 KiB
Python
"""
|
|
Tests for WP-10: refractor_card_state initialization on pack opening.
|
|
|
|
Covers `app/services/refractor_init.py` — the `initialize_card_refractor`
|
|
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_refractor
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_refractor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDetermineCardTypeEdgeCases:
|
|
"""T2-2: Parametrized edge cases for _determine_card_type.
|
|
|
|
Covers all the boundary inputs identified in the PO review:
|
|
DH, C, 2B (batters), empty string, None, and the compound 'SP/RP'
|
|
which contains both 'SP' and 'RP' substrings.
|
|
|
|
The function checks 'SP' before 'RP'/'CP', so 'SP/RP' resolves to 'sp'.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"pos_1, expected",
|
|
[
|
|
# Plain batter positions
|
|
("DH", "batter"),
|
|
("C", "batter"),
|
|
("2B", "batter"),
|
|
# Empty / None — fall through to batter default
|
|
("", "batter"),
|
|
(None, "batter"),
|
|
# Compound string containing 'SP' first — must resolve to 'sp'
|
|
# because _determine_card_type checks "SP" in pos.upper() before RP/CP
|
|
("SP/RP", "sp"),
|
|
],
|
|
)
|
|
def test_position_mapping(self, pos_1, expected):
|
|
"""_determine_card_type maps each pos_1 value to the expected card_type.
|
|
|
|
What: Directly exercises _determine_card_type with the given pos_1 string.
|
|
None is handled by the `(player.pos_1 or "").upper()` guard in the
|
|
implementation, so it falls through to 'batter'.
|
|
|
|
Why: The card_type string is the key used to look up a RefractorTrack.
|
|
An incorrect mapping silently assigns the wrong thresholds to a player's
|
|
entire refractor journey. Parametrized so each edge case is a
|
|
distinct, independently reported test failure.
|
|
"""
|
|
player = _FakePlayer(pos_1)
|
|
assert _determine_card_type(player) == expected, (
|
|
f"pos_1={pos_1!r}: expected {expected!r}, "
|
|
f"got {_determine_card_type(player)!r}"
|
|
)
|
|
|
|
|
|
class TestInitializeCardEvolution:
|
|
"""Integration tests for initialize_card_refractor 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_refractor 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_refractor(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_refractor(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_refractor(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_refractor(player_a.player_id, team.id, "batter")
|
|
state_b = initialize_card_refractor(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_refractor(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_refractor(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_refractor(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_refractor(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_refractor(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_refractor(player.player_id, team.id, card_type)
|
|
|
|
assert state is not None
|
|
assert state.track_id == self.rp_track.id
|