paper-dynasty-database/tests/test_refractor_boost.py
Cal Corum 830e703e76 fix: address PR #179 review — consolidate CSS, extract inline styles, add tests
- Consolidate T3 duplicate #header rule into single block with overflow/position
- Add explicit T2 #resultHeader border-bottom-width (4px) for clarity
- Move diamond quad filled box-shadow from inline styles to .diamond-quad.filled CSS rule
- Add TestResolveTier: 6 parametrized tests covering tier roundtrip, base card, unknown variant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:43:27 -05:00

1209 lines
50 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,
compute_batter_display_stats,
compute_pitcher_display_stats,
BATTER_OUTCOME_COLUMNS,
PITCHER_OUTCOME_COLUMNS,
PITCHER_PRIORITY,
PITCHER_XCHECK_COLUMNS,
)
from app.routers_v2.players import resolve_refractor_tier
# ---------------------------------------------------------------------------
# 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 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}"
)
# ---------------------------------------------------------------------------
# Tier resolution (from variant hash)
# ---------------------------------------------------------------------------
class TestResolveTier:
"""Verify resolve_refractor_tier correctly reverse-maps variant hashes to
tier numbers using compute_variant_hash as the source of truth.
"""
@pytest.mark.parametrize("tier", [1, 2, 3, 4])
def test_known_tier_roundtrips(self, tier):
"""resolve_refractor_tier returns the correct tier for a variant hash
produced by compute_variant_hash.
"""
player_id = 42
variant = compute_variant_hash(player_id, tier)
assert resolve_refractor_tier(player_id, variant) == tier
def test_base_card_returns_zero(self):
"""variant=0 (base card) always returns tier 0."""
assert resolve_refractor_tier(999, 0) == 0
def test_unknown_variant_returns_zero(self):
"""An unrecognized variant hash falls back to tier 0."""
assert resolve_refractor_tier(1, 99999999) == 0
# ---------------------------------------------------------------------------
# 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"]