paper-dynasty-database/tests/test_formula_engine.py
Cal Corum b7dec3f231 refactor: rename evolution system to refractor
Complete rename of the card progression system from "Evolution" to
"Refractor" across all code, routes, models, services, seeds, and tests.

- Route prefix: /api/v2/evolution → /api/v2/refractor
- Model classes: EvolutionTrack → RefractorTrack, etc.
- 12 files renamed, 8 files content-edited
- New migration to rename DB tables
- 117 tests pass, no logic changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:31:55 -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 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