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>
1306 lines
45 KiB
Python
1306 lines
45 KiB
Python
"""Integration tests for WP-13: Post-Game Callback Integration.
|
|
|
|
Tests cover both post-game callback endpoints:
|
|
POST /api/v2/season-stats/update-game/{game_id}
|
|
POST /api/v2/refractor/evaluate-game/{game_id}
|
|
|
|
All tests run against a named shared-memory SQLite database so that Peewee
|
|
model queries inside the route handlers (which execute in the TestClient's
|
|
thread) and test fixture setup/assertions (which execute in the pytest thread)
|
|
use the same underlying database connection. This is necessary because
|
|
SQLite :memory: databases are per-connection — a new thread gets a new empty
|
|
database unless a shared-cache URI is used.
|
|
|
|
The WP-13 tests therefore manage their own database fixture (_wp13_db) and do
|
|
not use the conftest autouse setup_test_db. The module-level setup_wp13_db
|
|
fixture creates tables before each test and drops them after.
|
|
|
|
The season_stats service 'db' reference is patched at module level so that
|
|
db.atomic() inside update_season_stats() operates on _wp13_db.
|
|
|
|
Test matrix:
|
|
test_update_game_creates_season_stats_rows
|
|
POST to update-game, assert player_season_stats rows are created.
|
|
test_update_game_response_shape
|
|
Response contains {"updated": N, "skipped": false}.
|
|
test_update_game_idempotent
|
|
Second POST to same game_id returns skipped=true, stats unchanged.
|
|
test_evaluate_game_increases_current_value
|
|
After update-game, POST to evaluate-game, assert current_value > 0.
|
|
test_evaluate_game_tier_advancement
|
|
Set up card near tier threshold, game pushes past it, assert tier advanced.
|
|
test_evaluate_game_no_tier_advancement
|
|
Player accumulates too few stats — tier stays at 0.
|
|
test_evaluate_game_tier_ups_in_response
|
|
Tier-up appears in tier_ups list with correct fields.
|
|
test_evaluate_game_skips_players_without_state
|
|
Players in game but without RefractorCardState are silently skipped.
|
|
test_auth_required_update_game
|
|
Missing bearer token returns 401 on update-game.
|
|
test_auth_required_evaluate_game
|
|
Missing bearer token returns 401 on evaluate-game.
|
|
"""
|
|
|
|
import os
|
|
|
|
# Set API_TOKEN before any app imports so that app.dependencies.AUTH_TOKEN
|
|
# is initialised to the same value as our test bearer token.
|
|
os.environ.setdefault("API_TOKEN", "test-token")
|
|
|
|
import app.services.season_stats as _season_stats_module
|
|
import app.services.refractor_boost as _refractor_boost_module
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.testclient import TestClient
|
|
from peewee import SqliteDatabase
|
|
|
|
from app.db_engine import (
|
|
BattingCard,
|
|
BattingCardRatings,
|
|
Cardset,
|
|
Decision,
|
|
Event,
|
|
MlbPlayer,
|
|
Pack,
|
|
PackType,
|
|
PitchingCard,
|
|
PitchingCardRatings,
|
|
Player,
|
|
BattingSeasonStats,
|
|
PitchingSeasonStats,
|
|
ProcessedGame,
|
|
Rarity,
|
|
RefractorBoostAudit,
|
|
RefractorCardState,
|
|
RefractorCosmetic,
|
|
RefractorTierBoost,
|
|
RefractorTrack,
|
|
Roster,
|
|
RosterSlot,
|
|
ScoutClaim,
|
|
ScoutOpportunity,
|
|
StratGame,
|
|
StratPlay,
|
|
Team,
|
|
Card,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared-memory SQLite database for WP-13 tests.
|
|
# A named shared-memory URI allows multiple connections (and therefore
|
|
# multiple threads) to share the same in-memory database, which is required
|
|
# because TestClient routes run in a different thread than pytest fixtures.
|
|
# ---------------------------------------------------------------------------
|
|
_wp13_db = SqliteDatabase(
|
|
"file:wp13test?mode=memory&cache=shared",
|
|
uri=True,
|
|
pragmas={"foreign_keys": 1},
|
|
)
|
|
|
|
_WP13_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' references to use our shared test database so
|
|
# that db.atomic() in update_season_stats() and apply_tier_boost() operate on
|
|
# the same connection.
|
|
_season_stats_module.db = _wp13_db
|
|
_refractor_boost_module.db = _wp13_db
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth header used by every authenticated request
|
|
# ---------------------------------------------------------------------------
|
|
AUTH_HEADER = {"Authorization": "Bearer test-token"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database fixture — binds all models to _wp13_db and creates/drops tables
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_wp13_db():
|
|
"""Bind WP-13 models to the shared-memory SQLite db and create tables.
|
|
|
|
autouse=True so every test in this module automatically gets a fresh
|
|
schema. Tables are dropped in reverse dependency order after each test.
|
|
|
|
This fixture replaces (and disables) the conftest autouse setup_test_db
|
|
for tests in this module because we need a different database backend
|
|
(shared-cache URI rather than :memory:) to support multi-thread access
|
|
via TestClient.
|
|
"""
|
|
_wp13_db.bind(_WP13_MODELS)
|
|
_wp13_db.connect(reuse_if_open=True)
|
|
_wp13_db.create_tables(_WP13_MODELS)
|
|
yield _wp13_db
|
|
_wp13_db.drop_tables(list(reversed(_WP13_MODELS)), safe=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slim test app — only mounts the two routers under test.
|
|
# A db_middleware ensures the shared-cache connection is open for each request.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_test_app() -> FastAPI:
|
|
"""Build a minimal FastAPI instance with just the WP-13 routers.
|
|
|
|
A db_middleware calls _wp13_db.connect(reuse_if_open=True) before each
|
|
request so that the route handler thread can use the shared-memory SQLite
|
|
connection even though it runs in a different thread from the fixture.
|
|
"""
|
|
from app.routers_v2.season_stats import router as ss_router
|
|
from app.routers_v2.refractor import router as evo_router
|
|
|
|
test_app = FastAPI()
|
|
|
|
@test_app.middleware("http")
|
|
async def db_middleware(request: Request, call_next):
|
|
_wp13_db.connect(reuse_if_open=True)
|
|
return await call_next(request)
|
|
|
|
test_app.include_router(ss_router)
|
|
test_app.include_router(evo_router)
|
|
return test_app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestClient fixture — function-scoped so it uses the per-test db binding.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def client(setup_wp13_db):
|
|
"""FastAPI TestClient backed by the slim test app and shared-memory SQLite."""
|
|
with TestClient(_build_test_app()) as c:
|
|
yield c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared helper factories (mirrors test_season_stats_update.py style)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_cardset():
|
|
cs, _ = Cardset.get_or_create(
|
|
name="WP13 Test Set",
|
|
defaults={"description": "wp13 cardset", "total_cards": 100},
|
|
)
|
|
return cs
|
|
|
|
|
|
def _make_rarity():
|
|
r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"})
|
|
return r
|
|
|
|
|
|
def _make_player(name: str, pos: str = "1B") -> Player:
|
|
return Player.create(
|
|
p_name=name,
|
|
rarity=_make_rarity(),
|
|
cardset=_make_cardset(),
|
|
set_num=1,
|
|
pos_1=pos,
|
|
image="https://example.com/img.png",
|
|
mlbclub="TST",
|
|
franchise="TST",
|
|
description=f"wp13 test: {name}",
|
|
)
|
|
|
|
|
|
def _make_team(abbrev: str, gmid: int) -> Team:
|
|
return Team.create(
|
|
abbrev=abbrev,
|
|
sname=abbrev,
|
|
lname=f"Team {abbrev}",
|
|
gmid=gmid,
|
|
gmname=f"gm_{abbrev.lower()}",
|
|
gsheet="https://docs.google.com/spreadsheets/wp13",
|
|
wallet=500,
|
|
team_value=1000,
|
|
collection_value=1000,
|
|
season=11,
|
|
is_ai=False,
|
|
)
|
|
|
|
|
|
def _make_game(team_a, team_b) -> StratGame:
|
|
return StratGame.create(
|
|
season=11,
|
|
game_type="ranked",
|
|
away_team=team_a,
|
|
home_team=team_b,
|
|
)
|
|
|
|
|
|
def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats):
|
|
"""Create a StratPlay with sensible zero-defaults for all stat columns."""
|
|
defaults = dict(
|
|
on_base_code="000",
|
|
inning_half="top",
|
|
inning_num=1,
|
|
batting_order=1,
|
|
starting_outs=0,
|
|
away_score=0,
|
|
home_score=0,
|
|
pa=0,
|
|
ab=0,
|
|
hit=0,
|
|
run=0,
|
|
double=0,
|
|
triple=0,
|
|
homerun=0,
|
|
bb=0,
|
|
so=0,
|
|
hbp=0,
|
|
rbi=0,
|
|
sb=0,
|
|
cs=0,
|
|
outs=0,
|
|
sac=0,
|
|
ibb=0,
|
|
gidp=0,
|
|
bphr=0,
|
|
bpfo=0,
|
|
bp1b=0,
|
|
bplo=0,
|
|
)
|
|
defaults.update(stats)
|
|
return StratPlay.create(
|
|
game=game,
|
|
play_num=play_num,
|
|
batter=batter,
|
|
batter_team=batter_team,
|
|
pitcher=pitcher,
|
|
pitcher_team=pitcher_team,
|
|
**defaults,
|
|
)
|
|
|
|
|
|
def _make_track(
|
|
name: str = "WP13 Batter Track", card_type: str = "batter"
|
|
) -> RefractorTrack:
|
|
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
|
|
) -> RefractorCardState:
|
|
return RefractorCardState.create(
|
|
player=player,
|
|
team=team,
|
|
track=track,
|
|
current_tier=current_tier,
|
|
current_value=current_value,
|
|
fully_evolved=False,
|
|
last_evaluated_at=None,
|
|
)
|
|
|
|
|
|
# Base batter ratings that sum to exactly 108 for use in tier advancement tests.
|
|
# apply_tier_boost() requires a base card (variant=0) with ratings rows to
|
|
# create boosted variant cards — tests that push past T1 must set this up.
|
|
_WP13_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,
|
|
}
|
|
|
|
|
|
def _make_base_batter_card(player):
|
|
"""Create a BattingCard (variant=0) with two ratings rows for apply_tier_boost()."""
|
|
card = BattingCard.create(
|
|
player=player,
|
|
variant=0,
|
|
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,
|
|
**_WP13_BASE_BATTER_RATINGS,
|
|
)
|
|
return card
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: POST /api/v2/season-stats/update-game/{game_id}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_game_creates_season_stats_rows(client):
|
|
"""POST update-game creates player_season_stats rows for players in the game.
|
|
|
|
What: Set up a batter and pitcher in a game with 3 PA for the batter.
|
|
After the endpoint call, assert a BattingSeasonStats row exists with pa=3.
|
|
|
|
Why: This is the core write path. If the row is not created, the
|
|
evolution evaluator will always see zero career stats.
|
|
"""
|
|
team_a = _make_team("WU1", gmid=20001)
|
|
team_b = _make_team("WU2", gmid=20002)
|
|
batter = _make_player("WP13 Batter A")
|
|
pitcher = _make_player("WP13 Pitcher A", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
|
|
for i in range(3):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
resp = client.post(
|
|
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
stats = BattingSeasonStats.get_or_none(
|
|
(BattingSeasonStats.player == batter)
|
|
& (BattingSeasonStats.team == team_a)
|
|
& (BattingSeasonStats.season == 11)
|
|
)
|
|
assert stats is not None
|
|
assert stats.pa == 3
|
|
|
|
|
|
def test_update_game_response_shape(client):
|
|
"""POST update-game returns {"updated": N, "skipped": false}.
|
|
|
|
What: A game with one batter and one pitcher produces updated >= 1 and
|
|
skipped is false on the first call.
|
|
|
|
Why: The bot relies on 'updated' to log how many rows were touched and
|
|
'skipped' to detect re-delivery.
|
|
"""
|
|
team_a = _make_team("WS1", gmid=20011)
|
|
team_b = _make_team("WS2", gmid=20012)
|
|
batter = _make_player("WP13 Batter S")
|
|
pitcher = _make_player("WP13 Pitcher S", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
|
|
_make_play(game, 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
resp = client.post(
|
|
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
assert "updated" in data
|
|
assert data["updated"] >= 1
|
|
assert data["skipped"] is False
|
|
|
|
|
|
def test_update_game_idempotent(client):
|
|
"""Calling update-game twice for the same game returns skipped=true on second call.
|
|
|
|
What: Process a game once (pa=3), then call the endpoint again with the
|
|
same game_id. The second response must have skipped=true and updated=0,
|
|
and pa in the DB must still be 3 (not 6).
|
|
|
|
Why: The bot infrastructure may deliver game-complete events more than
|
|
once. Double-counting would corrupt all evolution stats downstream.
|
|
"""
|
|
team_a = _make_team("WI1", gmid=20021)
|
|
team_b = _make_team("WI2", gmid=20022)
|
|
batter = _make_player("WP13 Batter I")
|
|
pitcher = _make_player("WP13 Pitcher I", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
|
|
for i in range(3):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
resp1 = client.post(
|
|
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp1.status_code == 200
|
|
assert resp1.json()["skipped"] is False
|
|
|
|
resp2 = client.post(
|
|
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp2.status_code == 200
|
|
data2 = resp2.json()
|
|
assert data2["skipped"] is True
|
|
assert data2["updated"] == 0
|
|
|
|
stats = BattingSeasonStats.get(
|
|
(BattingSeasonStats.player == batter) & (BattingSeasonStats.team == team_a)
|
|
)
|
|
assert stats.pa == 3 # not 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: POST /api/v2/refractor/evaluate-game/{game_id}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_increases_current_value(client):
|
|
"""After update-game, evaluate-game raises the card's current_value above 0.
|
|
|
|
What: Batter with a RefractorCardState gets 3 hits (pa=3, hit=3) from a
|
|
game. update-game writes those stats; evaluate-game then recomputes the
|
|
value. current_value in the DB must be > 0 after the evaluate call.
|
|
|
|
Why: This is the end-to-end path: stats in -> evaluate -> value updated.
|
|
If current_value stays 0, the card will never advance regardless of how
|
|
many games are played.
|
|
"""
|
|
team_a = _make_team("WE1", gmid=20031)
|
|
team_b = _make_team("WE2", gmid=20032)
|
|
batter = _make_player("WP13 Batter E")
|
|
pitcher = _make_player("WP13 Pitcher E", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track()
|
|
_make_state(batter, team_a, track)
|
|
|
|
for i in range(3):
|
|
_make_play(
|
|
game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0
|
|
)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
state = RefractorCardState.get(
|
|
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
|
|
)
|
|
assert state.current_value > 0
|
|
|
|
|
|
def test_evaluate_game_tier_advancement(client):
|
|
"""A game that pushes a card past a tier threshold advances the tier.
|
|
|
|
What: Set the batter's career value just below T1 (37) by manually seeding
|
|
a prior BattingSeasonStats row with pa=34. Then add a game that brings the
|
|
total past 37 and call evaluate-game. current_tier must advance to >= 1.
|
|
|
|
Why: Tier advancement is the core deliverable of card evolution. If the
|
|
threshold comparison is off-by-one or the tier is never written, the card
|
|
will never visually evolve.
|
|
"""
|
|
team_a = _make_team("WT1", gmid=20041)
|
|
team_b = _make_team("WT2", gmid=20042)
|
|
batter = _make_player("WP13 Batter T")
|
|
pitcher = _make_player("WP13 Pitcher T", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 Tier Adv Track")
|
|
_make_state(batter, team_a, track, current_tier=0, current_value=34.0)
|
|
# Phase 2: base card required so apply_tier_boost() can create a variant.
|
|
_make_base_batter_card(batter)
|
|
|
|
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
|
|
BattingSeasonStats.create(
|
|
player=batter,
|
|
team=team_a,
|
|
season=10, # previous season
|
|
pa=34,
|
|
)
|
|
|
|
# Game adds 4 more PA (total pa=38 > T1=37)
|
|
for i in range(4):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
updated_state = RefractorCardState.get(
|
|
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
|
|
)
|
|
assert updated_state.current_tier >= 1
|
|
|
|
|
|
def test_evaluate_game_no_tier_advancement(client):
|
|
"""A game with insufficient stats does not advance the tier.
|
|
|
|
What: A batter starts at tier=0 with current_value=0. The game adds only
|
|
2 PA (value=2 which is < T1 threshold of 37). After evaluate-game the
|
|
tier must still be 0.
|
|
|
|
Why: We need to confirm the threshold guard works correctly — cards should
|
|
not advance prematurely before earning the required stats.
|
|
"""
|
|
team_a = _make_team("WN1", gmid=20051)
|
|
team_b = _make_team("WN2", gmid=20052)
|
|
batter = _make_player("WP13 Batter N")
|
|
pitcher = _make_player("WP13 Pitcher N", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 No-Adv Track")
|
|
_make_state(batter, team_a, track, current_tier=0)
|
|
|
|
# Only 2 PA — far below T1=37
|
|
for i in range(2):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
assert data["tier_ups"] == []
|
|
|
|
state = RefractorCardState.get(
|
|
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
|
|
)
|
|
assert state.current_tier == 0
|
|
|
|
|
|
def test_evaluate_game_tier_ups_in_response(client):
|
|
"""evaluate-game response includes a tier_ups entry when a player advances.
|
|
|
|
What: Seed a batter at tier=0 with pa=34 (just below T1=37). A game adds
|
|
4 PA pushing total to 38. The response tier_ups list must contain one
|
|
entry with the correct fields: player_id, team_id, player_name, old_tier,
|
|
new_tier, current_value, track_name.
|
|
|
|
Why: The bot uses tier_ups to trigger in-game notifications and visual card
|
|
upgrade animations. A missing or malformed entry would silently skip the
|
|
announcement.
|
|
"""
|
|
team_a = _make_team("WR1", gmid=20061)
|
|
team_b = _make_team("WR2", gmid=20062)
|
|
batter = _make_player("WP13 Batter R")
|
|
pitcher = _make_player("WP13 Pitcher R", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 Tier-Ups Track")
|
|
_make_state(batter, team_a, track, current_tier=0)
|
|
# Phase 2: base card required so apply_tier_boost() can create a variant.
|
|
_make_base_batter_card(batter)
|
|
|
|
# Seed prior stats below threshold
|
|
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
|
|
|
# Game pushes past T1
|
|
for i in range(4):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
assert data["evaluated"] >= 1
|
|
assert len(data["tier_ups"]) == 1
|
|
|
|
tu = data["tier_ups"][0]
|
|
assert tu["player_id"] == batter.player_id
|
|
assert tu["team_id"] == team_a.id
|
|
assert tu["player_name"] == "WP13 Batter R"
|
|
assert tu["old_tier"] == 0
|
|
assert tu["new_tier"] >= 1
|
|
assert tu["current_value"] > 0
|
|
assert tu["track_name"] == "WP13 Tier-Ups Track"
|
|
|
|
|
|
def test_evaluate_game_skips_players_without_state(client):
|
|
"""Players in a game without a RefractorCardState are silently skipped.
|
|
|
|
What: A game has two players: one with a card state and one without.
|
|
After evaluate-game, evaluated should be 1 (only the player with state)
|
|
and the endpoint must return 200 without errors.
|
|
|
|
Why: Not every player on a roster will have started their evolution journey.
|
|
A hard 404 or 500 for missing states would break the entire batch.
|
|
"""
|
|
team_a = _make_team("WK1", gmid=20071)
|
|
team_b = _make_team("WK2", gmid=20072)
|
|
batter_with_state = _make_player("WP13 Batter WithState")
|
|
batter_no_state = _make_player("WP13 Batter NoState")
|
|
pitcher = _make_player("WP13 Pitcher K", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 Skip Track")
|
|
|
|
# Only batter_with_state gets a RefractorCardState
|
|
_make_state(batter_with_state, team_a, track)
|
|
|
|
_make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
_make_play(game, 2, batter_no_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# Only 1 evaluation (the player with a state)
|
|
assert data["evaluated"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Auth required on both endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_auth_required_update_game(client):
|
|
"""Missing bearer token on update-game returns 401.
|
|
|
|
What: POST to update-game without any Authorization header.
|
|
|
|
Why: Both endpoints are production-only callbacks that should never be
|
|
accessible without a valid bearer token.
|
|
"""
|
|
team_a = _make_team("WA1", gmid=20081)
|
|
team_b = _make_team("WA2", gmid=20082)
|
|
game = _make_game(team_a, team_b)
|
|
|
|
resp = client.post(f"/api/v2/season-stats/update-game/{game.id}")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_auth_required_evaluate_game(client):
|
|
"""Missing bearer token on evaluate-game returns 401.
|
|
|
|
What: POST to evaluate-game without any Authorization header.
|
|
|
|
Why: Same security requirement as update-game — callbacks must be
|
|
authenticated to prevent replay attacks and unauthorized stat manipulation.
|
|
"""
|
|
team_a = _make_team("WB1", gmid=20091)
|
|
team_b = _make_team("WB2", gmid=20092)
|
|
game = _make_game(team_a, team_b)
|
|
|
|
resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T1-3: evaluate-game with non-existent game_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_nonexistent_game_id(client):
|
|
"""POST /refractor/evaluate-game/99999 with a game_id that does not exist.
|
|
|
|
What: There is no StratGame row with id=99999. The endpoint queries
|
|
StratPlay for plays in that game, finds zero rows, builds an empty
|
|
pairs set, and returns without evaluating anyone.
|
|
|
|
Why: Documents the confirmed behaviour: 200 with {"evaluated": 0,
|
|
"tier_ups": []}. The endpoint does not treat a missing game as an
|
|
error because StratPlay.select().where(game_id=N) returning 0 rows is
|
|
a valid (if unusual) outcome — there are simply no players to evaluate.
|
|
|
|
If the implementation is ever changed to return 404 for missing games,
|
|
this test will fail and alert the developer to update the contract.
|
|
"""
|
|
resp = client.post("/api/v2/refractor/evaluate-game/99999", headers=AUTH_HEADER)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["evaluated"] == 0
|
|
assert data["tier_ups"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T2-3: evaluate-game with zero plays
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_zero_plays(client):
|
|
"""evaluate-game on a game with no StratPlay rows returns empty results.
|
|
|
|
What: Create a StratGame but insert zero StratPlay rows for it. POST
|
|
to evaluate-game for that game_id.
|
|
|
|
Why: The endpoint builds its player list from StratPlay rows. A game
|
|
with no plays has no players to evaluate. Verify the endpoint does not
|
|
crash and returns the expected empty-batch shape rather than raising a
|
|
KeyError or returning an unexpected structure.
|
|
"""
|
|
team_a = _make_team("ZP1", gmid=20101)
|
|
team_b = _make_team("ZP2", gmid=20102)
|
|
game = _make_game(team_a, team_b)
|
|
# Intentionally no plays created
|
|
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["evaluated"] == 0
|
|
assert data["tier_ups"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T2-9: Per-player error isolation in evaluate_game
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_error_isolation(client, monkeypatch):
|
|
"""An exception raised for one player does not abort the rest of the batch.
|
|
|
|
What: Create two batters in the same game. Both have RefractorCardState
|
|
rows. Patch evaluate_card in the refractor router to raise RuntimeError
|
|
on the first call and succeed on the second. Verify the endpoint returns
|
|
200, evaluated==1 (not 0 or 2), and no tier_ups from the failing player.
|
|
|
|
Why: The evaluate-game loop catches per-player exceptions and logs them.
|
|
If the isolation breaks, a single bad card would silently drop all
|
|
evaluations for the rest of the game. The 'evaluated' count is the
|
|
observable signal that error isolation is functioning.
|
|
|
|
Implementation note: we patch the evaluate_card function inside the
|
|
router module directly so that the test is independent of how the router
|
|
imports it. We use a counter to let the first call fail and the second
|
|
succeed.
|
|
"""
|
|
from app.services import refractor_evaluator
|
|
|
|
team_a = _make_team("EI1", gmid=20111)
|
|
team_b = _make_team("EI2", gmid=20112)
|
|
|
|
batter_fail = _make_player("WP13 Fail Batter", pos="1B")
|
|
batter_ok = _make_player("WP13 Ok Batter", pos="1B")
|
|
pitcher = _make_player("WP13 EI Pitcher", pos="SP")
|
|
|
|
game = _make_game(team_a, team_b)
|
|
|
|
# Both batters need season stats and a track/state so they are not
|
|
# skipped by the "no state" guard before evaluate_card is called.
|
|
track = _make_track(name="EI Batter Track")
|
|
_make_state(batter_fail, team_a, track)
|
|
_make_state(batter_ok, team_a, track)
|
|
|
|
_make_play(game, 1, batter_fail, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
_make_play(game, 2, batter_ok, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
# The real evaluate_card for batter_ok so we know what it returns
|
|
real_evaluate = refractor_evaluator.evaluate_card
|
|
|
|
call_count = {"n": 0}
|
|
fail_player_id = batter_fail.player_id
|
|
|
|
def patched_evaluate(player_id, team_id, **kwargs):
|
|
call_count["n"] += 1
|
|
if player_id == fail_player_id:
|
|
raise RuntimeError("simulated per-player error")
|
|
return real_evaluate(player_id, team_id, **kwargs)
|
|
|
|
# The router does `from ..services.refractor_evaluator import evaluate_card`
|
|
# inside the async function body, so the local import re-resolves on each
|
|
# call. Patching the function on its source module ensures the local `from`
|
|
# import picks up our patched version when the route handler executes.
|
|
monkeypatch.setattr(
|
|
"app.services.refractor_evaluator.evaluate_card", patched_evaluate
|
|
)
|
|
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# One player succeeded; one was caught by the exception handler
|
|
assert data["evaluated"] == 1
|
|
# The failing player must not appear in tier_ups
|
|
failing_ids = [tu["player_id"] for tu in data["tier_ups"]]
|
|
assert fail_player_id not in failing_ids
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Base pitcher card ratings that sum to exactly 108 for use in pitcher tier
|
|
# advancement tests.
|
|
# Variable columns (18): sum to 79.
|
|
# X-check columns (9): sum to 29.
|
|
# Total: 108.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_WP13_BASE_PITCHER_RATINGS = {
|
|
# 18 variable outcome columns (sum = 79)
|
|
"homerun": 2.0,
|
|
"bp_homerun": 1.0,
|
|
"triple": 0.5,
|
|
"double_three": 1.5,
|
|
"double_two": 2.0,
|
|
"double_cf": 2.0,
|
|
"single_two": 3.0,
|
|
"single_one": 4.0,
|
|
"single_center": 3.0,
|
|
"bp_single": 2.0,
|
|
"hbp": 1.0,
|
|
"walk": 3.0,
|
|
"strikeout": 30.0,
|
|
"flyout_lf_b": 4.0,
|
|
"flyout_cf_b": 5.0,
|
|
"flyout_rf_b": 5.0,
|
|
"groundout_a": 5.0,
|
|
"groundout_b": 5.0,
|
|
# 9 x-check columns (sum = 29)
|
|
"xcheck_p": 4.0,
|
|
"xcheck_c": 3.0,
|
|
"xcheck_1b": 3.0,
|
|
"xcheck_2b": 3.0,
|
|
"xcheck_3b": 3.0,
|
|
"xcheck_ss": 3.0,
|
|
"xcheck_lf": 3.0,
|
|
"xcheck_cf": 3.0,
|
|
"xcheck_rf": 4.0,
|
|
}
|
|
|
|
|
|
def _make_base_pitcher_card(player):
|
|
"""Create a PitchingCard (variant=0) with two ratings rows for apply_tier_boost().
|
|
|
|
Analogous to _make_base_batter_card but for pitcher cards. Ratings are
|
|
seeded from _WP13_BASE_PITCHER_RATINGS which satisfies the 108-sum invariant
|
|
required by apply_tier_boost() (18 variable cols summing to 79 plus 9
|
|
x-check cols summing to 29 = 108 total).
|
|
"""
|
|
card = PitchingCard.create(
|
|
player=player,
|
|
variant=0,
|
|
balk=1,
|
|
wild_pitch=2,
|
|
hold=3,
|
|
starter_rating=7,
|
|
relief_rating=5,
|
|
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.360,
|
|
**_WP13_BASE_PITCHER_RATINGS,
|
|
)
|
|
return card
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 1: REFRACTOR_BOOST_ENABLED=false kill switch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_boost_disabled_skips_tier_up(client, monkeypatch):
|
|
"""When REFRACTOR_BOOST_ENABLED=false, tier-ups are not reported even if formula says tier-up.
|
|
|
|
What: Seed a batter at tier=0 with stats above T1 (pa=34 prior + 4-PA game
|
|
pushes total to 38 > T1 threshold of 37). Set REFRACTOR_BOOST_ENABLED=false
|
|
before calling evaluate-game.
|
|
|
|
Why: The kill switch must suppress all tier-up notifications and leave
|
|
current_tier unchanged so that no variant card is created and no Discord
|
|
announcement is sent. If the kill switch is ignored the bot will announce
|
|
tier-ups during maintenance windows when card creation is deliberately
|
|
disabled.
|
|
"""
|
|
monkeypatch.setenv("REFRACTOR_BOOST_ENABLED", "false")
|
|
|
|
team_a = _make_team("BD1", gmid=20201)
|
|
team_b = _make_team("BD2", gmid=20202)
|
|
batter = _make_player("WP13 KillSwitch Batter")
|
|
pitcher = _make_player("WP13 KillSwitch Pitcher", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 KillSwitch Track")
|
|
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
|
|
_make_base_batter_card(batter)
|
|
|
|
# Seed prior stats just below T1
|
|
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
|
|
|
# Game adds 4 PA — total = 38 > T1 (37)
|
|
for i in range(4):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# Kill switch: the boost block is bypassed so apply_tier_boost() is never
|
|
# called and current_tier must remain 0 in the DB.
|
|
state = RefractorCardState.get(
|
|
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
|
|
)
|
|
assert state.current_tier == 0
|
|
|
|
# No BattingCard variant must have been created (boost never ran).
|
|
from app.services.refractor_boost import compute_variant_hash
|
|
|
|
t1_hash = compute_variant_hash(batter.player_id, 1)
|
|
assert (
|
|
BattingCard.get_or_none(
|
|
(BattingCard.player == batter) & (BattingCard.variant == t1_hash)
|
|
)
|
|
is None
|
|
), "Variant card must not be created when boost is disabled"
|
|
|
|
# When boost is disabled, no tier_up notification is sent — the router
|
|
# skips the append entirely to prevent false notifications to the bot.
|
|
assert len(data["tier_ups"]) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 4: Multi-tier jump T0 -> T2 at HTTP layer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_multi_tier_jump(client):
|
|
"""Player with stats above T2 threshold jumps from T0 to T2 in one game.
|
|
|
|
What: Seed a batter at tier=0 with no prior stats. The game itself
|
|
provides stats in range [T2=149, T3=448).
|
|
Using pa=50, hit=50 (all singles): value = 50 + 50*2 = 150.
|
|
|
|
Why: The evaluate-game loop must iterate through each tier from old+1 to
|
|
computed_tier, calling apply_tier_boost() once per tier. A multi-tier jump
|
|
must produce variant cards for every intermediate tier and report a single
|
|
tier_up entry whose new_tier equals the highest tier reached.
|
|
|
|
The variant_created in the response must match the T2 hash (not T1), because
|
|
the last apply_tier_boost() call returns the T2 variant.
|
|
"""
|
|
from app.services.refractor_boost import compute_variant_hash
|
|
|
|
team_a = _make_team("MJ1", gmid=20211)
|
|
team_b = _make_team("MJ2", gmid=20212)
|
|
batter = _make_player("WP13 MultiJump Batter")
|
|
pitcher = _make_player("WP13 MultiJump Pitcher", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 MultiJump Track")
|
|
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
|
|
_make_base_batter_card(batter)
|
|
|
|
# Target value in range [T2=149, T3=448).
|
|
# formula: pa + tb*2, tb = singles + 2*doubles + 3*triples + 4*HR.
|
|
# 50 PA, 50 hits (all singles): tb = 50; value = 50 + 50*2 = 150.
|
|
# 150 >= T2 (149) and < T3 (448) so tier lands exactly at 2.
|
|
for i in range(50):
|
|
_make_play(
|
|
game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0
|
|
)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# Must have exactly one tier_up entry for this player.
|
|
assert len(data["tier_ups"]) == 1
|
|
tu = data["tier_ups"][0]
|
|
assert tu["old_tier"] == 0
|
|
assert tu["new_tier"] == 2
|
|
|
|
# The variant_created must match T2 hash (last boost iteration).
|
|
expected_t2_hash = compute_variant_hash(batter.player_id, 2)
|
|
assert tu["variant_created"] == expected_t2_hash
|
|
|
|
# Both T1 and T2 variant BattingCard rows must exist.
|
|
t1_hash = compute_variant_hash(batter.player_id, 1)
|
|
t2_hash = compute_variant_hash(batter.player_id, 2)
|
|
assert (
|
|
BattingCard.get_or_none(
|
|
(BattingCard.player == batter) & (BattingCard.variant == t1_hash)
|
|
)
|
|
is not None
|
|
), "T1 variant card missing"
|
|
assert (
|
|
BattingCard.get_or_none(
|
|
(BattingCard.player == batter) & (BattingCard.variant == t2_hash)
|
|
)
|
|
is not None
|
|
), "T2 variant card missing"
|
|
|
|
# DB state must reflect T2.
|
|
state = RefractorCardState.get(
|
|
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
|
|
)
|
|
assert state.current_tier == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 5: Pitcher through evaluate-game
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_pitcher_tier_advancement(client):
|
|
"""Pitcher reaching T1 through evaluate-game creates a boosted PitchingCard variant.
|
|
|
|
What: Create a pitcher player with a PitchingCard + PitchingCardRatings
|
|
(variant=0) and a RefractorCardState on the 'sp' track. Seed
|
|
PitchingSeasonStats with outs and strikeouts just below T1 (prior season),
|
|
then add a game where the pitcher appears and records enough additional outs
|
|
to cross the threshold.
|
|
|
|
The pitcher formula is: outs/3 + strikeouts. Track thresholds are the same
|
|
(t1=37). Prior season: outs=60, strikeouts=16 -> value = 20 + 16 = 36.
|
|
Game adds 3 outs + 1 K -> career total outs=63, strikeouts=17 -> 21+17=38.
|
|
|
|
Why: Pitcher boost must follow the same evaluate-game flow as batter boost.
|
|
If card_type='sp' is not handled, the pitcher track silently skips the boost
|
|
and no tier_ups entry is emitted even when the threshold is passed.
|
|
"""
|
|
team_a = _make_team("PT1", gmid=20221)
|
|
team_b = _make_team("PT2", gmid=20222)
|
|
pitcher = _make_player("WP13 TierPitcher", pos="SP")
|
|
# We need a batter for the play records (pitcher is pitcher side).
|
|
batter = _make_player("WP13 PitcherTest Batter")
|
|
game = _make_game(team_a, team_b)
|
|
|
|
sp_track, _ = RefractorTrack.get_or_create(
|
|
name="WP13 SP Track",
|
|
defaults=dict(
|
|
card_type="sp",
|
|
formula="outs / 3 + strikeouts",
|
|
t1_threshold=37,
|
|
t2_threshold=149,
|
|
t3_threshold=448,
|
|
t4_threshold=896,
|
|
),
|
|
)
|
|
_make_state(pitcher, team_a, sp_track, current_tier=0, current_value=0.0)
|
|
_make_base_pitcher_card(pitcher)
|
|
|
|
# Prior season: outs=60, K=16 -> 60/3 + 16 = 36 (below T1=37)
|
|
PitchingSeasonStats.create(
|
|
player=pitcher,
|
|
team=team_a,
|
|
season=10,
|
|
outs=60,
|
|
strikeouts=16,
|
|
)
|
|
|
|
# Game: pitcher records 3 outs (1 inning) and 1 K.
|
|
# Career after game: outs=63, K=17 -> 63/3 + 17 = 21 + 17 = 38 > T1=37.
|
|
_make_play(
|
|
game,
|
|
1,
|
|
batter,
|
|
team_b,
|
|
pitcher,
|
|
team_a,
|
|
pa=1,
|
|
ab=1,
|
|
outs=3,
|
|
so=1,
|
|
)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# The pitcher must appear in tier_ups.
|
|
pitcher_ups = [
|
|
tu for tu in data["tier_ups"] if tu["player_id"] == pitcher.player_id
|
|
]
|
|
assert len(pitcher_ups) == 1, (
|
|
f"Expected 1 tier_up for pitcher, got: {data['tier_ups']}"
|
|
)
|
|
tu = pitcher_ups[0]
|
|
assert tu["old_tier"] == 0
|
|
assert tu["new_tier"] >= 1
|
|
|
|
# A boosted PitchingCard variant must exist in the database.
|
|
from app.services.refractor_boost import compute_variant_hash
|
|
|
|
t1_hash = compute_variant_hash(pitcher.player_id, 1)
|
|
variant_card = PitchingCard.get_or_none(
|
|
(PitchingCard.player == pitcher) & (PitchingCard.variant == t1_hash)
|
|
)
|
|
assert variant_card is not None, "T1 PitchingCard variant was not created"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 7: variant_created field in tier_up response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_tier_up_includes_variant_created(client):
|
|
"""Tier-up response includes variant_created with the correct hash.
|
|
|
|
What: Seed a batter at tier=0 with stats that push past T1. After
|
|
evaluate-game, the tier_ups entry must contain a 'variant_created' key
|
|
whose value matches compute_variant_hash(player_id, 1) and is a positive
|
|
non-zero integer.
|
|
|
|
Why: The bot reads variant_created to update the card image URL after a
|
|
tier-up. A missing or incorrect hash will point the bot at the wrong card
|
|
image (or no image at all), breaking the tier-up animation in Discord.
|
|
"""
|
|
from app.services.refractor_boost import compute_variant_hash
|
|
|
|
team_a = _make_team("VC1", gmid=20231)
|
|
team_b = _make_team("VC2", gmid=20232)
|
|
batter = _make_player("WP13 VariantCreated Batter")
|
|
pitcher = _make_player("WP13 VariantCreated Pitcher", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
track = _make_track(name="WP13 VariantCreated Track")
|
|
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
|
|
_make_base_batter_card(batter)
|
|
|
|
# Prior season: pa=34, well below T1=37
|
|
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
|
|
|
# Game: 4 PA -> total pa=38 > T1=37
|
|
for i in range(4):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
assert len(data["tier_ups"]) == 1
|
|
tu = data["tier_ups"][0]
|
|
|
|
# variant_created must be present, non-zero, and match the T1 hash.
|
|
assert "variant_created" in tu, "variant_created key missing from tier_up entry"
|
|
assert isinstance(tu["variant_created"], int)
|
|
assert tu["variant_created"] != 0
|
|
|
|
expected_hash = compute_variant_hash(batter.player_id, 1)
|
|
assert tu["variant_created"] == expected_hash
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 8: Empty card_type on track produces no tier-up
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_game_skips_boost_when_track_has_no_card_type(client):
|
|
"""Track with empty card_type produces no tier-up notification.
|
|
|
|
What: Create a RefractorTrack with card_type="" (empty string) and seed a
|
|
batter with stats above T1. Call evaluate-game.
|
|
|
|
Why: apply_tier_boost() requires a valid card_type to know which card model
|
|
to use. When card_type is empty or None the boost cannot run. The endpoint
|
|
must log a warning and skip the tier-up notification entirely — it must NOT
|
|
report a tier-up that was never applied to the database. Reporting a phantom
|
|
tier-up would cause the bot to announce a card upgrade that does not exist.
|
|
"""
|
|
team_a = _make_team("NC1", gmid=20241)
|
|
team_b = _make_team("NC2", gmid=20242)
|
|
batter = _make_player("WP13 NoCardType Batter")
|
|
pitcher = _make_player("WP13 NoCardType Pitcher", pos="SP")
|
|
game = _make_game(team_a, team_b)
|
|
|
|
# Create track with card_type="" — an intentionally invalid/empty value.
|
|
empty_type_track, _ = RefractorTrack.get_or_create(
|
|
name="WP13 NoCardType Track",
|
|
defaults=dict(
|
|
card_type="",
|
|
formula="pa + tb * 2",
|
|
t1_threshold=37,
|
|
t2_threshold=149,
|
|
t3_threshold=448,
|
|
t4_threshold=896,
|
|
),
|
|
)
|
|
_make_state(batter, team_a, empty_type_track, current_tier=0, current_value=0.0)
|
|
|
|
# Prior stats below T1; game pushes past T1.
|
|
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
|
|
|
for i in range(4):
|
|
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
|
|
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
|
|
resp = client.post(
|
|
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
|
|
# No tier-up must be reported when card_type is empty.
|
|
assert data["tier_ups"] == []
|
|
|
|
# current_tier must remain 0 — boost was never applied.
|
|
state = RefractorCardState.get(
|
|
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
|
|
)
|
|
assert state.current_tier == 0
|