When a card reaches a new Refractor tier during game evaluation, the system now creates a boosted variant card with modified ratings. This connects the Phase 2 Foundation pure functions (PR #176) to the live evaluate-game endpoint. Key changes: - evaluate_card() gains dry_run parameter so apply_tier_boost() is the sole writer of current_tier, ensuring atomicity with variant creation - apply_tier_boost() orchestrates the full boost flow: source card lookup, boost application, variant card + ratings creation, audit record, and atomic state mutations inside db.atomic() - evaluate_game() calls evaluate_card(dry_run=True) then loops through intermediate tiers on tier-up, with error isolation per player - Display stat helpers compute fresh avg/obp/slg for variant cards - REFRACTOR_BOOST_ENABLED env var provides a kill switch - 51 new tests: unit tests for display stats, integration tests for orchestration, HTTP endpoint tests for multi-tier jumps, pitcher path, kill switch, atomicity, idempotency, and cross-player isolation - Clarified all "79-sum" references to note the 108-total card invariant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1180 lines
48 KiB
Python
1180 lines
48 KiB
Python
"""Unit tests for refractor_boost.py — Phase 2 boost functions.
|
||
|
||
Tests are grouped by function: batter boost, pitcher boost, and variant hash.
|
||
Each class covers one behavioral concern.
|
||
|
||
The functions under test are pure (no DB, no I/O), so every test calls the
|
||
function directly with a plain dict and asserts on the returned dict.
|
||
"""
|
||
|
||
import pytest
|
||
from app.services.refractor_boost import (
|
||
apply_batter_boost,
|
||
apply_pitcher_boost,
|
||
compute_variant_hash,
|
||
compute_batter_display_stats,
|
||
compute_pitcher_display_stats,
|
||
BATTER_OUTCOME_COLUMNS,
|
||
PITCHER_OUTCOME_COLUMNS,
|
||
PITCHER_PRIORITY,
|
||
PITCHER_XCHECK_COLUMNS,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures: representative card ratings
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _silver_batter_vr():
|
||
"""Robinson Cano 2020 vR split — Silver batter with typical distribution.
|
||
|
||
Sum of the 22 outcome columns is exactly 108.0.
|
||
Has comfortable strikeout (14.7) and groundout_a (3.85) buffers so a
|
||
normal boost can be applied without triggering truncation.
|
||
flyout_rf_b adjusted to 21.2 so the 22-column total is exactly 108.0.
|
||
"""
|
||
return {
|
||
"homerun": 3.05,
|
||
"bp_homerun": 0.0,
|
||
"triple": 0.0,
|
||
"double_three": 2.25,
|
||
"double_two": 2.25,
|
||
"double_pull": 8.95,
|
||
"single_two": 3.35,
|
||
"single_one": 14.55,
|
||
"single_center": 5.0,
|
||
"bp_single": 0.0,
|
||
"hbp": 6.2,
|
||
"walk": 7.4,
|
||
"strikeout": 14.7,
|
||
"lineout": 0.0,
|
||
"popout": 0.0,
|
||
"flyout_a": 5.3,
|
||
"flyout_bq": 1.45,
|
||
"flyout_lf_b": 1.95,
|
||
"flyout_rf_b": 21.2,
|
||
"groundout_a": 3.85,
|
||
"groundout_b": 3.0,
|
||
"groundout_c": 3.55,
|
||
}
|
||
|
||
|
||
def _contact_batter_vl():
|
||
"""Low-strikeout contact hitter — different archetype for variety testing.
|
||
|
||
Strikeout (8.0) and groundout_a (5.0) are both non-zero, so the normal
|
||
boost applies without truncation. Sum of 22 columns is exactly 108.0.
|
||
flyout_rf_b raised to 16.5 to bring sum from 107.5 to 108.0.
|
||
"""
|
||
return {
|
||
"homerun": 1.5,
|
||
"bp_homerun": 0.0,
|
||
"triple": 0.5,
|
||
"double_three": 3.0,
|
||
"double_two": 3.0,
|
||
"double_pull": 7.0,
|
||
"single_two": 5.0,
|
||
"single_one": 18.0,
|
||
"single_center": 6.0,
|
||
"bp_single": 0.0,
|
||
"hbp": 4.0,
|
||
"walk": 8.0,
|
||
"strikeout": 8.0,
|
||
"lineout": 0.0,
|
||
"popout": 0.0,
|
||
"flyout_a": 6.0,
|
||
"flyout_bq": 2.0,
|
||
"flyout_lf_b": 3.0,
|
||
"flyout_rf_b": 16.5,
|
||
"groundout_a": 5.0,
|
||
"groundout_b": 6.0,
|
||
"groundout_c": 5.5,
|
||
}
|
||
|
||
|
||
def _power_batter_vr():
|
||
"""High-HR power hitter — third archetype for variety testing.
|
||
|
||
Large strikeout (22.0) and groundout_a (2.0). Sum of 22 columns is 108.0.
|
||
"""
|
||
return {
|
||
"homerun": 8.0,
|
||
"bp_homerun": 0.0,
|
||
"triple": 0.0,
|
||
"double_three": 2.0,
|
||
"double_two": 2.0,
|
||
"double_pull": 6.0,
|
||
"single_two": 3.0,
|
||
"single_one": 10.0,
|
||
"single_center": 4.0,
|
||
"bp_single": 0.0,
|
||
"hbp": 5.0,
|
||
"walk": 9.0,
|
||
"strikeout": 22.0,
|
||
"lineout": 0.0,
|
||
"popout": 0.0,
|
||
"flyout_a": 7.0,
|
||
"flyout_bq": 2.0,
|
||
"flyout_lf_b": 3.0,
|
||
"flyout_rf_b": 15.0,
|
||
"groundout_a": 2.0,
|
||
"groundout_b": 5.0,
|
||
"groundout_c": 3.0,
|
||
}
|
||
|
||
|
||
def _singles_pitcher_vl():
|
||
"""Gibson 2020 vL — contact/groundball SP with typical distribution.
|
||
|
||
Variable outcome columns (18) sum to 79. X-check columns sum to 29.
|
||
Full card sums to 108 (79 variable + 29 x-checks). double_cf=2.95 so
|
||
the priority algorithm will start there before moving to singles.
|
||
"""
|
||
return {
|
||
# Variable columns (79 of 108; x-checks add 29)
|
||
"homerun": 3.3,
|
||
"bp_homerun": 2.0,
|
||
"triple": 0.75,
|
||
"double_three": 0.0,
|
||
"double_two": 0.0,
|
||
"double_cf": 2.95,
|
||
"single_two": 5.7,
|
||
"single_one": 0.0,
|
||
"single_center": 5.0,
|
||
"bp_single": 5.0,
|
||
"hbp": 3.0,
|
||
"walk": 5.0,
|
||
"strikeout": 10.0,
|
||
"flyout_lf_b": 15.1,
|
||
"flyout_cf_b": 0.9,
|
||
"flyout_rf_b": 0.0,
|
||
"groundout_a": 15.1,
|
||
"groundout_b": 5.2,
|
||
# X-check columns (sum=29)
|
||
"xcheck_p": 1.0,
|
||
"xcheck_c": 3.0,
|
||
"xcheck_1b": 2.0,
|
||
"xcheck_2b": 6.0,
|
||
"xcheck_3b": 3.0,
|
||
"xcheck_ss": 7.0,
|
||
"xcheck_lf": 2.0,
|
||
"xcheck_cf": 3.0,
|
||
"xcheck_rf": 2.0,
|
||
}
|
||
|
||
|
||
def _no_doubles_pitcher():
|
||
"""Pitcher with 0 in all double columns — tests priority skip behavior.
|
||
|
||
All three double columns (double_cf, double_three, double_two) are 0.0,
|
||
so the algorithm must skip past them and start reducing singles.
|
||
Variable columns sum to 79 (79 of 108; x-checks add 29); full card sums to 108.
|
||
groundout_b raised to 10.5 to bring variable sum from 77.0 to 79.0.
|
||
"""
|
||
return {
|
||
# Variable columns (79 of 108; x-checks add 29)
|
||
"homerun": 2.0,
|
||
"bp_homerun": 1.0,
|
||
"triple": 0.5,
|
||
"double_three": 0.0,
|
||
"double_two": 0.0,
|
||
"double_cf": 0.0,
|
||
"single_two": 6.0,
|
||
"single_one": 3.0,
|
||
"single_center": 7.0,
|
||
"bp_single": 5.0,
|
||
"hbp": 2.0,
|
||
"walk": 6.0,
|
||
"strikeout": 12.0,
|
||
"flyout_lf_b": 8.0,
|
||
"flyout_cf_b": 4.0,
|
||
"flyout_rf_b": 2.0,
|
||
"groundout_a": 10.0,
|
||
"groundout_b": 10.5,
|
||
# X-check columns (sum=29)
|
||
"xcheck_p": 1.0,
|
||
"xcheck_c": 3.0,
|
||
"xcheck_1b": 2.0,
|
||
"xcheck_2b": 6.0,
|
||
"xcheck_3b": 3.0,
|
||
"xcheck_ss": 7.0,
|
||
"xcheck_lf": 2.0,
|
||
"xcheck_cf": 3.0,
|
||
"xcheck_rf": 2.0,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _batter_sum(d: dict) -> float:
|
||
"""Return sum of the 22 batter outcome columns."""
|
||
return sum(d[col] for col in BATTER_OUTCOME_COLUMNS)
|
||
|
||
|
||
def _pitcher_var_sum(d: dict) -> float:
|
||
"""Return sum of the 18 pitcher variable outcome columns."""
|
||
return sum(d[col] for col in PITCHER_OUTCOME_COLUMNS)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batter boost: 108-sum invariant
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBatterBoost108Sum:
|
||
"""Verify the 22-column 108-sum invariant is maintained after batter boost."""
|
||
|
||
def test_single_tier(self):
|
||
"""After one boost, all 22 batter outcome columns still sum to exactly 108.
|
||
|
||
What: Apply a single boost to the Cano vR card and assert that the sum
|
||
of all 22 outcome columns is 108.0 within float tolerance (1e-6).
|
||
|
||
Why: The fundamental invariant of every batter card is that outcomes
|
||
add to 108 (the number of results in a standard at-bat die table).
|
||
A boost that violates this would corrupt every game simulation that
|
||
uses the card.
|
||
"""
|
||
result = apply_batter_boost(_silver_batter_vr())
|
||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||
|
||
def test_cumulative_four_tiers(self):
|
||
"""Apply boost 4 times sequentially; sum must be 108.0 after each tier.
|
||
|
||
What: Start from the Cano vR card and apply four successive tier
|
||
boosts. Assert the 108-sum after each application — not just the
|
||
last one.
|
||
|
||
Why: Floating-point drift can accumulate over multiple applications.
|
||
By checking after every tier we catch any incremental drift before it
|
||
compounds to a visible error in gameplay.
|
||
"""
|
||
card = _silver_batter_vr()
|
||
for tier in range(1, 5):
|
||
card = apply_batter_boost(card)
|
||
total = _batter_sum(card)
|
||
assert total == pytest.approx(108.0, abs=1e-6), (
|
||
f"Sum drifted to {total} after tier {tier}"
|
||
)
|
||
|
||
def test_different_starting_ratings(self):
|
||
"""Apply boost to 3 different batter archetypes; all maintain 108.
|
||
|
||
What: Boost the Silver (Cano), Contact, and Power batter cards once
|
||
each and verify the 108-sum for every one.
|
||
|
||
Why: The 108-sum must hold regardless of the starting distribution.
|
||
A card with unusual column proportions (e.g. very high or low
|
||
strikeout) might expose an edge case in the scaling math.
|
||
"""
|
||
for card_fn in (_silver_batter_vr, _contact_batter_vl, _power_batter_vr):
|
||
result = apply_batter_boost(card_fn())
|
||
total = _batter_sum(result)
|
||
assert total == pytest.approx(108.0, abs=1e-6), (
|
||
f"108-sum violated for {card_fn.__name__}: got {total}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batter boost: correct deltas
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBatterBoostDeltas:
|
||
"""Verify that exactly the right columns change by exactly the right amounts."""
|
||
|
||
def test_positive_columns_increase(self):
|
||
"""homerun, double_pull, single_one, and walk each increase by exactly 0.5.
|
||
|
||
What: Compare the four positive-delta columns before and after a boost
|
||
on a card where no truncation occurs (Cano vR — strikeout=14.7 and
|
||
groundout_a=3.85 are well above the required reductions).
|
||
|
||
Why: The boost's offensive intent is encoded in these four columns.
|
||
Any deviation (wrong column, wrong amount) would silently change card
|
||
power without the intended effect being applied.
|
||
"""
|
||
before = _silver_batter_vr()
|
||
after = apply_batter_boost(before.copy())
|
||
|
||
assert after["homerun"] == pytest.approx(before["homerun"] + 0.5, abs=1e-9)
|
||
assert after["double_pull"] == pytest.approx(
|
||
before["double_pull"] + 0.5, abs=1e-9
|
||
)
|
||
assert after["single_one"] == pytest.approx(
|
||
before["single_one"] + 0.5, abs=1e-9
|
||
)
|
||
assert after["walk"] == pytest.approx(before["walk"] + 0.5, abs=1e-9)
|
||
|
||
def test_negative_columns_decrease(self):
|
||
"""strikeout decreases by 1.5 and groundout_a decreases by 0.5.
|
||
|
||
What: Compare the two negative-delta columns before and after a boost
|
||
where no truncation occurs (Cano vR card).
|
||
|
||
Why: These reductions are the cost of the offensive improvement.
|
||
If either column is not reduced by the correct amount the 108-sum
|
||
would remain balanced only by accident.
|
||
"""
|
||
before = _silver_batter_vr()
|
||
after = apply_batter_boost(before.copy())
|
||
|
||
assert after["strikeout"] == pytest.approx(before["strikeout"] - 1.5, abs=1e-9)
|
||
assert after["groundout_a"] == pytest.approx(
|
||
before["groundout_a"] - 0.5, abs=1e-9
|
||
)
|
||
|
||
def test_extra_keys_passed_through(self):
|
||
"""Extra keys in the input dict (like x-check columns) survive the boost unchanged.
|
||
|
||
What: Add a full set of x-check keys (xcheck_p, xcheck_c, xcheck_1b,
|
||
xcheck_2b, xcheck_3b, xcheck_ss, xcheck_lf, xcheck_cf, xcheck_rf) to
|
||
the standard Cano vR batter dict before boosting. Assert that every
|
||
x-check key is present in the output and that its value is identical to
|
||
what was passed in. Also assert the 22 outcome columns still sum to 108.
|
||
|
||
Why: The batter boost function only reads and writes BATTER_OUTCOME_COLUMNS.
|
||
Any additional keys in the input dict should be forwarded to the output
|
||
via the `result = dict(ratings_dict)` copy. This guards against a
|
||
regression where the function returns a freshly-constructed dict that
|
||
drops non-outcome keys, which would strip x-check data when the caller
|
||
chains batter and pitcher operations on a shared dict.
|
||
"""
|
||
xcheck_values = {
|
||
"xcheck_p": 1.0,
|
||
"xcheck_c": 3.0,
|
||
"xcheck_1b": 2.0,
|
||
"xcheck_2b": 6.0,
|
||
"xcheck_3b": 3.0,
|
||
"xcheck_ss": 7.0,
|
||
"xcheck_lf": 2.0,
|
||
"xcheck_cf": 3.0,
|
||
"xcheck_rf": 2.0,
|
||
}
|
||
card = _silver_batter_vr()
|
||
card.update(xcheck_values)
|
||
|
||
result = apply_batter_boost(card)
|
||
|
||
# All x-check keys survive with unchanged values
|
||
for col, expected in xcheck_values.items():
|
||
assert col in result, f"X-check key '{col}' missing from output"
|
||
assert result[col] == pytest.approx(expected, abs=1e-9), (
|
||
f"X-check column '{col}' value changed: {expected} → {result[col]}"
|
||
)
|
||
# The 22 outcome columns still satisfy the 108-sum invariant
|
||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||
|
||
def test_unmodified_columns_unchanged(self):
|
||
"""All 16 columns not in BATTER_POSITIVE_DELTAS or BATTER_NEGATIVE_DELTAS
|
||
are identical before and after a boost where no truncation occurs.
|
||
|
||
What: After boosting the Cano vR card, compare every column that is
|
||
NOT one of the six modified columns and assert it is unchanged.
|
||
|
||
Why: A bug that accidentally modifies an unrelated column (e.g. a
|
||
copy-paste error or an off-by-one in a column list) would corrupt
|
||
game balance silently. This acts as a "column integrity" guard.
|
||
"""
|
||
modified_cols = {
|
||
"homerun",
|
||
"double_pull",
|
||
"single_one",
|
||
"walk", # positive
|
||
"strikeout",
|
||
"groundout_a", # negative
|
||
}
|
||
unmodified_cols = [c for c in BATTER_OUTCOME_COLUMNS if c not in modified_cols]
|
||
|
||
before = _silver_batter_vr()
|
||
after = apply_batter_boost(before.copy())
|
||
|
||
for col in unmodified_cols:
|
||
assert after[col] == pytest.approx(before[col], abs=1e-9), (
|
||
f"Column '{col}' changed unexpectedly: {before[col]} → {after[col]}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batter boost: 0-floor truncation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestBatterBoostTruncation:
|
||
"""Verify that the 0-floor is enforced and positive deltas are scaled
|
||
proportionally so the 108-sum is preserved even when negative columns
|
||
cannot be fully reduced.
|
||
"""
|
||
|
||
def test_groundout_a_near_zero(self):
|
||
"""Card with groundout_a=0.3: only 0.3 is taken, positive deltas
|
||
scale down proportionally, and the sum still equals 108.
|
||
|
||
What: Construct a card where groundout_a=0.3 (less than the requested
|
||
0.5 reduction). The algorithm must floor groundout_a at 0 (taking only
|
||
0.3), and scale the positive deltas so that total_added == total_reduced
|
||
(1.5 + 0.3 = 1.8 from strikeout + groundout_a; positive budget is
|
||
also reduced accordingly).
|
||
|
||
Why: Without the 0-floor, groundout_a would go negative — an invalid
|
||
card state. Without proportional scaling the 108-sum would break.
|
||
This test validates both protections together.
|
||
"""
|
||
card = _silver_batter_vr()
|
||
card["groundout_a"] = 0.3
|
||
# Re-balance: move the excess 3.55 to groundout_b to keep sum==108
|
||
diff = 3.85 - 0.3 # original groundout_a was 3.85
|
||
card["groundout_b"] = card["groundout_b"] + diff
|
||
|
||
result = apply_batter_boost(card)
|
||
|
||
# groundout_a must be exactly 0 (floored)
|
||
assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9)
|
||
# 108-sum preserved
|
||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||
|
||
def test_strikeout_near_zero(self):
|
||
"""Card with strikeout=1.0: only 1.0 is taken instead of the requested
|
||
1.5, and positive deltas are scaled down to compensate.
|
||
|
||
What: Build a card with strikeout=1.0 (less than the requested 1.5
|
||
reduction). The algorithm can only take 1.0 from strikeout (floored
|
||
at 0), and the full 0.5 from groundout_a — so total_actually_reduced
|
||
is 1.5 against a total_requested_reduction of 2.0. Truncation DOES
|
||
occur: positive deltas are scaled to 0.75x (1.5 / 2.0) so the
|
||
108-sum is preserved, and strikeout lands exactly at 0.0.
|
||
|
||
Why: Strikeout is the most commonly near-zero column on elite contact
|
||
hitters. Verifying the floor specifically on strikeout ensures that
|
||
the tier-1 boost on a card already boosted multiple times won't
|
||
produce an invalid negative strikeout value.
|
||
"""
|
||
card = _silver_batter_vr()
|
||
# Replace strikeout=14.7 with strikeout=1.0 and re-balance via flyout_rf_b
|
||
diff = 14.7 - 1.0
|
||
card["strikeout"] = 1.0
|
||
card["flyout_rf_b"] = card["flyout_rf_b"] + diff
|
||
|
||
result = apply_batter_boost(card)
|
||
|
||
# strikeout must be exactly 0 (fully consumed by the floor)
|
||
assert result["strikeout"] == pytest.approx(0.0, abs=1e-9)
|
||
# 108-sum preserved regardless of truncation
|
||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||
|
||
def test_both_out_columns_zero(self):
|
||
"""When both strikeout=0 and groundout_a=0, the boost becomes a no-op.
|
||
|
||
What: Build a card where strikeout=0.0 and groundout_a=0.0 (the 18.55
|
||
chances freed by zeroing both are redistributed to flyout_rf_b so the
|
||
22-column sum remains exactly 108.0). When the boost runs,
|
||
total_actually_reduced is 0, so the scale factor is 0 and every
|
||
positive delta becomes 0 as well.
|
||
|
||
Why: This validates the edge of the truncation path where no budget
|
||
whatsoever is available. Without the `if total_requested_addition > 0`
|
||
guard, the algorithm would divide by zero; without correct scaling it
|
||
would silently apply non-zero positive deltas and break the 108-sum.
|
||
"""
|
||
card = _silver_batter_vr()
|
||
# Zero out both negative-delta source columns and compensate via flyout_rf_b
|
||
freed = card["strikeout"] + card["groundout_a"] # 14.7 + 3.85 = 18.55
|
||
card["strikeout"] = 0.0
|
||
card["groundout_a"] = 0.0
|
||
card["flyout_rf_b"] = card["flyout_rf_b"] + freed
|
||
|
||
before_homerun = card["homerun"]
|
||
before_double_pull = card["double_pull"]
|
||
before_single_one = card["single_one"]
|
||
before_walk = card["walk"]
|
||
|
||
result = apply_batter_boost(card)
|
||
|
||
# 108-sum is preserved (nothing moved)
|
||
assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6)
|
||
# Source columns remain at zero
|
||
assert result["strikeout"] == pytest.approx(0.0, abs=1e-9)
|
||
assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9)
|
||
# Positive-delta columns are unchanged (scale == 0 means no additions)
|
||
assert result["homerun"] == pytest.approx(before_homerun, abs=1e-9)
|
||
assert result["double_pull"] == pytest.approx(before_double_pull, abs=1e-9)
|
||
assert result["single_one"] == pytest.approx(before_single_one, abs=1e-9)
|
||
assert result["walk"] == pytest.approx(before_walk, abs=1e-9)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher boost: 108-sum invariant
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPitcherBoost108Sum:
|
||
"""Verify the pitcher card invariant: 18 variable columns sum to 79,
|
||
x-checks sum to 29, full card total is 108 — all after every boost.
|
||
"""
|
||
|
||
def test_singles_heavy_pitcher(self):
|
||
"""Gibson-like pitcher (double_cf=2.95, no other doubles) maintains
|
||
the 108-sum card invariant after one boost (variable subset stays at 79).
|
||
|
||
What: Boost the Gibson vL card once. Assert that the 18 variable
|
||
columns (PITCHER_OUTCOME_COLUMNS) still sum to 79 (the variable-column
|
||
subset of the 108-total card).
|
||
|
||
Why: The pitcher boost algorithm converts hit/walk chances to
|
||
strikeouts without changing the total number of outcomes. If any
|
||
chance is created or destroyed, the variable subset drifts from 79,
|
||
breaking the 108-sum card invariant and making game simulation
|
||
results unreliable.
|
||
"""
|
||
result = apply_pitcher_boost(_singles_pitcher_vl())
|
||
assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6)
|
||
|
||
def test_no_doubles_pitcher(self):
|
||
"""Pitcher with all three double columns at 0 skips them, reduces
|
||
singles instead, and the 18-column variable sum stays at 79 (of 108 total).
|
||
|
||
What: Boost the no-doubles pitcher fixture once. The priority list
|
||
starts with double_cf, double_three, double_two — all zero — so the
|
||
algorithm must skip them and begin consuming single_center. The
|
||
variable-column subset (79 of 108) must be preserved.
|
||
|
||
Why: Validates the zero-skip logic in the priority loop. Without it,
|
||
the algorithm would incorrectly deduct from a 0-value column, producing
|
||
a negative entry and violating the 108-sum card invariant.
|
||
"""
|
||
result = apply_pitcher_boost(_no_doubles_pitcher())
|
||
assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6)
|
||
|
||
def test_cumulative_four_tiers(self):
|
||
"""Four successive boosts: variable-column sum==79 (of 108 total) after
|
||
each tier; no column is negative.
|
||
|
||
What: Apply four boosts in sequence to the Gibson vL card (highest
|
||
number of reducible doubles/singles available). After each boost,
|
||
assert that the 18-column variable sum is 79.0 and that no individual
|
||
column went negative.
|
||
|
||
Why: Cumulative boost scenarios are the real production use case.
|
||
Float drift and edge cases in the priority budget loop could silently
|
||
corrupt the card over multiple tier-ups. Checking after every
|
||
iteration catches both issues early.
|
||
"""
|
||
card = _singles_pitcher_vl()
|
||
for tier in range(1, 5):
|
||
card = apply_pitcher_boost(card)
|
||
total = _pitcher_var_sum(card)
|
||
assert total == pytest.approx(79.0, abs=1e-6), (
|
||
f"Variable-column sum (79 of 108) drifted to {total} after tier {tier}"
|
||
)
|
||
for col in PITCHER_OUTCOME_COLUMNS:
|
||
assert card[col] >= -1e-9, (
|
||
f"Column '{col}' went negative ({card[col]}) after tier {tier}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher boost: determinism
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPitcherBoostDeterminism:
|
||
"""Same input always produces identical output across multiple calls."""
|
||
|
||
def test_repeated_calls_identical(self):
|
||
"""Call apply_pitcher_boost 10 times with the same input; all outputs match.
|
||
|
||
What: Pass the same Gibson vL dict to apply_pitcher_boost ten times
|
||
and assert that every result is byte-for-byte equal to the first.
|
||
|
||
Why: Determinism is a hard requirement for reproducible card states.
|
||
Any non-determinism (e.g. from random number usage, hash iteration
|
||
order, or floating-point compiler variance) would make two identically-
|
||
seeded boosts produce different card variants — a correctness bug.
|
||
"""
|
||
base = _singles_pitcher_vl()
|
||
first = apply_pitcher_boost(base.copy())
|
||
for i in range(1, 10):
|
||
result = apply_pitcher_boost(base.copy())
|
||
for col in PITCHER_OUTCOME_COLUMNS:
|
||
assert result[col] == first[col], (
|
||
f"Call {i} differed on column '{col}': "
|
||
f"first={first[col]}, got={result[col]}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher boost: TB budget accounting
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPitcherBoostTBAccounting:
|
||
"""Verify the TB budget is debited at the correct rate per outcome type."""
|
||
|
||
def test_doubles_cost_2tb(self):
|
||
"""Reducing a double column by 0.75 chances spends exactly 1.5 TB.
|
||
|
||
What: Build a minimal pitcher card where only double_cf is non-zero
|
||
(2.0 chances) and all singles/walks/HR are 0. With the default
|
||
budget of 1.5 TB and a cost of 2 TB per chance, the algorithm can
|
||
take at most 0.75 chances. Assert that double_cf decreases by exactly
|
||
0.75 and strikeout increases by exactly 0.75.
|
||
|
||
Why: If the TB cost factor for doubles were applied incorrectly (e.g.
|
||
as 1 instead of 2), the algorithm would over-consume chances and
|
||
produce a card that has been boosted more than a single tier allows.
|
||
"""
|
||
# Construct a minimal card: only double_cf has value
|
||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||
card["double_cf"] = 2.0
|
||
card["strikeout"] = 77.0 # 2.0 + 77.0 = 79.0 to satisfy invariant
|
||
for col in PITCHER_XCHECK_COLUMNS:
|
||
card[col] = 0.0
|
||
card["xcheck_2b"] = 29.0 # put all xcheck budget in one column
|
||
|
||
result = apply_pitcher_boost(card)
|
||
|
||
# Budget = 1.5 TB, cost = 2 TB/chance → max chances = 0.75
|
||
assert result["double_cf"] == pytest.approx(2.0 - 0.75, abs=1e-9)
|
||
assert result["strikeout"] == pytest.approx(77.0 + 0.75, abs=1e-9)
|
||
|
||
def test_singles_cost_1tb(self):
|
||
"""Reducing a singles column spends 1 TB per chance.
|
||
|
||
What: Build a minimal card where only single_center is non-zero
|
||
(5.0 chances) and all higher-priority columns are 0. With the
|
||
default budget of 1.5 TB and a cost of 1 TB per chance, the algorithm
|
||
takes exactly 1.5 chances from single_center.
|
||
|
||
Why: Singles are the most common target in the priority list. Getting
|
||
the cost factor wrong (e.g. 2 instead of 1) would halve the effective
|
||
boost impact on contact-heavy pitchers.
|
||
"""
|
||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||
card["single_center"] = 5.0
|
||
card["strikeout"] = 74.0 # 5.0 + 74.0 = 79.0
|
||
for col in PITCHER_XCHECK_COLUMNS:
|
||
card[col] = 0.0
|
||
card["xcheck_2b"] = 29.0
|
||
|
||
result = apply_pitcher_boost(card)
|
||
|
||
# Budget = 1.5 TB, cost = 1 TB/chance → takes exactly 1.5 chances
|
||
assert result["single_center"] == pytest.approx(5.0 - 1.5, abs=1e-9)
|
||
assert result["strikeout"] == pytest.approx(74.0 + 1.5, abs=1e-9)
|
||
|
||
def test_budget_exhaustion(self):
|
||
"""Algorithm stops exactly when the TB budget reaches 0, leaving the
|
||
next priority column untouched.
|
||
|
||
What: Build a card where double_cf=1.0 (costs 2 TB/chance). With
|
||
budget=1.5 the algorithm takes 0.75 chances from double_cf (spending
|
||
exactly 1.5 TB) and then stops. single_center, which comes later in
|
||
the priority list, must be completely untouched.
|
||
|
||
Why: Overspending the budget would boost the pitcher by more than one
|
||
tier allows. This test directly verifies the `if remaining <= 0:
|
||
break` guard in the loop.
|
||
"""
|
||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||
card["double_cf"] = 1.0
|
||
card["single_center"] = 5.0
|
||
card["strikeout"] = 73.0 # 1.0 + 5.0 + 73.0 = 79.0
|
||
for col in PITCHER_XCHECK_COLUMNS:
|
||
card[col] = 0.0
|
||
card["xcheck_2b"] = 29.0
|
||
|
||
result = apply_pitcher_boost(card)
|
||
|
||
# double_cf consumed 0.75 chances (1.5 TB at 2 TB/chance) — budget gone
|
||
assert result["double_cf"] == pytest.approx(1.0 - 0.75, abs=1e-9)
|
||
# single_center must be completely unchanged (budget was exhausted)
|
||
assert result["single_center"] == pytest.approx(5.0, abs=1e-9)
|
||
# strikeout gained exactly 0.75 (from double_cf only)
|
||
assert result["strikeout"] == pytest.approx(73.0 + 0.75, abs=1e-9)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher boost: zero-skip and x-check protection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestPitcherBoostZeroSkip:
|
||
"""Verify that zero-valued priority columns are skipped and that x-check
|
||
columns are never modified under any circumstances.
|
||
"""
|
||
|
||
def test_skip_zero_doubles(self):
|
||
"""Pitcher with all three double columns at 0: first reduction comes
|
||
from single_center (the first non-zero column in priority order).
|
||
|
||
What: Boost the no-doubles pitcher fixture once. Assert that
|
||
double_cf, double_three, and double_two are still 0.0 after the boost,
|
||
and that single_center has decreased (the first non-zero column in
|
||
the priority list after the three doubles).
|
||
|
||
Why: The priority loop must not subtract from columns that are already
|
||
at 0 — doing so would create negative values and an invalid card.
|
||
This test confirms the `if ratings[col] <= 0: continue` guard works.
|
||
"""
|
||
before = _no_doubles_pitcher()
|
||
after = apply_pitcher_boost(before.copy())
|
||
|
||
# All double columns were 0 and must remain 0
|
||
assert after["double_cf"] == pytest.approx(0.0, abs=1e-9)
|
||
assert after["double_three"] == pytest.approx(0.0, abs=1e-9)
|
||
assert after["double_two"] == pytest.approx(0.0, abs=1e-9)
|
||
|
||
# single_center (4th in priority) must have decreased
|
||
assert after["single_center"] < before["single_center"], (
|
||
"single_center should have been reduced when doubles were all 0"
|
||
)
|
||
|
||
def test_all_priority_columns_zero(self):
|
||
"""When every priority column is 0, the TB budget cannot be spent.
|
||
|
||
What: Build a pitcher card where all 12 PITCHER_PRIORITY columns
|
||
(double_cf, double_three, double_two, single_center, single_two,
|
||
single_one, bp_single, walk, homerun, bp_homerun, triple, hbp) are
|
||
0.0. The remaining 79 variable-column chances are distributed across
|
||
the non-priority variable columns (strikeout, flyout_lf_b, flyout_cf_b,
|
||
flyout_rf_b, groundout_a, groundout_b). X-check columns sum to 29 as
|
||
usual. The algorithm iterates all 12 priority entries, finds nothing
|
||
to take, logs a warning, and returns.
|
||
|
||
Why: This is the absolute edge of the zero-skip path. Without the
|
||
`if remaining > 0: logger.warning(...)` guard the unspent budget would
|
||
be silently discarded. More importantly, no column should be modified:
|
||
the variable-column subset (79 of 108 total) must be preserved,
|
||
strikeout must not change, and every priority column must still be 0.0.
|
||
"""
|
||
priority_cols = {col for col, _ in PITCHER_PRIORITY}
|
||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||
# Distribute all 79 variable chances across non-priority outcome columns
|
||
card["strikeout"] = 20.0
|
||
card["flyout_lf_b"] = 15.0
|
||
card["flyout_cf_b"] = 15.0
|
||
card["flyout_rf_b"] = 15.0
|
||
card["groundout_a"] = 7.0
|
||
card["groundout_b"] = 7.0
|
||
# Add x-check columns (sum=29), required by the card structure
|
||
for col in PITCHER_XCHECK_COLUMNS:
|
||
card[col] = 0.0
|
||
card["xcheck_2b"] = 29.0
|
||
|
||
before_strikeout = card["strikeout"]
|
||
|
||
result = apply_pitcher_boost(card)
|
||
|
||
# Variable columns still sum to 79 (79 of 108 total; x-checks unchanged at 29)
|
||
assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6)
|
||
# Strikeout is unchanged — nothing was moved into it
|
||
assert result["strikeout"] == pytest.approx(before_strikeout, abs=1e-9)
|
||
# All priority columns remain at 0
|
||
for col in priority_cols:
|
||
assert result[col] == pytest.approx(0.0, abs=1e-9), (
|
||
f"Priority column '{col}' changed unexpectedly: {result[col]}"
|
||
)
|
||
# X-check columns are untouched
|
||
for col in PITCHER_XCHECK_COLUMNS:
|
||
assert result[col] == pytest.approx(card[col], abs=1e-9)
|
||
|
||
def test_xcheck_columns_never_modified(self):
|
||
"""All 9 x-check columns are identical before and after a boost.
|
||
|
||
What: Boost both the Gibson vL card and the no-doubles pitcher card
|
||
once each and verify that every xcheck_* column is unchanged.
|
||
|
||
Why: X-check columns encode defensive routing weights that are
|
||
completely separate from the offensive outcome probabilities. The
|
||
boost algorithm must never touch them; modifying them would break
|
||
game simulation logic that relies on their fixed sum of 29.
|
||
"""
|
||
for card_fn in (_singles_pitcher_vl, _no_doubles_pitcher):
|
||
before = card_fn()
|
||
after = apply_pitcher_boost(before.copy())
|
||
for col in PITCHER_XCHECK_COLUMNS:
|
||
assert after[col] == pytest.approx(before[col], abs=1e-9), (
|
||
f"X-check column '{col}' was unexpectedly modified in "
|
||
f"{card_fn.__name__}: {before[col]} → {after[col]}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Variant hash
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestVariantHash:
|
||
"""Verify the behavior of compute_variant_hash: determinism, uniqueness,
|
||
never-zero guarantee, and cosmetics influence.
|
||
"""
|
||
|
||
def test_deterministic(self):
|
||
"""Same (player_id, refractor_tier, cosmetics) inputs produce the
|
||
same hash on every call.
|
||
|
||
What: Call compute_variant_hash 20 times with the same arguments and
|
||
assert every result equals the first.
|
||
|
||
Why: The variant hash is stored in the database as a stable card
|
||
identifier. Any non-determinism would cause the same card state to
|
||
generate a different variant key on different calls, creating phantom
|
||
duplicate variants in the DB.
|
||
"""
|
||
first = compute_variant_hash(42, 2, ["foil"])
|
||
for _ in range(19):
|
||
assert compute_variant_hash(42, 2, ["foil"]) == first
|
||
|
||
def test_different_tiers_different_hash(self):
|
||
"""Tier 1 and tier 2 for the same player produce different hashes.
|
||
|
||
What: Compare compute_variant_hash(player_id=1, refractor_tier=1)
|
||
vs compute_variant_hash(player_id=1, refractor_tier=2).
|
||
|
||
Why: Each Refractor tier represents a distinct card version. If two
|
||
tiers produced the same hash, the DB unique-key constraint on variant
|
||
would incorrectly merge them into a single entry.
|
||
"""
|
||
h1 = compute_variant_hash(1, 1)
|
||
h2 = compute_variant_hash(1, 2)
|
||
assert h1 != h2, f"Tier 1 and tier 2 unexpectedly produced the same hash: {h1}"
|
||
|
||
def test_different_players_different_hash(self):
|
||
"""Same tier for different players produces different hashes.
|
||
|
||
What: Compare compute_variant_hash(player_id=10, refractor_tier=1)
|
||
vs compute_variant_hash(player_id=11, refractor_tier=1).
|
||
|
||
Why: Each player's Refractor card is a distinct asset. If two players
|
||
at the same tier shared a hash, their boosted variants could not be
|
||
distinguished in the database.
|
||
"""
|
||
ha = compute_variant_hash(10, 1)
|
||
hb = compute_variant_hash(11, 1)
|
||
assert ha != hb, (
|
||
f"Player 10 and player 11 at tier 1 unexpectedly share hash: {ha}"
|
||
)
|
||
|
||
def test_never_zero(self):
|
||
"""Hash is never 0 across a broad set of (player_id, tier) combinations.
|
||
|
||
What: Generate hashes for player_ids 0–999 at tiers 0–4 (5000 total)
|
||
and assert every result is >= 1.
|
||
|
||
Why: Variant 0 is the reserved sentinel for base (un-boosted) cards.
|
||
The function must remap any SHA-256-derived value that happens to be 0
|
||
to 1. Testing with a large batch guards against the extremely unlikely
|
||
collision while confirming the guard is active.
|
||
"""
|
||
for player_id in range(1000):
|
||
for tier in range(5):
|
||
result = compute_variant_hash(player_id, tier)
|
||
assert result >= 1, (
|
||
f"compute_variant_hash({player_id}, {tier}) returned 0"
|
||
)
|
||
|
||
def test_cosmetics_affect_hash(self):
|
||
"""Adding cosmetics to the same player/tier produces a different hash.
|
||
|
||
What: Compare compute_variant_hash(1, 1, cosmetics=None) vs
|
||
compute_variant_hash(1, 1, cosmetics=["chrome"]).
|
||
|
||
Why: Cosmetic variants (special art, foil treatment, etc.) must be
|
||
distinguishable from the base-tier variant. If cosmetics were ignored
|
||
by the hash, two cards that look different would share the same variant
|
||
key and collide in the database.
|
||
"""
|
||
base = compute_variant_hash(1, 1)
|
||
with_cosmetic = compute_variant_hash(1, 1, ["chrome"])
|
||
assert base != with_cosmetic, "Adding cosmetics did not change the variant hash"
|
||
|
||
def test_cosmetics_order_independent(self):
|
||
"""Cosmetics list is sorted before hashing; order does not matter.
|
||
|
||
What: Compare compute_variant_hash(1, 1, ["foil", "chrome"]) vs
|
||
compute_variant_hash(1, 1, ["chrome", "foil"]). They must be equal.
|
||
|
||
Why: Callers should not need to sort cosmetics before passing them in.
|
||
If the hash were order-dependent, the same logical card state could
|
||
produce two different variant keys depending on how the caller
|
||
constructed the list.
|
||
"""
|
||
h1 = compute_variant_hash(1, 1, ["foil", "chrome"])
|
||
h2 = compute_variant_hash(1, 1, ["chrome", "foil"])
|
||
assert h1 == h2, (
|
||
f"Cosmetics order affected hash: ['foil','chrome']={h1}, "
|
||
f"['chrome','foil']={h2}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Batter display stats
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _zeroed_batter(outs: float = 108.0) -> dict:
|
||
"""Return a batter dict where all hit/walk/hbp columns are 0 and all
|
||
chances are absorbed by groundout_c.
|
||
|
||
The 22-column sum is exactly 108.0 by construction. Helper used to build
|
||
clean minimal cards that isolate individual formula terms.
|
||
"""
|
||
card = {col: 0.0 for col in BATTER_OUTCOME_COLUMNS}
|
||
card["groundout_c"] = outs
|
||
return card
|
||
|
||
|
||
class TestBatterDisplayStats:
|
||
"""Unit tests for compute_batter_display_stats(ratings) -> dict.
|
||
|
||
All tests call the function with plain dicts (no DB, no fixtures).
|
||
The function is pure: same input always produces the same output.
|
||
|
||
Formula under test (denominator is always 108):
|
||
avg = (HR + bp_HR/2 + triple + dbl_3 + dbl_2 + dbl_pull
|
||
+ sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108
|
||
obp = avg + (hbp + walk) / 108
|
||
slg = (HR*4 + bp_HR*2 + triple*3 + dbl_3*2 + dbl_2*2 + dbl_pull*2
|
||
+ sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108
|
||
"""
|
||
|
||
def test_avg_reflects_hit_chances(self):
|
||
"""homerun=9.0 alone (rest in outs summing to 108) yields avg == 9/108.
|
||
|
||
What: Build a card where only homerun is non-zero among the hit columns;
|
||
all 108 chances are accounted for (9 HR + 99 groundout_c). Assert that
|
||
avg equals 9.0/108.
|
||
|
||
Why: Verifies that the homerun column enters the avg numerator at full
|
||
weight (coefficient 1.0) and that the denominator is 108.
|
||
"""
|
||
card = _zeroed_batter(99.0)
|
||
card["homerun"] = 9.0
|
||
result = compute_batter_display_stats(card)
|
||
assert result["avg"] == pytest.approx(9.0 / 108, abs=1e-6)
|
||
|
||
def test_bp_homerun_half_weighted_in_avg(self):
|
||
"""bp_homerun=6.0 contributes only 3.0/108 to avg (half weight).
|
||
|
||
What: Card with bp_homerun=6.0, rest in outs. Assert avg == 3.0/108.
|
||
|
||
Why: Ballpark home runs are treated as weaker contact events — the
|
||
formula halves their contribution to batting average. Getting the
|
||
coefficient wrong (using 1.0 instead of 0.5) would inflate avg for
|
||
cards with significant bp_homerun values.
|
||
"""
|
||
card = _zeroed_batter(102.0)
|
||
card["bp_homerun"] = 6.0
|
||
result = compute_batter_display_stats(card)
|
||
assert result["avg"] == pytest.approx(3.0 / 108, abs=1e-6)
|
||
|
||
def test_bp_single_half_weighted_in_avg(self):
|
||
"""bp_single=8.0 contributes only 4.0/108 to avg (half weight).
|
||
|
||
What: Card with bp_single=8.0, rest in outs. Assert avg == 4.0/108.
|
||
|
||
Why: Ballpark singles are similarly half-weighted. Confirms the /2
|
||
divisor is applied to bp_single in the avg numerator.
|
||
"""
|
||
card = _zeroed_batter(100.0)
|
||
card["bp_single"] = 8.0
|
||
result = compute_batter_display_stats(card)
|
||
assert result["avg"] == pytest.approx(4.0 / 108, abs=1e-6)
|
||
|
||
def test_obp_adds_hbp_and_walk_on_top_of_avg(self):
|
||
"""obp == avg + (hbp + walk) / 108 when homerun=9, hbp=9, walk=9.
|
||
|
||
What: Card with homerun=9, hbp=9, walk=9, rest in outs (81 chances).
|
||
Compute avg first (9/108), then verify obp == avg + 18/108.
|
||
|
||
Why: OBP extends AVG by counting on-base events that are not hits.
|
||
If hbp or walk were inadvertently included in the avg numerator, obp
|
||
would double-count them. This test confirms they are added only once,
|
||
outside the avg sub-expression.
|
||
"""
|
||
card = _zeroed_batter(81.0)
|
||
card["homerun"] = 9.0
|
||
card["hbp"] = 9.0
|
||
card["walk"] = 9.0
|
||
result = compute_batter_display_stats(card)
|
||
expected_avg = 9.0 / 108
|
||
expected_obp = expected_avg + 18.0 / 108
|
||
assert result["avg"] == pytest.approx(expected_avg, abs=1e-6)
|
||
assert result["obp"] == pytest.approx(expected_obp, abs=1e-6)
|
||
|
||
def test_slg_uses_correct_weights(self):
|
||
"""SLG numerator: HR*4 + triple*3 + double_pull*2 + single_one*1.
|
||
|
||
What: Card with homerun=4, triple=3, double_pull=2, single_one=1 (and
|
||
98 outs to sum to 108). Assert slg == (4*4 + 3*3 + 2*2 + 1*1) / 108
|
||
== 30/108.
|
||
|
||
Why: Each extra-base hit type carries a different base-advancement
|
||
weight in SLG. Any coefficient error (e.g. treating a triple as a
|
||
double) would systematically understate or overstate slugging for
|
||
power hitters.
|
||
"""
|
||
card = _zeroed_batter(98.0)
|
||
card["homerun"] = 4.0
|
||
card["triple"] = 3.0
|
||
card["double_pull"] = 2.0
|
||
card["single_one"] = 1.0
|
||
result = compute_batter_display_stats(card)
|
||
expected_slg = (4 * 4 + 3 * 3 + 2 * 2 + 1 * 1) / 108
|
||
assert result["slg"] == pytest.approx(expected_slg, abs=1e-6)
|
||
|
||
def test_all_zeros_returns_zeros(self):
|
||
"""Card with all hit/walk/hbp columns set to 0 produces avg=obp=slg=0.
|
||
|
||
What: Build a card where the 22 outcome columns sum to 108 but every
|
||
hit, walk, and hbp column is 0 (all chances in groundout_c). Assert
|
||
that avg, obp, and slg are all 0.
|
||
|
||
Why: Verifies the function does not produce NaN or raise on a degenerate
|
||
all-out card and that the zero numerator path returns clean zeros.
|
||
"""
|
||
card = _zeroed_batter(108.0)
|
||
result = compute_batter_display_stats(card)
|
||
assert result["avg"] == pytest.approx(0.0, abs=1e-6)
|
||
assert result["obp"] == pytest.approx(0.0, abs=1e-6)
|
||
assert result["slg"] == pytest.approx(0.0, abs=1e-6)
|
||
|
||
def test_matches_known_card(self):
|
||
"""Display stats for the silver batter fixture are internally consistent.
|
||
|
||
What: Pass the _silver_batter_vr() fixture dict to
|
||
compute_batter_display_stats and verify that avg > 0, obp > avg, and
|
||
slg > avg — the expected ordering for any hitter with positive hit and
|
||
extra-base-hit chances.
|
||
|
||
Why: Confirms the function produces the correct relative ordering on a
|
||
realistic card. Absolute values are not hard-coded here because the
|
||
fixture is designed for boost tests, not display-stat tests; relative
|
||
ordering is sufficient to detect sign errors or column swaps.
|
||
"""
|
||
result = compute_batter_display_stats(_silver_batter_vr())
|
||
assert result["avg"] > 0
|
||
assert result["obp"] > result["avg"]
|
||
assert result["slg"] > result["avg"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pitcher display stats
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _zeroed_pitcher(strikeout: float = 79.0) -> dict:
|
||
"""Return a pitcher dict where all hit/walk/hbp columns are 0 and all
|
||
variable chances are in strikeout.
|
||
|
||
The 18 PITCHER_OUTCOME_COLUMNS sum to 79 by construction. X-check
|
||
columns are not included because compute_pitcher_display_stats only reads
|
||
the hit/walk columns from PITCHER_OUTCOME_COLUMNS.
|
||
"""
|
||
card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS}
|
||
card["strikeout"] = strikeout
|
||
return card
|
||
|
||
|
||
class TestPitcherDisplayStats:
|
||
"""Unit tests for compute_pitcher_display_stats(ratings) -> dict.
|
||
|
||
The pitcher formula mirrors the batter formula except that double_pull is
|
||
replaced by double_cf (the pitcher-specific double column). All other hit
|
||
columns are identical.
|
||
|
||
Formula under test (denominator is always 108):
|
||
avg = (HR + bp_HR/2 + triple + dbl_3 + dbl_2 + dbl_cf
|
||
+ sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108
|
||
obp = avg + (hbp + walk) / 108
|
||
slg = (HR*4 + bp_HR*2 + triple*3 + dbl_3*2 + dbl_2*2 + dbl_cf*2
|
||
+ sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108
|
||
"""
|
||
|
||
def test_pitcher_uses_double_cf_not_double_pull(self):
|
||
"""double_cf=6.0 contributes 6.0/108 to pitcher avg; double_pull is absent.
|
||
|
||
What: Card with double_cf=6.0 and strikeout=73.0 (sum=79). Assert avg
|
||
== 6.0/108.
|
||
|
||
Why: The pitcher formula uses double_cf instead of double_pull (which
|
||
does not exist on pitching cards). If the implementation accidentally
|
||
reads double_pull from a pitcher dict it would raise a KeyError or
|
||
silently read 0, producing a wrong avg.
|
||
"""
|
||
card = _zeroed_pitcher(73.0)
|
||
card["double_cf"] = 6.0
|
||
result = compute_pitcher_display_stats(card)
|
||
assert result["avg"] == pytest.approx(6.0 / 108, abs=1e-6)
|
||
|
||
def test_pitcher_slg_double_cf_costs_2(self):
|
||
"""double_cf=6.0 alone contributes 6.0*2/108 to pitcher slg.
|
||
|
||
What: Same card as above (double_cf=6.0, all else 0). Assert slg
|
||
== 12.0/108.
|
||
|
||
Why: Doubles carry a weight of 2 in SLG (two total bases). Verifies
|
||
that the coefficient is correctly applied to double_cf in the slg
|
||
formula.
|
||
"""
|
||
card = _zeroed_pitcher(73.0)
|
||
card["double_cf"] = 6.0
|
||
result = compute_pitcher_display_stats(card)
|
||
assert result["slg"] == pytest.approx(12.0 / 108, abs=1e-6)
|
||
|
||
def test_pitcher_bp_homerun_half_weighted(self):
|
||
"""bp_homerun=4.0 contributes only 2.0/108 to pitcher avg (half weight).
|
||
|
||
What: Card with bp_homerun=4.0 and strikeout=75.0. Assert avg == 2.0/108.
|
||
|
||
Why: Mirrors the batter bp_homerun test — the half-weight rule applies
|
||
to both card types. Confirms the /2 divisor is present in the pitcher
|
||
formula.
|
||
"""
|
||
card = _zeroed_pitcher(75.0)
|
||
card["bp_homerun"] = 4.0
|
||
result = compute_pitcher_display_stats(card)
|
||
assert result["avg"] == pytest.approx(2.0 / 108, abs=1e-6)
|
||
|
||
def test_pitcher_obp_formula_matches_batter(self):
|
||
"""obp == avg + (hbp + walk) / 108, identical structure to batter formula.
|
||
|
||
What: Build a pitcher card with homerun=6, hbp=6, walk=6 (strikeout=61
|
||
to reach variable sum of 79). Compute avg = 6/108, then assert obp ==
|
||
avg + 12/108.
|
||
|
||
Why: The obp addend (hbp + walk) / 108 must be present and correct on
|
||
pitcher cards, exactly as it is for batters. A formula that
|
||
accidentally omits hbp or walk from pitcher obp would understate on-base
|
||
percentage for walks-heavy pitchers.
|
||
"""
|
||
card = _zeroed_pitcher(61.0)
|
||
card["homerun"] = 6.0
|
||
card["hbp"] = 6.0
|
||
card["walk"] = 6.0
|
||
result = compute_pitcher_display_stats(card)
|
||
expected_avg = 6.0 / 108
|
||
expected_obp = expected_avg + 12.0 / 108
|
||
assert result["avg"] == pytest.approx(expected_avg, abs=1e-6)
|
||
assert result["obp"] == pytest.approx(expected_obp, abs=1e-6)
|
||
|
||
def test_matches_known_pitcher_card(self):
|
||
"""Display stats for the Gibson vL fixture are internally consistent.
|
||
|
||
What: Pass the _singles_pitcher_vl() fixture dict to
|
||
compute_pitcher_display_stats and verify avg > 0, obp > avg, slg > avg.
|
||
|
||
Why: The Gibson card has both hit and walk columns, so the correct
|
||
relative ordering (obp > avg, slg > avg) must hold. This confirms
|
||
the function works end-to-end on a realistic pitcher card rather than
|
||
a minimal synthetic one.
|
||
"""
|
||
result = compute_pitcher_display_stats(_singles_pitcher_vl())
|
||
assert result["avg"] > 0
|
||
assert result["obp"] > result["avg"]
|
||
assert result["slg"] > result["avg"]
|