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>
324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""Tests for the formula engine (WP-09).
|
||
|
||
Unit tests only — no database required. Stats inputs are simple namespace
|
||
objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose.
|
||
|
||
Tier thresholds used (from refractor_tracks.json seed data):
|
||
Batter: t1=37, t2=149, t3=448, t4=896
|
||
SP: t1=10, t2=40, t3=120, t4=240
|
||
RP: t1=3, t2=12, t3=35, t4=70
|
||
"""
|
||
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.services.formula_engine import (
|
||
compute_batter_value,
|
||
compute_rp_value,
|
||
compute_sp_value,
|
||
compute_value_for_track,
|
||
tier_from_value,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def batter_stats(**kwargs):
|
||
"""Build a minimal batter stats object with all fields defaulting to 0."""
|
||
defaults = {"pa": 0, "hits": 0, "doubles": 0, "triples": 0, "hr": 0}
|
||
defaults.update(kwargs)
|
||
return SimpleNamespace(**defaults)
|
||
|
||
|
||
def pitcher_stats(**kwargs):
|
||
"""Build a minimal pitcher stats object with all fields defaulting to 0."""
|
||
defaults = {"outs": 0, "strikeouts": 0}
|
||
defaults.update(kwargs)
|
||
return SimpleNamespace(**defaults)
|
||
|
||
|
||
def track_dict(card_type: str) -> dict:
|
||
"""Return the locked threshold dict for a given card_type."""
|
||
return {
|
||
"batter": {
|
||
"card_type": "batter",
|
||
"t1_threshold": 37,
|
||
"t2_threshold": 149,
|
||
"t3_threshold": 448,
|
||
"t4_threshold": 896,
|
||
},
|
||
"sp": {
|
||
"card_type": "sp",
|
||
"t1_threshold": 10,
|
||
"t2_threshold": 40,
|
||
"t3_threshold": 120,
|
||
"t4_threshold": 240,
|
||
},
|
||
"rp": {
|
||
"card_type": "rp",
|
||
"t1_threshold": 3,
|
||
"t2_threshold": 12,
|
||
"t3_threshold": 35,
|
||
"t4_threshold": 70,
|
||
},
|
||
}[card_type]
|
||
|
||
|
||
def track_ns(card_type: str):
|
||
"""Return a namespace (attribute-style) track for a given card_type."""
|
||
return SimpleNamespace(**track_dict(card_type))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# compute_batter_value
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_batter_formula_single_and_double():
|
||
"""4 PA, 1 single, 1 double: PA=4, TB=1+2=3, value = 4 + 3×2 = 10."""
|
||
stats = batter_stats(pa=4, hits=2, doubles=1)
|
||
assert compute_batter_value(stats) == 10.0
|
||
|
||
|
||
def test_batter_formula_no_hits():
|
||
"""4 PA, 0 hits: TB=0, value = 4 + 0 = 4."""
|
||
stats = batter_stats(pa=4)
|
||
assert compute_batter_value(stats) == 4.0
|
||
|
||
|
||
def test_batter_formula_hr_heavy():
|
||
"""4 PA, 2 HR: TB = 0 singles + 4×2 = 8, value = 4 + 8×2 = 20."""
|
||
stats = batter_stats(pa=4, hits=2, hr=2)
|
||
assert compute_batter_value(stats) == 20.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# compute_sp_value
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_sp_formula_standard():
|
||
"""18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0."""
|
||
stats = pitcher_stats(outs=18, strikeouts=5)
|
||
assert compute_sp_value(stats) == 11.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# compute_rp_value
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_rp_formula_standard():
|
||
"""3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0."""
|
||
stats = pitcher_stats(outs=3, strikeouts=2)
|
||
assert compute_rp_value(stats) == 3.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Zero stats
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_batter_zero_stats_returns_zero():
|
||
"""All-zero batter stats must return 0.0."""
|
||
assert compute_batter_value(batter_stats()) == 0.0
|
||
|
||
|
||
def test_sp_zero_stats_returns_zero():
|
||
"""All-zero SP stats must return 0.0."""
|
||
assert compute_sp_value(pitcher_stats()) == 0.0
|
||
|
||
|
||
def test_rp_zero_stats_returns_zero():
|
||
"""All-zero RP stats must return 0.0."""
|
||
assert compute_rp_value(pitcher_stats()) == 0.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Formula dispatch by track name
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_dispatch_batter():
|
||
"""compute_value_for_track('batter', ...) delegates to compute_batter_value."""
|
||
stats = batter_stats(pa=4, hits=2, doubles=1)
|
||
assert compute_value_for_track("batter", stats) == compute_batter_value(stats)
|
||
|
||
|
||
def test_dispatch_sp():
|
||
"""compute_value_for_track('sp', ...) delegates to compute_sp_value."""
|
||
stats = pitcher_stats(outs=18, strikeouts=5)
|
||
assert compute_value_for_track("sp", stats) == compute_sp_value(stats)
|
||
|
||
|
||
def test_dispatch_rp():
|
||
"""compute_value_for_track('rp', ...) delegates to compute_rp_value."""
|
||
stats = pitcher_stats(outs=3, strikeouts=2)
|
||
assert compute_value_for_track("rp", stats) == compute_rp_value(stats)
|
||
|
||
|
||
def test_dispatch_unknown_raises():
|
||
"""An unrecognised card_type must raise ValueError."""
|
||
with pytest.raises(ValueError, match="Unknown card_type"):
|
||
compute_value_for_track("dh", batter_stats())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# tier_from_value — batter thresholds (t1=37, t2=149, t3=448, t4=896)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_tier_exact_t1_boundary():
|
||
"""value=37 is exactly t1 for batter → T1."""
|
||
assert tier_from_value(37, track_dict("batter")) == 1
|
||
|
||
|
||
def test_tier_just_below_t1():
|
||
"""value=36 is just below t1=37 for batter → T0."""
|
||
assert tier_from_value(36, track_dict("batter")) == 0
|
||
|
||
|
||
def test_tier_t4_boundary():
|
||
"""value=896 is exactly t4 for batter → T4."""
|
||
assert tier_from_value(896, track_dict("batter")) == 4
|
||
|
||
|
||
def test_tier_above_t4():
|
||
"""value above t4 still returns T4 (fully evolved)."""
|
||
assert tier_from_value(1000, track_dict("batter")) == 4
|
||
|
||
|
||
def test_tier_t2_boundary():
|
||
"""value=149 is exactly t2 for batter → T2."""
|
||
assert tier_from_value(149, track_dict("batter")) == 2
|
||
|
||
|
||
def test_tier_t3_boundary():
|
||
"""value=448 is exactly t3 for batter → T3."""
|
||
assert tier_from_value(448, track_dict("batter")) == 3
|
||
|
||
|
||
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
|