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:
parent
bc6c23ef2e
commit
569dc53c00
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)."""
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user