test: add Tier 1 and Tier 2 refractor system test cases

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>
This commit is contained in:
Cal Corum 2026-03-24 09:02:30 -05:00
parent bc6c23ef2e
commit 569dc53c00
6 changed files with 718 additions and 0 deletions

View File

@ -204,3 +204,120 @@ def test_tier_t3_boundary():
def test_tier_accepts_namespace_track():
"""tier_from_value must work with attribute-style track objects (Peewee models)."""
assert tier_from_value(37, track_ns("batter")) == 1
# ---------------------------------------------------------------------------
# T1-1: Negative singles guard in compute_batter_value
# ---------------------------------------------------------------------------
def test_batter_negative_singles_component():
"""hits=1, doubles=1, triples=1, hr=0 produces singles=-1.
What: The formula computes singles = hits - doubles - triples - hr.
With hits=1, doubles=1, triples=1, hr=0 the result is singles = -1,
which is a physically impossible stat line but valid arithmetic input.
Why: Document the formula's actual behaviour when given an incoherent stat
line so that callers are aware that no clamping or guard exists. If a
guard is added in the future, this test will catch the change in behaviour.
singles = 1 - 1 - 1 - 0 = -1
tb = (-1)*1 + 1*2 + 1*3 + 0*4 = -1 + 2 + 3 = 4
value = pa + tb*2 = 0 + 4*2 = 8
"""
stats = batter_stats(hits=1, doubles=1, triples=1, hr=0)
# singles will be -1; the formula does NOT clamp, so TB = 4 and value = 8.0
result = compute_batter_value(stats)
assert result == 8.0, (
f"Expected 8.0 (negative singles flows through unclamped), got {result}"
)
def test_batter_negative_singles_is_not_clamped():
"""A singles value below zero is NOT clamped to zero by the formula.
What: Confirms that singles < 0 propagates into TB rather than being
floored at 0. If clamping were added, tb would be 0*1 + 1*2 + 1*3 = 5
and value would be 10.0, not 8.0.
Why: Guards future refactors if someone adds `singles = max(0, ...)`,
this assertion will fail immediately, surfacing the behaviour change.
"""
stats = batter_stats(hits=1, doubles=1, triples=1, hr=0)
unclamped_value = compute_batter_value(stats)
# If singles were clamped to 0: tb = 0+2+3 = 5, value = 10.0
clamped_value = 10.0
assert unclamped_value != clamped_value, (
"Formula appears to clamp negative singles — behaviour has changed"
)
# ---------------------------------------------------------------------------
# T1-2: Tier boundary precision with float SP values
# ---------------------------------------------------------------------------
def test_sp_tier_just_below_t1_outs29():
"""SP with outs=29 produces IP=9.666..., which is below T1 threshold (10) → T0.
What: 29 outs / 3 = 9.6666... IP + 0 K = 9.6666... value.
The SP T1 threshold is 10.0, so this value is strictly below T1.
Why: Floating-point IP values accumulate slowly for pitchers. A bug that
truncated or rounded IP upward could cause premature tier advancement.
Verify that tier_from_value uses a >= comparison (not >) and handles
non-integer values correctly.
"""
stats = pitcher_stats(outs=29, strikeouts=0)
value = compute_sp_value(stats)
assert value == pytest.approx(29 / 3) # 9.6666...
assert value < 10.0 # strictly below T1
assert tier_from_value(value, track_dict("sp")) == 0
def test_sp_tier_exactly_t1_outs30():
"""SP with outs=30 produces IP=10.0, exactly at T1 threshold → T1.
What: 30 outs / 3 = 10.0 IP + 0 K = 10.0 value.
The SP T1 threshold is 10.0, so value == t1 satisfies the >= condition.
Why: Off-by-one or strictly-greater-than comparisons would classify
this as T0 instead of T1. The boundary value must correctly promote
to the matching tier.
"""
stats = pitcher_stats(outs=30, strikeouts=0)
value = compute_sp_value(stats)
assert value == 10.0
assert tier_from_value(value, track_dict("sp")) == 1
def test_sp_float_value_at_exact_t2_boundary():
"""SP value exactly at T2 threshold (40.0) → T2.
What: outs=120 -> IP=40.0, strikeouts=0 -> value=40.0.
T2 threshold for SP is 40. The >= comparison must promote to T2.
Why: Validates that all four tier thresholds use inclusive lower-bound
comparisons for float values, not just T1.
"""
stats = pitcher_stats(outs=120, strikeouts=0)
value = compute_sp_value(stats)
assert value == 40.0
assert tier_from_value(value, track_dict("sp")) == 2
def test_sp_float_value_just_below_t2():
"""SP value just below T2 (39.999...) stays at T1.
What: outs=119 -> IP=39.6666..., strikeouts=0 -> value=39.666...
This is strictly less than T2=40, so tier should be 1 (already past T1=10).
Why: Confirms that sub-threshold float values are not prematurely promoted
due to floating-point comparison imprecision.
"""
stats = pitcher_stats(outs=119, strikeouts=0)
value = compute_sp_value(stats)
assert value == pytest.approx(119 / 3) # 39.666...
assert value < 40.0
assert tier_from_value(value, track_dict("sp")) == 1

