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>
243 lines
9.6 KiB
Python
243 lines
9.6 KiB
Python
"""
|
|
Tests for app/seed/refractor_tracks.py — seed_refractor_tracks().
|
|
|
|
What: Verify that the JSON-driven seed function correctly creates, counts,
|
|
and idempotently updates RefractorTrack rows in the database.
|
|
|
|
Why: The seed is the single source of truth for track configuration. A
|
|
regression here (duplicates, wrong thresholds, missing formula) would
|
|
silently corrupt refractor scoring for every card in the system.
|
|
|
|
Each test operates on a fresh in-memory SQLite database provided by the
|
|
autouse `setup_test_db` fixture in conftest.py. The seed reads its data
|
|
from `app/seed/refractor_tracks.json` on disk, so the tests also serve as
|
|
a light integration check between the JSON file and the Peewee model.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.db_engine import RefractorTrack
|
|
from app.seed.refractor_tracks import seed_refractor_tracks
|
|
|
|
# Path to the JSON fixture that the seed reads from at runtime
|
|
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "refractor_tracks.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def json_tracks():
|
|
"""Load the raw JSON definitions so tests can assert against them.
|
|
|
|
This avoids hardcoding expected values — if the JSON changes, tests
|
|
automatically follow without needing manual updates.
|
|
"""
|
|
return json.loads(_JSON_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_seed_creates_three_tracks(json_tracks):
|
|
"""After one seed call, exactly 3 RefractorTrack rows must exist.
|
|
|
|
Why: The JSON currently defines three card-type tracks (batter, sp, rp).
|
|
If the count is wrong the system would either be missing tracks
|
|
(refractor disabled for a card type) or have phantom extras.
|
|
"""
|
|
seed_refractor_tracks()
|
|
assert RefractorTrack.select().count() == 3
|
|
|
|
|
|
def test_seed_correct_card_types(json_tracks):
|
|
"""The set of card_type values persisted must match the JSON exactly.
|
|
|
|
Why: card_type is used as a discriminator throughout the refractor engine.
|
|
An unexpected value (e.g. 'pitcher' instead of 'sp') would cause
|
|
track-lookup misses and silently skip refractor scoring for that role.
|
|
"""
|
|
seed_refractor_tracks()
|
|
expected_types = {d["card_type"] for d in json_tracks}
|
|
actual_types = {t.card_type for t in RefractorTrack.select()}
|
|
assert actual_types == expected_types
|
|
|
|
|
|
def test_seed_thresholds_ascending():
|
|
"""For every track, t1 < t2 < t3 < t4.
|
|
|
|
Why: The refractor engine uses these thresholds to determine tier
|
|
boundaries. If they are not strictly ascending, tier comparisons
|
|
would produce incorrect or undefined results (e.g. a player could
|
|
simultaneously satisfy tier 3 and not satisfy tier 2).
|
|
"""
|
|
seed_refractor_tracks()
|
|
for track in RefractorTrack.select():
|
|
assert track.t1_threshold < track.t2_threshold, (
|
|
f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})"
|
|
)
|
|
assert track.t2_threshold < track.t3_threshold, (
|
|
f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})"
|
|
)
|
|
assert track.t3_threshold < track.t4_threshold, (
|
|
f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})"
|
|
)
|
|
|
|
|
|
def test_seed_thresholds_positive():
|
|
"""All tier threshold values must be strictly greater than zero.
|
|
|
|
Why: A zero or negative threshold would mean a card starts the game
|
|
already evolved (tier >= 1 at 0 accumulated stat points), which would
|
|
bypass the entire refractor progression system.
|
|
"""
|
|
seed_refractor_tracks()
|
|
for track in RefractorTrack.select():
|
|
assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive"
|
|
assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive"
|
|
assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive"
|
|
assert track.t4_threshold > 0, f"{track.name}: t4_threshold is not positive"
|
|
|
|
|
|
def test_seed_formula_present():
|
|
"""Every persisted track must have a non-empty formula string.
|
|
|
|
Why: The formula is evaluated at runtime to compute a player's refractor
|
|
score. An empty formula would cause either a Python eval error or
|
|
silently produce 0 for every player, halting all refractor progress.
|
|
"""
|
|
seed_refractor_tracks()
|
|
for track in RefractorTrack.select():
|
|
assert track.formula and track.formula.strip(), (
|
|
f"{track.name}: formula is empty or whitespace-only"
|
|
)
|
|
|
|
|
|
def test_seed_idempotent():
|
|
"""Calling seed_refractor_tracks() twice must still yield exactly 3 rows.
|
|
|
|
Why: The seed is designed to be safe to re-run (e.g. as part of a
|
|
migration or CI bootstrap). If it inserts duplicates on a second call,
|
|
the unique constraint on RefractorTrack.name would raise an IntegrityError
|
|
in PostgreSQL, and in SQLite it would silently create phantom rows that
|
|
corrupt tier-lookup joins.
|
|
"""
|
|
seed_refractor_tracks()
|
|
seed_refractor_tracks()
|
|
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.
|
|
|
|
What: Seed once, manually mutate a threshold in the DB, then seed again.
|
|
Assert that the threshold is now back to the JSON-defined value.
|
|
|
|
Why: The seed must act as the authoritative source of truth. If
|
|
re-seeding does not overwrite local changes, configuration drift can
|
|
build up silently and the production database would diverge from the
|
|
checked-in JSON without any visible error.
|
|
"""
|
|
seed_refractor_tracks()
|
|
|
|
# Pick the first track and corrupt its t1_threshold
|
|
first_def = json_tracks[0]
|
|
track = RefractorTrack.get(RefractorTrack.name == first_def["name"])
|
|
original_t1 = track.t1_threshold
|
|
corrupted_value = original_t1 + 9999
|
|
track.t1_threshold = corrupted_value
|
|
track.save()
|
|
|
|
# Confirm the corruption took effect before re-seeding
|
|
track_check = RefractorTrack.get(RefractorTrack.name == first_def["name"])
|
|
assert track_check.t1_threshold == corrupted_value
|
|
|
|
# Re-seed — should restore the JSON value
|
|
seed_refractor_tracks()
|
|
|
|
restored = RefractorTrack.get(RefractorTrack.name == first_def["name"])
|
|
assert restored.t1_threshold == first_def["t1_threshold"], (
|
|
f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, "
|
|
f"got {restored.t1_threshold}"
|
|
)
|