"""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