- Rename `evolution_tier` parameter to `refractor_tier` in compute_variant_hash() to match the refractor naming convention established in PR #131 - Update hash input dict key accordingly (safe: function is new, no stored hashes) - Update test docstrings referencing the old parameter name - Remove redundant parentheses on boost_delta_json TextField declaration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
907 lines
37 KiB
Python
907 lines
37 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,
|
||
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. double_cf=2.95 so the priority algorithm will
|
||
start there before moving to singles.
|
||
"""
|
||
return {
|
||
# Variable columns (sum=79)
|
||
"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; x-checks sum to 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 (sum=79)
|
||
"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, total is 108 — all after every boost.
|
||
"""
|
||
|
||
def test_singles_heavy_pitcher(self):
|
||
"""Gibson-like pitcher (double_cf=2.95, no other doubles) maintains
|
||
79-sum for the 18 variable columns after one boost.
|
||
|
||
What: Boost the Gibson vL card once. Assert that the 18 variable
|
||
columns (PITCHER_OUTCOME_COLUMNS) still sum to 79.
|
||
|
||
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 79-sum breaks and game simulation
|
||
results become 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.
|
||
|
||
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
|
||
79-sum 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 an invalid 79-sum.
|
||
"""
|
||
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: sum==79 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 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"79-sum 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 79-sum must be preserved, strikeout must not change, and every
|
||
priority column must still be exactly 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
|
||
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}"
|
||
)
|