When a card reaches a new Refractor tier during game evaluation, the system now creates a boosted variant card with modified ratings. This connects the Phase 2 Foundation pure functions (PR #176) to the live evaluate-game endpoint. Key changes: - evaluate_card() gains dry_run parameter so apply_tier_boost() is the sole writer of current_tier, ensuring atomicity with variant creation - apply_tier_boost() orchestrates the full boost flow: source card lookup, boost application, variant card + ratings creation, audit record, and atomic state mutations inside db.atomic() - evaluate_game() calls evaluate_card(dry_run=True) then loops through intermediate tiers on tier-up, with error isolation per player - Display stat helpers compute fresh avg/obp/slg for variant cards - REFRACTOR_BOOST_ENABLED env var provides a kill switch - 51 new tests: unit tests for display stats, integration tests for orchestration, HTTP endpoint tests for multi-tier jumps, pitcher path, kill switch, atomicity, idempotency, and cross-player isolation - Clarified all "79-sum" references to note the 108-total card invariant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
805 lines
31 KiB
Python
805 lines
31 KiB
Python
"""Tests for the refractor evaluator service (WP-08).
|
|
|
|
Unit tests verify tier assignment, advancement, partial progress, idempotency,
|
|
full refractor tier, and no-regression behaviour without touching any database,
|
|
using stub Peewee models bound to an in-memory SQLite database.
|
|
|
|
The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported
|
|
from db_engine/formula_engine; instead the tests supply minimal stubs and
|
|
inject them via the _stats_model, _state_model, _compute_value_fn, and
|
|
_tier_from_value_fn overrides on evaluate_card().
|
|
|
|
Stub track thresholds (batter):
|
|
T1: 37 T2: 149 T3: 448 T4: 896
|
|
|
|
Useful reference values:
|
|
value=30 → T0 (below T1=37)
|
|
value=50 → T1 (37 <= 50 < 149)
|
|
value=100 → T1 (stays T1; T2 threshold is 149)
|
|
value=160 → T2 (149 <= 160 < 448)
|
|
value=900 → T4 (>= 896) → fully_evolved
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
from peewee import (
|
|
BooleanField,
|
|
CharField,
|
|
DateTimeField,
|
|
FloatField,
|
|
ForeignKeyField,
|
|
IntegerField,
|
|
Model,
|
|
SqliteDatabase,
|
|
)
|
|
|
|
from app.services.refractor_evaluator import evaluate_card
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub models — mirror WP-01/WP-04/WP-07 schema without importing db_engine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_test_db = SqliteDatabase(":memory:")
|
|
|
|
|
|
class TrackStub(Model):
|
|
"""Minimal RefractorTrack stub for evaluator tests."""
|
|
|
|
card_type = CharField(unique=True)
|
|
t1_threshold = IntegerField()
|
|
t2_threshold = IntegerField()
|
|
t3_threshold = IntegerField()
|
|
t4_threshold = IntegerField()
|
|
|
|
class Meta:
|
|
database = _test_db
|
|
table_name = "refractor_track"
|
|
|
|
|
|
class CardStateStub(Model):
|
|
"""Minimal RefractorCardState stub for evaluator tests."""
|
|
|
|
player_id = IntegerField()
|
|
team_id = IntegerField()
|
|
track = ForeignKeyField(TrackStub)
|
|
current_tier = IntegerField(default=0)
|
|
current_value = FloatField(default=0.0)
|
|
fully_evolved = BooleanField(default=False)
|
|
last_evaluated_at = DateTimeField(null=True)
|
|
|
|
class Meta:
|
|
database = _test_db
|
|
table_name = "refractor_card_state"
|
|
indexes = ((("player_id", "team_id"), True),)
|
|
|
|
|
|
class StatsStub(Model):
|
|
"""Minimal PlayerSeasonStats stub for evaluator tests."""
|
|
|
|
player_id = IntegerField()
|
|
team_id = IntegerField()
|
|
season = IntegerField()
|
|
pa = IntegerField(default=0)
|
|
hits = IntegerField(default=0)
|
|
doubles = IntegerField(default=0)
|
|
triples = IntegerField(default=0)
|
|
hr = IntegerField(default=0)
|
|
outs = IntegerField(default=0)
|
|
strikeouts = IntegerField(default=0)
|
|
|
|
class Meta:
|
|
database = _test_db
|
|
table_name = "player_season_stats"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Formula stubs — avoid importing app.services.formula_engine before WP-09
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _compute_value(card_type: str, stats) -> float:
|
|
"""Stub compute_value_for_track: returns pa for batter, outs/3+k for pitchers."""
|
|
if card_type == "batter":
|
|
singles = stats.hits - stats.doubles - stats.triples - stats.hr
|
|
tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr
|
|
return float(stats.pa + tb * 2)
|
|
return stats.outs / 3 + stats.strikeouts
|
|
|
|
|
|
def _tier_from_value(value: float, track) -> int:
|
|
"""Stub tier_from_value using TrackStub fields t1_threshold/t2_threshold/etc."""
|
|
if isinstance(track, dict):
|
|
t1, t2, t3, t4 = (
|
|
track["t1_threshold"],
|
|
track["t2_threshold"],
|
|
track["t3_threshold"],
|
|
track["t4_threshold"],
|
|
)
|
|
else:
|
|
t1, t2, t3, t4 = (
|
|
track.t1_threshold,
|
|
track.t2_threshold,
|
|
track.t3_threshold,
|
|
track.t4_threshold,
|
|
)
|
|
if value >= t4:
|
|
return 4
|
|
if value >= t3:
|
|
return 3
|
|
if value >= t2:
|
|
return 2
|
|
if value >= t1:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _db():
|
|
"""Create tables before each test and drop them afterwards."""
|
|
_test_db.connect(reuse_if_open=True)
|
|
_test_db.create_tables([TrackStub, CardStateStub, StatsStub])
|
|
yield
|
|
_test_db.drop_tables([StatsStub, CardStateStub, TrackStub])
|
|
|
|
|
|
@pytest.fixture()
|
|
def batter_track():
|
|
return TrackStub.create(
|
|
card_type="batter",
|
|
t1_threshold=37,
|
|
t2_threshold=149,
|
|
t3_threshold=448,
|
|
t4_threshold=896,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def sp_track():
|
|
return TrackStub.create(
|
|
card_type="sp",
|
|
t1_threshold=10,
|
|
t2_threshold=40,
|
|
t3_threshold=120,
|
|
t4_threshold=240,
|
|
)
|
|
|
|
|
|
def _make_state(player_id, team_id, track, current_tier=0, current_value=0.0):
|
|
return CardStateStub.create(
|
|
player_id=player_id,
|
|
team_id=team_id,
|
|
track=track,
|
|
current_tier=current_tier,
|
|
current_value=current_value,
|
|
fully_evolved=False,
|
|
last_evaluated_at=None,
|
|
)
|
|
|
|
|
|
def _make_stats(player_id, team_id, season, **kwargs):
|
|
return StatsStub.create(
|
|
player_id=player_id, team_id=team_id, season=season, **kwargs
|
|
)
|
|
|
|
|
|
def _eval(player_id, team_id, dry_run: bool = False):
|
|
return evaluate_card(
|
|
player_id,
|
|
team_id,
|
|
dry_run=dry_run,
|
|
_stats_model=StatsStub,
|
|
_state_model=CardStateStub,
|
|
_compute_value_fn=_compute_value,
|
|
_tier_from_value_fn=_tier_from_value,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTierAssignment:
|
|
"""Tier assigned from computed value against track thresholds."""
|
|
|
|
def test_value_below_t1_stays_t0(self, batter_track):
|
|
"""value=30 is below T1 threshold (37) → tier stays 0."""
|
|
_make_state(1, 1, batter_track)
|
|
# pa=30, no extra hits → value = 30 + 0 = 30 < 37
|
|
_make_stats(1, 1, 1, pa=30)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 0
|
|
|
|
def test_value_at_t1_threshold_assigns_tier_1(self, batter_track):
|
|
"""value=50 → T1 (37 <= 50 < 149)."""
|
|
_make_state(1, 1, batter_track)
|
|
# pa=50, no hits → value = 50 + 0 = 50
|
|
_make_stats(1, 1, 1, pa=50)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 1
|
|
|
|
def test_tier_advancement_to_t2(self, batter_track):
|
|
"""value=160 → T2 (149 <= 160 < 448)."""
|
|
_make_state(1, 1, batter_track)
|
|
# pa=160, no hits → value = 160
|
|
_make_stats(1, 1, 1, pa=160)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 2
|
|
|
|
def test_partial_progress_stays_t1(self, batter_track):
|
|
"""value=100 with T2=149 → stays T1, does not advance to T2."""
|
|
_make_state(1, 1, batter_track)
|
|
# pa=100 → value = 100, T2 threshold = 149 → tier 1
|
|
_make_stats(1, 1, 1, pa=100)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 1
|
|
assert result["fully_evolved"] is False
|
|
|
|
def test_fully_evolved_at_t4(self, batter_track):
|
|
"""value >= T4 (896) → tier=4 and fully_evolved=True."""
|
|
_make_state(1, 1, batter_track)
|
|
# pa=900 → value = 900 >= 896
|
|
_make_stats(1, 1, 1, pa=900)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 4
|
|
assert result["fully_evolved"] is True
|
|
|
|
|
|
class TestNoRegression:
|
|
"""current_tier never decreases."""
|
|
|
|
def test_tier_never_decreases(self, batter_track):
|
|
"""If current_tier=2 and new value only warrants T1, tier stays 2."""
|
|
# Seed state at tier 2
|
|
_make_state(1, 1, batter_track, current_tier=2, current_value=160.0)
|
|
# Sparse stats: value=50 → would be T1, but current is T2
|
|
_make_stats(1, 1, 1, pa=50)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 2 # no regression
|
|
|
|
def test_tier_advances_when_value_improves(self, batter_track):
|
|
"""If current_tier=1 and new value warrants T3, tier advances to 3."""
|
|
_make_state(1, 1, batter_track, current_tier=1, current_value=50.0)
|
|
# pa=500 → value = 500 >= 448 → T3
|
|
_make_stats(1, 1, 1, pa=500)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 3
|
|
|
|
|
|
class TestIdempotency:
|
|
"""Calling evaluate_card twice with same stats returns the same result."""
|
|
|
|
def test_idempotent_same_result(self, batter_track):
|
|
"""Two evaluations with identical stats produce the same tier and value."""
|
|
_make_state(1, 1, batter_track)
|
|
_make_stats(1, 1, 1, pa=160)
|
|
result1 = _eval(1, 1)
|
|
result2 = _eval(1, 1)
|
|
assert result1["current_tier"] == result2["current_tier"]
|
|
assert result1["current_value"] == result2["current_value"]
|
|
assert result1["fully_evolved"] == result2["fully_evolved"]
|
|
|
|
def test_idempotent_at_fully_evolved(self, batter_track):
|
|
"""Repeated evaluation at T4 remains fully_evolved=True."""
|
|
_make_state(1, 1, batter_track)
|
|
_make_stats(1, 1, 1, pa=900)
|
|
_eval(1, 1)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 4
|
|
assert result["fully_evolved"] is True
|
|
|
|
|
|
class TestCareerTotals:
|
|
"""Stats are summed across all seasons for the player/team pair."""
|
|
|
|
def test_multi_season_stats_summed(self, batter_track):
|
|
"""Stats from two seasons are aggregated into a single career total."""
|
|
_make_state(1, 1, batter_track)
|
|
# Season 1: pa=80, Season 2: pa=90 → total pa=170 → value=170 → T2
|
|
_make_stats(1, 1, 1, pa=80)
|
|
_make_stats(1, 1, 2, pa=90)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 2
|
|
assert result["current_value"] == 170.0
|
|
|
|
def test_zero_stats_stays_t0(self, batter_track):
|
|
"""No stats rows → all zeros → value=0 → tier=0."""
|
|
_make_state(1, 1, batter_track)
|
|
result = _eval(1, 1)
|
|
assert result["current_tier"] == 0
|
|
assert result["current_value"] == 0.0
|
|
|
|
def test_other_team_stats_not_included(self, batter_track):
|
|
"""Stats for the same player on a different team are not counted."""
|
|
_make_state(1, 1, batter_track)
|
|
_make_stats(1, 1, 1, pa=50)
|
|
# Same player, different team — should not count
|
|
_make_stats(1, 2, 1, pa=200)
|
|
result = _eval(1, 1)
|
|
# Only pa=50 counted → value=50 → T1
|
|
assert result["current_tier"] == 1
|
|
assert result["current_value"] == 50.0
|
|
|
|
|
|
class TestFullyEvolvedPersistence:
|
|
"""T2-1: fully_evolved=True is preserved even when stats drop or are absent."""
|
|
|
|
def test_fully_evolved_persists_when_stats_zeroed(self, batter_track):
|
|
"""Card at T4/fully_evolved=True stays fully_evolved after stats are removed.
|
|
|
|
What: Set up a RefractorCardState at tier=4 with fully_evolved=True.
|
|
Then call evaluate_card with no season stats rows (zero career totals).
|
|
The evaluator computes value=0 -> new_tier=0, but current_tier must
|
|
stay at 4 (no regression) and fully_evolved must remain True.
|
|
|
|
Why: fully_evolved is a permanent achievement flag — it must not be
|
|
revoked if a team's stats are rolled back, corrected, or simply not
|
|
yet imported. The no-regression rule (max(current, new)) prevents
|
|
tier demotion; this test confirms that fully_evolved follows the same
|
|
protection.
|
|
"""
|
|
# Seed state at T4 fully_evolved
|
|
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
|
|
# No stats rows — career totals will be all zeros
|
|
# (no _make_stats call)
|
|
|
|
result = _eval(1, 1)
|
|
|
|
# The no-regression rule keeps tier at 4
|
|
assert result["current_tier"] == 4, (
|
|
f"Expected tier=4 (no regression), got {result['current_tier']}"
|
|
)
|
|
# fully_evolved must still be True since tier >= 4
|
|
assert result["fully_evolved"] is True, (
|
|
"fully_evolved was reset to False after re-evaluation with zero stats"
|
|
)
|
|
|
|
def test_fully_evolved_persists_with_partial_stats(self, batter_track):
|
|
"""Card at T4 stays fully_evolved even with stats below T1.
|
|
|
|
What: Same setup as above but with a season stats row giving value=30
|
|
(below T1=37). The computed tier would be 0, but current_tier must
|
|
not regress from 4.
|
|
|
|
Why: Validates that no-regression applies regardless of whether stats
|
|
are zero or merely insufficient for the achieved tier.
|
|
"""
|
|
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
|
|
# pa=30 -> value=30, which is below T1=37 -> computed tier=0
|
|
_make_stats(1, 1, 1, pa=30)
|
|
|
|
result = _eval(1, 1)
|
|
|
|
assert result["current_tier"] == 4
|
|
assert result["fully_evolved"] is True
|
|
|
|
|
|
class TestMissingState:
|
|
"""ValueError when no card state exists for (player_id, team_id)."""
|
|
|
|
def test_missing_state_raises(self, batter_track):
|
|
"""evaluate_card raises ValueError when no state row exists."""
|
|
# No card state created
|
|
with pytest.raises(ValueError, match="No refractor_card_state"):
|
|
_eval(99, 99)
|
|
|
|
|
|
class TestReturnShape:
|
|
"""Return dict has the expected keys and types."""
|
|
|
|
def test_return_keys(self, batter_track):
|
|
"""Result dict contains all expected keys.
|
|
|
|
Phase 2 addition: 'computed_tier' is included alongside 'current_tier'
|
|
so that evaluate-game can detect tier-ups without writing the tier
|
|
(dry_run=True path). Both keys must always be present.
|
|
"""
|
|
_make_state(1, 1, batter_track)
|
|
result = _eval(1, 1)
|
|
assert set(result.keys()) == {
|
|
"player_id",
|
|
"team_id",
|
|
"current_tier",
|
|
"computed_tier",
|
|
"computed_fully_evolved",
|
|
"current_value",
|
|
"fully_evolved",
|
|
"last_evaluated_at",
|
|
}
|
|
|
|
def test_last_evaluated_at_is_iso_string(self, batter_track):
|
|
"""last_evaluated_at is a non-empty ISO-8601 string."""
|
|
_make_state(1, 1, batter_track)
|
|
result = _eval(1, 1)
|
|
ts = result["last_evaluated_at"]
|
|
assert isinstance(ts, str) and len(ts) > 0
|
|
# Must be parseable as a datetime
|
|
datetime.fromisoformat(ts)
|
|
|
|
|
|
class TestFullyEvolvedFlagCorrection:
|
|
"""T3-7: fully_evolved/tier mismatch is corrected by evaluate_card.
|
|
|
|
A database corruption where fully_evolved=True but current_tier < 4 can
|
|
occur if the flag was set incorrectly by a migration or external script.
|
|
evaluate_card must re-derive fully_evolved from the freshly-computed tier
|
|
(after the no-regression max() is applied), not trust the stored flag.
|
|
"""
|
|
|
|
def test_fully_evolved_flag_corrected_when_tier_below_4(self, batter_track):
|
|
"""fully_evolved=True with current_tier=3 is corrected to False after evaluation.
|
|
|
|
What: Manually set database state to fully_evolved=True, current_tier=3
|
|
(a corruption scenario — tier 3 cannot be "fully evolved" since T4 is
|
|
the maximum tier). Provide stats that compute to a value in the T3
|
|
range (value=500, which is >= T3=448 but < T4=896).
|
|
|
|
After evaluate_card:
|
|
- computed value = 500 → new_tier = 3
|
|
- no-regression: max(current_tier=3, new_tier=3) = 3 → tier stays 3
|
|
- fully_evolved = (3 >= 4) = False → flag is corrected
|
|
|
|
Why: The evaluator always recomputes fully_evolved from the final
|
|
current_tier rather than preserving the stored flag. This ensures
|
|
that a corrupted fully_evolved=True at tier<4 is silently repaired
|
|
on the next evaluation without requiring a separate migration.
|
|
"""
|
|
# Inject corruption: fully_evolved=True but tier=3
|
|
state = CardStateStub.create(
|
|
player_id=1,
|
|
team_id=1,
|
|
track=batter_track,
|
|
current_tier=3,
|
|
current_value=500.0,
|
|
fully_evolved=True, # intentionally wrong
|
|
last_evaluated_at=None,
|
|
)
|
|
# Stats that compute to value=500: pa=500, no hits → value=500+0=500
|
|
# T3 threshold=448, T4 threshold=896 → tier=3, NOT 4
|
|
_make_stats(1, 1, 1, pa=500)
|
|
|
|
result = _eval(1, 1)
|
|
|
|
assert result["current_tier"] == 3, (
|
|
f"Expected tier=3 after evaluation with value=500, got {result['current_tier']}"
|
|
)
|
|
assert result["fully_evolved"] is False, (
|
|
"fully_evolved should have been corrected to False for tier=3, "
|
|
f"got {result['fully_evolved']}"
|
|
)
|
|
|
|
# Confirm the database row was updated (not just the return dict)
|
|
state_reloaded = CardStateStub.get_by_id(state.id)
|
|
assert state_reloaded.fully_evolved is False, (
|
|
"fully_evolved was not persisted as False after correction"
|
|
)
|
|
|
|
def test_fully_evolved_flag_preserved_when_tier_reaches_4(self, batter_track):
|
|
"""fully_evolved=True with current_tier=3 stays True when new stats push to T4.
|
|
|
|
What: Same corruption setup as above (fully_evolved=True, tier=3),
|
|
but now provide stats with value=900 (>= T4=896).
|
|
|
|
After evaluate_card:
|
|
- computed value = 900 → new_tier = 4
|
|
- no-regression: max(current_tier=3, new_tier=4) = 4 → advances to 4
|
|
- fully_evolved = (4 >= 4) = True → flag stays True (correctly)
|
|
|
|
Why: Confirms the evaluator correctly sets fully_evolved=True when
|
|
the re-computed tier legitimately reaches T4 regardless of whether
|
|
the stored flag was already True before evaluation.
|
|
"""
|
|
CardStateStub.create(
|
|
player_id=1,
|
|
team_id=1,
|
|
track=batter_track,
|
|
current_tier=3,
|
|
current_value=500.0,
|
|
fully_evolved=True, # stored flag (will be re-derived)
|
|
last_evaluated_at=None,
|
|
)
|
|
# pa=900 → value=900 >= T4=896 → new_tier=4
|
|
_make_stats(1, 1, 1, pa=900)
|
|
|
|
result = _eval(1, 1)
|
|
|
|
assert result["current_tier"] == 4, (
|
|
f"Expected tier=4 for value=900, got {result['current_tier']}"
|
|
)
|
|
assert result["fully_evolved"] is True, (
|
|
f"Expected fully_evolved=True for tier=4, got {result['fully_evolved']}"
|
|
)
|
|
|
|
|
|
class TestMultiTeamStatIsolation:
|
|
"""T3-8: A player's refractor value is isolated to a specific team's stats.
|
|
|
|
The evaluator queries BattingSeasonStats WHERE player_id=? AND team_id=?.
|
|
When a player has stats on two different teams in the same season, each
|
|
team's RefractorCardState must reflect only that team's stats — not a
|
|
combined total.
|
|
"""
|
|
|
|
def test_multi_team_same_season_stats_isolated(self, batter_track):
|
|
"""Each team's refractor value reflects only that team's stats, not combined.
|
|
|
|
What: Create one player with BattingSeasonStats on team_id=1 (pa=80)
|
|
and team_id=2 (pa=120) in the same season. Create a RefractorCardState
|
|
for each team. Evaluate each team's card separately and verify:
|
|
- Team 1 state: value = 80 → tier = T1 (80 >= T1=37, < T2=149)
|
|
- Team 2 state: value = 120 → tier = T1 (120 >= T1=37, < T2=149)
|
|
- Neither value equals the combined total (80+120=200 → would be T2)
|
|
|
|
Why: Confirms the `WHERE player_id=? AND team_id=?` filter in the
|
|
evaluator is correctly applied. Without proper team isolation, the
|
|
combined total of 200 would cross the T2 threshold (149) and both
|
|
states would be incorrectly assigned to T2. This is a critical
|
|
correctness requirement: a player traded between teams should have
|
|
separate refractor progressions for their time with each franchise.
|
|
"""
|
|
# Stats on team 1: pa=80 → value=80 (T1: 37<=80<149)
|
|
_make_stats(player_id=1, team_id=1, season=11, pa=80)
|
|
# Stats on team 2: pa=120 → value=120 (T1: 37<=120<149)
|
|
_make_stats(player_id=1, team_id=2, season=11, pa=120)
|
|
|
|
# combined pa would be 200 → value=200 → T2 (149<=200<448)
|
|
# Each team must see only its own stats, not 200
|
|
|
|
_make_state(player_id=1, team_id=1, track=batter_track)
|
|
_make_state(player_id=1, team_id=2, track=batter_track)
|
|
|
|
result_team1 = _eval(player_id=1, team_id=1)
|
|
result_team2 = _eval(player_id=1, team_id=2)
|
|
|
|
# Team 1: only pa=80 counted → value=80 → T1
|
|
assert result_team1["current_value"] == 80.0, (
|
|
f"Team 1 value should be 80.0 (its own stats only), "
|
|
f"got {result_team1['current_value']}"
|
|
)
|
|
assert result_team1["current_tier"] == 1, (
|
|
f"Team 1 tier should be T1 for value=80, got {result_team1['current_tier']}"
|
|
)
|
|
|
|
# Team 2: only pa=120 counted → value=120 → T1
|
|
assert result_team2["current_value"] == 120.0, (
|
|
f"Team 2 value should be 120.0 (its own stats only), "
|
|
f"got {result_team2['current_value']}"
|
|
)
|
|
assert result_team2["current_tier"] == 1, (
|
|
f"Team 2 tier should be T1 for value=120, got {result_team2['current_tier']}"
|
|
)
|
|
|
|
# Sanity: neither team crossed T2 (which would happen if stats were combined)
|
|
assert (
|
|
result_team1["current_tier"] != 2 and result_team2["current_tier"] != 2
|
|
), (
|
|
"At least one team was incorrectly assigned T2 — stats may have been combined"
|
|
)
|
|
|
|
def test_multi_team_different_seasons_isolated(self, batter_track):
|
|
"""Stats for the same player across multiple seasons remain per-team isolated.
|
|
|
|
What: Same player with two seasons of stats for each of two teams:
|
|
- team_id=1: season 10 pa=90, season 11 pa=70 → combined=160
|
|
- team_id=2: season 10 pa=100, season 11 pa=80 → combined=180
|
|
|
|
After evaluation:
|
|
- Team 1: value=160 → T2 (149<=160<448)
|
|
- Team 2: value=180 → T2 (149<=180<448)
|
|
|
|
The test confirms that cross-team season aggregation does not bleed
|
|
stats from team 2 into team 1's calculation or vice versa.
|
|
|
|
Why: Multi-season aggregation and multi-team isolation must work
|
|
together. A bug that incorrectly sums all player stats regardless
|
|
of team would produce combined values of 340 → T2, which coincidentally
|
|
passes, but the per-team values and tiers would be wrong.
|
|
This test uses values where cross-contamination would produce a
|
|
materially different value (340 vs 160/180), catching that class of bug.
|
|
"""
|
|
# Team 1 stats: total pa=160 → value=160 → T2
|
|
_make_stats(player_id=1, team_id=1, season=10, pa=90)
|
|
_make_stats(player_id=1, team_id=1, season=11, pa=70)
|
|
|
|
# Team 2 stats: total pa=180 → value=180 → T2
|
|
_make_stats(player_id=1, team_id=2, season=10, pa=100)
|
|
_make_stats(player_id=1, team_id=2, season=11, pa=80)
|
|
|
|
_make_state(player_id=1, team_id=1, track=batter_track)
|
|
_make_state(player_id=1, team_id=2, track=batter_track)
|
|
|
|
result_team1 = _eval(player_id=1, team_id=1)
|
|
result_team2 = _eval(player_id=1, team_id=2)
|
|
|
|
assert result_team1["current_value"] == 160.0, (
|
|
f"Team 1 multi-season value should be 160.0, got {result_team1['current_value']}"
|
|
)
|
|
assert result_team1["current_tier"] == 2, (
|
|
f"Team 1 tier should be T2 for value=160, got {result_team1['current_tier']}"
|
|
)
|
|
|
|
assert result_team2["current_value"] == 180.0, (
|
|
f"Team 2 multi-season value should be 180.0, got {result_team2['current_value']}"
|
|
)
|
|
assert result_team2["current_tier"] == 2, (
|
|
f"Team 2 tier should be T2 for value=180, got {result_team2['current_tier']}"
|
|
)
|
|
|
|
|
|
class TestDryRun:
|
|
"""dry_run=True writes current_value and last_evaluated_at but NOT current_tier
|
|
or fully_evolved, allowing apply_tier_boost() to write tier + variant atomically.
|
|
|
|
All tests use stats that would produce a tier-up (value=160 → T2) on a card
|
|
seeded at tier=0, so the delta between dry and non-dry behaviour is obvious.
|
|
|
|
Stub thresholds (batter): T1=37, T2=149, T3=448, T4=896.
|
|
value=160 → T2 (149 <= 160 < 448); starting current_tier=0 → tier-up to T2.
|
|
"""
|
|
|
|
def test_dry_run_does_not_write_current_tier(self, batter_track):
|
|
"""dry_run=True leaves current_tier unchanged in the database.
|
|
|
|
What: Seed a card at tier=0. Provide stats that would advance to T2
|
|
(value=160). Call evaluate_card with dry_run=True. Re-read the DB row
|
|
and assert current_tier is still 0.
|
|
|
|
Why: The dry_run path must not persist the tier so that apply_tier_boost()
|
|
can write tier + variant atomically on the next step. If current_tier
|
|
were written here, a boost failure would leave the tier advanced with no
|
|
corresponding variant, causing an inconsistent state.
|
|
"""
|
|
_make_state(1, 1, batter_track, current_tier=0)
|
|
_make_stats(1, 1, 1, pa=160)
|
|
|
|
_eval(1, 1, dry_run=True)
|
|
|
|
reloaded = CardStateStub.get(
|
|
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
|
|
)
|
|
assert reloaded.current_tier == 0, (
|
|
f"dry_run should not write current_tier; expected 0, got {reloaded.current_tier}"
|
|
)
|
|
|
|
def test_dry_run_does_not_write_fully_evolved(self, batter_track):
|
|
"""dry_run=True leaves fully_evolved=False unchanged in the database.
|
|
|
|
What: Seed a card at tier=0 with fully_evolved=False. Provide stats that
|
|
would push to T4 (value=900). Call evaluate_card with dry_run=True.
|
|
Re-read the DB row and assert fully_evolved is still False.
|
|
|
|
Why: fully_evolved follows current_tier and must be written atomically
|
|
by apply_tier_boost(). Writing it here would let the flag get out of
|
|
sync with the tier if the boost subsequently fails.
|
|
"""
|
|
_make_state(1, 1, batter_track, current_tier=0)
|
|
_make_stats(1, 1, 1, pa=900) # value=900 → T4 → fully_evolved=True normally
|
|
|
|
_eval(1, 1, dry_run=True)
|
|
|
|
reloaded = CardStateStub.get(
|
|
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
|
|
)
|
|
assert reloaded.fully_evolved is False, (
|
|
"dry_run should not write fully_evolved; expected False, "
|
|
f"got {reloaded.fully_evolved}"
|
|
)
|
|
|
|
def test_dry_run_writes_current_value(self, batter_track):
|
|
"""dry_run=True DOES update current_value in the database.
|
|
|
|
What: Seed a card with current_value=0. Provide stats giving value=160.
|
|
Call evaluate_card with dry_run=True. Re-read the DB row and assert
|
|
current_value has been updated to 160.0.
|
|
|
|
Why: current_value tracks formula progress and is safe to write
|
|
at any time — it does not affect game logic atomicity, so it is
|
|
always persisted regardless of dry_run.
|
|
"""
|
|
_make_state(1, 1, batter_track, current_value=0.0)
|
|
_make_stats(1, 1, 1, pa=160)
|
|
|
|
_eval(1, 1, dry_run=True)
|
|
|
|
reloaded = CardStateStub.get(
|
|
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
|
|
)
|
|
assert reloaded.current_value == 160.0, (
|
|
f"dry_run should still write current_value; expected 160.0, "
|
|
f"got {reloaded.current_value}"
|
|
)
|
|
|
|
def test_dry_run_writes_last_evaluated_at(self, batter_track):
|
|
"""dry_run=True DOES update last_evaluated_at in the database.
|
|
|
|
What: Seed a card with last_evaluated_at=None. Call evaluate_card with
|
|
dry_run=True. Re-read the DB row and assert last_evaluated_at is now a
|
|
non-None datetime.
|
|
|
|
Why: last_evaluated_at is a bookkeeping field used for scheduling and
|
|
audit purposes. It is safe to update independently of tier writes
|
|
and should always reflect the most recent evaluation attempt.
|
|
"""
|
|
_make_state(1, 1, batter_track)
|
|
_make_stats(1, 1, 1, pa=160)
|
|
|
|
_eval(1, 1, dry_run=True)
|
|
|
|
reloaded = CardStateStub.get(
|
|
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
|
|
)
|
|
assert reloaded.last_evaluated_at is not None, (
|
|
"dry_run should still write last_evaluated_at; got None"
|
|
)
|
|
|
|
def test_dry_run_returns_computed_tier(self, batter_track):
|
|
"""dry_run=True return dict has computed_tier=T2 while current_tier stays 0.
|
|
|
|
What: Seed at tier=0. Stats → value=160 → T2. Call dry_run=True.
|
|
Assert:
|
|
- result["computed_tier"] == 2 (what the formula says)
|
|
- result["current_tier"] == 0 (what is stored; unchanged)
|
|
|
|
Why: Callers use the divergence between computed_tier and current_tier
|
|
to detect a pending tier-up. Both keys must be present and correct for
|
|
the evaluate-game endpoint to gate apply_tier_boost() correctly.
|
|
"""
|
|
_make_state(1, 1, batter_track, current_tier=0)
|
|
_make_stats(1, 1, 1, pa=160)
|
|
|
|
result = _eval(1, 1, dry_run=True)
|
|
|
|
assert result["computed_tier"] == 2, (
|
|
f"computed_tier should reflect formula result T2; got {result['computed_tier']}"
|
|
)
|
|
assert result["current_tier"] == 0, (
|
|
f"current_tier should reflect unchanged DB value 0; got {result['current_tier']}"
|
|
)
|
|
|
|
def test_dry_run_returns_computed_fully_evolved(self, batter_track):
|
|
"""dry_run=True sets computed_fully_evolved correctly in the return dict.
|
|
|
|
What: Two sub-cases:
|
|
- Stats → value=160 → T2: computed_fully_evolved should be False.
|
|
- Stats → value=900 → T4: computed_fully_evolved should be True.
|
|
In both cases fully_evolved in the DB remains False (tier not written).
|
|
|
|
Why: computed_fully_evolved lets callers know whether the pending tier-up
|
|
will result in a fully-evolved card without having to re-query the DB
|
|
or recalculate the tier themselves. It must match (computed_tier >= 4),
|
|
not the stored fully_evolved value.
|
|
"""
|
|
# Sub-case 1: computed T2 → computed_fully_evolved=False
|
|
_make_state(1, 1, batter_track, current_tier=0)
|
|
_make_stats(1, 1, 1, pa=160)
|
|
|
|
result = _eval(1, 1, dry_run=True)
|
|
|
|
assert result["computed_fully_evolved"] is False, (
|
|
f"computed_fully_evolved should be False for T2; got {result['computed_fully_evolved']}"
|
|
)
|
|
assert result["fully_evolved"] is False, (
|
|
"stored fully_evolved should remain False after dry_run"
|
|
)
|
|
|
|
# Reset for sub-case 2: computed T4 → computed_fully_evolved=True
|
|
CardStateStub.delete().execute()
|
|
StatsStub.delete().execute()
|
|
|
|
_make_state(1, 1, batter_track, current_tier=0)
|
|
_make_stats(1, 1, 1, pa=900) # value=900 → T4
|
|
|
|
result2 = _eval(1, 1, dry_run=True)
|
|
|
|
assert result2["computed_fully_evolved"] is True, (
|
|
f"computed_fully_evolved should be True for T4; got {result2['computed_fully_evolved']}"
|
|
)
|
|
assert result2["fully_evolved"] is False, (
|
|
"stored fully_evolved should remain False after dry_run even at T4"
|
|
)
|