View File

@ -665,3 +665,136 @@ def test_auth_required_evaluate_game(client):
resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# T1-3: evaluate-game with non-existent game_id
# ---------------------------------------------------------------------------
def test_evaluate_game_nonexistent_game_id(client):
"""POST /refractor/evaluate-game/99999 with a game_id that does not exist.
What: There is no StratGame row with id=99999. The endpoint queries
StratPlay for plays in that game, finds zero rows, builds an empty
pairs set, and returns without evaluating anyone.
Why: Documents the confirmed behaviour: 200 with {"evaluated": 0,
"tier_ups": []}. The endpoint does not treat a missing game as an
error because StratPlay.select().where(game_id=N) returning 0 rows is
a valid (if unusual) outcome there are simply no players to evaluate.
If the implementation is ever changed to return 404 for missing games,
this test will fail and alert the developer to update the contract.
"""
resp = client.post("/api/v2/refractor/evaluate-game/99999", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["evaluated"] == 0
assert data["tier_ups"] == []
# ---------------------------------------------------------------------------
# T2-3: evaluate-game with zero plays
# ---------------------------------------------------------------------------
def test_evaluate_game_zero_plays(client):
"""evaluate-game on a game with no StratPlay rows returns empty results.
What: Create a StratGame but insert zero StratPlay rows for it. POST
to evaluate-game for that game_id.
Why: The endpoint builds its player list from StratPlay rows. A game
with no plays has no players to evaluate. Verify the endpoint does not
crash and returns the expected empty-batch shape rather than raising a
KeyError or returning an unexpected structure.
"""
team_a = _make_team("ZP1", gmid=20101)
team_b = _make_team("ZP2", gmid=20102)
game = _make_game(team_a, team_b)
# Intentionally no plays created
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["evaluated"] == 0
assert data["tier_ups"] == []
# ---------------------------------------------------------------------------
# T2-9: Per-player error isolation in evaluate_game
# ---------------------------------------------------------------------------
def test_evaluate_game_error_isolation(client, monkeypatch):
"""An exception raised for one player does not abort the rest of the batch.
What: Create two batters in the same game. Both have RefractorCardState
rows. Patch evaluate_card in the refractor router to raise RuntimeError
on the first call and succeed on the second. Verify the endpoint returns
200, evaluated==1 (not 0 or 2), and no tier_ups from the failing player.
Why: The evaluate-game loop catches per-player exceptions and logs them.
If the isolation breaks, a single bad card would silently drop all
evaluations for the rest of the game. The 'evaluated' count is the
observable signal that error isolation is functioning.
Implementation note: we patch the evaluate_card function inside the
router module directly so that the test is independent of how the router
imports it. We use a counter to let the first call fail and the second
succeed.
"""
from app.services import refractor_evaluator
team_a = _make_team("EI1", gmid=20111)
team_b = _make_team("EI2", gmid=20112)
batter_fail = _make_player("WP13 Fail Batter", pos="1B")
batter_ok = _make_player("WP13 Ok Batter", pos="1B")
pitcher = _make_player("WP13 EI Pitcher", pos="SP")
game = _make_game(team_a, team_b)
# Both batters need season stats and a track/state so they are not
# skipped by the "no state" guard before evaluate_card is called.
track = _make_track(name="EI Batter Track")
_make_state(batter_fail, team_a, track)
_make_state(batter_ok, team_a, track)
_make_play(game, 1, batter_fail, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
_make_play(game, 2, batter_ok, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
# The real evaluate_card for batter_ok so we know what it returns
real_evaluate = refractor_evaluator.evaluate_card
call_count = {"n": 0}
fail_player_id = batter_fail.player_id
def patched_evaluate(player_id, team_id, **kwargs):
call_count["n"] += 1
if player_id == fail_player_id:
raise RuntimeError("simulated per-player error")
return real_evaluate(player_id, team_id, **kwargs)
# The router does `from ..services.refractor_evaluator import evaluate_card`
# inside the async function body, so the local import re-resolves on each
# call. Patching the function on its source module ensures the local `from`
# import picks up our patched version when the route handler executes.
monkeypatch.setattr(
"app.services.refractor_evaluator.evaluate_card", patched_evaluate
)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# One player succeeded; one was caught by the exception handler
assert data["evaluated"] == 1
# The failing player must not appear in tier_ups
failing_ids = [tu["player_id"] for tu in data["tier_ups"]]
assert fail_player_id not in failing_ids

View File

@ -325,6 +325,59 @@ class TestCareerTotals:
assert result["current_value"] == 50.0
class TestFullyEvolvedPersistence:
"""T2-1: fully_evolved=True is preserved even when stats drop or are absent."""
def test_fully_evolved_persists_when_stats_zeroed(self, batter_track):
"""Card at T4/fully_evolved=True stays fully_evolved after stats are removed.
What: Set up a RefractorCardState at tier=4 with fully_evolved=True.
Then call evaluate_card with no season stats rows (zero career totals).
The evaluator computes value=0 -> new_tier=0, but current_tier must
stay at 4 (no regression) and fully_evolved must remain True.
Why: fully_evolved is a permanent achievement flag it must not be
revoked if a team's stats are rolled back, corrected, or simply not
yet imported. The no-regression rule (max(current, new)) prevents
tier demotion; this test confirms that fully_evolved follows the same
protection.
"""
# Seed state at T4 fully_evolved
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
# No stats rows — career totals will be all zeros
# (no _make_stats call)
result = _eval(1, 1)
# The no-regression rule keeps tier at 4
assert result["current_tier"] == 4, (
f"Expected tier=4 (no regression), got {result['current_tier']}"
)
# fully_evolved must still be True since tier >= 4
assert result["fully_evolved"] is True, (
"fully_evolved was reset to False after re-evaluation with zero stats"
)
def test_fully_evolved_persists_with_partial_stats(self, batter_track):
"""Card at T4 stays fully_evolved even with stats below T1.
What: Same setup as above but with a season stats row giving value=30
(below T1=37). The computed tier would be 0, but current_tier must
not regress from 4.
Why: Validates that no-regression applies regardless of whether stats
are zero or merely insufficient for the achieved tier.
"""
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
# pa=30 -> value=30, which is below T1=37 -> computed tier=0
_make_stats(1, 1, 1, pa=30)
result = _eval(1, 1)
assert result["current_tier"] == 4
assert result["fully_evolved"] is True
class TestMissingState:
"""ValueError when no card state exists for (player_id, team_id)."""

View File

@ -158,6 +158,50 @@ class TestDetermineCardType:
# ---------------------------------------------------------------------------
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.

View File

@ -124,6 +124,89 @@ def test_seed_idempotent():
assert RefractorTrack.select().count() == 3
# ---------------------------------------------------------------------------
# T1-4: Seed threshold ordering invariant (t1 < t2 < t3 < t4 + all positive)
# ---------------------------------------------------------------------------
def test_seed_all_thresholds_strictly_ascending_after_seed():
"""After seeding, every track satisfies t1 < t2 < t3 < t4.
What: Call seed_refractor_tracks(), then assert the full ordering chain
t1 < t2 < t3 < t4 for every row in the database. Also assert that all
four thresholds are strictly positive (> 0).
Why: The refractor tier engine uses these thresholds as exclusive partition
points. If any threshold is out-of-order or zero the tier assignment
becomes incorrect or undefined. This test is the authoritative invariant
guard; if a JSON edit accidentally violates the ordering this test fails
loudly before any cards are affected.
Separate from test_seed_thresholds_ascending which was written earlier
this test combines ordering + positivity into a single explicit assertion
block and uses more descriptive messages to aid debugging.
"""
seed_refractor_tracks()
for track in RefractorTrack.select():
assert track.t1_threshold > 0, (
f"{track.name}: t1_threshold={track.t1_threshold} is not positive"
)
assert track.t2_threshold > 0, (
f"{track.name}: t2_threshold={track.t2_threshold} is not positive"
)
assert track.t3_threshold > 0, (
f"{track.name}: t3_threshold={track.t3_threshold} is not positive"
)
assert track.t4_threshold > 0, (
f"{track.name}: t4_threshold={track.t4_threshold} is not positive"
)
assert (
track.t1_threshold
< track.t2_threshold
< track.t3_threshold
< track.t4_threshold
), (
f"{track.name}: thresholds are not strictly ascending: "
f"t1={track.t1_threshold}, t2={track.t2_threshold}, "
f"t3={track.t3_threshold}, t4={track.t4_threshold}"
)
# ---------------------------------------------------------------------------
# T2-10: Duplicate card_type tracks guard
# ---------------------------------------------------------------------------
def test_seed_each_card_type_has_exactly_one_track():
"""Each card_type must appear exactly once across all RefractorTrack rows.
What: After seeding, group the rows by card_type and assert that every
card_type has a count of exactly 1.
Why: RefractorTrack rows are looked up by card_type (e.g.
RefractorTrack.get(card_type='batter')). If a card_type appears more
than once, Peewee's .get() raises MultipleObjectsReturned, crashing
every pack opening and card evaluation for that type. This test acts as
a uniqueness contract so that seed bugs or accidental DB drift surface
immediately.
"""
seed_refractor_tracks()
from peewee import fn as peewee_fn
# Group by card_type and count occurrences
query = (
RefractorTrack.select(
RefractorTrack.card_type, peewee_fn.COUNT(RefractorTrack.id).alias("cnt")
)
.group_by(RefractorTrack.card_type)
.tuples()
)
for card_type, count in query:
assert count == 1, (
f"card_type={card_type!r} has {count} tracks; expected exactly 1"
)
def test_seed_updates_on_rerun(json_tracks):
"""A second seed call must restore any manually changed threshold to the JSON value.

View File

@ -38,8 +38,38 @@ Test matrix
import os
os.environ.setdefault("API_TOKEN", "test-token")
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
)
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
_skip_no_pg = pytest.mark.skipif(
@ -607,3 +637,261 @@ def test_auth_required(client, seeded_data):
resp_card = client.get(f"/api/v2/refractor/cards/{card_id}")
assert resp_card.status_code == 401
# ===========================================================================
# SQLite-backed tests for T2-4, T2-5, T2-6
#
# These tests use the same shared-memory SQLite pattern as test_postgame_refractor
# so they run without a PostgreSQL connection. They test the
# GET /api/v2/teams/{team_id}/refractors and POST /refractor/cards/{card_id}/evaluate
# endpoints in isolation.
# ===========================================================================
_state_api_db = SqliteDatabase(
"file:stateapitest?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
_STATE_API_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
@pytest.fixture(autouse=False)
def setup_state_api_db():
"""Bind state-api test models to shared-memory SQLite and create tables.
Not autouse only the SQLite-backed tests in this section depend on it.
"""
_state_api_db.bind(_STATE_API_MODELS)
_state_api_db.connect(reuse_if_open=True)
_state_api_db.create_tables(_STATE_API_MODELS)
yield _state_api_db
_state_api_db.drop_tables(list(reversed(_STATE_API_MODELS)), safe=True)
def _build_state_api_app() -> FastAPI:
"""Minimal FastAPI app with teams + refractor routers for SQLite tests."""
from app.routers_v2.teams import router as teams_router
from app.routers_v2.refractor import router as refractor_router
app = FastAPI()
@app.middleware("http")
async def db_middleware(request: Request, call_next):
_state_api_db.connect(reuse_if_open=True)
return await call_next(request)
app.include_router(teams_router)
app.include_router(refractor_router)
return app
@pytest.fixture
def state_api_client(setup_state_api_db):
"""FastAPI TestClient for the SQLite-backed state API tests."""
with TestClient(_build_state_api_app()) as c:
yield c
# ---------------------------------------------------------------------------
# Helper factories for SQLite-backed tests
# ---------------------------------------------------------------------------
def _sa_make_rarity():
r, _ = Rarity.get_or_create(
value=50, name="SA_Common", defaults={"color": "#aabbcc"}
)
return r
def _sa_make_cardset():
cs, _ = Cardset.get_or_create(
name="SA Test Set",
defaults={"description": "state api test", "total_cards": 10},
)
return cs
def _sa_make_team(abbrev: str, gmid: int) -> Team:
return Team.create(
abbrev=abbrev,
sname=abbrev,
lname=f"Team {abbrev}",
gmid=gmid,
gmname=f"gm_{abbrev.lower()}",
gsheet="https://docs.google.com/sa_test",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=False,
)
def _sa_make_player(name: str, pos: str = "1B") -> Player:
return Player.create(
p_name=name,
rarity=_sa_make_rarity(),
cardset=_sa_make_cardset(),
set_num=1,
pos_1=pos,
image="https://example.com/sa.png",
mlbclub="TST",
franchise="TST",
description=f"sa test: {name}",
)
def _sa_make_track(card_type: str = "batter") -> RefractorTrack:
track, _ = RefractorTrack.get_or_create(
name=f"SA {card_type} Track",
defaults=dict(
card_type=card_type,
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
return track
def _sa_make_pack(team: Team) -> Pack:
pt, _ = PackType.get_or_create(
name="SA PackType",
defaults={"cost": 100, "card_count": 5, "description": "sa test pack type"},
)
return Pack.create(team=team, pack_type=pt)
def _sa_make_card(player: Player, team: Team) -> Card:
pack = _sa_make_pack(team)
return Card.create(player=player, team=team, pack=pack, value=0)
def _sa_make_state(player, team, track, current_tier=0, current_value=0.0):
return RefractorCardState.create(
player=player,
team=team,
track=track,
current_tier=current_tier,
current_value=current_value,
fully_evolved=False,
last_evaluated_at=None,
)
# ---------------------------------------------------------------------------
# T2-4: GET /teams/{valid_team_id}/refractors — team exists, zero states
# ---------------------------------------------------------------------------
def test_team_refractors_zero_states(setup_state_api_db, state_api_client):
"""GET /teams/{id}/refractors for a team with no RefractorCardState rows.
What: Create a Team with no associated RefractorCardState rows.
Call the endpoint and verify the response is {"count": 0, "items": []}.
Why: The endpoint uses a JOIN from RefractorCardState to RefractorTrack
filtered by team_id. If the WHERE produces no rows, the correct response
is an empty list with count=0, not a 404 or 500. This is the base-case
for a newly-created team that hasn't opened any packs yet.
"""
team = _sa_make_team("SA4", gmid=30041)
resp = state_api_client.get(
f"/api/v2/teams/{team.id}/refractors", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["items"] == []
# ---------------------------------------------------------------------------
# T2-5: GET /teams/99999/refractors — non-existent team
# ---------------------------------------------------------------------------
def test_team_refractors_nonexistent_team(setup_state_api_db, state_api_client):
"""GET /teams/99999/refractors where team_id 99999 does not exist.
What: Call the endpoint with a team_id that has no Team row and no
RefractorCardState rows.
Why: Documents the confirmed behaviour: 200 with {"count": 0, "items": []}.
The endpoint queries RefractorCardState WHERE team_id=99999. Because no
state rows reference that team, the result is an empty list. The endpoint
does NOT validate that the Team row itself exists, so it does not return 404.
If the implementation is ever changed to validate team existence and return
404 for missing teams, this test will fail and surface the contract change.
"""
resp = state_api_client.get("/api/v2/teams/99999/refractors", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
# No state rows reference team 99999 — empty list with count=0
assert data["count"] == 0
assert data["items"] == []
# ---------------------------------------------------------------------------
# T2-6: POST /refractor/cards/{card_id}/evaluate — zero season stats → T0
# ---------------------------------------------------------------------------
def test_evaluate_card_zero_stats_stays_t0(setup_state_api_db, state_api_client):
"""POST /cards/{card_id}/evaluate for a card with no season stats stays at T0.
What: Create a Player, Team, Card, and RefractorCardState. Do NOT create
any BattingSeasonStats rows for this player+team. Call the evaluate
endpoint. The response must show current_tier=0 and current_value=0.0.
Why: A player who has never appeared in a game has zero career stats.
The evaluator sums all stats rows (none) -> all-zero totals ->
compute_batter_value(zeros) = 0.0 -> tier_from_value(0.0) = T0.
Verifies the happy-path zero-stats case returns a valid response rather
than crashing on an empty aggregation.
"""
team = _sa_make_team("SA6", gmid=30061)
player = _sa_make_player("SA6 Batter", pos="1B")
track = _sa_make_track("batter")
card = _sa_make_card(player, team)
_sa_make_state(player, team, track, current_tier=0, current_value=0.0)
# No BattingSeasonStats rows — intentionally empty
resp = state_api_client.post(
f"/api/v2/refractor/cards/{card.id}/evaluate", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["current_tier"] == 0
assert data["current_value"] == 0.0