- Rename _CareerTotals.k → .strikeouts to match formula engine's stats.strikeouts Protocol - Update test stubs: TrackStub fields t1→t1_threshold etc. to match EvolutionTrack model - Fix fully_evolved logic: derive from post-max current_tier, not new_tier (prevents contradictory state on tier regression) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""Tests for the evolution evaluator service (WP-08).
|
|
|
|
Unit tests verify tier assignment, advancement, partial progress, idempotency,
|
|
full evolution, 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.evolution_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 EvolutionTrack 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 = "evolution_track"
|
|
|
|
|
|
class CardStateStub(Model):
|
|
"""Minimal EvolutionCardState 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 = "evolution_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)
|
|
k = 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):
|
|
return evaluate_card(
|
|
player_id,
|
|
team_id,
|
|
_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 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 evolution_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."""
|
|
_make_state(1, 1, batter_track)
|
|
result = _eval(1, 1)
|
|
assert set(result.keys()) == {
|
|
"player_id",
|
|
"team_id",
|
|
"current_tier",
|
|
"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)
|