paper-dynasty-database/tests/test_refractor_boost.py
Cal Corum 776f1a5302 fix: address PR review findings — rename evolution_tier to refractor_tier
- 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>
2026-03-30 11:06:38 -05:00

907 lines
37 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.

"""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 0999 at tiers 04 (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}"
)