"""Unit tests for refractor_boost.py — Phase 2 boost functions. Tests are grouped by function: batter boost, pitcher boost, and variant hash. Each class covers one behavioral concern. The functions under test are pure (no DB, no I/O), so every test calls the function directly with a plain dict and asserts on the returned dict. """ import pytest from app.services.refractor_boost import ( apply_batter_boost, apply_pitcher_boost, compute_variant_hash, BATTER_OUTCOME_COLUMNS, PITCHER_OUTCOME_COLUMNS, PITCHER_PRIORITY, PITCHER_XCHECK_COLUMNS, ) # --------------------------------------------------------------------------- # Fixtures: representative card ratings # --------------------------------------------------------------------------- def _silver_batter_vr(): """Robinson Cano 2020 vR split — Silver batter with typical distribution. Sum of the 22 outcome columns is exactly 108.0. Has comfortable strikeout (14.7) and groundout_a (3.85) buffers so a normal boost can be applied without triggering truncation. flyout_rf_b adjusted to 21.2 so the 22-column total is exactly 108.0. """ return { "homerun": 3.05, "bp_homerun": 0.0, "triple": 0.0, "double_three": 2.25, "double_two": 2.25, "double_pull": 8.95, "single_two": 3.35, "single_one": 14.55, "single_center": 5.0, "bp_single": 0.0, "hbp": 6.2, "walk": 7.4, "strikeout": 14.7, "lineout": 0.0, "popout": 0.0, "flyout_a": 5.3, "flyout_bq": 1.45, "flyout_lf_b": 1.95, "flyout_rf_b": 21.2, "groundout_a": 3.85, "groundout_b": 3.0, "groundout_c": 3.55, } def _contact_batter_vl(): """Low-strikeout contact hitter — different archetype for variety testing. Strikeout (8.0) and groundout_a (5.0) are both non-zero, so the normal boost applies without truncation. Sum of 22 columns is exactly 108.0. flyout_rf_b raised to 16.5 to bring sum from 107.5 to 108.0. """ return { "homerun": 1.5, "bp_homerun": 0.0, "triple": 0.5, "double_three": 3.0, "double_two": 3.0, "double_pull": 7.0, "single_two": 5.0, "single_one": 18.0, "single_center": 6.0, "bp_single": 0.0, "hbp": 4.0, "walk": 8.0, "strikeout": 8.0, "lineout": 0.0, "popout": 0.0, "flyout_a": 6.0, "flyout_bq": 2.0, "flyout_lf_b": 3.0, "flyout_rf_b": 16.5, "groundout_a": 5.0, "groundout_b": 6.0, "groundout_c": 5.5, } def _power_batter_vr(): """High-HR power hitter — third archetype for variety testing. Large strikeout (22.0) and groundout_a (2.0). Sum of 22 columns is 108.0. """ return { "homerun": 8.0, "bp_homerun": 0.0, "triple": 0.0, "double_three": 2.0, "double_two": 2.0, "double_pull": 6.0, "single_two": 3.0, "single_one": 10.0, "single_center": 4.0, "bp_single": 0.0, "hbp": 5.0, "walk": 9.0, "strikeout": 22.0, "lineout": 0.0, "popout": 0.0, "flyout_a": 7.0, "flyout_bq": 2.0, "flyout_lf_b": 3.0, "flyout_rf_b": 15.0, "groundout_a": 2.0, "groundout_b": 5.0, "groundout_c": 3.0, } def _singles_pitcher_vl(): """Gibson 2020 vL — contact/groundball SP with typical distribution. Variable outcome columns (18) sum to 79. X-check columns sum to 29. Full card sums to 108. double_cf=2.95 so the priority algorithm will start there before moving to singles. """ return { # Variable columns (sum=79) "homerun": 3.3, "bp_homerun": 2.0, "triple": 0.75, "double_three": 0.0, "double_two": 0.0, "double_cf": 2.95, "single_two": 5.7, "single_one": 0.0, "single_center": 5.0, "bp_single": 5.0, "hbp": 3.0, "walk": 5.0, "strikeout": 10.0, "flyout_lf_b": 15.1, "flyout_cf_b": 0.9, "flyout_rf_b": 0.0, "groundout_a": 15.1, "groundout_b": 5.2, # X-check columns (sum=29) "xcheck_p": 1.0, "xcheck_c": 3.0, "xcheck_1b": 2.0, "xcheck_2b": 6.0, "xcheck_3b": 3.0, "xcheck_ss": 7.0, "xcheck_lf": 2.0, "xcheck_cf": 3.0, "xcheck_rf": 2.0, } def _no_doubles_pitcher(): """Pitcher with 0 in all double columns — tests priority skip behavior. All three double columns (double_cf, double_three, double_two) are 0.0, so the algorithm must skip past them and start reducing singles. Variable columns sum to 79; x-checks sum to 29; full card sums to 108. groundout_b raised to 10.5 to bring variable sum from 77.0 to 79.0. """ return { # Variable columns (sum=79) "homerun": 2.0, "bp_homerun": 1.0, "triple": 0.5, "double_three": 0.0, "double_two": 0.0, "double_cf": 0.0, "single_two": 6.0, "single_one": 3.0, "single_center": 7.0, "bp_single": 5.0, "hbp": 2.0, "walk": 6.0, "strikeout": 12.0, "flyout_lf_b": 8.0, "flyout_cf_b": 4.0, "flyout_rf_b": 2.0, "groundout_a": 10.0, "groundout_b": 10.5, # X-check columns (sum=29) "xcheck_p": 1.0, "xcheck_c": 3.0, "xcheck_1b": 2.0, "xcheck_2b": 6.0, "xcheck_3b": 3.0, "xcheck_ss": 7.0, "xcheck_lf": 2.0, "xcheck_cf": 3.0, "xcheck_rf": 2.0, } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _batter_sum(d: dict) -> float: """Return sum of the 22 batter outcome columns.""" return sum(d[col] for col in BATTER_OUTCOME_COLUMNS) def _pitcher_var_sum(d: dict) -> float: """Return sum of the 18 pitcher variable outcome columns.""" return sum(d[col] for col in PITCHER_OUTCOME_COLUMNS) # --------------------------------------------------------------------------- # Batter boost: 108-sum invariant # --------------------------------------------------------------------------- class TestBatterBoost108Sum: """Verify the 22-column 108-sum invariant is maintained after batter boost.""" def test_single_tier(self): """After one boost, all 22 batter outcome columns still sum to exactly 108. What: Apply a single boost to the Cano vR card and assert that the sum of all 22 outcome columns is 108.0 within float tolerance (1e-6). Why: The fundamental invariant of every batter card is that outcomes add to 108 (the number of results in a standard at-bat die table). A boost that violates this would corrupt every game simulation that uses the card. """ result = apply_batter_boost(_silver_batter_vr()) assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) def test_cumulative_four_tiers(self): """Apply boost 4 times sequentially; sum must be 108.0 after each tier. What: Start from the Cano vR card and apply four successive tier boosts. Assert the 108-sum after each application — not just the last one. Why: Floating-point drift can accumulate over multiple applications. By checking after every tier we catch any incremental drift before it compounds to a visible error in gameplay. """ card = _silver_batter_vr() for tier in range(1, 5): card = apply_batter_boost(card) total = _batter_sum(card) assert total == pytest.approx(108.0, abs=1e-6), ( f"Sum drifted to {total} after tier {tier}" ) def test_different_starting_ratings(self): """Apply boost to 3 different batter archetypes; all maintain 108. What: Boost the Silver (Cano), Contact, and Power batter cards once each and verify the 108-sum for every one. Why: The 108-sum must hold regardless of the starting distribution. A card with unusual column proportions (e.g. very high or low strikeout) might expose an edge case in the scaling math. """ for card_fn in (_silver_batter_vr, _contact_batter_vl, _power_batter_vr): result = apply_batter_boost(card_fn()) total = _batter_sum(result) assert total == pytest.approx(108.0, abs=1e-6), ( f"108-sum violated for {card_fn.__name__}: got {total}" ) # --------------------------------------------------------------------------- # Batter boost: correct deltas # --------------------------------------------------------------------------- class TestBatterBoostDeltas: """Verify that exactly the right columns change by exactly the right amounts.""" def test_positive_columns_increase(self): """homerun, double_pull, single_one, and walk each increase by exactly 0.5. What: Compare the four positive-delta columns before and after a boost on a card where no truncation occurs (Cano vR — strikeout=14.7 and groundout_a=3.85 are well above the required reductions). Why: The boost's offensive intent is encoded in these four columns. Any deviation (wrong column, wrong amount) would silently change card power without the intended effect being applied. """ before = _silver_batter_vr() after = apply_batter_boost(before.copy()) assert after["homerun"] == pytest.approx(before["homerun"] + 0.5, abs=1e-9) assert after["double_pull"] == pytest.approx( before["double_pull"] + 0.5, abs=1e-9 ) assert after["single_one"] == pytest.approx( before["single_one"] + 0.5, abs=1e-9 ) assert after["walk"] == pytest.approx(before["walk"] + 0.5, abs=1e-9) def test_negative_columns_decrease(self): """strikeout decreases by 1.5 and groundout_a decreases by 0.5. What: Compare the two negative-delta columns before and after a boost where no truncation occurs (Cano vR card). Why: These reductions are the cost of the offensive improvement. If either column is not reduced by the correct amount the 108-sum would remain balanced only by accident. """ before = _silver_batter_vr() after = apply_batter_boost(before.copy()) assert after["strikeout"] == pytest.approx(before["strikeout"] - 1.5, abs=1e-9) assert after["groundout_a"] == pytest.approx( before["groundout_a"] - 0.5, abs=1e-9 ) def test_extra_keys_passed_through(self): """Extra keys in the input dict (like x-check columns) survive the boost unchanged. What: Add a full set of x-check keys (xcheck_p, xcheck_c, xcheck_1b, xcheck_2b, xcheck_3b, xcheck_ss, xcheck_lf, xcheck_cf, xcheck_rf) to the standard Cano vR batter dict before boosting. Assert that every x-check key is present in the output and that its value is identical to what was passed in. Also assert the 22 outcome columns still sum to 108. Why: The batter boost function only reads and writes BATTER_OUTCOME_COLUMNS. Any additional keys in the input dict should be forwarded to the output via the `result = dict(ratings_dict)` copy. This guards against a regression where the function returns a freshly-constructed dict that drops non-outcome keys, which would strip x-check data when the caller chains batter and pitcher operations on a shared dict. """ xcheck_values = { "xcheck_p": 1.0, "xcheck_c": 3.0, "xcheck_1b": 2.0, "xcheck_2b": 6.0, "xcheck_3b": 3.0, "xcheck_ss": 7.0, "xcheck_lf": 2.0, "xcheck_cf": 3.0, "xcheck_rf": 2.0, } card = _silver_batter_vr() card.update(xcheck_values) result = apply_batter_boost(card) # All x-check keys survive with unchanged values for col, expected in xcheck_values.items(): assert col in result, f"X-check key '{col}' missing from output" assert result[col] == pytest.approx(expected, abs=1e-9), ( f"X-check column '{col}' value changed: {expected} → {result[col]}" ) # The 22 outcome columns still satisfy the 108-sum invariant assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) def test_unmodified_columns_unchanged(self): """All 16 columns not in BATTER_POSITIVE_DELTAS or BATTER_NEGATIVE_DELTAS are identical before and after a boost where no truncation occurs. What: After boosting the Cano vR card, compare every column that is NOT one of the six modified columns and assert it is unchanged. Why: A bug that accidentally modifies an unrelated column (e.g. a copy-paste error or an off-by-one in a column list) would corrupt game balance silently. This acts as a "column integrity" guard. """ modified_cols = { "homerun", "double_pull", "single_one", "walk", # positive "strikeout", "groundout_a", # negative } unmodified_cols = [c for c in BATTER_OUTCOME_COLUMNS if c not in modified_cols] before = _silver_batter_vr() after = apply_batter_boost(before.copy()) for col in unmodified_cols: assert after[col] == pytest.approx(before[col], abs=1e-9), ( f"Column '{col}' changed unexpectedly: {before[col]} → {after[col]}" ) # --------------------------------------------------------------------------- # Batter boost: 0-floor truncation # --------------------------------------------------------------------------- class TestBatterBoostTruncation: """Verify that the 0-floor is enforced and positive deltas are scaled proportionally so the 108-sum is preserved even when negative columns cannot be fully reduced. """ def test_groundout_a_near_zero(self): """Card with groundout_a=0.3: only 0.3 is taken, positive deltas scale down proportionally, and the sum still equals 108. What: Construct a card where groundout_a=0.3 (less than the requested 0.5 reduction). The algorithm must floor groundout_a at 0 (taking only 0.3), and scale the positive deltas so that total_added == total_reduced (1.5 + 0.3 = 1.8 from strikeout + groundout_a; positive budget is also reduced accordingly). Why: Without the 0-floor, groundout_a would go negative — an invalid card state. Without proportional scaling the 108-sum would break. This test validates both protections together. """ card = _silver_batter_vr() card["groundout_a"] = 0.3 # Re-balance: move the excess 3.55 to groundout_b to keep sum==108 diff = 3.85 - 0.3 # original groundout_a was 3.85 card["groundout_b"] = card["groundout_b"] + diff result = apply_batter_boost(card) # groundout_a must be exactly 0 (floored) assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9) # 108-sum preserved assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) def test_strikeout_near_zero(self): """Card with strikeout=1.0: only 1.0 is taken instead of the requested 1.5, and positive deltas are scaled down to compensate. What: Build a card with strikeout=1.0 (less than the requested 1.5 reduction). The algorithm can only take 1.0 from strikeout (floored at 0), and the full 0.5 from groundout_a — so total_actually_reduced is 1.5 against a total_requested_reduction of 2.0. Truncation DOES occur: positive deltas are scaled to 0.75x (1.5 / 2.0) so the 108-sum is preserved, and strikeout lands exactly at 0.0. Why: Strikeout is the most commonly near-zero column on elite contact hitters. Verifying the floor specifically on strikeout ensures that the tier-1 boost on a card already boosted multiple times won't produce an invalid negative strikeout value. """ card = _silver_batter_vr() # Replace strikeout=14.7 with strikeout=1.0 and re-balance via flyout_rf_b diff = 14.7 - 1.0 card["strikeout"] = 1.0 card["flyout_rf_b"] = card["flyout_rf_b"] + diff result = apply_batter_boost(card) # strikeout must be exactly 0 (fully consumed by the floor) assert result["strikeout"] == pytest.approx(0.0, abs=1e-9) # 108-sum preserved regardless of truncation assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) def test_both_out_columns_zero(self): """When both strikeout=0 and groundout_a=0, the boost becomes a no-op. What: Build a card where strikeout=0.0 and groundout_a=0.0 (the 18.55 chances freed by zeroing both are redistributed to flyout_rf_b so the 22-column sum remains exactly 108.0). When the boost runs, total_actually_reduced is 0, so the scale factor is 0 and every positive delta becomes 0 as well. Why: This validates the edge of the truncation path where no budget whatsoever is available. Without the `if total_requested_addition > 0` guard, the algorithm would divide by zero; without correct scaling it would silently apply non-zero positive deltas and break the 108-sum. """ card = _silver_batter_vr() # Zero out both negative-delta source columns and compensate via flyout_rf_b freed = card["strikeout"] + card["groundout_a"] # 14.7 + 3.85 = 18.55 card["strikeout"] = 0.0 card["groundout_a"] = 0.0 card["flyout_rf_b"] = card["flyout_rf_b"] + freed before_homerun = card["homerun"] before_double_pull = card["double_pull"] before_single_one = card["single_one"] before_walk = card["walk"] result = apply_batter_boost(card) # 108-sum is preserved (nothing moved) assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) # Source columns remain at zero assert result["strikeout"] == pytest.approx(0.0, abs=1e-9) assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9) # Positive-delta columns are unchanged (scale == 0 means no additions) assert result["homerun"] == pytest.approx(before_homerun, abs=1e-9) assert result["double_pull"] == pytest.approx(before_double_pull, abs=1e-9) assert result["single_one"] == pytest.approx(before_single_one, abs=1e-9) assert result["walk"] == pytest.approx(before_walk, abs=1e-9) # --------------------------------------------------------------------------- # Pitcher boost: 108-sum invariant # --------------------------------------------------------------------------- class TestPitcherBoost108Sum: """Verify the pitcher card invariant: 18 variable columns sum to 79, x-checks sum to 29, total is 108 — all after every boost. """ def test_singles_heavy_pitcher(self): """Gibson-like pitcher (double_cf=2.95, no other doubles) maintains 79-sum for the 18 variable columns after one boost. What: Boost the Gibson vL card once. Assert that the 18 variable columns (PITCHER_OUTCOME_COLUMNS) still sum to 79. Why: The pitcher boost algorithm converts hit/walk chances to strikeouts without changing the total number of outcomes. If any chance is created or destroyed, the 79-sum breaks and game simulation results become unreliable. """ result = apply_pitcher_boost(_singles_pitcher_vl()) assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6) def test_no_doubles_pitcher(self): """Pitcher with all three double columns at 0 skips them, reduces singles instead, and the 18-column variable sum stays at 79. What: Boost the no-doubles pitcher fixture once. The priority list starts with double_cf, double_three, double_two — all zero — so the algorithm must skip them and begin consuming single_center. The 79-sum must be preserved. Why: Validates the zero-skip logic in the priority loop. Without it, the algorithm would incorrectly deduct from a 0-value column, producing a negative entry and an invalid 79-sum. """ result = apply_pitcher_boost(_no_doubles_pitcher()) assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6) def test_cumulative_four_tiers(self): """Four successive boosts: sum==79 after each tier; no column is negative. What: Apply four boosts in sequence to the Gibson vL card (highest number of reducible doubles/singles available). After each boost, assert that the 18-column sum is 79.0 and that no individual column went negative. Why: Cumulative boost scenarios are the real production use case. Float drift and edge cases in the priority budget loop could silently corrupt the card over multiple tier-ups. Checking after every iteration catches both issues early. """ card = _singles_pitcher_vl() for tier in range(1, 5): card = apply_pitcher_boost(card) total = _pitcher_var_sum(card) assert total == pytest.approx(79.0, abs=1e-6), ( f"79-sum drifted to {total} after tier {tier}" ) for col in PITCHER_OUTCOME_COLUMNS: assert card[col] >= -1e-9, ( f"Column '{col}' went negative ({card[col]}) after tier {tier}" ) # --------------------------------------------------------------------------- # Pitcher boost: determinism # --------------------------------------------------------------------------- class TestPitcherBoostDeterminism: """Same input always produces identical output across multiple calls.""" def test_repeated_calls_identical(self): """Call apply_pitcher_boost 10 times with the same input; all outputs match. What: Pass the same Gibson vL dict to apply_pitcher_boost ten times and assert that every result is byte-for-byte equal to the first. Why: Determinism is a hard requirement for reproducible card states. Any non-determinism (e.g. from random number usage, hash iteration order, or floating-point compiler variance) would make two identically- seeded boosts produce different card variants — a correctness bug. """ base = _singles_pitcher_vl() first = apply_pitcher_boost(base.copy()) for i in range(1, 10): result = apply_pitcher_boost(base.copy()) for col in PITCHER_OUTCOME_COLUMNS: assert result[col] == first[col], ( f"Call {i} differed on column '{col}': " f"first={first[col]}, got={result[col]}" ) # --------------------------------------------------------------------------- # Pitcher boost: TB budget accounting # --------------------------------------------------------------------------- class TestPitcherBoostTBAccounting: """Verify the TB budget is debited at the correct rate per outcome type.""" def test_doubles_cost_2tb(self): """Reducing a double column by 0.75 chances spends exactly 1.5 TB. What: Build a minimal pitcher card where only double_cf is non-zero (2.0 chances) and all singles/walks/HR are 0. With the default budget of 1.5 TB and a cost of 2 TB per chance, the algorithm can take at most 0.75 chances. Assert that double_cf decreases by exactly 0.75 and strikeout increases by exactly 0.75. Why: If the TB cost factor for doubles were applied incorrectly (e.g. as 1 instead of 2), the algorithm would over-consume chances and produce a card that has been boosted more than a single tier allows. """ # Construct a minimal card: only double_cf has value card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} card["double_cf"] = 2.0 card["strikeout"] = 77.0 # 2.0 + 77.0 = 79.0 to satisfy invariant for col in PITCHER_XCHECK_COLUMNS: card[col] = 0.0 card["xcheck_2b"] = 29.0 # put all xcheck budget in one column result = apply_pitcher_boost(card) # Budget = 1.5 TB, cost = 2 TB/chance → max chances = 0.75 assert result["double_cf"] == pytest.approx(2.0 - 0.75, abs=1e-9) assert result["strikeout"] == pytest.approx(77.0 + 0.75, abs=1e-9) def test_singles_cost_1tb(self): """Reducing a singles column spends 1 TB per chance. What: Build a minimal card where only single_center is non-zero (5.0 chances) and all higher-priority columns are 0. With the default budget of 1.5 TB and a cost of 1 TB per chance, the algorithm takes exactly 1.5 chances from single_center. Why: Singles are the most common target in the priority list. Getting the cost factor wrong (e.g. 2 instead of 1) would halve the effective boost impact on contact-heavy pitchers. """ card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} card["single_center"] = 5.0 card["strikeout"] = 74.0 # 5.0 + 74.0 = 79.0 for col in PITCHER_XCHECK_COLUMNS: card[col] = 0.0 card["xcheck_2b"] = 29.0 result = apply_pitcher_boost(card) # Budget = 1.5 TB, cost = 1 TB/chance → takes exactly 1.5 chances assert result["single_center"] == pytest.approx(5.0 - 1.5, abs=1e-9) assert result["strikeout"] == pytest.approx(74.0 + 1.5, abs=1e-9) def test_budget_exhaustion(self): """Algorithm stops exactly when the TB budget reaches 0, leaving the next priority column untouched. What: Build a card where double_cf=1.0 (costs 2 TB/chance). With budget=1.5 the algorithm takes 0.75 chances from double_cf (spending exactly 1.5 TB) and then stops. single_center, which comes later in the priority list, must be completely untouched. Why: Overspending the budget would boost the pitcher by more than one tier allows. This test directly verifies the `if remaining <= 0: break` guard in the loop. """ card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} card["double_cf"] = 1.0 card["single_center"] = 5.0 card["strikeout"] = 73.0 # 1.0 + 5.0 + 73.0 = 79.0 for col in PITCHER_XCHECK_COLUMNS: card[col] = 0.0 card["xcheck_2b"] = 29.0 result = apply_pitcher_boost(card) # double_cf consumed 0.75 chances (1.5 TB at 2 TB/chance) — budget gone assert result["double_cf"] == pytest.approx(1.0 - 0.75, abs=1e-9) # single_center must be completely unchanged (budget was exhausted) assert result["single_center"] == pytest.approx(5.0, abs=1e-9) # strikeout gained exactly 0.75 (from double_cf only) assert result["strikeout"] == pytest.approx(73.0 + 0.75, abs=1e-9) # --------------------------------------------------------------------------- # Pitcher boost: zero-skip and x-check protection # --------------------------------------------------------------------------- class TestPitcherBoostZeroSkip: """Verify that zero-valued priority columns are skipped and that x-check columns are never modified under any circumstances. """ def test_skip_zero_doubles(self): """Pitcher with all three double columns at 0: first reduction comes from single_center (the first non-zero column in priority order). What: Boost the no-doubles pitcher fixture once. Assert that double_cf, double_three, and double_two are still 0.0 after the boost, and that single_center has decreased (the first non-zero column in the priority list after the three doubles). Why: The priority loop must not subtract from columns that are already at 0 — doing so would create negative values and an invalid card. This test confirms the `if ratings[col] <= 0: continue` guard works. """ before = _no_doubles_pitcher() after = apply_pitcher_boost(before.copy()) # All double columns were 0 and must remain 0 assert after["double_cf"] == pytest.approx(0.0, abs=1e-9) assert after["double_three"] == pytest.approx(0.0, abs=1e-9) assert after["double_two"] == pytest.approx(0.0, abs=1e-9) # single_center (4th in priority) must have decreased assert after["single_center"] < before["single_center"], ( "single_center should have been reduced when doubles were all 0" ) def test_all_priority_columns_zero(self): """When every priority column is 0, the TB budget cannot be spent. What: Build a pitcher card where all 12 PITCHER_PRIORITY columns (double_cf, double_three, double_two, single_center, single_two, single_one, bp_single, walk, homerun, bp_homerun, triple, hbp) are 0.0. The remaining 79 variable-column chances are distributed across the non-priority variable columns (strikeout, flyout_lf_b, flyout_cf_b, flyout_rf_b, groundout_a, groundout_b). X-check columns sum to 29 as usual. The algorithm iterates all 12 priority entries, finds nothing to take, logs a warning, and returns. Why: This is the absolute edge of the zero-skip path. Without the `if remaining > 0: logger.warning(...)` guard the unspent budget would be silently discarded. More importantly, no column should be modified: the 79-sum must be preserved, strikeout must not change, and every priority column must still be exactly 0.0. """ priority_cols = {col for col, _ in PITCHER_PRIORITY} card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} # Distribute all 79 variable chances across non-priority outcome columns card["strikeout"] = 20.0 card["flyout_lf_b"] = 15.0 card["flyout_cf_b"] = 15.0 card["flyout_rf_b"] = 15.0 card["groundout_a"] = 7.0 card["groundout_b"] = 7.0 # Add x-check columns (sum=29), required by the card structure for col in PITCHER_XCHECK_COLUMNS: card[col] = 0.0 card["xcheck_2b"] = 29.0 before_strikeout = card["strikeout"] result = apply_pitcher_boost(card) # Variable columns still sum to 79 assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6) # Strikeout is unchanged — nothing was moved into it assert result["strikeout"] == pytest.approx(before_strikeout, abs=1e-9) # All priority columns remain at 0 for col in priority_cols: assert result[col] == pytest.approx(0.0, abs=1e-9), ( f"Priority column '{col}' changed unexpectedly: {result[col]}" ) # X-check columns are untouched for col in PITCHER_XCHECK_COLUMNS: assert result[col] == pytest.approx(card[col], abs=1e-9) def test_xcheck_columns_never_modified(self): """All 9 x-check columns are identical before and after a boost. What: Boost both the Gibson vL card and the no-doubles pitcher card once each and verify that every xcheck_* column is unchanged. Why: X-check columns encode defensive routing weights that are completely separate from the offensive outcome probabilities. The boost algorithm must never touch them; modifying them would break game simulation logic that relies on their fixed sum of 29. """ for card_fn in (_singles_pitcher_vl, _no_doubles_pitcher): before = card_fn() after = apply_pitcher_boost(before.copy()) for col in PITCHER_XCHECK_COLUMNS: assert after[col] == pytest.approx(before[col], abs=1e-9), ( f"X-check column '{col}' was unexpectedly modified in " f"{card_fn.__name__}: {before[col]} → {after[col]}" ) # --------------------------------------------------------------------------- # Variant hash # --------------------------------------------------------------------------- class TestVariantHash: """Verify the behavior of compute_variant_hash: determinism, uniqueness, never-zero guarantee, and cosmetics influence. """ def test_deterministic(self): """Same (player_id, refractor_tier, cosmetics) inputs produce the same hash on every call. What: Call compute_variant_hash 20 times with the same arguments and assert every result equals the first. Why: The variant hash is stored in the database as a stable card identifier. Any non-determinism would cause the same card state to generate a different variant key on different calls, creating phantom duplicate variants in the DB. """ first = compute_variant_hash(42, 2, ["foil"]) for _ in range(19): assert compute_variant_hash(42, 2, ["foil"]) == first def test_different_tiers_different_hash(self): """Tier 1 and tier 2 for the same player produce different hashes. What: Compare compute_variant_hash(player_id=1, refractor_tier=1) vs compute_variant_hash(player_id=1, refractor_tier=2). Why: Each Refractor tier represents a distinct card version. If two tiers produced the same hash, the DB unique-key constraint on variant would incorrectly merge them into a single entry. """ h1 = compute_variant_hash(1, 1) h2 = compute_variant_hash(1, 2) assert h1 != h2, f"Tier 1 and tier 2 unexpectedly produced the same hash: {h1}" def test_different_players_different_hash(self): """Same tier for different players produces different hashes. What: Compare compute_variant_hash(player_id=10, refractor_tier=1) vs compute_variant_hash(player_id=11, refractor_tier=1). Why: Each player's Refractor card is a distinct asset. If two players at the same tier shared a hash, their boosted variants could not be distinguished in the database. """ ha = compute_variant_hash(10, 1) hb = compute_variant_hash(11, 1) assert ha != hb, ( f"Player 10 and player 11 at tier 1 unexpectedly share hash: {ha}" ) def test_never_zero(self): """Hash is never 0 across a broad set of (player_id, tier) combinations. What: Generate hashes for player_ids 0–999 at tiers 0–4 (5000 total) and assert every result is >= 1. Why: Variant 0 is the reserved sentinel for base (un-boosted) cards. The function must remap any SHA-256-derived value that happens to be 0 to 1. Testing with a large batch guards against the extremely unlikely collision while confirming the guard is active. """ for player_id in range(1000): for tier in range(5): result = compute_variant_hash(player_id, tier) assert result >= 1, ( f"compute_variant_hash({player_id}, {tier}) returned 0" ) def test_cosmetics_affect_hash(self): """Adding cosmetics to the same player/tier produces a different hash. What: Compare compute_variant_hash(1, 1, cosmetics=None) vs compute_variant_hash(1, 1, cosmetics=["chrome"]). Why: Cosmetic variants (special art, foil treatment, etc.) must be distinguishable from the base-tier variant. If cosmetics were ignored by the hash, two cards that look different would share the same variant key and collide in the database. """ base = compute_variant_hash(1, 1) with_cosmetic = compute_variant_hash(1, 1, ["chrome"]) assert base != with_cosmetic, "Adding cosmetics did not change the variant hash" def test_cosmetics_order_independent(self): """Cosmetics list is sorted before hashing; order does not matter. What: Compare compute_variant_hash(1, 1, ["foil", "chrome"]) vs compute_variant_hash(1, 1, ["chrome", "foil"]). They must be equal. Why: Callers should not need to sort cosmetics before passing them in. If the hash were order-dependent, the same logical card state could produce two different variant keys depending on how the caller constructed the list. """ h1 = compute_variant_hash(1, 1, ["foil", "chrome"]) h2 = compute_variant_hash(1, 1, ["chrome", "foil"]) assert h1 == h2, ( f"Cosmetics order affected hash: ['foil','chrome']={h1}, " f"['chrome','foil']={h2}" )