paper-dynasty-database/tests/test_formula_engine.py
Cal Corum eba23369ca fix: align tier_from_value with DB model field names (t1_threshold)
formula_engine.tier_from_value read track.t1/t2/t3/t4 but the
EvolutionTrack model defines t1_threshold/t2_threshold/etc. Updated
both the function and test fixtures to use the _threshold suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:07:07 -05:00

207 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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