diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index d02504b..99735b8 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -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 diff --git a/tests/test_postgame_refractor.py b/tests/test_postgame_refractor.py index 894f5fc..51659a5 100644 --- a/tests/test_postgame_refractor.py +++ b/tests/test_postgame_refractor.py @@ -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 diff --git a/tests/test_refractor_evaluator.py b/tests/test_refractor_evaluator.py index d1873cc..dabb144 100644 --- a/tests/test_refractor_evaluator.py +++ b/tests/test_refractor_evaluator.py @@ -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).""" diff --git a/tests/test_refractor_init.py b/tests/test_refractor_init.py index d8748bf..36f08ca 100644 --- a/tests/test_refractor_init.py +++ b/tests/test_refractor_init.py @@ -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. diff --git a/tests/test_refractor_seed.py b/tests/test_refractor_seed.py index 383e213..40b9365 100644 --- a/tests/test_refractor_seed.py +++ b/tests/test_refractor_seed.py @@ -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. diff --git a/tests/test_refractor_state_api.py b/tests/test_refractor_state_api.py index 6394219..2d9cb22 100644 --- a/tests/test_refractor_state_api.py +++ b/tests/test_refractor_state_api.py @@ -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