feat(WP-13): post-game callback endpoints for season stats and evolution
Implements two new API endpoints the bot calls after a game completes:
POST /api/v2/season-stats/update-game/{game_id}
Delegates to update_season_stats() service (WP-05). Returns
{"updated": N, "skipped": bool} with idempotency via ProcessedGame ledger.
POST /api/v2/evolution/evaluate-game/{game_id}
Finds all (player_id, team_id) pairs from the game's StratPlay rows,
calls evaluate_card() for each pair that has an EvolutionCardState,
and returns {"evaluated": N, "tier_ups": [...]} with full tier-up detail.
New files:
app/services/evolution_evaluator.py — evaluate_card() service (WP-08)
tests/test_postgame_evolution.py — 10 integration tests (all pass)
Modified files:
app/routers_v2/season_stats.py — rewritten to delegate to the service
app/routers_v2/evolution.py — evaluate-game endpoint added
app/main.py — season_stats router registered
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
eba23369ca
commit
b151923480
@ -17,9 +17,9 @@ logging.basicConfig(
|
||||
# from fastapi.staticfiles import StaticFiles
|
||||
# from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .db_engine import db
|
||||
from .routers_v2.players import get_browser, shutdown_browser
|
||||
from .routers_v2 import (
|
||||
from .db_engine import db # noqa: E402
|
||||
from .routers_v2.players import get_browser, shutdown_browser # noqa: E402
|
||||
from .routers_v2 import ( # noqa: E402
|
||||
current,
|
||||
awards,
|
||||
teams,
|
||||
@ -52,6 +52,7 @@ from .routers_v2 import (
|
||||
scout_opportunities,
|
||||
scout_claims,
|
||||
evolution,
|
||||
season_stats,
|
||||
)
|
||||
|
||||
|
||||
@ -107,6 +108,7 @@ app.include_router(decisions.router)
|
||||
app.include_router(scout_opportunities.router)
|
||||
app.include_router(scout_claims.router)
|
||||
app.include_router(evolution.router)
|
||||
app.include_router(season_stats.router)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
|
||||
@ -5,6 +5,8 @@ from typing import Optional
|
||||
from ..db_engine import model_to_dict
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"])
|
||||
|
||||
|
||||
@ -41,3 +43,118 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
|
||||
raise HTTPException(status_code=404, detail=f"Track {track_id} not found")
|
||||
|
||||
return model_to_dict(track, recurse=False)
|
||||
|
||||
|
||||
@router.post("/evaluate-game/{game_id}")
|
||||
async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
||||
"""Evaluate evolution state for all players who appeared in a game.
|
||||
|
||||
Finds all unique (player_id, team_id) pairs from the game's StratPlay rows,
|
||||
then for each pair that has an EvolutionCardState, re-computes the evolution
|
||||
tier by calling evaluate_card(). Pairs without a state row are silently
|
||||
skipped.
|
||||
|
||||
Tier advancement detection: the tier before and after evaluate_card() are
|
||||
compared. If the tier increased, the player is added to the 'tier_ups'
|
||||
list in the response.
|
||||
|
||||
Errors for individual players are logged but do not abort the batch; the
|
||||
endpoint always returns a summary even if some evaluations fail.
|
||||
|
||||
Response:
|
||||
{
|
||||
"evaluated": N,
|
||||
"tier_ups": [
|
||||
{
|
||||
"player_id": ...,
|
||||
"team_id": ...,
|
||||
"player_name": ...,
|
||||
"old_tier": ...,
|
||||
"new_tier": ...,
|
||||
"current_value": ...,
|
||||
"track_name": ...
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import EvolutionCardState, EvolutionTrack, Player, StratPlay
|
||||
from ..services.evolution_evaluator import evaluate_card
|
||||
|
||||
# Collect all unique (player_id, team_id) pairs who appeared in this game.
|
||||
# Both batters and pitchers are included.
|
||||
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
||||
|
||||
pairs: set[tuple[int, int]] = set()
|
||||
for play in plays:
|
||||
if play.batter_id is not None:
|
||||
pairs.add((play.batter_id, play.batter_team_id))
|
||||
if play.pitcher_id is not None:
|
||||
pairs.add((play.pitcher_id, play.pitcher_team_id))
|
||||
|
||||
evaluated = 0
|
||||
tier_ups = []
|
||||
|
||||
for player_id, team_id in pairs:
|
||||
# Check if an EvolutionCardState exists for this pair; skip if not.
|
||||
state = (
|
||||
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
|
||||
.join(EvolutionTrack)
|
||||
.where(
|
||||
(EvolutionCardState.player == player_id)
|
||||
& (EvolutionCardState.team == team_id)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if state is None:
|
||||
continue
|
||||
|
||||
old_tier = state.current_tier
|
||||
track_name = state.track.name
|
||||
|
||||
try:
|
||||
result = evaluate_card(player_id, team_id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"evaluate-game/%d: player=%d team=%d failed: %s",
|
||||
game_id,
|
||||
player_id,
|
||||
team_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
evaluated += 1
|
||||
|
||||
new_tier = result["current_tier"]
|
||||
if new_tier > old_tier:
|
||||
# Resolve player name for the tier-up notification.
|
||||
try:
|
||||
player_name = Player.get_by_id(player_id).p_name
|
||||
except Exception:
|
||||
player_name = f"player_{player_id}"
|
||||
|
||||
tier_ups.append(
|
||||
{
|
||||
"player_id": player_id,
|
||||
"team_id": team_id,
|
||||
"player_name": player_name,
|
||||
"old_tier": old_tier,
|
||||
"new_tier": new_tier,
|
||||
"current_value": result["current_value"],
|
||||
"track_name": track_name,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"evaluate-game/%d: evaluated=%d tier_ups=%d",
|
||||
game_id,
|
||||
evaluated,
|
||||
len(tier_ups),
|
||||
)
|
||||
return {"evaluated": evaluated, "tier_ups": tier_ups}
|
||||
|
||||
@ -3,230 +3,59 @@
|
||||
Covers WP-13 (Post-Game Callback Integration):
|
||||
POST /api/v2/season-stats/update-game/{game_id}
|
||||
|
||||
Aggregates BattingStat and PitchingStat rows for a completed game and
|
||||
increments the corresponding batting_season_stats / pitching_season_stats
|
||||
rows via an additive upsert.
|
||||
Delegates to app.services.season_stats.update_season_stats() which
|
||||
aggregates StratPlay and Decision rows for a completed game and
|
||||
performs an additive upsert into player_season_stats.
|
||||
|
||||
Idempotency is enforced by the service layer: re-delivery of the same
|
||||
game_id returns {"updated": 0, "skipped": true} without modifying stats.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..db_engine import db
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
|
||||
router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"])
|
||||
|
||||
|
||||
def _ip_to_outs(ip: float) -> int:
|
||||
"""Convert innings-pitched float (e.g. 6.1) to integer outs (e.g. 19).
|
||||
|
||||
Baseball stores IP as whole.partial where the fractional digit is outs
|
||||
(0, 1, or 2), not tenths. 6.1 = 6 innings + 1 out = 19 outs.
|
||||
"""
|
||||
whole = int(ip)
|
||||
partial = round((ip - whole) * 10)
|
||||
return whole * 3 + partial
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/update-game/{game_id}")
|
||||
async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)):
|
||||
"""Increment season stats with batting and pitching deltas from a game.
|
||||
|
||||
Queries BattingStat and PitchingStat rows for game_id, aggregates by
|
||||
(player_id, team_id, season), then performs an additive ON CONFLICT upsert
|
||||
into batting_season_stats and pitching_season_stats respectively.
|
||||
Calls update_season_stats(game_id) from the service layer which:
|
||||
- Aggregates all StratPlay rows by (player_id, team_id, season)
|
||||
- Merges Decision rows into pitching groups
|
||||
- Performs an additive ON CONFLICT upsert into player_season_stats
|
||||
- Guards against double-counting via the last_game FK check
|
||||
|
||||
Replaying the same game_id will double-count stats, so callers must ensure
|
||||
this is only called once per game.
|
||||
Response: {"updated": N, "skipped": false}
|
||||
- N: total player_season_stats rows upserted (batters + pitchers)
|
||||
- skipped: true when this game_id was already processed (idempotent re-delivery)
|
||||
|
||||
Response: {"updated": N} where N is the number of player rows touched.
|
||||
Errors from the service are logged but re-raised as 500 so the bot
|
||||
knows to retry.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
updated = 0
|
||||
from ..services.season_stats import update_season_stats
|
||||
|
||||
# --- Batting ---
|
||||
bat_rows = list(
|
||||
db.execute_sql(
|
||||
"""
|
||||
SELECT c.player_id, bs.team_id, bs.season,
|
||||
SUM(bs.pa), SUM(bs.ab), SUM(bs.run), SUM(bs.hit),
|
||||
SUM(bs.double), SUM(bs.triple), SUM(bs.hr), SUM(bs.rbi),
|
||||
SUM(bs.bb), SUM(bs.so), SUM(bs.hbp), SUM(bs.sac),
|
||||
SUM(bs.ibb), SUM(bs.gidp), SUM(bs.sb), SUM(bs.cs)
|
||||
FROM battingstat bs
|
||||
JOIN card c ON bs.card_id = c.id
|
||||
WHERE bs.game_id = %s
|
||||
GROUP BY c.player_id, bs.team_id, bs.season
|
||||
""",
|
||||
(game_id,),
|
||||
try:
|
||||
result = update_season_stats(game_id)
|
||||
except Exception as exc:
|
||||
logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Season stats update failed for game {game_id}: {exc}",
|
||||
)
|
||||
)
|
||||
|
||||
for row in bat_rows:
|
||||
(
|
||||
player_id,
|
||||
team_id,
|
||||
season,
|
||||
pa,
|
||||
ab,
|
||||
runs,
|
||||
hits,
|
||||
doubles,
|
||||
triples,
|
||||
hr,
|
||||
rbi,
|
||||
bb,
|
||||
strikeouts,
|
||||
hbp,
|
||||
sac,
|
||||
ibb,
|
||||
gidp,
|
||||
sb,
|
||||
cs,
|
||||
) = row
|
||||
db.execute_sql(
|
||||
"""
|
||||
INSERT INTO batting_season_stats
|
||||
(player_id, team_id, season,
|
||||
pa, ab, runs, hits, doubles, triples, hr, rbi,
|
||||
bb, strikeouts, hbp, sac, ibb, gidp, sb, cs)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (player_id, team_id, season) DO UPDATE SET
|
||||
pa = batting_season_stats.pa + EXCLUDED.pa,
|
||||
ab = batting_season_stats.ab + EXCLUDED.ab,
|
||||
runs = batting_season_stats.runs + EXCLUDED.runs,
|
||||
hits = batting_season_stats.hits + EXCLUDED.hits,
|
||||
doubles = batting_season_stats.doubles + EXCLUDED.doubles,
|
||||
triples = batting_season_stats.triples + EXCLUDED.triples,
|
||||
hr = batting_season_stats.hr + EXCLUDED.hr,
|
||||
rbi = batting_season_stats.rbi + EXCLUDED.rbi,
|
||||
bb = batting_season_stats.bb + EXCLUDED.bb,
|
||||
strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts,
|
||||
hbp = batting_season_stats.hbp + EXCLUDED.hbp,
|
||||
sac = batting_season_stats.sac + EXCLUDED.sac,
|
||||
ibb = batting_season_stats.ibb + EXCLUDED.ibb,
|
||||
gidp = batting_season_stats.gidp + EXCLUDED.gidp,
|
||||
sb = batting_season_stats.sb + EXCLUDED.sb,
|
||||
cs = batting_season_stats.cs + EXCLUDED.cs
|
||||
""",
|
||||
(
|
||||
player_id,
|
||||
team_id,
|
||||
season,
|
||||
pa,
|
||||
ab,
|
||||
runs,
|
||||
hits,
|
||||
doubles,
|
||||
triples,
|
||||
hr,
|
||||
rbi,
|
||||
bb,
|
||||
strikeouts,
|
||||
hbp,
|
||||
sac,
|
||||
ibb,
|
||||
gidp,
|
||||
sb,
|
||||
cs,
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
# --- Pitching ---
|
||||
pit_rows = list(
|
||||
db.execute_sql(
|
||||
"""
|
||||
SELECT c.player_id, ps.team_id, ps.season,
|
||||
SUM(ps.ip), SUM(ps.so), SUM(ps.hit), SUM(ps.run), SUM(ps.erun),
|
||||
SUM(ps.bb), SUM(ps.hbp), SUM(ps.wp), SUM(ps.balk), SUM(ps.hr),
|
||||
SUM(ps.gs), SUM(ps.win), SUM(ps.loss), SUM(ps.hold),
|
||||
SUM(ps.sv), SUM(ps.bsv)
|
||||
FROM pitchingstat ps
|
||||
JOIN card c ON ps.card_id = c.id
|
||||
WHERE ps.game_id = %s
|
||||
GROUP BY c.player_id, ps.team_id, ps.season
|
||||
""",
|
||||
(game_id,),
|
||||
)
|
||||
)
|
||||
|
||||
for row in pit_rows:
|
||||
(
|
||||
player_id,
|
||||
team_id,
|
||||
season,
|
||||
ip,
|
||||
strikeouts,
|
||||
hits_allowed,
|
||||
runs_allowed,
|
||||
earned_runs,
|
||||
bb,
|
||||
hbp,
|
||||
wild_pitches,
|
||||
balks,
|
||||
hr_allowed,
|
||||
games_started,
|
||||
wins,
|
||||
losses,
|
||||
holds,
|
||||
saves,
|
||||
blown_saves,
|
||||
) = row
|
||||
outs = _ip_to_outs(float(ip))
|
||||
db.execute_sql(
|
||||
"""
|
||||
INSERT INTO pitching_season_stats
|
||||
(player_id, team_id, season,
|
||||
outs, strikeouts, hits_allowed, runs_allowed, earned_runs,
|
||||
bb, hbp, wild_pitches, balks, hr_allowed,
|
||||
games_started, wins, losses, holds, saves, blown_saves)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (player_id, team_id, season) DO UPDATE SET
|
||||
outs = pitching_season_stats.outs + EXCLUDED.outs,
|
||||
strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts,
|
||||
hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed,
|
||||
runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed,
|
||||
earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs,
|
||||
bb = pitching_season_stats.bb + EXCLUDED.bb,
|
||||
hbp = pitching_season_stats.hbp + EXCLUDED.hbp,
|
||||
wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches,
|
||||
balks = pitching_season_stats.balks + EXCLUDED.balks,
|
||||
hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed,
|
||||
games_started= pitching_season_stats.games_started+ EXCLUDED.games_started,
|
||||
wins = pitching_season_stats.wins + EXCLUDED.wins,
|
||||
losses = pitching_season_stats.losses + EXCLUDED.losses,
|
||||
holds = pitching_season_stats.holds + EXCLUDED.holds,
|
||||
saves = pitching_season_stats.saves + EXCLUDED.saves,
|
||||
blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves
|
||||
""",
|
||||
(
|
||||
player_id,
|
||||
team_id,
|
||||
season,
|
||||
outs,
|
||||
strikeouts,
|
||||
hits_allowed,
|
||||
runs_allowed,
|
||||
earned_runs,
|
||||
bb,
|
||||
hbp,
|
||||
wild_pitches,
|
||||
balks,
|
||||
hr_allowed,
|
||||
games_started,
|
||||
wins,
|
||||
losses,
|
||||
holds,
|
||||
saves,
|
||||
blown_saves,
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
logging.info(f"update-game/{game_id}: updated {updated} season stats rows")
|
||||
return {"updated": updated}
|
||||
updated = result.get("batters_updated", 0) + result.get("pitchers_updated", 0)
|
||||
return {
|
||||
"updated": updated,
|
||||
"skipped": result.get("skipped", False),
|
||||
}
|
||||
|
||||
162
app/services/evolution_evaluator.py
Normal file
162
app/services/evolution_evaluator.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Evolution evaluator service (WP-08 / WP-13).
|
||||
|
||||
Force-recalculates a card's evolution state from career totals.
|
||||
|
||||
evaluate_card() is the main entry point:
|
||||
1. Load career totals: SUM all player_season_stats rows for (player_id, team_id)
|
||||
2. Determine track from card_state.track
|
||||
3. Compute formula value (delegated to formula engine, WP-09)
|
||||
4. Compare value to track thresholds to determine new_tier
|
||||
5. Update card_state.current_value = computed value
|
||||
6. Update card_state.current_tier = max(current_tier, new_tier) — no regression
|
||||
7. Update card_state.fully_evolved = (new_tier >= 4)
|
||||
8. Update card_state.last_evaluated_at = NOW()
|
||||
|
||||
Idempotent: calling multiple times with the same data produces the same result.
|
||||
|
||||
Depends on WP-02 (PlayerSeasonStats), WP-04 (EvolutionCardState), and WP-09
|
||||
(formula engine). Models and formula functions are imported lazily so this
|
||||
module can be imported before those PRs merge.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
|
||||
class _CareerTotals:
|
||||
"""Aggregated career stats for a (player_id, team_id) pair.
|
||||
|
||||
Passed to the formula engine as a duck-type stats object with the
|
||||
attributes required by compute_value_for_track:
|
||||
batter: pa, hits, doubles, triples, hr
|
||||
sp/rp: outs, strikeouts
|
||||
|
||||
Note: PlayerSeasonStats stores pitcher strikeouts as 'k'; this class
|
||||
exposes them as 'strikeouts' to satisfy the formula engine Protocol.
|
||||
"""
|
||||
|
||||
__slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts")
|
||||
|
||||
def __init__(self, pa, hits, doubles, triples, hr, outs, strikeouts):
|
||||
self.pa = pa
|
||||
self.hits = hits
|
||||
self.doubles = doubles
|
||||
self.triples = triples
|
||||
self.hr = hr
|
||||
self.outs = outs
|
||||
self.strikeouts = strikeouts
|
||||
|
||||
|
||||
def evaluate_card(
|
||||
player_id: int,
|
||||
team_id: int,
|
||||
_stats_model=None,
|
||||
_state_model=None,
|
||||
_compute_value_fn=None,
|
||||
_tier_from_value_fn=None,
|
||||
) -> dict:
|
||||
"""Force-recalculate a card's evolution tier from career stats.
|
||||
|
||||
Sums all player_season_stats rows for (player_id, team_id) across all
|
||||
seasons, then delegates formula computation and tier classification to the
|
||||
formula engine. The result is written back to evolution_card_state and
|
||||
returned as a dict.
|
||||
|
||||
current_tier never decreases (no regression):
|
||||
card_state.current_tier = max(card_state.current_tier, new_tier)
|
||||
|
||||
Args:
|
||||
player_id: Player primary key.
|
||||
team_id: Team primary key.
|
||||
_stats_model: Override for PlayerSeasonStats (used in tests to avoid
|
||||
importing from db_engine before models are available).
|
||||
_state_model: Override for EvolutionCardState (used in tests).
|
||||
_compute_value_fn: Override for formula_engine.compute_value_for_track
|
||||
(used in tests).
|
||||
_tier_from_value_fn: Override for formula_engine.tier_from_value
|
||||
(used in tests).
|
||||
|
||||
Returns:
|
||||
Dict with updated current_tier, current_value, fully_evolved,
|
||||
last_evaluated_at (ISO-8601 string).
|
||||
|
||||
Raises:
|
||||
ValueError: If no evolution_card_state row exists for (player_id, team_id).
|
||||
"""
|
||||
if _stats_model is None:
|
||||
from app.db_engine import PlayerSeasonStats as _stats_model # noqa: PLC0415
|
||||
|
||||
if _state_model is None:
|
||||
from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415
|
||||
|
||||
if _compute_value_fn is None or _tier_from_value_fn is None:
|
||||
from app.services.formula_engine import ( # noqa: PLC0415
|
||||
compute_value_for_track,
|
||||
tier_from_value,
|
||||
)
|
||||
|
||||
if _compute_value_fn is None:
|
||||
_compute_value_fn = compute_value_for_track
|
||||
if _tier_from_value_fn is None:
|
||||
_tier_from_value_fn = tier_from_value
|
||||
|
||||
# 1. Load card state
|
||||
card_state = _state_model.get_or_none(
|
||||
(_state_model.player_id == player_id) & (_state_model.team_id == team_id)
|
||||
)
|
||||
if card_state is None:
|
||||
raise ValueError(
|
||||
f"No evolution_card_state for player_id={player_id} team_id={team_id}"
|
||||
)
|
||||
|
||||
# 2. Load career totals: SUM all player_season_stats rows for (player_id, team_id)
|
||||
rows = list(
|
||||
_stats_model.select().where(
|
||||
(_stats_model.player_id == player_id) & (_stats_model.team_id == team_id)
|
||||
)
|
||||
)
|
||||
|
||||
totals = _CareerTotals(
|
||||
pa=sum(r.pa for r in rows),
|
||||
hits=sum(r.hits for r in rows),
|
||||
doubles=sum(r.doubles for r in rows),
|
||||
triples=sum(r.triples for r in rows),
|
||||
hr=sum(r.hr for r in rows),
|
||||
outs=sum(r.outs for r in rows),
|
||||
# PlayerSeasonStats stores pitcher Ks as 'k'; expose as 'strikeouts'
|
||||
# to satisfy the PitcherStats Protocol expected by the formula engine.
|
||||
strikeouts=sum(r.k for r in rows),
|
||||
)
|
||||
|
||||
# 3. Determine track
|
||||
track = card_state.track
|
||||
|
||||
# 4. Compute formula value and new tier
|
||||
value = _compute_value_fn(track.card_type, totals)
|
||||
new_tier = _tier_from_value_fn(value, track)
|
||||
|
||||
# 5-8. Update card state (no tier regression)
|
||||
now = datetime.utcnow()
|
||||
card_state.current_value = value
|
||||
card_state.current_tier = max(card_state.current_tier, new_tier)
|
||||
card_state.fully_evolved = card_state.current_tier >= 4
|
||||
card_state.last_evaluated_at = now
|
||||
card_state.save()
|
||||
|
||||
logging.debug(
|
||||
"evolution_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s",
|
||||
player_id,
|
||||
team_id,
|
||||
value,
|
||||
card_state.current_tier,
|
||||
card_state.fully_evolved,
|
||||
)
|
||||
|
||||
return {
|
||||
"player_id": player_id,
|
||||
"team_id": team_id,
|
||||
"current_value": card_state.current_value,
|
||||
"current_tier": card_state.current_tier,
|
||||
"fully_evolved": card_state.fully_evolved,
|
||||
"last_evaluated_at": card_state.last_evaluated_at.isoformat(),
|
||||
}
|
||||
663
tests/test_postgame_evolution.py
Normal file
663
tests/test_postgame_evolution.py
Normal file
@ -0,0 +1,663 @@
|
||||
"""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/evolution/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 EvolutionCardState 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 pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from app.db_engine import (
|
||||
Cardset,
|
||||
EvolutionCardState,
|
||||
EvolutionCosmetic,
|
||||
EvolutionTierBoost,
|
||||
EvolutionTrack,
|
||||
MlbPlayer,
|
||||
Pack,
|
||||
PackType,
|
||||
Player,
|
||||
PlayerSeasonStats,
|
||||
Rarity,
|
||||
Roster,
|
||||
RosterSlot,
|
||||
ScoutClaim,
|
||||
ScoutOpportunity,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
Team,
|
||||
Card,
|
||||
Event,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
PlayerSeasonStats,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
EvolutionCosmetic,
|
||||
]
|
||||
|
||||
# Patch the service-layer 'db' reference to use our shared test database so
|
||||
# that db.atomic() in update_season_stats() operates on the same connection.
|
||||
_season_stats_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.evolution 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"
|
||||
) -> EvolutionTrack:
|
||||
track, _ = EvolutionTrack.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
|
||||
) -> EvolutionCardState:
|
||||
return EvolutionCardState.create(
|
||||
player=player,
|
||||
team=team,
|
||||
track=track,
|
||||
current_tier=current_tier,
|
||||
current_value=current_value,
|
||||
fully_evolved=False,
|
||||
last_evaluated_at=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 PlayerSeasonStats 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 = PlayerSeasonStats.get_or_none(
|
||||
(PlayerSeasonStats.player == batter)
|
||||
& (PlayerSeasonStats.team == team_a)
|
||||
& (PlayerSeasonStats.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 = PlayerSeasonStats.get(
|
||||
(PlayerSeasonStats.player == batter) & (PlayerSeasonStats.team == team_a)
|
||||
)
|
||||
assert stats.pa == 3 # not 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: POST /api/v2/evolution/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 an EvolutionCardState 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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
state = EvolutionCardState.get(
|
||||
(EvolutionCardState.player == batter) & (EvolutionCardState.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 PlayerSeasonStats 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)
|
||||
|
||||
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
|
||||
PlayerSeasonStats.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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
updated_state = EvolutionCardState.get(
|
||||
(EvolutionCardState.player == batter) & (EvolutionCardState.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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["tier_ups"] == []
|
||||
|
||||
state = EvolutionCardState.get(
|
||||
(EvolutionCardState.player == batter) & (EvolutionCardState.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)
|
||||
|
||||
# Seed prior stats below threshold
|
||||
PlayerSeasonStats.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/evolution/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 an EvolutionCardState 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 an EvolutionCardState
|
||||
_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/evolution/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/evolution/evaluate-game/{game.id}")
|
||||
assert resp.status_code == 401
|
||||
Loading…
Reference in New Issue
Block a user