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