paper-dynasty-database/tests/test_refractor_boost_integration.py
Cal Corum 7f17c9b9f2 fix: address PR #177 review — move import os to top-level, add audit idempotency guard
- Move `import os` from inside evaluate_game() to module top-level imports
  (lazy imports are only for circular dependency avoidance)
- Add get_or_none idempotency guard before RefractorBoostAudit.create()
  inside db.atomic() to prevent IntegrityError on UNIQUE(card_state, tier)
  constraint in PostgreSQL when apply_tier_boost is called twice for the
  same tier
- Update atomicity test stub to provide card_state/tier attributes for
  the new Peewee expression in the idempotency guard

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

1318 lines
49 KiB
Python

"""Integration tests for apply_tier_boost() orchestration.
Tests the full flow: create base card + ratings -> apply boost -> verify
variant card created with correct ratings, audit record written, card state
updated, and Card instances propagated.
Uses a named shared-memory SQLite database (same pattern as
test_postgame_refractor.py) so that db.atomic() inside apply_tier_boost()
and test assertions operate on the same underlying connection. The
refractor_boost module's 'db' reference is patched to point at this
shared-memory database.
The conftest autouse setup_test_db fixture is overridden by the module-level
setup_boost_int_db fixture (autouse=True) so each test gets a fresh schema.
BattingCard, BattingCardRatings, PitchingCard, PitchingCardRatings, and
RefractorBoostAudit are included in the model list so apply_tier_boost() can
create rows during tests.
All pitcher sum assertions use the full 108-sum (18 variable + 9 x-check
columns), not just the 79 variable-column subset, because that is the
card-level invariant apply_tier_boost() must preserve.
"""
import os
# Must set before any app imports so SKIP_TABLE_CREATION is True in db_engine.
os.environ["DATABASE_TYPE"] = "postgresql"
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
os.environ.setdefault("API_TOKEN", "test-token")
import app.services.refractor_boost as _refractor_boost_module
import pytest
from peewee import SqliteDatabase
from app.db_engine import (
BattingCard,
BattingCardRatings,
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingCard,
PitchingCardRatings,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorBoostAudit,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
)
from app.services.refractor_boost import (
BATTER_OUTCOME_COLUMNS,
PITCHER_OUTCOME_COLUMNS,
PITCHER_XCHECK_COLUMNS,
apply_tier_boost,
compute_variant_hash,
)
from app.services.refractor_evaluator import evaluate_card
# ---------------------------------------------------------------------------
# Named shared-memory SQLite for integration tests.
# A shared-cache URI allows all threads (e.g. TestClient routes and pytest
# fixtures) to share the same in-memory DB. Required because SQLite
# :memory: is per-connection.
# ---------------------------------------------------------------------------
_boost_int_db = SqliteDatabase(
"file:boost_int_test?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
# All models in dependency order (parents before children).
_BOOST_INT_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
BattingCard,
BattingCardRatings,
PitchingCard,
PitchingCardRatings,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
RefractorBoostAudit,
]
# Patch the service-layer 'db' reference so that db.atomic() inside
# apply_tier_boost() operates on the shared-memory SQLite connection.
_refractor_boost_module.db = _boost_int_db
# ---------------------------------------------------------------------------
# Database fixture — binds all models to the shared-memory SQLite db.
# autouse=True so every test automatically gets a fresh schema.
# This overrides the conftest autouse setup_test_db fixture for this module.
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def setup_boost_int_db():
"""Bind integration test models to the shared-memory SQLite db.
Creates tables before each test and drops them in reverse order after.
The autouse=True ensures every test in this module gets an isolated schema
without explicitly requesting the fixture.
"""
_boost_int_db.bind(_BOOST_INT_MODELS)
_boost_int_db.connect(reuse_if_open=True)
_boost_int_db.create_tables(_BOOST_INT_MODELS)
yield _boost_int_db
_boost_int_db.drop_tables(list(reversed(_BOOST_INT_MODELS)), safe=True)
# ---------------------------------------------------------------------------
# Shared factory helpers
# ---------------------------------------------------------------------------
def _make_rarity():
r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"})
return r
def _make_player(name="Test Player", pos="1B"):
cs, _ = Cardset.get_or_create(
name="BI Test Set",
defaults={"description": "boost int test", "total_cards": 100},
)
return Player.create(
p_name=name,
rarity=_make_rarity(),
cardset=cs,
set_num=1,
pos_1=pos,
image="https://example.com/img.png",
mlbclub="TST",
franchise="TST",
description=f"boost int test: {name}",
)
def _make_team(abbrev="TST", gmid=99001):
return Team.create(
abbrev=abbrev,
sname=abbrev,
lname=f"Team {abbrev}",
gmid=gmid,
gmname=f"gm_{abbrev.lower()}",
gsheet="https://docs.google.com/spreadsheets/bi",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=False,
)
def _make_track(name="Batter Track", card_type="batter"):
track, _ = RefractorTrack.get_or_create(
name=name,
defaults=dict(
card_type=card_type,
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
return track
def _make_state(player, team, track, current_tier=0, current_value=0.0):
return RefractorCardState.create(
player=player,
team=team,
track=track,
current_tier=current_tier,
current_value=current_value,
fully_evolved=False,
last_evaluated_at=None,
)
# Representative batter ratings that sum to exactly 108.
_BASE_BATTER_RATINGS = {
"homerun": 3.0,
"bp_homerun": 1.0,
"triple": 0.5,
"double_three": 2.0,
"double_two": 2.0,
"double_pull": 6.0,
"single_two": 4.0,
"single_one": 12.0,
"single_center": 5.0,
"bp_single": 2.0,
"hbp": 3.0,
"walk": 7.0,
"strikeout": 15.0,
"lineout": 3.0,
"popout": 2.0,
"flyout_a": 5.0,
"flyout_bq": 4.0,
"flyout_lf_b": 3.0,
"flyout_rf_b": 9.0,
"groundout_a": 6.0,
"groundout_b": 8.0,
"groundout_c": 5.5,
}
# Representative pitcher ratings: 18 variable columns sum to 79,
# 9 x-check columns sum to 29, full card sums to 108.
_BASE_PITCHER_RATINGS = {
# 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 _create_base_batter(player, variant=0):
"""Create a BattingCard with variant=0 and two ratings rows (vL, vR)."""
card = BattingCard.create(
player=player,
variant=variant,
steal_low=1,
steal_high=6,
steal_auto=False,
steal_jump=0.5,
bunting="C",
hit_and_run="B",
running=3,
offense_col=2,
hand="R",
)
for vs_hand in ("L", "R"):
BattingCardRatings.create(
battingcard=card,
vs_hand=vs_hand,
pull_rate=0.4,
center_rate=0.35,
slap_rate=0.25,
avg=0.300,
obp=0.370,
slg=0.450,
**_BASE_BATTER_RATINGS,
)
return card
def _create_base_pitcher(player, variant=0):
"""Create a PitchingCard with variant=0 and two ratings rows (vL, vR)."""
card = PitchingCard.create(
player=player,
variant=variant,
balk=1,
wild_pitch=2,
hold=3,
starter_rating=60,
relief_rating=50,
closer_rating=None,
batting=None,
offense_col=1,
hand="R",
)
for vs_hand in ("L", "R"):
PitchingCardRatings.create(
pitchingcard=card,
vs_hand=vs_hand,
avg=0.250,
obp=0.310,
slg=0.380,
**_BASE_PITCHER_RATINGS,
)
return card
def _injectable_kwargs(model_map: dict) -> dict:
"""Build apply_tier_boost() injectable kwargs from a name->model mapping."""
return {
"_batting_card_model": model_map.get("BattingCard", BattingCard),
"_batting_ratings_model": model_map.get(
"BattingCardRatings", BattingCardRatings
),
"_pitching_card_model": model_map.get("PitchingCard", PitchingCard),
"_pitching_ratings_model": model_map.get(
"PitchingCardRatings", PitchingCardRatings
),
"_card_model": model_map.get("Card", Card),
"_state_model": model_map.get("RefractorCardState", RefractorCardState),
"_audit_model": model_map.get("RefractorBoostAudit", RefractorBoostAudit),
}
# Default injectable kwargs point at the real models (which are now bound to
# the shared-memory DB via the fixture).
_DEFAULT_KWARGS = _injectable_kwargs({})
# ---------------------------------------------------------------------------
# TestBatterTierUpFlow
# ---------------------------------------------------------------------------
class TestBatterTierUpFlow:
"""Full batter T1 boost flow: card + ratings created, state updated, base unchanged."""
def test_creates_variant_card(self):
"""T1 boost creates a new BattingCard row with the correct variant hash.
What: Set up a player with a base BattingCard (variant=0) and a
RefractorCardState. Call apply_tier_boost for T1. Assert that a new
BattingCard row exists with the expected variant hash.
Why: The variant card is the persistent record of the boosted card.
If it is not created, the tier-up has no lasting effect on the card's
identity in the database.
"""
player = _make_player()
team = _make_team()
track = _make_track()
_make_state(player, team, track)
_create_base_batter(player)
expected_variant = compute_variant_hash(player.player_id, 1)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
variant_card = BattingCard.get_or_none(
(BattingCard.player == player) & (BattingCard.variant == expected_variant)
)
assert variant_card is not None, (
f"Expected BattingCard with variant={expected_variant} to be created"
)
def test_creates_boosted_ratings(self):
"""T1 boost creates BattingCardRatings rows with positive deltas applied.
What: After T1 boost, the variant card's vR ratings must have
homerun > base_homerun (the primary positive delta column is homerun
+0.5 per tier, funded by strikeout -1.5 and groundout_a -0.5).
Why: If the ratings rows are not created, or if the boost formula is
not applied, the variant card has the same outcomes as the base card
and offers no gameplay advantage.
"""
player = _make_player()
team = _make_team()
track = _make_track()
_make_state(player, team, track)
_create_base_batter(player)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
new_variant = compute_variant_hash(player.player_id, 1)
new_card = BattingCard.get(
(BattingCard.player == player) & (BattingCard.variant == new_variant)
)
vr_ratings = BattingCardRatings.get(
(BattingCardRatings.battingcard == new_card)
& (BattingCardRatings.vs_hand == "R")
)
# homerun must be higher than base (base + 0.5 delta)
assert vr_ratings.homerun > _BASE_BATTER_RATINGS["homerun"], (
f"Expected homerun > {_BASE_BATTER_RATINGS['homerun']}, "
f"got {vr_ratings.homerun}"
)
# strikeout must be lower (reduced by 1.5)
assert vr_ratings.strikeout < _BASE_BATTER_RATINGS["strikeout"], (
f"Expected strikeout < {_BASE_BATTER_RATINGS['strikeout']}, "
f"got {vr_ratings.strikeout}"
)
def test_ratings_sum_108(self):
"""Boosted batter ratings rows sum to exactly 108.
What: After T1 boost, sum all 22 BATTER_OUTCOME_COLUMNS for both the
vL and vR ratings rows on the variant card. Each must be 108.0.
Why: The 108-sum is the card-level invariant. Peewee bypasses Pydantic
validators, so apply_tier_boost() must explicitly assert and preserve
this invariant. A sum other than 108 would corrupt game simulation.
"""
player = _make_player()
team = _make_team()
track = _make_track()
_make_state(player, team, track)
_create_base_batter(player)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
new_variant = compute_variant_hash(player.player_id, 1)
new_card = BattingCard.get(
(BattingCard.player == player) & (BattingCard.variant == new_variant)
)
for ratings_row in BattingCardRatings.select().where(
BattingCardRatings.battingcard == new_card
):
total = sum(getattr(ratings_row, col) for col in BATTER_OUTCOME_COLUMNS)
assert abs(total - 108.0) < 0.01, (
f"Batter 108-sum violated for vs_hand={ratings_row.vs_hand}: "
f"sum={total:.6f}"
)
def test_audit_record_created(self):
"""RefractorBoostAudit row is created with correct tier and variant.
What: After T1 boost, a RefractorBoostAudit row must exist for the
card state with tier=1 and variant_created matching the expected hash.
Why: The audit record is the permanent log of when and how each tier
boost was applied. Without it, there is no way to debug tier-up
regressions or replay boost history.
"""
player = _make_player()
team = _make_team()
track = _make_track()
state = _make_state(player, team, track)
_create_base_batter(player)
expected_variant = compute_variant_hash(player.player_id, 1)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
audit = RefractorBoostAudit.get_or_none(
(RefractorBoostAudit.card_state == state) & (RefractorBoostAudit.tier == 1)
)
assert audit is not None, "Expected RefractorBoostAudit row to be created"
assert audit.variant_created == expected_variant
def test_card_state_variant_updated(self):
"""RefractorCardState.variant and current_tier are updated after boost.
What: After T1 boost, RefractorCardState.variant must equal the new
variant hash and current_tier must be 1.
Why: apply_tier_boost() is the sole writer of current_tier on tier-up.
If either field is not updated, the card state is inconsistent with
the variant card that was created.
"""
player = _make_player()
team = _make_team()
track = _make_track()
state = _make_state(player, team, track)
_create_base_batter(player)
expected_variant = compute_variant_hash(player.player_id, 1)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
state = RefractorCardState.get_by_id(state.id)
assert state.current_tier == 1
assert state.variant == expected_variant
def test_base_card_unchanged(self):
"""variant=0 BattingCard and BattingCardRatings are not modified.
What: After T1 boost, the base card (variant=0) and its ratings rows
must be byte-for-byte identical to what they were before the boost.
Why: The base card is the source of truth for all variant calculations.
If it is modified, subsequent tier-ups will compute incorrect boosts and
the original card identity is lost.
"""
player = _make_player()
team = _make_team()
track = _make_track()
_make_state(player, team, track)
base_card = _create_base_batter(player)
# Capture base card homerun before boost
base_vr = BattingCardRatings.get(
(BattingCardRatings.battingcard == base_card)
& (BattingCardRatings.vs_hand == "R")
)
base_homerun_before = base_vr.homerun
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
# Re-fetch the base card ratings and verify unchanged
base_vr_after = BattingCardRatings.get(
(BattingCardRatings.battingcard == base_card)
& (BattingCardRatings.vs_hand == "R")
)
assert base_vr_after.homerun == base_homerun_before, (
f"Base card homerun was modified: "
f"{base_homerun_before} -> {base_vr_after.homerun}"
)
# Verify there is still only one base card
base_cards = list(
BattingCard.select().where(
(BattingCard.player == player) & (BattingCard.variant == 0)
)
)
assert len(base_cards) == 1, f"Expected 1 base card, found {len(base_cards)}"
# ---------------------------------------------------------------------------
# TestPitcherTierUpFlow
# ---------------------------------------------------------------------------
class TestPitcherTierUpFlow:
"""Full pitcher T1 boost flow: x-check columns unchanged, 108-sum preserved."""
def test_creates_variant_card(self):
"""T1 boost creates a PitchingCard with the correct variant hash.
What: Set up a player with a base PitchingCard (variant=0) and a
RefractorCardState with card_type='sp'. Call apply_tier_boost for T1.
Assert that a new PitchingCard exists with the expected variant hash.
Why: Pitcher cards follow a different boost algorithm (TB budget
priority) but must still produce a variant card row.
"""
player = _make_player(pos="SP")
team = _make_team(abbrev="PT1", gmid=99010)
track = _make_track(name="SP Track", card_type="sp")
_make_state(player, team, track)
_create_base_pitcher(player)
expected_variant = compute_variant_hash(player.player_id, 1)
apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS)
variant_card = PitchingCard.get_or_none(
(PitchingCard.player == player) & (PitchingCard.variant == expected_variant)
)
assert variant_card is not None
def test_xcheck_columns_unchanged(self):
"""X-check columns on boosted pitcher ratings are identical to the base card.
What: After T1 boost, every xcheck_* column on the variant card's
ratings rows must exactly match the corresponding value on the base card.
Why: X-check columns encode defensive routing weights. The pitcher
boost algorithm is explicitly designed to never touch them. If any
x-check column is modified, game simulation defensive logic breaks and
the 108-sum invariant (variable 79 + x-check 29) is violated.
"""
player = _make_player(pos="SP")
team = _make_team(abbrev="PT2", gmid=99011)
track = _make_track(name="SP Track2", card_type="sp")
_make_state(player, team, track)
base_card = _create_base_pitcher(player)
apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS)
new_variant = compute_variant_hash(player.player_id, 1)
new_card = PitchingCard.get(
(PitchingCard.player == player) & (PitchingCard.variant == new_variant)
)
for vs_hand in ("L", "R"):
base_row = PitchingCardRatings.get(
(PitchingCardRatings.pitchingcard == base_card)
& (PitchingCardRatings.vs_hand == vs_hand)
)
new_row = PitchingCardRatings.get(
(PitchingCardRatings.pitchingcard == new_card)
& (PitchingCardRatings.vs_hand == vs_hand)
)
for col in PITCHER_XCHECK_COLUMNS:
assert getattr(new_row, col) == pytest.approx(
getattr(base_row, col), abs=1e-9
), (
f"X-check column '{col}' was modified for vs_hand={vs_hand}: "
f"{getattr(base_row, col)} -> {getattr(new_row, col)}"
)
def test_108_sum_pitcher(self):
"""Boosted pitcher ratings sum to exactly 108 (variable 79 + x-check 29).
What: After T1 boost, sum all 18 PITCHER_OUTCOME_COLUMNS and all 9
PITCHER_XCHECK_COLUMNS for each vs_hand split on the variant card.
The combined total must be 108.0 for each split.
Why: The card-level invariant is the full 108 total — not just the 79
variable-column subset. If the x-check columns are not preserved or
the variable columns drift, the invariant is broken.
"""
player = _make_player(pos="SP")
team = _make_team(abbrev="PT3", gmid=99012)
track = _make_track(name="SP Track3", card_type="sp")
_make_state(player, team, track)
_create_base_pitcher(player)
apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS)
new_variant = compute_variant_hash(player.player_id, 1)
new_card = PitchingCard.get(
(PitchingCard.player == player) & (PitchingCard.variant == new_variant)
)
for row in PitchingCardRatings.select().where(
PitchingCardRatings.pitchingcard == new_card
):
var_sum = sum(getattr(row, col) for col in PITCHER_OUTCOME_COLUMNS)
xcheck_sum = sum(getattr(row, col) for col in PITCHER_XCHECK_COLUMNS)
total = var_sum + xcheck_sum
assert abs(total - 108.0) < 0.01, (
f"Pitcher 108-sum (variable + x-check) violated for "
f"vs_hand={row.vs_hand}: var={var_sum:.4f} xcheck={xcheck_sum:.4f} "
f"total={total:.6f}"
)
def test_correct_priority_columns_reduced(self):
"""T1 boost reduces the highest-priority non-zero column (double_cf).
What: The base pitcher has double_cf=2.95. After T1 boost the TB
budget algorithm starts with double_cf (cost=2 TB per chance) and
should reduce it by 0.75 chances (1.5 TB / 2 TB-per-chance).
Why: Validates that the pitcher priority algorithm is applied correctly
in the orchestration layer and that the outcome columns propagate to
the ratings row.
"""
player = _make_player(pos="SP")
team = _make_team(abbrev="PT4", gmid=99013)
track = _make_track(name="SP Track4", card_type="sp")
_make_state(player, team, track)
_create_base_pitcher(player)
apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS)
new_variant = compute_variant_hash(player.player_id, 1)
new_card = PitchingCard.get(
(PitchingCard.player == player) & (PitchingCard.variant == new_variant)
)
vr_row = PitchingCardRatings.get(
(PitchingCardRatings.pitchingcard == new_card)
& (PitchingCardRatings.vs_hand == "R")
)
# Budget=1.5, double_cf cost=2 -> takes 0.75 chances
expected_double_cf = _BASE_PITCHER_RATINGS["double_cf"] - 0.75
expected_strikeout = _BASE_PITCHER_RATINGS["strikeout"] + 0.75
assert vr_row.double_cf == pytest.approx(expected_double_cf, abs=1e-4)
assert vr_row.strikeout == pytest.approx(expected_strikeout, abs=1e-4)
# ---------------------------------------------------------------------------
# TestCumulativeT1ToT4
# ---------------------------------------------------------------------------
class TestCumulativeT1ToT4:
"""Apply all 4 tiers sequentially to a batter; verify cumulative state."""
def test_four_variant_cards_exist(self):
"""After T1 through T4, four distinct BattingCard rows exist (plus variant=0).
What: Call apply_tier_boost for tiers 1, 2, 3, 4 sequentially.
Assert that there are exactly 5 BattingCard rows for the player
(1 base + 4 variants).
Why: Each tier must create exactly one new variant card. If tiers
share a card or skip creating one, the tier progression is broken.
"""
player = _make_player()
team = _make_team(abbrev="CT1", gmid=99020)
track = _make_track(name="Cumulative Track")
_make_state(player, team, track)
_create_base_batter(player)
for tier in range(1, 5):
apply_tier_boost(
player.player_id, team.id, tier, "batter", **_DEFAULT_KWARGS
)
all_cards = list(BattingCard.select().where(BattingCard.player == player))
assert len(all_cards) == 5, (
f"Expected 5 BattingCard rows (1 base + 4 variants), got {len(all_cards)}"
)
variants = {c.variant for c in all_cards}
assert 0 in variants, "Base card (variant=0) should still exist"
def test_each_tier_sums_to_108(self):
"""After each of the four sequential boosts, ratings sum to 108.
What: Apply T1 through T4 sequentially and after each tier verify
that all ratings rows for the new variant card sum to 108.
Why: Each boost sources from the previous tier's variant. Any
drift introduced by one tier compounds into subsequent tiers.
Checking after each tier catches drift early rather than only at T4.
"""
player = _make_player()
team = _make_team(abbrev="CT2", gmid=99021)
track = _make_track(name="Cumulative Track2")
_make_state(player, team, track)
_create_base_batter(player)
for tier in range(1, 5):
apply_tier_boost(
player.player_id, team.id, tier, "batter", **_DEFAULT_KWARGS
)
new_variant = compute_variant_hash(player.player_id, tier)
new_card = BattingCard.get(
(BattingCard.player == player) & (BattingCard.variant == new_variant)
)
for row in BattingCardRatings.select().where(
BattingCardRatings.battingcard == new_card
):
total = sum(getattr(row, col) for col in BATTER_OUTCOME_COLUMNS)
assert abs(total - 108.0) < 0.01, (
f"108-sum violated at tier {tier} vs_hand={row.vs_hand}: "
f"sum={total:.6f}"
)
def test_t4_has_cumulative_deltas(self):
"""T4 ratings equal base + 4 cumulative boost deltas.
What: After applying T1 through T4, verify that the T4 variant's
homerun column is approximately base_homerun + 4 * 0.5 = base + 2.0.
Why: Each tier applies +0.5 to homerun. Four sequential tiers must
produce a cumulative +2.0 delta. If the algorithm sources from the
wrong base (e.g. always from variant=0 instead of the previous tier's
variant), the cumulative delta would be wrong.
"""
player = _make_player()
team = _make_team(abbrev="CT3", gmid=99022)
track = _make_track(name="Cumulative Track3")
_make_state(player, team, track)
_create_base_batter(player)
for tier in range(1, 5):
apply_tier_boost(
player.player_id, team.id, tier, "batter", **_DEFAULT_KWARGS
)
t4_variant = compute_variant_hash(player.player_id, 4)
t4_card = BattingCard.get(
(BattingCard.player == player) & (BattingCard.variant == t4_variant)
)
vr_row = BattingCardRatings.get(
(BattingCardRatings.battingcard == t4_card)
& (BattingCardRatings.vs_hand == "R")
)
expected_homerun = _BASE_BATTER_RATINGS["homerun"] + 4 * 0.5
assert vr_row.homerun == pytest.approx(expected_homerun, abs=0.01), (
f"T4 homerun expected {expected_homerun}, got {vr_row.homerun}"
)
# ---------------------------------------------------------------------------
# TestIdempotency
# ---------------------------------------------------------------------------
class TestIdempotency:
"""Calling apply_tier_boost twice for the same tier produces no duplicates."""
def test_no_duplicate_card(self):
"""Second call for the same tier reuses the existing variant card.
What: Call apply_tier_boost for T1 twice. Assert that there is still
exactly one BattingCard with the T1 variant hash (not two).
Why: Idempotency is required because evaluate-game may be called
multiple times for the same game if the bot retries. Duplicate cards
would corrupt the inventory and break the unique-variant DB constraint.
"""
player = _make_player()
team = _make_team(abbrev="ID1", gmid=99030)
track = _make_track(name="Idempotency Track")
_make_state(player, team, track)
_create_base_batter(player)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
t1_variant = compute_variant_hash(player.player_id, 1)
cards = list(
BattingCard.select().where(
(BattingCard.player == player) & (BattingCard.variant == t1_variant)
)
)
assert len(cards) == 1, (
f"Expected exactly 1 T1 variant card, found {len(cards)}"
)
def test_no_duplicate_ratings(self):
"""Second call for the same tier creates only one ratings row per split.
What: Call apply_tier_boost for T1 twice. Assert that each vs_hand
split has exactly one BattingCardRatings row on the variant card.
Why: Duplicate ratings rows for the same (card, vs_hand) combination
would cause the game engine to pick an arbitrary row, producing
non-deterministic outcomes.
"""
player = _make_player()
team = _make_team(abbrev="ID2", gmid=99031)
track = _make_track(name="Idempotency Track2")
_make_state(player, team, track)
_create_base_batter(player)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
t1_variant = compute_variant_hash(player.player_id, 1)
t1_card = BattingCard.get(
(BattingCard.player == player) & (BattingCard.variant == t1_variant)
)
ratings_count = (
BattingCardRatings.select()
.where(BattingCardRatings.battingcard == t1_card)
.count()
)
assert ratings_count == 2, (
f"Expected 2 ratings rows (vL + vR), found {ratings_count}"
)
# ---------------------------------------------------------------------------
# TestCardVariantPropagation
# ---------------------------------------------------------------------------
class TestCardVariantPropagation:
"""Card.variant is propagated to all matching (player, team) Card rows."""
def test_multiple_cards_updated(self):
"""Three Card rows for same player/team all get variant updated after boost.
What: Create 3 Card rows for the same (player, team) pair with
variant=0. Call apply_tier_boost for T1. Assert that all 3 Card
rows now have variant equal to the T1 hash.
Why: A player may have multiple Card instances (e.g. from different
cardsets or pack types). All must reflect the current tier's variant
so that any display pathway shows the boosted art.
"""
player = _make_player()
team = _make_team(abbrev="VP1", gmid=99040)
track = _make_track(name="Propagation Track")
_make_state(player, team, track)
_create_base_batter(player)
cs, _ = Cardset.get_or_create(
name="Prop Test Set", defaults={"description": "prop", "total_cards": 10}
)
rarity = _make_rarity()
card_ids = []
for _ in range(3):
c = Card.create(
player=player,
team=team,
variant=0,
cardset=cs,
rarity=rarity,
price=10,
)
card_ids.append(c.id)
expected_variant = compute_variant_hash(player.player_id, 1)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
for cid in card_ids:
updated = Card.get_by_id(cid)
assert updated.variant == expected_variant, (
f"Card id={cid} variant not updated: "
f"expected {expected_variant}, got {updated.variant}"
)
def test_other_teams_unaffected(self):
"""Card rows for the same player on a different team are NOT updated.
What: Create a Card for (player, team_a) and a Card for (player, team_b).
Call apply_tier_boost for T1 on team_a only. Assert that the team_b
card's variant is still 0.
Why: Variant propagation is scoped to (player, team) — applying a
boost for one team must not bleed into another team's card instances.
"""
player = _make_player()
team_a = _make_team(abbrev="VA1", gmid=99041)
team_b = _make_team(abbrev="VA2", gmid=99042)
track = _make_track(name="Propagation Track2")
_make_state(player, team_a, track)
_create_base_batter(player)
cs, _ = Cardset.get_or_create(
name="Prop Test Set2", defaults={"description": "prop2", "total_cards": 10}
)
rarity = _make_rarity()
card_a = Card.create(
player=player, team=team_a, variant=0, cardset=cs, rarity=rarity, price=10
)
card_b = Card.create(
player=player, team=team_b, variant=0, cardset=cs, rarity=rarity, price=10
)
apply_tier_boost(player.player_id, team_a.id, 1, "batter", **_DEFAULT_KWARGS)
# team_a card should be updated
updated_a = Card.get_by_id(card_a.id)
assert updated_a.variant != 0
# team_b card should still be 0
updated_b = Card.get_by_id(card_b.id)
assert updated_b.variant == 0, (
f"team_b card was unexpectedly updated to variant={updated_b.variant}"
)
# ---------------------------------------------------------------------------
# TestCardTypeValidation
# ---------------------------------------------------------------------------
class TestCardTypeValidation:
"""apply_tier_boost rejects invalid card_type values."""
def test_invalid_card_type_raises_value_error(self):
"""card_type='dh' is not valid — must be 'batter', 'sp', or 'rp'.
What: Call apply_tier_boost() with card_type='dh'. Assert that a
ValueError is raised before any DB interaction occurs.
Why: The card_type guard runs at the top of apply_tier_boost(), before
any model lookup. Passing an unsupported type would silently use the
wrong boost formula; an early ValueError prevents corrupted data.
No DB setup is needed because the raise happens before any model is
touched.
"""
with pytest.raises(ValueError, match=r"Invalid card_type"):
apply_tier_boost(1, 1, 1, "dh", **_DEFAULT_KWARGS)
def test_empty_card_type_raises_value_error(self):
"""Empty string card_type is rejected before any DB interaction.
What: Call apply_tier_boost() with card_type=''. Assert that a
ValueError is raised before any DB interaction occurs.
Why: An empty string is not one of the three valid types and must be
caught by the same guard that rejects 'dh'. Ensures the validation
uses an allowlist check rather than a partial-string check that might
accidentally pass an empty value.
"""
with pytest.raises(ValueError, match=r"Invalid card_type"):
apply_tier_boost(1, 1, 1, "", **_DEFAULT_KWARGS)
# ---------------------------------------------------------------------------
# TestCrossPlayerIsolation
# ---------------------------------------------------------------------------
class TestCrossPlayerIsolation:
"""Boosting one player must not affect another player's cards on the same team."""
def test_boost_does_not_update_other_players_cards_on_same_team(self):
"""Two players on the same team. Boost player_a. Player_b's Card.variant stays 0.
What: Create two players on the same team, each with a base BattingCard,
a RefractorCardState, and a Card inventory row (variant=0). Call
apply_tier_boost for player_a only. Assert that player_a's Card row
is updated to the new variant hash while player_b's Card row remains
at variant=0.
Why: The variant propagation query inside apply_tier_boost() filters on
both player_id AND team_id. This test guards against a regression where
only the team_id filter is applied, which would update every player's
Card row on that team.
"""
player_a = _make_player(name="Player A Cross", pos="1B")
player_b = _make_player(name="Player B Cross", pos="2B")
team = _make_team(abbrev="CP1", gmid=99080)
track = _make_track(name="CrossPlayer Track")
_make_state(player_a, team, track)
_make_state(player_b, team, track)
_create_base_batter(player_a)
_create_base_batter(player_b)
Card.create(player=player_a, team=team, variant=0)
Card.create(player=player_b, team=team, variant=0)
apply_tier_boost(player_a.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
card_a = Card.get((Card.player == player_a) & (Card.team == team))
assert card_a.variant != 0, (
f"Player A's card variant should have been updated from 0, "
f"got {card_a.variant}"
)
card_b = Card.get((Card.player == player_b) & (Card.team == team))
assert card_b.variant == 0, (
f"Player B's card variant should still be 0, got {card_b.variant}"
)
# ---------------------------------------------------------------------------
# TestAtomicity
# ---------------------------------------------------------------------------
class TestAtomicity:
"""Tier increment and variant writes are guarded so a mid-boost failure
does not leave RefractorCardState partially updated.
One test verifies that a pre-atomic failure (missing source card) does
not touch the state row at all. A second test verifies that a failure
INSIDE the db.atomic() block rolls back the state mutations even though
the card and ratings rows (created before the block) persist.
"""
def test_missing_source_card_does_not_modify_current_tier(self):
"""Failed boost due to missing source card does not modify current_tier.
What: Set up a RefractorCardState but do NOT create a BattingCard.
Call apply_tier_boost for T1 — it should raise ValueError because
the source card is missing. After the failure, current_tier must
still be 0.
Why: The ValueError is raised at the source-card fetch step, which
happens BEFORE the db.atomic() block. No write is ever attempted,
so the state row should be completely untouched. This guards against
regressions where early validation logic is accidentally removed and
writes are attempted with bad data.
"""
player = _make_player()
team = _make_team(abbrev="AT1", gmid=99050)
track = _make_track(name="Atomicity Track")
state = _make_state(player, team, track, current_tier=0)
# Intentionally no base card created
with pytest.raises(ValueError, match=r"No battingcard.*player="):
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
state = RefractorCardState.get_by_id(state.id)
assert state.current_tier == 0, (
f"current_tier should still be 0 after failed boost, got {state.current_tier}"
)
def test_audit_failure_inside_atomic_rolls_back_state_mutations(self):
"""Failure inside db.atomic() rolls back card_state tier and variant writes.
What: Create a valid base BattingCard with ratings so the function
progresses past source-card validation and card/ratings creation.
Override _audit_model with a stub whose .create() method raises
RuntimeError — this triggers inside the db.atomic() block (step 8a),
after the new card and ratings rows have already been written (steps
6-7, which are outside the atomic block).
After the exception propagates out, verify that RefractorCardState
still has current_tier==0 and variant==None (the pre-boost values).
The atomic rollback must prevent the card_state.save() and
Card.update() writes from being committed even though card/ratings
rows persist (they were written before the atomic block began).
Why: db.atomic() guarantees that either ALL writes inside the block
are committed together or NONE are. If this guarantee breaks, a
partially-committed state would show a tier advance without a
corresponding audit record, leaving the card in an inconsistent state
that is invisible to the evaluator's retry logic.
"""
class _FailingAuditModel:
"""Stub that raises on .create() to simulate audit write failure.
Provides card_state/tier attributes for the Peewee expression in the
idempotency guard, and get_or_none returns None so it proceeds to
create(), which then raises to simulate the failure.
"""
card_state = RefractorBoostAudit.card_state
tier = RefractorBoostAudit.tier
@staticmethod
def get_or_none(*args, **kwargs):
return None
@staticmethod
def create(**kwargs):
raise RuntimeError("Simulated audit write failure inside atomic block")
player = _make_player(name="Atomic Rollback Player", pos="CF")
team = _make_team(abbrev="RB1", gmid=99052)
track = _make_track(name="Atomicity Track3")
state = _make_state(player, team, track, current_tier=0)
_create_base_batter(player)
failing_kwargs = dict(_DEFAULT_KWARGS)
failing_kwargs["_audit_model"] = _FailingAuditModel
with pytest.raises(RuntimeError, match="Simulated audit write failure"):
apply_tier_boost(player.player_id, team.id, 1, "batter", **failing_kwargs)
# The atomic block should have rolled back — state row must be unchanged.
state = RefractorCardState.get_by_id(state.id)
assert state.current_tier == 0, (
f"current_tier should still be 0 after atomic rollback, got {state.current_tier}"
)
assert state.variant is None or state.variant == 0, (
f"variant should not have been updated after atomic rollback, got {state.variant}"
)
def test_successful_boost_writes_tier_and_variant_together(self):
"""Successful boost atomically writes current_tier and variant.
What: After a successful T1 boost, both current_tier == 1 AND
variant == compute_variant_hash(player_id, 1) must be true on the
same RefractorCardState row.
Why: If the writes were not atomic, a read between the tier write
and the variant write could see an inconsistent state. The atomic
block ensures both are committed together.
"""
player = _make_player()
team = _make_team(abbrev="AT2", gmid=99051)
track = _make_track(name="Atomicity Track2")
state = _make_state(player, team, track)
_create_base_batter(player)
expected_variant = compute_variant_hash(player.player_id, 1)
apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS)
state = RefractorCardState.get_by_id(state.id)
assert state.current_tier == 1
assert state.variant == expected_variant
# ---------------------------------------------------------------------------
# TestEvaluateCardDryRun
# ---------------------------------------------------------------------------
def _make_stats_for_t1(player, team):
"""Create a BattingSeasonStats row to give the player enough value for T1.
The default track formula is 'pa + tb * 2'. T1 threshold is 37.
We seed 40 PA with 0 extra bases so value = 40 > 37.
"""
return BattingSeasonStats.create(
player=player,
team=team,
season=11,
pa=40,
)
class TestEvaluateCardDryRun:
"""evaluate_card(dry_run=True) computes the new tier without writing it."""
def test_dry_run_does_not_write_current_tier(self):
"""dry_run=True evaluation leaves current_tier at 0 even when formula says tier=1.
What: Seed enough stats for T1 (pa=40 > threshold 37). Call
evaluate_card with dry_run=True. Assert current_tier is still 0.
Why: The dry_run=True path must not write current_tier so that
apply_tier_boost() can write it atomically with the variant card.
If current_tier were written here, a subsequent boost failure would
leave the tier advanced but no variant created.
"""
player = _make_player()
team = _make_team(abbrev="DR1", gmid=99060)
track = _make_track(name="DryRun Track")
state = _make_state(player, team, track, current_tier=0)
_make_stats_for_t1(player, team)
evaluate_card(
player.player_id,
team.id,
dry_run=True,
_state_model=RefractorCardState,
)
state = RefractorCardState.get_by_id(state.id)
assert state.current_tier == 0, (
f"dry_run=True must not write current_tier; got {state.current_tier}"
)
def test_dry_run_writes_current_value(self):
"""dry_run=True DOES update current_value even though current_tier is skipped.
What: After evaluate_card(dry_run=True) with pa=40 stats, current_value
must be > 0 (formula: pa + tb*2 = 40).
Why: current_value must be updated so that progress display (progress_pct)
reflects the latest stats even when boost is pending.
"""
player = _make_player()
team = _make_team(abbrev="DR2", gmid=99061)
track = _make_track(name="DryRun Track2")
state = _make_state(player, team, track, current_tier=0)
_make_stats_for_t1(player, team)
evaluate_card(
player.player_id,
team.id,
dry_run=True,
_state_model=RefractorCardState,
)
state = RefractorCardState.get_by_id(state.id)
assert state.current_value > 0, (
f"dry_run=True should still update current_value; got {state.current_value}"
)
def test_dry_run_returns_computed_tier(self):
"""Return dict includes 'computed_tier' reflecting the formula result.
What: With pa=40 (value=40 > T1 threshold=37), evaluate_card with
dry_run=True must return {'computed_tier': 1, 'current_tier': 0, ...}.
Why: The evaluate-game endpoint uses computed_tier to detect tier-ups.
If it were absent or equal to current_tier, the endpoint would not
call apply_tier_boost() and no boost would be applied.
"""
player = _make_player()
team = _make_team(abbrev="DR3", gmid=99062)
track = _make_track(name="DryRun Track3")
_make_state(player, team, track, current_tier=0)
_make_stats_for_t1(player, team)
result = evaluate_card(
player.player_id,
team.id,
dry_run=True,
_state_model=RefractorCardState,
)
assert "computed_tier" in result, "Return dict must include 'computed_tier'"
assert result["computed_tier"] >= 1, (
f"computed_tier should be >= 1 with pa=40, got {result['computed_tier']}"
)
assert result["current_tier"] == 0, (
f"current_tier must remain 0 in dry_run mode, got {result['current_tier']}"
)
def test_non_dry_run_preserves_existing_behaviour(self):
"""dry_run=False (default) writes current_tier as before.
What: With pa=40 (value=40 > T1 threshold=37), evaluate_card with
dry_run=False must write current_tier=1 to the database.
Why: The manual /evaluate endpoint uses dry_run=False. Existing
behaviour must be preserved so that cards can still be manually
re-evaluated without a boost cycle.
"""
player = _make_player()
team = _make_team(abbrev="DR4", gmid=99063)
track = _make_track(name="DryRun Track4")
state = _make_state(player, team, track, current_tier=0)
_make_stats_for_t1(player, team)
evaluate_card(
player.player_id,
team.id,
dry_run=False,
_state_model=RefractorCardState,
)
state = RefractorCardState.get_by_id(state.id)
assert state.current_tier >= 1, (
f"dry_run=False must write current_tier; got {state.current_tier}"
)