"""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 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}" ) # --------------------------------------------------------------------------- # 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"]